* feat: add LINE plugin (#1630) (thanks @plum-dawg) * feat: complete LINE plugin (#1630) (thanks @plum-dawg) * chore: drop line plugin node_modules (#1630) (thanks @plum-dawg) * test: mock /context report in commands test (#1630) (thanks @plum-dawg) * test: limit macOS CI workers to avoid OOM (#1630) (thanks @plum-dawg) * test: reduce macOS CI vitest workers (#1630) (thanks @plum-dawg) --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
450 lines
12 KiB
TypeScript
450 lines
12 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
extractMarkdownTables,
|
|
extractCodeBlocks,
|
|
extractLinks,
|
|
stripMarkdown,
|
|
processLineMessage,
|
|
convertTableToFlexBubble,
|
|
convertCodeBlockToFlexBubble,
|
|
hasMarkdownToConvert,
|
|
} from "./markdown-to-line.js";
|
|
|
|
describe("extractMarkdownTables", () => {
|
|
it("extracts a simple 2-column table", () => {
|
|
const text = `Here is a table:
|
|
|
|
| Name | Value |
|
|
|------|-------|
|
|
| foo | 123 |
|
|
| bar | 456 |
|
|
|
|
And some more text.`;
|
|
|
|
const { tables, textWithoutTables } = extractMarkdownTables(text);
|
|
|
|
expect(tables).toHaveLength(1);
|
|
expect(tables[0].headers).toEqual(["Name", "Value"]);
|
|
expect(tables[0].rows).toEqual([
|
|
["foo", "123"],
|
|
["bar", "456"],
|
|
]);
|
|
expect(textWithoutTables).toContain("Here is a table:");
|
|
expect(textWithoutTables).toContain("And some more text.");
|
|
expect(textWithoutTables).not.toContain("|");
|
|
});
|
|
|
|
it("extracts a multi-column table", () => {
|
|
const text = `| Col A | Col B | Col C |
|
|
|-------|-------|-------|
|
|
| 1 | 2 | 3 |
|
|
| a | b | c |`;
|
|
|
|
const { tables } = extractMarkdownTables(text);
|
|
|
|
expect(tables).toHaveLength(1);
|
|
expect(tables[0].headers).toEqual(["Col A", "Col B", "Col C"]);
|
|
expect(tables[0].rows).toHaveLength(2);
|
|
});
|
|
|
|
it("extracts multiple tables", () => {
|
|
const text = `Table 1:
|
|
|
|
| A | B |
|
|
|---|---|
|
|
| 1 | 2 |
|
|
|
|
Table 2:
|
|
|
|
| X | Y |
|
|
|---|---|
|
|
| 3 | 4 |`;
|
|
|
|
const { tables } = extractMarkdownTables(text);
|
|
|
|
expect(tables).toHaveLength(2);
|
|
expect(tables[0].headers).toEqual(["A", "B"]);
|
|
expect(tables[1].headers).toEqual(["X", "Y"]);
|
|
});
|
|
|
|
it("handles tables with alignment markers", () => {
|
|
const text = `| Left | Center | Right |
|
|
|:-----|:------:|------:|
|
|
| a | b | c |`;
|
|
|
|
const { tables } = extractMarkdownTables(text);
|
|
|
|
expect(tables).toHaveLength(1);
|
|
expect(tables[0].headers).toEqual(["Left", "Center", "Right"]);
|
|
expect(tables[0].rows).toEqual([["a", "b", "c"]]);
|
|
});
|
|
|
|
it("returns empty when no tables present", () => {
|
|
const text = "Just some plain text without tables.";
|
|
|
|
const { tables, textWithoutTables } = extractMarkdownTables(text);
|
|
|
|
expect(tables).toHaveLength(0);
|
|
expect(textWithoutTables).toBe(text);
|
|
});
|
|
});
|
|
|
|
describe("extractCodeBlocks", () => {
|
|
it("extracts a code block with language", () => {
|
|
const text = `Here is some code:
|
|
|
|
\`\`\`javascript
|
|
const x = 1;
|
|
console.log(x);
|
|
\`\`\`
|
|
|
|
And more text.`;
|
|
|
|
const { codeBlocks, textWithoutCode } = extractCodeBlocks(text);
|
|
|
|
expect(codeBlocks).toHaveLength(1);
|
|
expect(codeBlocks[0].language).toBe("javascript");
|
|
expect(codeBlocks[0].code).toBe("const x = 1;\nconsole.log(x);");
|
|
expect(textWithoutCode).toContain("Here is some code:");
|
|
expect(textWithoutCode).toContain("And more text.");
|
|
expect(textWithoutCode).not.toContain("```");
|
|
});
|
|
|
|
it("extracts a code block without language", () => {
|
|
const text = `\`\`\`
|
|
plain code
|
|
\`\`\``;
|
|
|
|
const { codeBlocks } = extractCodeBlocks(text);
|
|
|
|
expect(codeBlocks).toHaveLength(1);
|
|
expect(codeBlocks[0].language).toBeUndefined();
|
|
expect(codeBlocks[0].code).toBe("plain code");
|
|
});
|
|
|
|
it("extracts multiple code blocks", () => {
|
|
const text = `\`\`\`python
|
|
print("hello")
|
|
\`\`\`
|
|
|
|
Some text
|
|
|
|
\`\`\`bash
|
|
echo "world"
|
|
\`\`\``;
|
|
|
|
const { codeBlocks } = extractCodeBlocks(text);
|
|
|
|
expect(codeBlocks).toHaveLength(2);
|
|
expect(codeBlocks[0].language).toBe("python");
|
|
expect(codeBlocks[1].language).toBe("bash");
|
|
});
|
|
|
|
it("returns empty when no code blocks present", () => {
|
|
const text = "No code here, just text.";
|
|
|
|
const { codeBlocks, textWithoutCode } = extractCodeBlocks(text);
|
|
|
|
expect(codeBlocks).toHaveLength(0);
|
|
expect(textWithoutCode).toBe(text);
|
|
});
|
|
});
|
|
|
|
describe("extractLinks", () => {
|
|
it("extracts markdown links", () => {
|
|
const text = "Check out [Google](https://google.com) and [GitHub](https://github.com).";
|
|
|
|
const { links, textWithLinks } = extractLinks(text);
|
|
|
|
expect(links).toHaveLength(2);
|
|
expect(links[0]).toEqual({ text: "Google", url: "https://google.com" });
|
|
expect(links[1]).toEqual({ text: "GitHub", url: "https://github.com" });
|
|
expect(textWithLinks).toBe("Check out Google and GitHub.");
|
|
});
|
|
|
|
it("handles text without links", () => {
|
|
const text = "No links here.";
|
|
|
|
const { links, textWithLinks } = extractLinks(text);
|
|
|
|
expect(links).toHaveLength(0);
|
|
expect(textWithLinks).toBe(text);
|
|
});
|
|
});
|
|
|
|
describe("stripMarkdown", () => {
|
|
it("strips bold markers", () => {
|
|
expect(stripMarkdown("This is **bold** text")).toBe("This is bold text");
|
|
expect(stripMarkdown("This is __bold__ text")).toBe("This is bold text");
|
|
});
|
|
|
|
it("strips italic markers", () => {
|
|
expect(stripMarkdown("This is *italic* text")).toBe("This is italic text");
|
|
expect(stripMarkdown("This is _italic_ text")).toBe("This is italic text");
|
|
});
|
|
|
|
it("strips strikethrough markers", () => {
|
|
expect(stripMarkdown("This is ~~deleted~~ text")).toBe("This is deleted text");
|
|
});
|
|
|
|
it("strips headers", () => {
|
|
expect(stripMarkdown("# Heading 1")).toBe("Heading 1");
|
|
expect(stripMarkdown("## Heading 2")).toBe("Heading 2");
|
|
expect(stripMarkdown("### Heading 3")).toBe("Heading 3");
|
|
});
|
|
|
|
it("strips blockquotes", () => {
|
|
expect(stripMarkdown("> This is a quote")).toBe("This is a quote");
|
|
expect(stripMarkdown(">This is also a quote")).toBe("This is also a quote");
|
|
});
|
|
|
|
it("removes horizontal rules", () => {
|
|
expect(stripMarkdown("Above\n---\nBelow")).toBe("Above\n\nBelow");
|
|
expect(stripMarkdown("Above\n***\nBelow")).toBe("Above\n\nBelow");
|
|
});
|
|
|
|
it("strips inline code markers", () => {
|
|
expect(stripMarkdown("Use `const` keyword")).toBe("Use const keyword");
|
|
});
|
|
|
|
it("handles complex markdown", () => {
|
|
const input = `# Title
|
|
|
|
This is **bold** and *italic* text.
|
|
|
|
> A quote
|
|
|
|
Some ~~deleted~~ content.`;
|
|
|
|
const result = stripMarkdown(input);
|
|
|
|
expect(result).toContain("Title");
|
|
expect(result).toContain("This is bold and italic text.");
|
|
expect(result).toContain("A quote");
|
|
expect(result).toContain("Some deleted content.");
|
|
expect(result).not.toContain("#");
|
|
expect(result).not.toContain("**");
|
|
expect(result).not.toContain("~~");
|
|
expect(result).not.toContain(">");
|
|
});
|
|
});
|
|
|
|
describe("convertTableToFlexBubble", () => {
|
|
it("creates a receipt-style card for 2-column tables", () => {
|
|
const table = {
|
|
headers: ["Item", "Price"],
|
|
rows: [
|
|
["Apple", "$1"],
|
|
["Banana", "$2"],
|
|
],
|
|
};
|
|
|
|
const bubble = convertTableToFlexBubble(table);
|
|
|
|
expect(bubble.type).toBe("bubble");
|
|
expect(bubble.body).toBeDefined();
|
|
});
|
|
|
|
it("creates a multi-column layout for 3+ column tables", () => {
|
|
const table = {
|
|
headers: ["A", "B", "C"],
|
|
rows: [["1", "2", "3"]],
|
|
};
|
|
|
|
const bubble = convertTableToFlexBubble(table);
|
|
|
|
expect(bubble.type).toBe("bubble");
|
|
expect(bubble.body).toBeDefined();
|
|
});
|
|
|
|
it("replaces empty cells with placeholders", () => {
|
|
const table = {
|
|
headers: ["A", "B"],
|
|
rows: [["", ""]],
|
|
};
|
|
|
|
const bubble = convertTableToFlexBubble(table);
|
|
const body = bubble.body as {
|
|
contents: Array<{ contents?: Array<{ contents?: Array<{ text: string }> }> }>;
|
|
};
|
|
const rowsBox = body.contents[2] as { contents: Array<{ contents: Array<{ text: string }> }> };
|
|
|
|
expect(rowsBox.contents[0].contents[0].text).toBe("-");
|
|
expect(rowsBox.contents[0].contents[1].text).toBe("-");
|
|
});
|
|
|
|
it("strips bold markers and applies weight for fully bold cells", () => {
|
|
const table = {
|
|
headers: ["**Name**", "Status"],
|
|
rows: [["**Alpha**", "OK"]],
|
|
};
|
|
|
|
const bubble = convertTableToFlexBubble(table);
|
|
const body = bubble.body as {
|
|
contents: Array<{ contents?: Array<{ text: string; weight?: string }> }>;
|
|
};
|
|
const headerRow = body.contents[0] as { contents: Array<{ text: string; weight?: string }> };
|
|
const dataRow = body.contents[2] as { contents: Array<{ text: string; weight?: string }> };
|
|
|
|
expect(headerRow.contents[0].text).toBe("Name");
|
|
expect(headerRow.contents[0].weight).toBe("bold");
|
|
expect(dataRow.contents[0].text).toBe("Alpha");
|
|
expect(dataRow.contents[0].weight).toBe("bold");
|
|
});
|
|
});
|
|
|
|
describe("convertCodeBlockToFlexBubble", () => {
|
|
it("creates a code card with language label", () => {
|
|
const block = { language: "typescript", code: "const x = 1;" };
|
|
|
|
const bubble = convertCodeBlockToFlexBubble(block);
|
|
|
|
expect(bubble.type).toBe("bubble");
|
|
expect(bubble.body).toBeDefined();
|
|
|
|
const body = bubble.body as { contents: Array<{ text: string }> };
|
|
expect(body.contents[0].text).toBe("Code (typescript)");
|
|
});
|
|
|
|
it("creates a code card without language", () => {
|
|
const block = { code: "plain code" };
|
|
|
|
const bubble = convertCodeBlockToFlexBubble(block);
|
|
|
|
const body = bubble.body as { contents: Array<{ text: string }> };
|
|
expect(body.contents[0].text).toBe("Code");
|
|
});
|
|
|
|
it("truncates very long code", () => {
|
|
const longCode = "x".repeat(3000);
|
|
const block = { code: longCode };
|
|
|
|
const bubble = convertCodeBlockToFlexBubble(block);
|
|
|
|
const body = bubble.body as { contents: Array<{ contents: Array<{ text: string }> }> };
|
|
const codeText = body.contents[1].contents[0].text;
|
|
expect(codeText.length).toBeLessThan(longCode.length);
|
|
expect(codeText).toContain("...");
|
|
});
|
|
});
|
|
|
|
describe("processLineMessage", () => {
|
|
it("processes text with tables", () => {
|
|
const text = `Here's the data:
|
|
|
|
| Key | Value |
|
|
|-----|-------|
|
|
| a | 1 |
|
|
|
|
Done.`;
|
|
|
|
const result = processLineMessage(text);
|
|
|
|
expect(result.flexMessages).toHaveLength(1);
|
|
expect(result.flexMessages[0].type).toBe("flex");
|
|
expect(result.text).toContain("Here's the data:");
|
|
expect(result.text).toContain("Done.");
|
|
expect(result.text).not.toContain("|");
|
|
});
|
|
|
|
it("processes text with code blocks", () => {
|
|
const text = `Check this code:
|
|
|
|
\`\`\`js
|
|
console.log("hi");
|
|
\`\`\`
|
|
|
|
That's it.`;
|
|
|
|
const result = processLineMessage(text);
|
|
|
|
expect(result.flexMessages).toHaveLength(1);
|
|
expect(result.text).toContain("Check this code:");
|
|
expect(result.text).toContain("That's it.");
|
|
expect(result.text).not.toContain("```");
|
|
});
|
|
|
|
it("processes text with markdown formatting", () => {
|
|
const text = "This is **bold** and *italic* text.";
|
|
|
|
const result = processLineMessage(text);
|
|
|
|
expect(result.text).toBe("This is bold and italic text.");
|
|
expect(result.flexMessages).toHaveLength(0);
|
|
});
|
|
|
|
it("handles mixed content", () => {
|
|
const text = `# Summary
|
|
|
|
Here's **important** info:
|
|
|
|
| Item | Count |
|
|
|------|-------|
|
|
| A | 5 |
|
|
|
|
\`\`\`python
|
|
print("done")
|
|
\`\`\`
|
|
|
|
> Note: Check the link [here](https://example.com).`;
|
|
|
|
const result = processLineMessage(text);
|
|
|
|
// Should have 2 flex messages (table + code)
|
|
expect(result.flexMessages).toHaveLength(2);
|
|
|
|
// Text should be cleaned
|
|
expect(result.text).toContain("Summary");
|
|
expect(result.text).toContain("important");
|
|
expect(result.text).toContain("Note: Check the link here.");
|
|
expect(result.text).not.toContain("#");
|
|
expect(result.text).not.toContain("**");
|
|
expect(result.text).not.toContain("|");
|
|
expect(result.text).not.toContain("```");
|
|
expect(result.text).not.toContain("[here]");
|
|
});
|
|
|
|
it("handles plain text unchanged", () => {
|
|
const text = "Just plain text with no markdown.";
|
|
|
|
const result = processLineMessage(text);
|
|
|
|
expect(result.text).toBe(text);
|
|
expect(result.flexMessages).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("hasMarkdownToConvert", () => {
|
|
it("detects tables", () => {
|
|
const text = `| A | B |
|
|
|---|---|
|
|
| 1 | 2 |`;
|
|
expect(hasMarkdownToConvert(text)).toBe(true);
|
|
});
|
|
|
|
it("detects code blocks", () => {
|
|
const text = "```js\ncode\n```";
|
|
expect(hasMarkdownToConvert(text)).toBe(true);
|
|
});
|
|
|
|
it("detects bold", () => {
|
|
expect(hasMarkdownToConvert("**bold**")).toBe(true);
|
|
});
|
|
|
|
it("detects strikethrough", () => {
|
|
expect(hasMarkdownToConvert("~~deleted~~")).toBe(true);
|
|
});
|
|
|
|
it("detects headers", () => {
|
|
expect(hasMarkdownToConvert("# Title")).toBe(true);
|
|
});
|
|
|
|
it("detects blockquotes", () => {
|
|
expect(hasMarkdownToConvert("> quote")).toBe(true);
|
|
});
|
|
|
|
it("returns false for plain text", () => {
|
|
expect(hasMarkdownToConvert("Just plain text.")).toBe(false);
|
|
});
|
|
});
|