import { describe, expect, it } from "vitest"; import { mdq } from "mdq-cli"; import { blocksToMarkdown, markdownToBlocks, fixMalformedImageBlocks, type CustomEditorBlock, type CustomPartialBlock, } from "./customMarkdownConverter"; import { setFileDisplayUrlResolver } from "./fileDisplayUrl"; const baseProps = { textAlignment: "left" as const, textColor: "default" as const, backgroundColor: "default" as const, }; const cellProps = { backgroundColor: "default" as const, textColor: "default" as const, textAlignment: "left" as const, }; const toPartial = (block: CustomEditorBlock): CustomPartialBlock => ({ type: block.type, props: block.props as any, content: block.content as any, children: block.children?.map(toPartial), }); describe("blocksToMarkdown", () => { it("converts styled paragraphs", () => { const blocks: CustomEditorBlock[] = [ { id: "p1", type: "paragraph", props: baseProps, content: [ { type: "text", text: "Hello ", styles: {} }, { type: "text", text: "world", styles: { bold: true } }, { type: "text", text: "!", styles: { italic: true } }, ], children: [], }, ]; expect(blocksToMarkdown(blocks)).toBe("Hello **world**_!_"); }); it("preserves HTML comments without escaping", () => { const blocks: CustomEditorBlock[] = [ { id: "c1", type: "paragraph", props: baseProps, content: [ { type: "text", text: "", styles: {} }, ], children: [], }, ]; expect(blocksToMarkdown(blocks)).toBe(""); }); it("preserves HTML comments inline among text without escaping stray angle brackets", () => { const blocks: CustomEditorBlock[] = [ { id: "c2", type: "paragraph", props: baseProps, content: [ { type: "text", text: "before after
", styles: {} }, ], children: [], }, ]; expect(blocksToMarkdown(blocks)).toBe("before after
"); }); it("does not escape `<` in plain text (e.g. comparison operators)", () => { const blocks: CustomEditorBlock[] = [ { id: "p_lt", type: "paragraph", props: baseProps, content: [ { type: "text", text: "< 768px is mobile", styles: {} }, ], children: [], }, ]; expect(blocksToMarkdown(blocks)).toBe("< 768px is mobile"); }); it("does not escape `<` in table cells (viewport breakpoints case)", () => { const markdown = [ "| Viewport Width | Layout Expected | Nav Behavior |", "| --- | --- | --- |", "| < 768px | Single column | Hamburger menu |", "| 768px – 1024px | Two column | Collapsed sidebar |", "| > 1024px | Full desktop layout | Full nav visible |", ].join("\n"); const blocks = markdownToBlocks(markdown); const out = blocksToMarkdown(blocks as CustomEditorBlock[]); expect(out).toBe(markdown); expect(out).not.toContain("\\<"); }); it("does not escape markdown chars inside inline code", () => { const blocks: CustomEditorBlock[] = [ { id: "p_code", type: "paragraph", props: baseProps, content: [ { type: "text", text: "**bold**", styles: { code: true } }, ], children: [], }, ]; expect(blocksToMarkdown(blocks)).toBe("`**bold**`"); }); it("does not escape markdown chars inside inline code in a table (syntax/rendered case)", () => { const markdown = [ "| Syntax | Rendered As |", "| --- | --- |", "| `**bold**` | **bold** |", "| `*italic*` | _italic_ |", "| `~~strike~~` | ~~strike~~ |", ].join("\n"); const blocks = markdownToBlocks(markdown); const out = blocksToMarkdown(blocks as CustomEditorBlock[]); expect(out).toBe(markdown); expect(out).not.toMatch(/\\[*_~]/); }); it("does not escape markdown chars inside fenced code blocks", () => { const markdown = [ "```", "**bold** _italic_ ~~strike~~
", "```", ].join("\n"); const blocks = markdownToBlocks(markdown); const out = blocksToMarkdown(blocks as CustomEditorBlock[]); expect(out).toBe(markdown); expect(out).not.toMatch(/\\[*_~<]/); }); it("wraps inline code containing backticks in a longer delimiter", () => { const blocks: CustomEditorBlock[] = [ { id: "p_tick", type: "paragraph", props: baseProps, content: [ { type: "text", text: "a`b", styles: { code: true } }, ], children: [], }, ]; // Backslash escapes do not work inside code spans (CommonMark), so a // literal backtick needs a longer delimiter instead. expect(blocksToMarkdown(blocks)).toBe("``a`b``"); }); it("places bold markers outside leading/trailing spaces", () => { const blocks: CustomEditorBlock[] = [ { id: "1", type: "paragraph", props: baseProps, content: [ { type: "text", text: "some ", styles: {} }, { type: "text", text: " bold ", styles: { bold: true } }, { type: "text", text: " text", styles: {} }, ], children: [], }, ]; expect(blocksToMarkdown(blocks)).toBe("some **bold** text"); }); it("places italic markers outside trailing space", () => { const blocks: CustomEditorBlock[] = [ { id: "1", type: "paragraph", props: baseProps, content: [ { type: "text", text: "word ", styles: { italic: true } }, { type: "text", text: "next", styles: {} }, ], children: [], }, ]; expect(blocksToMarkdown(blocks)).toBe("_word_ next"); }); it("places code backticks outside leading/trailing spaces", () => { const blocks: CustomEditorBlock[] = [ { id: "1", type: "paragraph", props: baseProps, content: [ { type: "text", text: "see ", styles: {} }, { type: "text", text: " code ", styles: { code: true } }, { type: "text", text: " here", styles: {} }, ], children: [], }, ]; expect(blocksToMarkdown(blocks)).toBe("see `code` here"); }); it("returns all-whitespace text unformatted", () => { const blocks: CustomEditorBlock[] = [ { id: "1", type: "paragraph", props: baseProps, content: [ { type: "text", text: "a", styles: {} }, { type: "text", text: " ", styles: { bold: true } }, { type: "text", text: "b", styles: {} }, ], children: [], }, ]; expect(blocksToMarkdown(blocks)).toBe("a b"); }); it("serializes a numbered list", () => { const blocks: CustomEditorBlock[] = [ { id: "n1", type: "numberedListItem", props: { ...baseProps, start: 1 }, content: [{ type: "text", text: "First", styles: {} }], children: [], }, { id: "n2", type: "numberedListItem", props: { ...baseProps, start: 2 }, content: [{ type: "text", text: "Second", styles: {} }], children: [], }, ]; expect(blocksToMarkdown(blocks)).toBe("1. First\n2. Second"); }); it("serializes a custom test step block", () => { const blocks: CustomEditorBlock[] = [ { id: "s1", type: "testStep", props: { stepTitle: "Open the Login page.", stepData: "", expectedResult: "The Login page loads successfully.", listStyle: "bullet", }, content: undefined, children: [], }, { id: "s2", type: "testStep", props: { stepTitle: "Enter a valid username.", stepData: "", expectedResult: "The username is accepted.", listStyle: "bullet", }, content: undefined, children: [], }, ]; expect(blocksToMarkdown(blocks)).toBe( [ "* Open the Login page.", " *Expected result*: The Login page loads successfully.", "* Enter a valid username.", " *Expected result*: The username is accepted.", ].join("\n"), ); }); it("serializes a step with empty title but with stepData", () => { const blocks: CustomEditorBlock[] = [ { id: "s1", type: "testStep", props: { stepTitle: "", stepData: "Navigate to the page", expectedResult: "", listStyle: "bullet", }, content: undefined, children: [], }, ]; expect(blocksToMarkdown(blocks)).toBe( ["* ", " Navigate to the page"].join("\n"), ); }); it("serializes a step with empty title but with expectedResult", () => { const blocks: CustomEditorBlock[] = [ { id: "s1", type: "testStep", props: { stepTitle: "", stepData: "", expectedResult: "Login form visible", listStyle: "bullet", }, content: undefined, children: [], }, ]; expect(blocksToMarkdown(blocks)).toBe( ["* ", " *Expected result*: Login form visible"].join("\n"), ); }); it("drops completely empty step (no title, no data, no expected)", () => { const blocks: CustomEditorBlock[] = [ { id: "s1", type: "testStep", props: { stepTitle: "", stepData: "", expectedResult: "", listStyle: "bullet", }, content: undefined, children: [], }, ]; expect(blocksToMarkdown(blocks)).toBe(""); }); it("serializes a snippet block with prefixed title", () => { const blocks: CustomEditorBlock[] = [ { id: "sn1", type: "snippet", props: { snippetId: "501", snippetTitle: "Open the login page", snippetData: "Navigate to /login", snippetExpectedResult: "Login form renders", }, content: undefined, children: [], }, ]; expect(blocksToMarkdown(blocks)).toBe( [ "", "Navigate to /login", "", ].join("\n"), ); }); it("serializes snippet bodies without duplicating wrapper comments", () => { const blocks: CustomEditorBlock[] = [ { id: "sn2", type: "snippet", props: { snippetId: "777", snippetTitle: "Has inline wrappers", snippetData: [ "", "Line 1", "Line 2", "", ].join("\n"), snippetExpectedResult: "", }, content: undefined, children: [], }, ]; expect(blocksToMarkdown(blocks)).toBe( [ "", "Line 1", "Line 2", "", ].join("\n"), ); }); it("cleans escaped formatting markers when toggling styles repeatedly", () => { const blocks: CustomEditorBlock[] = [ { id: "esc1", type: "paragraph", props: baseProps, content: [{ type: "text", text: "text", styles: { bold: true, italic: true } }], children: [], }, ]; const markdown = blocksToMarkdown(blocks); expect(markdown).toBe("_**text**_"); }); it("keeps inline formatting inside step fields", () => { const blocks: CustomEditorBlock[] = [ { id: "s3", type: "testStep", props: { stepTitle: "**Click** the _Login_ button", stepData: "", expectedResult: "**Success** is shown\nSecond line with underline", listStyle: "bullet", }, content: undefined, children: [], }, ]; expect(blocksToMarkdown(blocks)).toBe( [ "* **Click** the _Login_ button", " *Expected result*: **Success** is shown", " Second line with underline", ].join("\n"), ); }); it("serializes test step with data", () => { const blocks: CustomEditorBlock[] = [ { id: "s4", type: "testStep", props: { stepTitle: "Navigate to login", stepData: "Open browser\nGo to login page", expectedResult: "Login form visible", listStyle: "bullet", }, content: undefined, children: [], }, ]; expect(blocksToMarkdown(blocks)).toBe( [ "* Navigate to login", " Open browser", " Go to login page", " *Expected result*: Login form visible", ].join("\n"), ); }); it("serializes step data containing code fences, blank lines, and images", () => { const blocks: CustomEditorBlock[] = [ { id: "s5", type: "testStep", props: { stepTitle: "Update an order status.", stepData: [ "```", "SQL CREATE bnbmnbm mnbmb mm", "mn,nm nm, m,nm,n,nn,m,", ",n,n,mnm,n asdsad", "asdsadsa", "", "asdsadsadsadsad", "", "asdsadas", "```", "![](/attachments/HMhkVtlDrO.png)", ].join("\n"), expectedResult: "The user receives a real-time notification for the order update.", listStyle: "bullet", }, content: undefined, children: [], }, ]; expect(blocksToMarkdown(blocks)).toBe( [ "* Update an order status.", " ```", " SQL CREATE bnbmnbm mnbmb mm", " mn,nm nm, m,nm,n,nn,m,", " ,n,n,mnm,n asdsad", " asdsadsa", " ", " asdsadsadsadsad", " ", " asdsadas", " ```", " ![](/attachments/HMhkVtlDrO.png)", " *Expected result*: The user receives a real-time notification for the order update.", ].join("\n"), ); }); it("serializes tables", () => { const blocks: CustomEditorBlock[] = [ { id: "tbl1", type: "table", props: { textColor: "default" }, content: { type: "tableContent", columnWidths: [undefined, undefined], headerRows: 1, rows: [ { cells: [ { type: "tableCell", props: cellProps, content: [{ type: "text", text: "Step", styles: {} }], }, { type: "tableCell", props: cellProps, content: [{ type: "text", text: "Expected", styles: {} }], }, ], }, { cells: [ { type: "tableCell", props: cellProps, content: [{ type: "text", text: "Do thing", styles: {} }], }, { type: "tableCell", props: cellProps, content: [{ type: "text", text: "It works", styles: {} }], }, ], }, ], }, children: [], }, ]; expect(blocksToMarkdown(blocks)).toBe( [ "| Step | Expected |", "| --- | --- |", "| Do thing | It works |", ].join("\n"), ); }); it("serializes a blockquote", () => { const blocks: CustomEditorBlock[] = [ { id: "q1", type: "quote", props: { textColor: "default", backgroundColor: "default", textAlignment: "left" }, content: [{ type: "text", text: "Hello world", styles: {} }], children: [], }, ]; expect(blocksToMarkdown(blocks)).toBe("> Hello world"); }); it("round-trips a blockquote without escaping > in content", () => { const markdown = "> Some quoted text"; const blocks = markdownToBlocks(markdown); expect(blocksToMarkdown(blocks as any)).toBe("> Some quoted text"); }); it("serializes table cells containing newlines as
", () => { const blocks: CustomEditorBlock[] = [ { id: "tbl2", type: "table", props: { textColor: "default" }, content: { type: "tableContent", columnWidths: [undefined, undefined], headerRows: 1, rows: [ { cells: [ { type: "tableCell", props: cellProps, content: [{ type: "text", text: "Steps", styles: {} }], }, { type: "tableCell", props: cellProps, content: [{ type: "text", text: "Expected Results", styles: {} }], }, ], }, { cells: [ { type: "tableCell", props: cellProps, content: [{ type: "text", text: "line1\nline2", styles: {} }], }, { type: "tableCell", props: cellProps, content: [{ type: "text", text: "ok", styles: {} }], }, ], }, ], }, children: [], }, ]; expect(blocksToMarkdown(blocks)).toBe( [ "| Steps | Expected Results |", "| --- | --- |", "| line1
line2 | ok |", ].join("\n"), ); }); it("serializes table cell with code style and newline placing br outside backticks", () => { const blocks: CustomEditorBlock[] = [ { id: "tbl-code", type: "table", props: { textColor: "default" }, content: { type: "tableContent", columnWidths: [undefined, undefined], headerRows: 1, rows: [ { cells: [ { type: "tableCell", props: cellProps, content: [{ type: "text", text: "A", styles: {} }], }, { type: "tableCell", props: cellProps, content: [{ type: "text", text: "B", styles: {} }], }, ], }, { cells: [ { type: "tableCell", props: cellProps, content: [ { type: "text", text: "code", styles: { code: true } }, { type: "text", text: "\nnext line", styles: {} }, ], }, { type: "tableCell", props: cellProps, content: [{ type: "text", text: "ok", styles: {} }], }, ], }, ], }, children: [], }, ]; expect(blocksToMarkdown(blocks)).toBe( [ "| A | B |", "| --- | --- |", "| `code`
next line | ok |", ].join("\n"), ); }); it("serializes table cells with styled text and newlines without trapping
inside markers", () => { const blocks: CustomEditorBlock[] = [ { id: "tbl3", type: "table", props: { textColor: "default" }, content: { type: "tableContent", columnWidths: [undefined, undefined], headerRows: 1, rows: [ { cells: [ { type: "tableCell", props: cellProps, content: [{ type: "text", text: "Col A", styles: {} }], }, { type: "tableCell", props: cellProps, content: [{ type: "text", text: "Col B", styles: {} }], }, ], }, { cells: [ { type: "tableCell", props: cellProps, content: [{ type: "text", text: "ok", styles: {} }], }, { type: "tableCell", props: cellProps, content: [ { type: "text", text: "opened\nnewline", styles: { bold: true } }, ], }, ], }, ], }, children: [], }, ]; expect(blocksToMarkdown(blocks)).toBe( [ "| Col A | Col B |", "| --- | --- |", "| ok | **opened**
**newline** |", ].join("\n"), ); }); it("parses a test step with inline image in the title, moving the image to step data", () => { const markdown = [ "## Steps", "* asdsadsad aaaaa asd ![](https://placehold.co/600x400?text=Uploaded+1763329962213)", ].join("\n"); const blocks = markdownToBlocks(markdown); const step = blocks.find((b) => b.type === "testStep") as any; expect(step).toBeTruthy(); expect(step.props.stepTitle).toBe("asdsadsad aaaaa asd !"); expect(step.props.stepData).toBe("![](https://placehold.co/600x400?text=Uploaded+1763329962213)"); expect(step.props.expectedResult).toBe(""); }); }); describe("markdownToBlocks", () => { it("parses nested blockquote by flattening to single-level quote", () => { const markdown = ">> The Witch bade her clean the pots."; const blocks = markdownToBlocks(markdown); expect(blocks).toEqual([ { type: "quote", props: { textColor: "default", backgroundColor: "default", textAlignment: "left" }, content: [{ type: "text", text: "The Witch bade her clean the pots.", styles: {} }], children: [], }, ]); }); it("parses spaced nested blockquote > > text", () => { const markdown = "> > The Witch bade her clean the pots."; const blocks = markdownToBlocks(markdown); expect(blocks).toEqual([ { type: "quote", props: { textColor: "default", backgroundColor: "default", textAlignment: "left" }, content: [{ type: "text", text: "The Witch bade her clean the pots.", styles: {} }], children: [], }, ]); }); it("round-trips nested blockquote without \\> escaping", () => { const markdown = [ "> Dorothy followed her through many of the beautiful rooms in her castle.", ">> The Witch bade her clean the pots.", ].join("\n"); const blocks = markdownToBlocks(markdown); const result = blocksToMarkdown(blocks as any); expect(result).toBe( [ "> Dorothy followed her through many of the beautiful rooms in her castle.", "> The Witch bade her clean the pots.", ].join("\n"), ); }); it("parses test steps and test cases", () => { const markdown = [ "### Steps", "", "* Open the Login page.", " *Expected*: The Login page loads successfully.", ].join("\n"); const blocks = markdownToBlocks(markdown); const stepBlocks = blocks.filter((b) => b.type === "testStep"); expect(stepBlocks).toEqual([ { type: "testStep", props: { stepTitle: "Open the Login page.", stepData: "", expectedResult: "The Login page loads successfully.", listStyle: "bullet", }, children: [], }, ]); }); it("parses sub-bulleted *Expected*: lines as the step's expected result", () => { const markdown = [ "### Steps", "", "* Navigate to the “Transfer Funds” page.", " * *Expected*: The Transfer Funds page loads, showing fields for source account, destination account, and amount.", "", "* Select the user’s own source account from the dropdown list.", " * *Expected*: The selected account is displayed and its current balance is shown.", ].join("\n"); const blocks = markdownToBlocks(markdown); const stepBlocks = blocks.filter((b) => b.type === "testStep"); expect(stepBlocks).toEqual([ { type: "testStep", props: { stepTitle: "Navigate to the “Transfer Funds” page.", stepData: "", expectedResult: "The Transfer Funds page loads, showing fields for source account, destination account, and amount.\n", listStyle: "bullet", }, children: [], }, { type: "testStep", props: { stepTitle: "Select the user’s own source account from the dropdown list.", stepData: "", expectedResult: "The selected account is displayed and its current balance is shown.", listStyle: "bullet", }, children: [], }, ]); }); it("parses sub-bulleted **Expected**: (bold) lines as the step's expected result", () => { const markdown = [ "### Steps", "", "* Open the Login page.", " * **Expected**: The Login page loads successfully.", ].join("\n"); const blocks = markdownToBlocks(markdown); const stepBlocks = blocks.filter((b) => b.type === "testStep"); expect(stepBlocks).toEqual([ { type: "testStep", props: { stepTitle: "Open the Login page.", stepData: "", expectedResult: "The Login page loads successfully.", listStyle: "bullet", }, children: [], }, ]); }); it("round-trips sub-bulleted *Expected*: lines through the canonical serialized form", () => { const markdown = [ "### Steps", "", "* Navigate to the “Transfer Funds” page.", " * *Expected*: The Transfer Funds page loads.", ].join("\n"); const firstPass = markdownToBlocks(markdown); const serialized = blocksToMarkdown(firstPass as CustomEditorBlock[]); const secondPass = markdownToBlocks(serialized); const firstSteps = firstPass.filter((b) => b.type === "testStep"); const secondSteps = secondPass.filter((b) => b.type === "testStep"); expect(secondSteps).toEqual(firstSteps); }); it("parses a step with empty title but with step data", () => { const markdown = ["### Steps", "", "* ", " Navigate to the page"].join("\n"); const blocks = markdownToBlocks(markdown); const stepBlocks = blocks.filter((b) => b.type === "testStep"); expect(stepBlocks).toEqual([ { type: "testStep", props: { stepTitle: "", stepData: "Navigate to the page", expectedResult: "", listStyle: "bullet", }, children: [], }, ]); }); it("round-trips a title-less step with data", () => { const blocks: CustomEditorBlock[] = [ { id: "h1", type: "heading", props: { level: 3, textColor: "default", backgroundColor: "default", textAlignment: "left" as const }, content: [{ type: "text" as const, text: "Steps", styles: {} }], children: [], }, { id: "s1", type: "testStep", props: { stepTitle: "", stepData: "Open the browser", expectedResult: "Page loads", listStyle: "bullet", }, content: undefined, children: [], }, ]; const md = blocksToMarkdown(blocks); const parsed = markdownToBlocks(md); const stepBlocks = parsed.filter((b) => b.type === "testStep"); expect(stepBlocks).toEqual([ { type: "testStep", props: { stepTitle: "", stepData: "Open the browser", expectedResult: "Page loads", listStyle: "bullet", }, children: [], }, ]); }); it("parses snippet markdown into snippet blocks", () => { const markdown = [ "### Steps", "", "", "Run the seeder", "", ].join("\n"); const blocks = markdownToBlocks(markdown); const snippetBlocks = blocks.filter((b) => b.type === "snippet"); expect(snippetBlocks).toEqual([ { type: "snippet", props: { snippetId: "501", snippetTitle: "", snippetData: "Run the seeder", snippetExpectedResult: "", }, children: [], }, ]); }); it("parses snippet markdown with space between # and ID", () => { const markdown = [ "### Steps", "", "", "* Fill `` with correct registered email", "* Verify that the update to the target is successful", "", ].join("\n"); const blocks = markdownToBlocks(markdown); const snippetBlocks = blocks.filter((b) => b.type === "snippet"); expect(snippetBlocks).toEqual([ { type: "snippet", props: { snippetId: "22289", snippetTitle: "", snippetData: "* Fill `` with correct registered email\n* Verify that the update to the target is successful", snippetExpectedResult: "", }, children: [], }, ]); }); it("parses snippet bodies and ignores nested snippet markers", () => { const markdown = [ "### Steps", "", "", "Prep DB", "", "Do not keep this marker", "", "", ].join("\n"); const blocks = markdownToBlocks(markdown); const snippetBlocks = blocks.filter((b) => b.type === "snippet"); expect(snippetBlocks).toEqual([ { type: "snippet", props: { snippetId: "888", snippetTitle: "", snippetData: "Prep DB\nDo not keep this marker", snippetExpectedResult: "", }, children: [], }, ]); const roundTrip = blocksToMarkdown([ { id: "sn888", type: "snippet", props: { snippetId: "888", snippetTitle: "", snippetData: "Prep DB\nDo not keep this marker", snippetExpectedResult: "", }, content: undefined, children: [], }, ]); expect(roundTrip).toBe( [ "", "Prep DB", "Do not keep this marker", "", ].join("\n"), ); }); it("parses step lists with inline expected results label", () => { const markdown = [ "## Test Description: Real-time notifications (chat, order updates, file received)", "", "This test case verifies the functionality of real-time notifications for chat messages, order updates, and file receipts within the application.", "", "### Preconditions", "* The user is logged into the application.", "* The user has the necessary permissions to receive notifications.", "* The application is configured to send real-time notifications.", "", "### Steps", "", "* Step 1: Send a chat message to the user.", "**Expected**: The user receives a real-time notification for the chat message.", "* Step 2: Update an order status.", "**Expected**: The user receives a real-time notification for the order update.", "* Step 3: Send a file to the user.", "**Expected**: The user receives a real-time notification for the file received.", "* Step 4: Verify that the notifications are displayed correctly in the application's notification panel.", "**Expected**: All notifications (chat message, order update, file received) are listed in the notification panel with the correct information (e.g., timestamp, message content).", "", "### Postconditions", "* The user has received and viewed the notifications.", "* The application continues to function as expected after receiving and processing the notifications.", ].join("\n"); const blocks = markdownToBlocks(markdown); const stepBlocks = blocks.filter((block) => block.type === "testStep"); // Preconditions and Postconditions bullets are now parsed as bulletListItem (not testStep) // Only items under the "Steps" heading with Expected markers remain as testSteps expect(stepBlocks).toEqual([ { type: "testStep", props: { stepTitle: "Step 1: Send a chat message to the user.", stepData: "", expectedResult: "The user receives a real-time notification for the chat message.", listStyle: "bullet", }, children: [], }, { type: "testStep", props: { stepTitle: "Step 2: Update an order status.", stepData: "", expectedResult: "The user receives a real-time notification for the order update.", listStyle: "bullet", }, children: [], }, { type: "testStep", props: { stepTitle: "Step 3: Send a file to the user.", stepData: "", expectedResult: "The user receives a real-time notification for the file received.", listStyle: "bullet", }, children: [], }, { type: "testStep", props: { stepTitle: "Step 4: Verify that the notifications are displayed correctly in the application's notification panel.", stepData: "", expectedResult: "All notifications (chat message, order update, file received) are listed in the notification panel with the correct information (e.g., timestamp, message content).\n", listStyle: "bullet", }, children: [], }, ]); }); it("parses step data containing code fences, blank lines, and images", () => { const markdown = [ "### Steps", "", "* Step 2: Update an order status.", " ```", " SQL CREATE bnbmnbm mnbmb mm", " mn,nm nm, m,nm,n,nn,m,", " ,n,n,mnm,n asdsad", " asdsadsa", " ", " asdsadsadsadsad", " ", " asdsadas", " ```", " ![](/attachments/HMhkVtlDrO.png)", "**Expected**: The user receives a real-time notification for the order update.", ].join("\n"); const expectedData = [ "```", "SQL CREATE bnbmnbm mnbmb mm", "mn,nm nm, m,nm,n,nn,m,", ",n,n,mnm,n asdsad", "asdsadsa", "", "asdsadsadsadsad", "", "asdsadas", "```", "![](/attachments/HMhkVtlDrO.png)", ].join("\n"); const blocks = markdownToBlocks(markdown); const stepBlocks = blocks.filter((b) => b.type === "testStep"); expect(stepBlocks).toEqual([ { type: "testStep", props: { stepTitle: "Step 2: Update an order status.", stepData: expectedData, expectedResult: "The user receives a real-time notification for the order update.", listStyle: "bullet", }, children: [], }, ]); const markdownRoundTrip = blocksToMarkdown([ { id: "step2", type: "testStep", props: { stepTitle: "Step 2: Update an order status.", stepData: expectedData, expectedResult: "The user receives a real-time notification for the order update.", listStyle: "bullet", }, content: undefined, children: [], }, ]); expect(markdownRoundTrip).toBe( [ "* Step 2: Update an order status.", " ```", " SQL CREATE bnbmnbm mnbmb mm", " mn,nm nm, m,nm,n,nn,m,", " ,n,n,mnm,n asdsad", " asdsadsa", " ", " asdsadsadsadsad", " ", " asdsadas", " ```", " ![](/attachments/HMhkVtlDrO.png)", " *Expected result*: The user receives a real-time notification for the order update.", ].join("\n"), ); }); it("does not escape dots inside fenced code blocks in step data", () => { const stepData = [ "```", "curl 'https://stable.testomat.io/api/runs/54?page=1&entry=' \\", " -H 'accept-language: en-US,en;q=0.9' \\", " -b 'gclau=1.1.1681594017.1781077572;'", "```", ].join("\n"); const markdown = blocksToMarkdown([ { id: "step1", type: "testStep", props: { // The ordered marker turns dot escaping on for the whole block. stepTitle: "1. Run the request.", stepData, expectedResult: "", listStyle: "bullet", }, content: undefined, children: [], }, ]); expect(markdown).toBe( [ // Title outside the fence is still escaped. "* 1\\. Run the request\\.", // Dots inside the fence stay literal. " ```", " curl 'https://stable.testomat.io/api/runs/54?page=1&entry=' \\", " -H 'accept-language: en-US,en;q=0.9' \\", " -b 'gclau=1.1.1681594017.1781077572;'", " ```", ].join("\n"), ); // Round-trip stays stable. const roundTrip = blocksToMarkdown( markdownToBlocks(["### Steps", "", markdown].join("\n")) as CustomEditorBlock[], ); expect(roundTrip).toContain( " curl 'https://stable.testomat.io/api/runs/54?page=1&entry=' \\", ); expect(roundTrip).toContain(" -b 'gclau=1.1.1681594017.1781077572;'"); }); it("does not escape dots inside inline code in step data", () => { const markdown = blocksToMarkdown([ { id: "step1", type: "testStep", props: { // The ordered marker turns dot escaping on for the whole block. stepTitle: "1. Run the request.", stepData: "`curl https://example.com/api`", expectedResult: "", listStyle: "bullet", }, content: undefined, children: [], }, ]); expect(markdown).toBe( [ // Title outside code is still escaped. "* 1\\. Run the request\\.", // Dots inside the inline code span stay literal. " `curl https://example.com/api`", ].join("\n"), ); }); it("does not escape dots when a fenced block opens in the title and the body lands in step data", () => { // The step editor splits a multi-line code block: the opening ``` becomes // the title and the body + closing ``` become the step data. const markdown = blocksToMarkdown([ { id: "step1", type: "testStep", props: { stepTitle: "```", stepData: [ "curl --location 'https://example.com.ua' \\", "--data-raw '{ \"method\": \"url.method\" }'", "```", ].join("\n"), expectedResult: "", listStyle: "bullet", }, content: undefined, children: [], }, ]); expect(markdown).toBe( [ "* ```", // Dots inside the fence stay literal even though it opened in the title. " curl --location 'https://example.com.ua' \\", " --data-raw '{ \"method\": \"url.method\" }'", " ```", ].join("\n"), ); }); it("leaves dots alone when the step carries no ordered list marker", () => { const markdown = blocksToMarkdown([ { id: "step1", type: "testStep", props: { stepTitle: "Open the Login page.", stepData: "Version 4.2 of the app.", expectedResult: "The page loads. See report.png", listStyle: "bullet", }, content: undefined, children: [], }, ]); expect(markdown).not.toContain("\\."); expect(markdown).toBe( [ "* Open the Login page.", " Version 4.2 of the app.", " *Expected result*: The page loads. See report.png", ].join("\n"), ); }); it("does not treat a mid-sentence number followed by a dot as an ordered list marker", () => { const markdown = blocksToMarkdown([ { id: "step1", type: "testStep", props: { // Markdown only reads an ordered marker at the start of a line, so // "200. " inside a sentence cannot become a list. stepTitle: "Check the output.", stepData: "The build takes 1. 5 minutes on CI.", expectedResult: "Response is 200. See report.png", listStyle: "bullet", }, content: undefined, children: [], }, ]); expect(markdown).not.toContain("\\."); expect(markdown).toBe( [ "* Check the output.", " The build takes 1. 5 minutes on CI.", " *Expected result*: Response is 200. See report.png", ].join("\n"), ); }); it("escapes every dot of the block when step data carries an ordered list marker", () => { const markdown = blocksToMarkdown([ { id: "step1", type: "testStep", props: { stepTitle: "Follow the wizard.", stepData: ["1. Pick a plan", "2. Confirm"].join("\n"), expectedResult: "The wizard closes.", listStyle: "bullet", }, content: undefined, children: [], }, ]); // The marker sits in the step data, but the gate covers the whole block so // no field ends up half escaped. expect(markdown).toBe( [ "* Follow the wizard\\.", " 1\\. Pick a plan", " 2\\. Confirm", " *Expected result*: The wizard closes\\.", ].join("\n"), ); }); it("escapes every dot of the block when only the expected result carries an ordered list marker", () => { const markdown = blocksToMarkdown([ { id: "step1", type: "testStep", props: { stepTitle: "Open the report.", stepData: "", expectedResult: "Two rows appear:\n1. Total\n2. Average", listStyle: "bullet", }, content: undefined, children: [], }, ]); expect(markdown).toBe( [ "* Open the report\\.", " *Expected result*: Two rows appear:", " 1\\. Total", " 2\\. Average", ].join("\n"), ); }); describe("expected result that starts with a block", () => { const stepWithExpected = (id: string, stepTitle: string, expectedResult: string) => ({ id, type: "testStep", props: { stepTitle, stepData: "", expectedResult, listStyle: "bullet" }, content: undefined, children: [], }); // mdq selects top-level list items only, so a step's nested blocks are // queried by re-parsing the item body without its marker line. const stepBody = (markdown: string, stepIndex: number) => mdq( mdq(markdown).section("Steps").item().at(stepIndex).text().split("\n").slice(1).join("\n"), ); it("puts a bullet list on its own line under the label", () => { const markdown = blocksToMarkdown([ stepWithExpected("step1", "step", "- expected result 1\n- expected result 2"), stepWithExpected("step2", "step number 2", ""), ] as CustomEditorBlock[]); expect(markdown).toBe( [ "* step", " *Expected result*:", " - expected result 1", " - expected result 2", "* step number 2", ].join("\n"), ); }); it("renders as one nested list per step for a CommonMark parser", () => { const markdown = [ "### Steps", "", blocksToMarkdown([ stepWithExpected("step1", "step", "- expected result 1\n- expected result 2"), stepWithExpected("step2", "step number 2", ""), ] as CustomEditorBlock[]), ].join("\n"); expect(mdq(markdown).section("Steps").item().count()).toBe(2); const body = stepBody(markdown, 0); expect(body.list().count()).toBe(1); expect(body.item().each().map((item) => item.text().trim())).toEqual([ "- expected result 1", "- expected result 2", ]); }); it("keeps a fenced code block out of the label line", () => { const markdown = blocksToMarkdown([ stepWithExpected("step1", "Run the script", "```\nok\n```"), ] as CustomEditorBlock[]); expect(markdown).toBe( ["* Run the script", " *Expected result*:", " ```", " ok", " ```"].join("\n"), ); expect(stepBody(["### Steps", "", markdown].join("\n"), 0).code().count()).toBe(1); }); it("round-trips the list back into the expected result", () => { const expectedResult = "- expected result 1\n- expected result 2"; const markdown = blocksToMarkdown([ stepWithExpected("step1", "step", expectedResult), ] as CustomEditorBlock[]); const blocks = markdownToBlocks(["### Steps", "", markdown].join("\n")); const step = blocks.find((block) => block.type === "testStep"); expect((step?.props as { expectedResult?: string }).expectedResult).toBe(expectedResult); }); it("still parses the legacy form with the first item glued to the label", () => { const blocks = markdownToBlocks( [ "### Steps", "* step", " *Expected result*: - expected result 1", " - expected result 2", "* step number 2", ].join("\n"), ); const steps = blocks.filter((block) => block.type === "testStep"); expect(steps).toHaveLength(2); expect((steps[0].props as { expectedResult?: string }).expectedResult).toBe("- expected result 1\n- expected result 2"); }); }); it("does not escape dots when the only ordered list marker sits inside code", () => { const markdown = blocksToMarkdown([ { id: "step1", type: "testStep", props: { // "1. " inside a fence never renders as a list, so it must not switch // escaping on for the rest of the block. stepTitle: "Check the output.", stepData: ["```", "1. first line of output", "```"].join("\n"), expectedResult: "The list is printed.", listStyle: "bullet", }, content: undefined, children: [], }, ]); expect(markdown).not.toContain("\\."); expect(markdown).toBe( [ "* Check the output.", " ```", " 1. first line of output", " ```", " *Expected result*: The list is printed.", ].join("\n"), ); }); it("keeps a step with an ordered list marker in step data as a single block across a round-trip", () => { const markdown = [ "### Steps", "", "* Follow the wizard\\.", " 1\\. Pick a plan", " 2\\. Confirm", ].join("\n"); const blocks = markdownToBlocks(markdown) as CustomEditorBlock[]; const steps = blocks.filter((block) => block.type === "testStep"); expect(steps).toHaveLength(1); expect(steps[0].props).toMatchObject({ stepData: "1. Pick a plan\n2. Confirm" }); // Escaping is reproduced identically, so saving twice does not churn. expect(blocksToMarkdown(blocks)).toBe(markdown); }); it("does not include content after a blank line in step data", () => { const markdown = [ "### Steps", "", "* step", " expected", "", " ![](http://localhost:3000/attachments/mlbix3nOJa.png =303x*)", ].join("\n"); const blocks = markdownToBlocks(markdown); const stepBlocks = blocks.filter((b) => b.type === "testStep"); expect(stepBlocks).toHaveLength(1); expect(stepBlocks[0].props).toMatchObject({ stepTitle: "step", stepData: "expected", expectedResult: "", }); }); it("does not include a file block after a blank line in step with expected result", () => { const markdown = [ "### Steps", "", "* Open the Login page.", " *Expected*: The Login page loads successfully.", "", "[![report.pdf](/images/file-type-icons/pdf.svg)](https://example.com/file.pdf)", ].join("\n"); const blocks = markdownToBlocks(markdown); const stepBlocks = blocks.filter((b) => b.type === "testStep"); const fileBlocks = blocks.filter((b) => b.type === "file"); expect(stepBlocks).toHaveLength(1); expect(stepBlocks[0].props).toMatchObject({ stepTitle: "Open the Login page.", stepData: "", }); expect(((stepBlocks[0].props as any).expectedResult ?? "").trim()).toBe( "The Login page loads successfully.", ); expect(fileBlocks).toHaveLength(1); expect((fileBlocks[0].props as any).url).toBe("https://example.com/file.pdf"); expect((fileBlocks[0].props as any).name).toBe("report.pdf"); }); it("does not include a file block after a blank line in step with step data", () => { const markdown = [ "### Steps", "", "* Open the Login page.", " Enter credentials", "", "[![report.pdf](/images/file-type-icons/pdf.svg)](https://example.com/file.pdf)", ].join("\n"); const blocks = markdownToBlocks(markdown); const stepBlocks = blocks.filter((b) => b.type === "testStep"); const fileBlocks = blocks.filter((b) => b.type === "file"); expect(stepBlocks).toHaveLength(1); expect(stepBlocks[0].props).toMatchObject({ stepTitle: "Open the Login page.", stepData: "Enter credentials", expectedResult: "", }); expect(fileBlocks).toHaveLength(1); expect((fileBlocks[0].props as any).url).toBe("https://example.com/file.pdf"); expect((fileBlocks[0].props as any).name).toBe("report.pdf"); }); it("does not include a file block after a blank line in step with title only", () => { const markdown = [ "### Steps", "", "* Open the Login page.", "", "[![report.pdf](/images/file-type-icons/pdf.svg)](https://example.com/file.pdf)", ].join("\n"); const blocks = markdownToBlocks(markdown); const stepBlocks = blocks.filter((b) => b.type === "testStep"); const fileBlocks = blocks.filter((b) => b.type === "file"); expect(stepBlocks).toHaveLength(1); expect(stepBlocks[0].props).toMatchObject({ stepTitle: "Open the Login page.", stepData: "", expectedResult: "", }); expect(fileBlocks).toHaveLength(1); expect((fileBlocks[0].props as any).url).toBe("https://example.com/file.pdf"); expect((fileBlocks[0].props as any).name).toBe("report.pdf"); }); it("parses bullet lists written with asterisk markers", () => { const markdown = [ "### Preconditions", "", "* The user is logged into the application.", "* The user has the necessary permissions to receive notifications.", "* The application is configured to send real-time notifications.", ].join("\n"); expect(markdownToBlocks(markdown)).toEqual([ { type: "heading", props: { ...baseProps, level: 3 }, content: [{ type: "text", text: "Preconditions", styles: {} }], children: [], }, { type: "paragraph", content: [], children: [], }, { type: "bulletListItem", props: baseProps, content: [{ type: "text", text: "The user is logged into the application.", styles: {} }], children: [], }, { type: "bulletListItem", props: baseProps, content: [{ type: "text", text: "The user has the necessary permissions to receive notifications.", styles: {} }], children: [], }, { type: "bulletListItem", props: baseProps, content: [{ type: "text", text: "The application is configured to send real-time notifications.", styles: {} }], children: [], }, ]); }); it("handles nested lists with uneven indentation", () => { const markdown = [ "### Requirements", "", "* Verify that each individual unit test completes in ≤ 50 ms (target) and never exceeds 200 ms (hard limit).", "", "### Instructions", "", "1. Execute the full unit test suite with a timer wrapper.", " * Each individual test case.", " * Each test file.", "2. Collect timing data for the security-critical modules.", ].join("\n"); const blocks = markdownToBlocks(markdown); const numbered = blocks.filter((block) => block.type === "numberedListItem"); expect(numbered).not.toHaveLength(0); const nestedChildren = numbered.flatMap((block) => block.children ?? []); expect(nestedChildren.some((child) => child.type === "bulletListItem")).toBe(true); }); it("parses a uniformly indented list as a flat top-level list", () => { const markdown = [ "# Requirements", "", " * User has an active account on the platform.", " * User has sufficient funds in the source account.", " * QR code contains valid transfer details and is scannable.", " * The device has camera access and QR scanning capability.", " * The user is authenticated and authorized to perform transfers.", ].join("\n"); const blocks = markdownToBlocks(markdown); const bulletItems = blocks.filter((b) => b.type === "bulletListItem"); expect(bulletItems).toHaveLength(5); for (const item of bulletItems) { expect(item.children ?? []).toEqual([]); } }); it("does not freeze on indented list items without a parent", () => { const markdown = [ "### Requirements", "", " * The system should log in the user {{username}} with password ${password} successfully.", "", " ### Steps", "", " * Open login page", " *Expected*: The main page is opened", " * Enter username {{username}} and password ${password}", " * Click login button", " * Verify user is redirected to the dashboard", ].join("\n"); const blocks = markdownToBlocks(markdown); expect(blocks.length).toBeGreaterThan(0); const steps = blocks.filter((b) => b.type === "testStep"); expect(steps).toHaveLength(4); expect((steps[0].props as any).stepTitle).toBe("Open login page"); expect((steps[0].props as any).expectedResult).toBe("The main page is opened"); expect((steps[1].props as any).stepTitle).toBe("Enter username {{username}} and password ${password}"); expect((steps[2].props as any).stepTitle).toBe("Click login button"); expect((steps[3].props as any).stepTitle).toBe("Verify user is redirected to the dashboard"); }); it("parses expected result prefixes with emphasis", () => { const markdown = [ "### Steps", "", "* Open the form.", " **Expected:** The form opens.", " Expected: Fields are empty.", ].join("\n"); const blocks = markdownToBlocks(markdown); const stepBlocks = blocks.filter((b) => b.type === "testStep"); expect(stepBlocks).toEqual([ { type: "testStep", props: { stepTitle: "Open the form.", stepData: "", expectedResult: "The form opens.\nFields are empty.", listStyle: "bullet", }, children: [], }, ]); }); it("parses test step with data", () => { const markdown = [ "### Steps", "", "* Navigate to login", " Open browser", " Go to login page", " *Expected*: Login form visible", ].join("\n"); const blocks = markdownToBlocks(markdown); const stepBlocks = blocks.filter((b) => b.type === "testStep"); expect(stepBlocks).toEqual([ { type: "testStep", props: { stepTitle: "Navigate to login", stepData: "Open browser\nGo to login page", expectedResult: "Login form visible", listStyle: "bullet", }, children: [], }, ]); }); it("parses unindented step data between the title and expected result", () => { const markdown = [ "### Steps", "", "* Prepare test fixtures", "Collect user accounts from staging.", "Reset passwords for all test accounts.", "*Expected*: Test accounts are ready for execution.", ].join("\n"); const blocks = markdownToBlocks(markdown); const stepBlocks = blocks.filter((b) => b.type === "testStep"); expect(stepBlocks).toEqual([ { type: "testStep", props: { stepTitle: "Prepare test fixtures", stepData: "Collect user accounts from staging.\nReset passwords for all test accounts.", expectedResult: "Test accounts are ready for execution.", listStyle: "bullet", }, children: [], }, ]); }); it("parses expected result containing a markdown image", () => { const markdown = [ "### Steps", "", "* Display the generated report.", " *Expected*: ![](/attachments/report.png)", ].join("\n"); const blocks = markdownToBlocks(markdown); const stepBlocks = blocks.filter((b) => b.type === "testStep"); expect(stepBlocks).toEqual([ { type: "testStep", props: { stepTitle: "Display the generated report.", stepData: "", expectedResult: "![](/attachments/report.png)", listStyle: "bullet", }, children: [], }, ]); const markdownRoundTrip = blocksToMarkdown([ { id: "step-image", type: "testStep", props: { stepTitle: "Display the generated report.", stepData: "", expectedResult: "![](/attachments/report.png)", listStyle: "bullet", }, content: undefined, children: [], }, ]); expect(markdownRoundTrip).toBe( [ "* Display the generated report.", " *Expected result*: ![](/attachments/report.png)", ].join("\n"), ); }); it("parses expected result with short expected label and image", () => { const markdown = [ "### Steps", "", "* Should open login screen", " *Expected*: Login should look like this", " ![](/login.png)", ].join("\n"); const blocks = markdownToBlocks(markdown); const stepBlocks = blocks.filter((b) => b.type === "testStep"); expect(stepBlocks).toEqual([ { type: "testStep", props: { stepTitle: "Should open login screen", stepData: "", expectedResult: "Login should look like this\n![](/login.png)", listStyle: "bullet", }, children: [], }, ]); const roundTrip = blocksToMarkdown([ { id: "step-login", type: "testStep", props: { stepTitle: "Should open login screen", stepData: "", expectedResult: "Login should look like this\n![](/login.png)", listStyle: "bullet", }, content: undefined, children: [], }, ]); expect(roundTrip).toBe( [ "* Should open login screen", " *Expected result*: Login should look like this", " ![](/login.png)", ].join("\n"), ); }); it("parses steps under a Steps heading even when expected results are missing", () => { const markdown = [ "### Steps", "", "* Pass onboarding as mobile user", "* Navigate to More tab -≻ My Profile -≻ Log into the app with user from preconditions", " *Expected:* Upsell SS screen is displayed", "* Close SS", " *Expected:* My Course and More tab are displayed", ].join("\n"); const blocks = markdownToBlocks(markdown); const stepBlocks = blocks.filter((block) => block.type === "testStep"); expect(stepBlocks).toEqual([ { type: "testStep", props: { stepTitle: "Pass onboarding as mobile user", stepData: "", expectedResult: "", listStyle: "bullet", }, children: [], }, { type: "testStep", props: { stepTitle: "Navigate to More tab -≻ My Profile -≻ Log into the app with user from preconditions", stepData: "", expectedResult: "Upsell SS screen is displayed", listStyle: "bullet", }, children: [], }, { type: "testStep", props: { stepTitle: "Close SS", stepData: "", expectedResult: "My Course and More tab are displayed", listStyle: "bullet", }, children: [], }, ]); }); it("parses steps under a 'Step' heading (singular)", () => { const markdown = ["## Step", "", "* Open the app", "* Click login"].join("\n"); const blocks = markdownToBlocks(markdown); const stepBlocks = blocks.filter((block) => block.type === "testStep"); expect(stepBlocks).toHaveLength(2); expect(stepBlocks[0].props).toMatchObject({ stepTitle: "Open the app" }); expect(stepBlocks[1].props).toMatchObject({ stepTitle: "Click login" }); }); it("parses steps under a 'Step(s)' heading", () => { const markdown = ["# Step(s)", "", "1. First step", "2. Second step"].join("\n"); const blocks = markdownToBlocks(markdown); const stepBlocks = blocks.filter((block) => block.type === "testStep"); expect(stepBlocks).toHaveLength(2); expect(stepBlocks[0].props).toMatchObject({ stepTitle: "First step", listStyle: "ordered" }); expect(stepBlocks[1].props).toMatchObject({ stepTitle: "Second step", listStyle: "ordered" }); }); it("serializes a new ordered step appended to a numbered list as N.", () => { const orderedSteps: CustomEditorBlock[] = [ { id: "s1", type: "testStep", props: { stepTitle: "First step", stepData: "", expectedResult: "", listStyle: "ordered" }, content: undefined as any, children: [], } as any, { id: "s2", type: "testStep", props: { stepTitle: "Second step", stepData: "", expectedResult: "", listStyle: "ordered" }, content: undefined as any, children: [], } as any, { id: "s3", type: "testStep", props: { stepTitle: "Newly added step", stepData: "", expectedResult: "", listStyle: "ordered" }, content: undefined as any, children: [], } as any, ]; expect(blocksToMarkdown(orderedSteps)).toBe( ["1. First step", "2. Second step", "3. Newly added step"].join("\n"), ); }); it("parses steps under an h4 'step' heading (lowercase)", () => { const markdown = ["#### step", "", "* Do something"].join("\n"); const blocks = markdownToBlocks(markdown); const stepBlocks = blocks.filter((block) => block.type === "testStep"); expect(stepBlocks).toHaveLength(1); expect(stepBlocks[0].props).toMatchObject({ stepTitle: "Do something" }); }); it("parses steps under a 'Step:' heading with trailing colon", () => { const markdown = ["### Step:", "", "* Verify output"].join("\n"); const blocks = markdownToBlocks(markdown); const stepBlocks = blocks.filter((block) => block.type === "testStep"); expect(stepBlocks).toHaveLength(1); expect(stepBlocks[0].props).toMatchObject({ stepTitle: "Verify output" }); }); it("handles multiple steps with expected results without extra asterisks", () => { const markdown = [ "### Preconditions", "", "User on the Sign In with Email Screen", "", "### Steps", "", "* Existing email + invalid password", " *Expected*: 'Oops, wrong email or password' is displayed", "* Not existing email + valid password", " *Expected*: 'Oops, wrong email or password' is displayed", ].join("\n"); const blocks = markdownToBlocks(markdown); const stepBlocks = blocks.filter((block) => block.type === "testStep"); expect(stepBlocks).toHaveLength(2); expect(stepBlocks[0]).toEqual({ type: "testStep", props: { stepTitle: "Existing email + invalid password", stepData: "", expectedResult: "'Oops, wrong email or password' is displayed", listStyle: "bullet", }, children: [], }); expect(stepBlocks[1]).toEqual({ type: "testStep", props: { stepTitle: "Not existing email + valid password", stepData: "", expectedResult: "'Oops, wrong email or password' is displayed", listStyle: "bullet", }, children: [], }); // Verify round-trip doesn't add extra asterisks const roundTrip = blocksToMarkdown(stepBlocks.map((block, index) => ({ ...block, id: `step${index}`, })) as CustomEditorBlock[]); expect(roundTrip).toBe( [ "* Existing email + invalid password", " *Expected result*: 'Oops, wrong email or password' is displayed", "* Not existing email + valid password", " *Expected result*: 'Oops, wrong email or password' is displayed", ].join("\n"), ); }); it("parses multiple steps separated by blank lines", () => { const markdown = [ "## Steps", "* asjkldnkasndkj", " *Expected*: slkednfkjsdnfkjsdnf", "", "* sdfnsikdfnsikdn", " *Expected*: sedfnskdijfns", "", "* sdfnksdjfnsdknf", " *Expected*: sdjfnskdjfnskdfn", ].join("\n"); const blocks = markdownToBlocks(markdown); const stepBlocks = blocks.filter((block) => block.type === "testStep"); expect(stepBlocks).toHaveLength(3); expect(stepBlocks[0].props).toEqual({ stepTitle: "asjkldnkasndkj", stepData: "", expectedResult: "slkednfkjsdnfkjsdnf\n", listStyle: "bullet", }); expect(stepBlocks[1].props).toEqual({ stepTitle: "sdfnsikdfnsikdn", stepData: "", expectedResult: "sedfnskdijfns\n", listStyle: "bullet", }); expect(stepBlocks[2].props).toEqual({ stepTitle: "sdfnksdjfnsdknf", stepData: "", expectedResult: "sdjfnskdjfnskdfn", listStyle: "bullet", }); }); it("parses all bullet items as steps when blank line follows Steps heading", () => { const markdown = [ "### Requirements", "", "### Steps", "", "* next", " *Expected*: expected", "* next 22", "* next 3", ].join("\n"); const blocks = markdownToBlocks(markdown); const stepBlocks = blocks.filter((block) => block.type === "testStep"); expect(stepBlocks).toHaveLength(3); expect(stepBlocks[0].props).toMatchObject({ stepTitle: "next", expectedResult: "expected" }); expect(stepBlocks[1].props).toMatchObject({ stepTitle: "next 22" }); expect(stepBlocks[2].props).toMatchObject({ stepTitle: "next 3" }); // Ensure no bullet list items leaked const bulletBlocks = blocks.filter((block) => block.type === "bulletListItem"); expect(bulletBlocks).toHaveLength(0); }); it("parses all bullet items as steps WITHOUT blank line after Steps heading", () => { const markdown = [ "### Requirements", "", "### Steps", "* next", " *Expected*: expected", "* next 22", "* next 3", ].join("\n"); const blocks = markdownToBlocks(markdown); const stepBlocks = blocks.filter((block) => block.type === "testStep"); expect(stepBlocks).toHaveLength(3); expect(stepBlocks[0].props).toMatchObject({ stepTitle: "next", expectedResult: "expected" }); expect(stepBlocks[1].props).toMatchObject({ stepTitle: "next 22" }); expect(stepBlocks[2].props).toMatchObject({ stepTitle: "next 3" }); const bulletBlocks = blocks.filter((block) => block.type === "bulletListItem"); expect(bulletBlocks).toHaveLength(0); }); it("round-trips steps with blank line after Steps heading", () => { const markdown = [ "### Requirements", "", "### Steps", "", "* next", " *Expected*: expected", "* next 22", "* next 3", ].join("\n"); const blocks = markdownToBlocks(markdown); const md = blocksToMarkdown(blocks as CustomEditorBlock[]); const blocks2 = markdownToBlocks(md); const stepBlocks2 = blocks2.filter((block) => block.type === "testStep"); expect(stepBlocks2).toHaveLength(3); }); it("preserveBlankLines: creates empty paragraphs for each blank line", () => { const markdown = [ "### Requirements", "", "### Steps", "", "* next", " *Expected*: expected", "* next 22", "* next 3", ].join("\n"); const blocks = markdownToBlocks(markdown, { preserveBlankLines: true }); const stepBlocks = blocks.filter((block) => block.type === "testStep"); const emptyParas = blocks.filter( (block) => block.type === "paragraph" && (!block.content || (block.content as any[]).length === 0), ); // All 3 items should be test steps expect(stepBlocks).toHaveLength(3); expect(stepBlocks[0].props).toMatchObject({ stepTitle: "next" }); expect(stepBlocks[1].props).toMatchObject({ stepTitle: "next 22" }); expect(stepBlocks[2].props).toMatchObject({ stepTitle: "next 3" }); // Each blank line should produce an empty paragraph expect(emptyParas.length).toBeGreaterThanOrEqual(2); // No bullet list items const bulletBlocks = blocks.filter((block) => block.type === "bulletListItem"); expect(bulletBlocks).toHaveLength(0); }); it("round-trips simple blocks", () => { const blocks: CustomEditorBlock[] = [ { id: "p1", type: "paragraph", props: baseProps, content: [{ type: "text", text: "Paragraph", styles: {} }], children: [], }, { id: "b1", type: "bulletListItem", props: baseProps, content: [{ type: "text", text: "Bullet", styles: {} }], children: [], }, ]; const markdown = blocksToMarkdown(blocks); const parsed = markdownToBlocks(markdown); expect(parsed).toEqual([ { type: "paragraph", props: baseProps, content: [{ type: "text", text: "Paragraph", styles: {} }], children: [], }, { type: "bulletListItem", props: baseProps, content: [{ type: "text", text: "Bullet", styles: {} }], children: [], }, ]); }); it("round-trips bullet steps preserving bullet style", () => { const markdown = [ "* Open the page", " *Expected*: Page loads", "* Click button", ].join("\n"); const blocks = markdownToBlocks(markdown); const output = blocksToMarkdown(blocks as CustomEditorBlock[]); expect(output).toContain("* Open the page"); expect(output).toContain("* Click button"); expect(output).not.toMatch(/^\d+\./m); }); it("round-trips ordered steps preserving ordered style", () => { const markdown = [ "### Steps", "", "1. Open the page", " *Expected*: Page loads", "2. Click button", ].join("\n"); const blocks = markdownToBlocks(markdown); const output = blocksToMarkdown(blocks as CustomEditorBlock[]); expect(output).toContain("1. Open the page"); expect(output).toContain("2. Click button"); }); it("parses markdown tables", () => { const markdown = [ "| Step | Expected |", "| :--- | ---: |", "| Do thing | It works |", ].join("\n"); expect(markdownToBlocks(markdown)).toEqual([ { type: "table", props: { textColor: "default" }, content: { type: "tableContent", columnWidths: [undefined, undefined], headerRows: 1, rows: [ { cells: [ { type: "tableCell", props: { ...cellProps, textAlignment: "left" }, content: [{ type: "text", text: "Step", styles: {} }], }, { type: "tableCell", props: { ...cellProps, textAlignment: "right" }, content: [{ type: "text", text: "Expected", styles: {} }], }, ], }, { cells: [ { type: "tableCell", props: { ...cellProps, textAlignment: "left" }, content: [{ type: "text", text: "Do thing", styles: {} }], }, { type: "tableCell", props: { ...cellProps, textAlignment: "right" }, content: [{ type: "text", text: "It works", styles: {} }], }, ], }, ], }, children: [], }, ]); }); it("parses
in table cells back to newline", () => { const markdown = [ "| A | B |", "| --- | --- |", "| line1
line2 | ok |", ].join("\n"); const blocks = markdownToBlocks(markdown); expect(blocks).toEqual([ { type: "table", props: { textColor: "default" }, content: { type: "tableContent", columnWidths: [undefined, undefined], headerRows: 1, rows: [ { cells: [ { type: "tableCell", props: cellProps, content: [{ type: "text", text: "A", styles: {} }], }, { type: "tableCell", props: cellProps, content: [{ type: "text", text: "B", styles: {} }], }, ], }, { cells: [ { type: "tableCell", props: cellProps, content: [{ type: "text", text: "line1\nline2", styles: {} }], }, { type: "tableCell", props: cellProps, content: [{ type: "text", text: "ok", styles: {} }], }, ], }, ], }, children: [], }, ]); }); it("round-trips newlines in table cells", () => { const blocks: CustomEditorBlock[] = [ { id: "tbl3", type: "table", props: { textColor: "default" }, content: { type: "tableContent", columnWidths: [undefined, undefined], headerRows: 1, rows: [ { cells: [ { type: "tableCell", props: cellProps, content: [{ type: "text", text: "Header", styles: {} }], }, { type: "tableCell", props: cellProps, content: [{ type: "text", text: "Info", styles: {} }], }, ], }, { cells: [ { type: "tableCell", props: cellProps, content: [{ type: "text", text: "first\nsecond\nthird", styles: {} }], }, { type: "tableCell", props: cellProps, content: [{ type: "text", text: "value", styles: {} }], }, ], }, ], }, children: [], }, ]; const markdown = blocksToMarkdown(blocks); expect(markdown).toContain("first
second
third"); const parsed = markdownToBlocks(markdown); const row = (parsed[0] as any).content.rows[1]; const cellContent = row.cells[0].content; expect(cellContent).toEqual([ { type: "text", text: "first\nsecond\nthird", styles: {} }, ]); }); it("parses expected result lines written with bold 'Expected Result' prefix for compatibility", () => { const markdown = [ "### Steps", "", "* Step 1: Send a chat message to the user.", "**Expected Result**: The user receives a real-time notification for the chat message.", ].join("\n"); const blocks = markdownToBlocks(markdown); const stepBlocks = blocks.filter((b) => b.type === "testStep"); expect(stepBlocks).toEqual([ { type: "testStep", props: { stepTitle: "Step 1: Send a chat message to the user.", stepData: "", expectedResult: "The user receives a real-time notification for the chat message.", listStyle: "bullet", }, children: [], }, ]); }); it("parses multiple Expected blocks within a single test step", () => { const markdown = [ "### Steps", "", "* Swipe Back", "* Check UI of Sleep score info screen", " - Back button", " Header: Sleep Score Info", " Text: Ever wonder if 6, 8, or 9 hours of sleep are enough? Sleep score takes the guesswork out of your ZZZ's and shows you how well you slept last night based on duration, efficiency, and consistency.", " *Expected:* - 1st block:", " *Expected:* - 2nd block:", " *Expected:* - 3d block:", "* Tap 'Back' button", ].join("\n"); const blocks = markdownToBlocks(markdown); const stepBlocks = blocks.filter((block) => block.type === "testStep"); expect(stepBlocks).toHaveLength(3); expect(stepBlocks[0]).toEqual({ type: "testStep", props: { stepTitle: "Swipe Back", stepData: "", expectedResult: "", listStyle: "bullet", }, children: [], }); expect(stepBlocks[1]).toEqual({ type: "testStep", props: { stepTitle: "Check UI of Sleep score info screen", stepData: "- Back button\nHeader: Sleep Score Info\nText: Ever wonder if 6, 8, or 9 hours of sleep are enough? Sleep score takes the guesswork out of your ZZZ's and shows you how well you slept last night based on duration, efficiency, and consistency.", expectedResult: "- 1st block:\n- 2nd block:\n- 3d block:", listStyle: "bullet", }, children: [], }); expect(stepBlocks[2]).toEqual({ type: "testStep", props: { stepTitle: "Tap 'Back' button", stepData: "", expectedResult: "", listStyle: "bullet", }, children: [], }); }); it("correctly parses images at the end of the document", () => { const markdown = [ "#### Steps:", "1. Navigate to the product listing page.", " Expected open", "3. Select a product and click the \"Add to Cart\" button.", " Expected result close", "5. Open the shopping cart page.", " **Expected** edit", "6. Verify that the added item is displayed with the correct name, price, and quantity.", " _Expected_ close", "", "### **Expected Result:** The item appears in the cart with correct details and price calculation.", "", "![logs](/attachments/se2n8jaGon.png)", "", "![](/attachments/p5DgklVeMg.png)", ].join("\n"); const blocks = markdownToBlocks(markdown); // Find the image blocks const imageBlocks = blocks.filter(block => block.type === "image"); // Should have two image blocks expect(imageBlocks.length).toBe(2); // Check image block props expect((imageBlocks[0].props as any).url).toBe("/attachments/se2n8jaGon.png"); expect((imageBlocks[0].props as any).caption).toBe("logs"); expect((imageBlocks[1].props as any).url).toBe("/attachments/p5DgklVeMg.png"); expect((imageBlocks[1].props as any).caption).toBe(""); // Test round-trip conversion const roundTripMarkdown = blocksToMarkdown(blocks as CustomEditorBlock[]); expect(roundTripMarkdown).toContain("![logs](/attachments/se2n8jaGon.png)"); expect(roundTripMarkdown).toContain("![](/attachments/p5DgklVeMg.png)"); }); it("parses numbered lists under a Steps heading as test steps", () => { const markdown = [ "#### Steps:", "1. Navigate to the product listing page.", " Expected open", "3. Select a product and click the \"Add to Cart\" button.", " Expected result close", "5. Open the shopping cart page.", " **Expected** edit", "6. Verify that the added item is displayed with the correct name, price, and quantity.", " _Expected_ close", "", "### **Expected Result:** The item appears in the cart with correct details and price calculation.", "", "![logs](/attachments/se2n8jaGon.png)", "", "![](/attachments/p5DgklVeMg.png)", ].join("\n"); const blocks = markdownToBlocks(markdown); const testSteps = blocks.filter(block => block.type === "testStep"); // Should have 4 test steps expect(testSteps).toHaveLength(4); // Check the first test step expect(testSteps[0]).toEqual({ type: "testStep", props: { stepTitle: "Navigate to the product listing page.", stepData: "Expected open", expectedResult: "", listStyle: "ordered", }, children: [], }); // Check the second test step expect(testSteps[1]).toEqual({ type: "testStep", props: { stepTitle: "Select a product and click the \"Add to Cart\" button.", stepData: "Expected result close", expectedResult: "", listStyle: "ordered", }, children: [], }); // Check the third test step expect(testSteps[2]).toEqual({ type: "testStep", props: { stepTitle: "Open the shopping cart page.", stepData: "**Expected** edit", expectedResult: "", listStyle: "ordered", }, children: [], }); // Check the fourth test step expect(testSteps[3]).toEqual({ type: "testStep", props: { stepTitle: "Verify that the added item is displayed with the correct name, price, and quantity.", stepData: "_Expected_ close", expectedResult: "", listStyle: "ordered", }, children: [], }); // Test round-trip conversion — numbered steps preserve their ordered style const roundTripMarkdown = blocksToMarkdown(blocks as CustomEditorBlock[]); // The "1." prefix is generated from listStyle, not part of the step content, // so it does not switch dot escaping on. expect(roundTripMarkdown).toContain("1. Navigate to the product listing page."); expect(roundTripMarkdown).toContain("2. Select a product and click the \"Add to Cart\" button."); expect(roundTripMarkdown).toContain("3. Open the shopping cart page."); expect(roundTripMarkdown).toContain("4. Verify that the added item is displayed with the correct name, price, and quantity."); // Check that step data is preserved expect(roundTripMarkdown).toContain(" Expected open"); expect(roundTripMarkdown).toContain(" Expected result close"); expect(roundTripMarkdown).toContain(" **Expected** edit"); expect(roundTripMarkdown).toContain(" _Expected_ close"); }); it("handles standalone images without bullet list interference", () => { const markdown = [ "### Steps:", "1. Navigate to the product listing page.", " Expected open", "", "### **Expected Result:** The item appears in the cart with correct details and price calculation.", "", "![logs](/attachments/se2n8jaGon.png)", "", "![](/attachments/p5DgklVeMg.png)", ].join("\n"); const blocks = markdownToBlocks(markdown); // Find image blocks const imageBlocks = blocks.filter(block => block.type === "image"); // Should have exactly 2 image blocks expect(imageBlocks).toHaveLength(2); // First image with alt text expect((imageBlocks[0].props as any).url).toBe("/attachments/se2n8jaGon.png"); expect((imageBlocks[0].props as any).caption).toBe("logs"); // Second image without alt text expect((imageBlocks[1].props as any).url).toBe("/attachments/p5DgklVeMg.png"); expect((imageBlocks[1].props as any).caption).toBe(""); // Test round-trip conversion const roundTripMarkdown = blocksToMarkdown(blocks as CustomEditorBlock[]); expect(roundTripMarkdown).toContain("![logs](/attachments/se2n8jaGon.png)"); expect(roundTripMarkdown).toContain("![](/attachments/p5DgklVeMg.png)"); }); it("handles images with multiple blank lines between them", () => { const markdown = ` ![logs](/attachments/se2n8jaGon.png) ![](/attachments/p5DgklVeMg.png) `; const blocks = markdownToBlocks(markdown); // Should have exactly 2 image blocks const imageBlocks = blocks.filter(block => block.type === "image"); expect(imageBlocks).toHaveLength(2); // Check for malformed image blocks (paragraphs with just "!" but no link) const malformedBlocks = blocks.filter(block => block.type === "paragraph" && block.content && Array.isArray(block.content) && block.content.some((item: any) => item.type === "text" && item.text === "!") && !block.content.some((item: any) => item.type === "link") ); expect(malformedBlocks).toHaveLength(0); // Test round-trip conversion const roundTripMarkdown = blocksToMarkdown(blocks as CustomEditorBlock[]); expect(roundTripMarkdown).toContain("![logs](/attachments/se2n8jaGon.png)"); expect(roundTripMarkdown).toContain("![](/attachments/p5DgklVeMg.png)"); }); it("reproduces the exact issue from user's example", () => { const markdown = `#### Steps: 1. Navigate to the product listing page. Expected open 3. Select a product and click the "Add to Cart" button. Expected result close 5. Open the shopping cart page. **Expected** edit 6. Verify that the added item is displayed with the correct name, price, and quantity. _Expected_ close ### **Expected Result:** The item appears in the cart with correct details and price calculation. ![logs](/attachments/se2n8jaGon.png) ![](/attachments/p5DgklVeMg.png) `; const blocks = markdownToBlocks(markdown); // Test round-trip conversion const roundTripMarkdown = blocksToMarkdown(blocks as CustomEditorBlock[]); // Check that both images are preserved expect(roundTripMarkdown).toContain("![logs](/attachments/se2n8jaGon.png)"); expect(roundTripMarkdown).toContain("![](/attachments/p5DgklVeMg.png)"); // Make sure we don't have a standalone "!" without the rest of the image const lines = roundTripMarkdown.split('\n'); const exclamationLines = lines.filter(line => line.trim() === '!' || line.trim() === '! '); expect(exclamationLines.length).toBe(0); }); it("handles empty alt text images correctly", () => { const markdown = "![](/attachments/test.png)"; const blocks = markdownToBlocks(markdown); const roundTripMarkdown = blocksToMarkdown(blocks as CustomEditorBlock[]); expect(roundTripMarkdown).toContain("![](/attachments/test.png)"); }); it("parses image with size suffix =WIDTHxHEIGHT", () => { const markdown = "![caption](http://localhost:3000/attachments/img.png =200x100)"; const blocks = markdownToBlocks(markdown); const imageBlocks = blocks.filter(block => block.type === "image"); expect(imageBlocks.length).toBe(1); expect((imageBlocks[0].props as any).url).toBe("http://localhost:3000/attachments/img.png"); expect((imageBlocks[0].props as any).caption).toBe("caption"); expect((imageBlocks[0].props as any).previewWidth).toBe(200); }); it("parses image with size suffix =WIDTHx*", () => { const markdown = "![](http://localhost:3000/attachments/img.png =150x*)"; const blocks = markdownToBlocks(markdown); const imageBlocks = blocks.filter(block => block.type === "image"); expect(imageBlocks.length).toBe(1); expect((imageBlocks[0].props as any).url).toBe("http://localhost:3000/attachments/img.png"); expect((imageBlocks[0].props as any).previewWidth).toBe(150); }); it("serializes image block with previewWidth to =WIDTHx*", () => { const blocks: any[] = [ { type: "image", props: { url: "/attachments/test.png", caption: "test", name: "", previewWidth: 300, }, content: [], children: [], }, ]; const markdown = blocksToMarkdown(blocks as CustomEditorBlock[]); expect(markdown).toContain("![test](/attachments/test.png =300x*)"); }); it("serializes image block without previewWidth normally", () => { const blocks: any[] = [ { type: "image", props: { url: "/attachments/test.png", caption: "", name: "", }, content: [], children: [], }, ]; const markdown = blocksToMarkdown(blocks as CustomEditorBlock[]); expect(markdown).toContain("![](/attachments/test.png)"); expect(markdown).not.toContain("="); }); it("round-trips image with size preserving width", () => { const markdown = "![photo](/attachments/photo.png =400x250)"; const blocks = markdownToBlocks(markdown); const roundTripMarkdown = blocksToMarkdown(blocks as CustomEditorBlock[]); expect(roundTripMarkdown).toContain("![photo](/attachments/photo.png =400x*)"); }); it("removes malformed image blocks through post-processing", () => { // Simulate the malformed blocks you're seeing const malformedBlocks: any[] = [ { type: "heading", props: { level: 3 }, content: [{ type: "text", text: "Expected Result:", styles: {} }], children: [] }, { type: "paragraph", props: {}, content: [ { type: "text", text: "!", styles: {} }, { type: "link", href: "/attachments/se2n8jaGon.png", content: [{ type: "text", text: "logs", styles: {} }] } ], children: [] }, { type: "paragraph", props: {}, content: [{ type: "text", text: "!", styles: {} }], children: [] }, { type: "paragraph", props: {}, content: [], children: [] } ]; // Apply the fixMalformedImageBlocks function const fixedBlocks = fixMalformedImageBlocks(malformedBlocks); // Should have removed the malformed "!" only block but kept the empty paragraph and image block expect(fixedBlocks.length).toBe(3); expect(fixedBlocks[0].type).toBe("heading"); expect(fixedBlocks[1].type).toBe("paragraph"); expect(fixedBlocks[1].content).toContainEqual( { type: "text", text: "!", styles: {} } ); expect(fixedBlocks[1].content).toContainEqual( { type: "link", href: "/attachments/se2n8jaGon.png", content: [{ type: "text", text: "logs", styles: {} }] } ); expect(fixedBlocks[2].type).toBe("paragraph"); expect(fixedBlocks[2].content).toHaveLength(0); }); it("reproduces the exact Unsplash URL issue", () => { const markdown = ` ### **Expected Result:** The item appears in the cart with correct details and price calculation. ![logs](https://images.unsplash.com/photo-1765873360413-6c79486d1fda?q=80&w=2350&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D) ![](https://plus.unsplash.com/premium_photo-1765228499795-e58288bc382b?q=80&w=1925&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D) `; const blocks = markdownToBlocks(markdown); // Should have at least 3 blocks expect(blocks.length).toBeGreaterThanOrEqual(3); // Should have image blocks const imageBlocks = blocks.filter(b => b.type === "image"); expect(imageBlocks.length).toBe(2); // Test round-trip conversion - check that we get the images back const roundTripMarkdown = blocksToMarkdown(blocks as CustomEditorBlock[]); // Most importantly: should not have a standalone "!" at the end expect(roundTripMarkdown).not.toMatch(/\n!\s*$/); }); it("parses one-line ```code``` as an inline code span, not a code block", () => { // A backtick fence's info string cannot contain backticks, so CommonMark // (and the Markdown preview) reads this line as inline code. const markdown = "```{{baseURL}}/endpoint?query_param_one=value_one&query_param_two=value_two```"; const blocks = markdownToBlocks(markdown); expect(blocks).toEqual([ { type: "paragraph", props: baseProps, content: [ { type: "text", text: "{{baseURL}}/endpoint?query_param_one=value_one&query_param_two=value_two", styles: { code: true }, }, ], children: [], }, ]); }); it("treats a lone run of six backticks as an unclosed fence", () => { const markdown = ["``````", "next line"].join("\n"); const blocks = markdownToBlocks(markdown); expect(blocks).toEqual([ { type: "codeBlock", props: { language: "" }, content: [{ type: "text", text: "next line", styles: {} }], children: [], }, ]); }); it("round-trips one-line ```code``` as an inline code span", () => { const blocks = markdownToBlocks("```hello world```"); expect(blocksToMarkdown(blocks as CustomEditorBlock[])).toBe("`hello world`"); }); it("still treats an opening fence with a language identifier as multi-line", () => { const markdown = ["```js", "const x = 1;", "```"].join("\n"); const blocks = markdownToBlocks(markdown); expect(blocks).toEqual([ { type: "codeBlock", props: { language: "js" }, content: [{ type: "text", text: "const x = 1;", styles: {} }], children: [], }, ]); }); }); describe("file block serialization", () => { it("serializes a file block using caption as display url", () => { const blocks: CustomEditorBlock[] = [ { id: "1", type: "file", props: { ...baseProps, url: "https://example.com/file.pdf", name: "report.pdf", caption: "/images/file-type-icons/pdf.svg", }, content: undefined as any, children: [], }, ]; const md = blocksToMarkdown(blocks); expect(md).toBe("[![report.pdf](/images/file-type-icons/pdf.svg)](https://example.com/file.pdf)"); }); it("derives icon from url when no caption is set", () => { const blocks: CustomEditorBlock[] = [ { id: "1", type: "file", props: { ...baseProps, url: "https://example.com/file.pdf", name: "", caption: "", }, content: undefined as any, children: [], }, ]; const md = blocksToMarkdown(blocks); expect(md).toBe("[![](/images/file-type-icons/file.svg)](https://example.com/file.pdf)"); }); it("uses custom resolver when set", () => { setFileDisplayUrlResolver(() => { return `/custom-icons/file.svg`; }); const blocks: CustomEditorBlock[] = [ { id: "1", type: "file", props: { ...baseProps, url: "https://example.com/file.pdf", name: "", caption: "", }, content: undefined as any, children: [], }, ]; const md = blocksToMarkdown(blocks); expect(md).toBe("[![](/custom-icons/file.svg)](https://example.com/file.pdf)"); setFileDisplayUrlResolver(null); }); it("outputs nothing when url is empty", () => { const blocks: CustomEditorBlock[] = [ { id: "1", type: "file", props: { ...baseProps, url: "", name: "file.pdf", caption: "", }, content: undefined as any, children: [], }, ]; const md = blocksToMarkdown(blocks); expect(md.trim()).toBe(""); }); }); describe("file block parsing", () => { it("parses file markdown and stores display url in caption", () => { const markdown = "[![report.pdf](/images/file-type-icons/pdf.svg)](https://example.com/file.pdf)"; const blocks = markdownToBlocks(markdown); expect(blocks).toHaveLength(1); expect(blocks[0].type).toBe("file"); expect((blocks[0].props as any).name).toBe("report.pdf"); expect((blocks[0].props as any).url).toBe("https://example.com/file.pdf"); expect((blocks[0].props as any).caption).toBe("/images/file-type-icons/pdf.svg"); }); it("parses file markdown with empty name", () => { const markdown = "[![](/images/file-type-icons/json.svg)](https://example.com/file.json)"; const blocks = markdownToBlocks(markdown); expect(blocks).toHaveLength(1); expect(blocks[0].type).toBe("file"); expect((blocks[0].props as any).name).toBe(""); expect((blocks[0].props as any).url).toBe("https://example.com/file.json"); expect((blocks[0].props as any).caption).toBe("/images/file-type-icons/json.svg"); }); it("does not confuse file blocks with image blocks", () => { const markdown = "![caption](https://example.com/image.png)"; const blocks = markdownToBlocks(markdown); expect(blocks).toHaveLength(1); expect(blocks[0].type).toBe("image"); }); it("round-trips file blocks preserving icon url in caption", () => { const blocks: CustomEditorBlock[] = [ { id: "1", type: "file", props: { ...baseProps, url: "https://example.com/doc.xlsx", name: "doc.xlsx", caption: "/images/file-type-icons/xlsx.svg", }, content: undefined as any, children: [], }, ]; const md = blocksToMarkdown(blocks); expect(md).toBe("[![doc.xlsx](/images/file-type-icons/xlsx.svg)](https://example.com/doc.xlsx)"); const parsed = markdownToBlocks(md); expect(parsed).toHaveLength(1); expect(parsed[0].type).toBe("file"); expect((parsed[0].props as any).url).toBe("https://example.com/doc.xlsx"); expect((parsed[0].props as any).name).toBe("doc.xlsx"); expect((parsed[0].props as any).caption).toBe("/images/file-type-icons/xlsx.svg"); }); it("round-trips file blocks without name", () => { const markdown = "[![](/images/file-type-icons/json.svg)](https://example.com/data.json)"; const parsed = markdownToBlocks(markdown); const md = blocksToMarkdown(parsed as CustomEditorBlock[]); expect(md).toBe(markdown); }); }); describe("video/audio block serialization", () => { it("serializes a video block using the file format", () => { const blocks: CustomEditorBlock[] = [ { id: "1", type: "video", props: { ...baseProps, url: "https://example.com/video.mp4", name: "recording.mp4", caption: "/images/file-type-icons/mp4.svg", showPreview: true, previewWidth: 512, }, content: undefined as any, children: [], }, ]; const md = blocksToMarkdown(blocks); expect(md).toBe("[![recording.mp4](/images/file-type-icons/mp4.svg)](https://example.com/video.mp4)"); }); it("serializes an audio block using the file format", () => { const blocks: CustomEditorBlock[] = [ { id: "1", type: "audio", props: { ...baseProps, url: "https://example.com/sound.mp3", name: "sound.mp3", caption: "/images/file-type-icons/file.svg", showPreview: true, }, content: undefined as any, children: [], }, ]; const md = blocksToMarkdown(blocks); expect(md).toBe("[![sound.mp3](/images/file-type-icons/file.svg)](https://example.com/sound.mp3)"); }); }); describe("steps require Steps heading", () => { it("does not parse bullet items with Expected markers as steps without a Steps heading", () => { const markdown = [ "### Preconditions", "", "* Open the Login page.", " *Expected*: The Login page loads successfully.", ].join("\n"); const blocks = markdownToBlocks(markdown); expect(blocks.filter((b) => b.type === "testStep")).toHaveLength(0); expect(blocks.filter((b) => b.type === "bulletListItem")).toHaveLength(1); }); it("does not parse snippet comments as snippet blocks without a Steps heading", () => { const markdown = [ "", "Run the seeder", "", ].join("\n"); const blocks = markdownToBlocks(markdown); expect(blocks.filter((b) => b.type === "snippet")).toHaveLength(0); }); it("parses bullet items as steps when preceded by a Steps heading", () => { const markdown = [ "### Steps", "", "* next 22", ].join("\n"); const blocks = markdownToBlocks(markdown); const stepBlocks = blocks.filter((b) => b.type === "testStep"); expect(stepBlocks).toHaveLength(1); expect((stepBlocks[0].props as any).stepTitle).toBe("next 22"); }); }); describe("blank line <-> empty paragraph mapping", () => { const isEmptyParagraph = (block: CustomPartialBlock | CustomEditorBlock) => block.type === "paragraph" && (!block.content || (block.content as any[]).length === 0); it("parses a single blank line between headings as one empty paragraph", () => { const blocks = markdownToBlocks("### A\n\n### B"); expect(blocks).toHaveLength(3); expect(blocks[0].type).toBe("heading"); expect(isEmptyParagraph(blocks[1])).toBe(true); expect(blocks[2].type).toBe("heading"); }); it("parses two blank lines between headings as two empty paragraphs", () => { const blocks = markdownToBlocks("### A\n\n\n### B"); expect(blocks).toHaveLength(4); expect(blocks[0].type).toBe("heading"); expect(isEmptyParagraph(blocks[1])).toBe(true); expect(isEmptyParagraph(blocks[2])).toBe(true); expect(blocks[3].type).toBe("heading"); }); it("drops leading and trailing blank lines", () => { const blocks = markdownToBlocks("\n\n### A\n\n"); expect(blocks).toHaveLength(1); expect(blocks[0].type).toBe("heading"); }); it("serializes an empty paragraph between two blocks as one blank line", () => { const blocks: CustomEditorBlock[] = [ { id: "h1", type: "heading", props: { ...baseProps, level: 3 } as any, content: [{ type: "text", text: "A", styles: {} }], children: [], }, { id: "p1", type: "paragraph", props: baseProps, content: [], children: [], }, { id: "h2", type: "heading", props: { ...baseProps, level: 3 } as any, content: [{ type: "text", text: "B", styles: {} }], children: [], }, ]; expect(blocksToMarkdown(blocks)).toBe("### A\n\n### B"); }); it("round-trips a single blank line between headings", () => { const markdown = "### A\n\n### B"; const blocks = markdownToBlocks(markdown); expect(blocksToMarkdown(blocks as CustomEditorBlock[])).toBe(markdown); }); it("round-trips two blank lines between headings", () => { const markdown = "### A\n\n\n### B"; const blocks = markdownToBlocks(markdown); expect(blocksToMarkdown(blocks as CustomEditorBlock[])).toBe(markdown); }); it("round-trips headings adjacent with no blank line", () => { const markdown = "### A\n### B"; const blocks = markdownToBlocks(markdown); expect(blocks.some(isEmptyParagraph)).toBe(false); expect(blocksToMarkdown(blocks as CustomEditorBlock[])).toBe(markdown); }); it("deleting an empty paragraph removes the blank line from the output", () => { const blocks = markdownToBlocks("### A\n\n### B") as CustomEditorBlock[]; const withoutEmpty = blocks.filter((block) => !isEmptyParagraph(block)); expect(withoutEmpty).toHaveLength(2); expect(blocksToMarkdown(withoutEmpty)).toBe("### A\n### B"); }); it("inserting an empty paragraph adds a blank line to the output", () => { const blocks = markdownToBlocks("### A\n### B") as CustomEditorBlock[]; const withEmpty: CustomEditorBlock[] = [ blocks[0], { id: "inserted", type: "paragraph", props: baseProps, content: [], children: [], }, blocks[1], ]; expect(blocksToMarkdown(withEmpty)).toBe("### A\n\n### B"); }); it("preserves blank lines between a heading and a bullet list", () => { const markdown = "### Steps\n\n* first\n* second"; const blocks = markdownToBlocks(markdown); expect(isEmptyParagraph(blocks[1])).toBe(true); expect(blocksToMarkdown(blocks as CustomEditorBlock[])).toBe(markdown); }); it("preserves a blank line between a bullet list and the next heading", () => { const markdown = [ "### Requirements", "* first requirement", "* second requirement", "", "### Steps", "", "* do the thing", " *Expected result*: it works", ].join("\n"); const blocks = markdownToBlocks(markdown) as CustomEditorBlock[]; const types = blocks.map((b) => b.type); // Blank line between the last bullet and the next heading must survive // parseList and become an empty paragraph. const lastBulletIdx = types.lastIndexOf("bulletListItem"); expect(blocks[lastBulletIdx + 1].type).toBe("paragraph"); expect((blocks[lastBulletIdx + 1].content as any[]).length).toBe(0); expect(blocksToMarkdown(blocks)).toBe(markdown); }); it("preserves blank lines across the user-reported screenshot example", () => { const markdown = [ "### Requirements", "", "### Steps", "", "* *open* **webiste**", " step data with `image`", ].join("\n"); const blocks = markdownToBlocks(markdown); expect(blocksToMarkdown(blocks as CustomEditorBlock[])).toBe(markdown); }); it("preserves a blank line between a numbered step list and a following ---", () => { const markdown = [ "### Steps", "1. Navigate to the appropriate environment using the links above", "2. Login with a ``", "3. ...", "", "---", ].join("\n"); const blocks = markdownToBlocks(markdown) as CustomEditorBlock[]; const types = blocks.map((b) => b.type); const lastStepIdx = types.lastIndexOf("testStep"); // The blank line separating the last step from --- must survive as an // empty paragraph so the round-trip keeps the layout (otherwise // "5. ..." + "---" renders as a setext heading). expect(isEmptyParagraph(blocks[lastStepIdx + 1])).toBe(true); expect(blocks[lastStepIdx + 2].type).toBe("paragraph"); expect(blocksToMarkdown(blocks)).toBe(markdown); }); it("preserves a blank line between a step and a following heading", () => { const markdown = [ "### Steps", "1. do the thing", "", "### Expected Results", ].join("\n"); const blocks = markdownToBlocks(markdown) as CustomEditorBlock[]; const types = blocks.map((b) => b.type); const lastStepIdx = types.lastIndexOf("testStep"); expect(isEmptyParagraph(blocks[lastStepIdx + 1])).toBe(true); expect(blocksToMarkdown(blocks)).toBe(markdown); }); it("does not insert an empty paragraph between two numbered steps separated by a blank line", () => { const markdown = ["### Steps", "1. one", "", "2. two"].join("\n"); const blocks = markdownToBlocks(markdown) as CustomEditorBlock[]; // Blanks between steps stay consumed so numbering remains contiguous. expect(blocks.some(isEmptyParagraph)).toBe(false); expect(blocksToMarkdown(blocks)).toBe(["### Steps", "1. one", "2. two"].join("\n")); }); }); describe("test/suite metadata comments", () => { it("parses a one-liner test comment into a testMeta block", () => { const blocks = markdownToBlocks(""); expect(blocks).toEqual([ { type: "testMeta", props: { metaKind: "test", metaFields: JSON.stringify([{ key: "id", value: "@T12345678" }]), metaInline: true, }, children: [], }, ]); }); it("round-trips a one-liner test comment", () => { const markdown = ""; const blocks = markdownToBlocks(markdown); expect(blocksToMarkdown(blocks as CustomEditorBlock[])).toBe(markdown); }); it("parses an marker into an exampleMarker block", () => { const blocks = markdownToBlocks(""); expect(blocks).toEqual([ { type: "exampleMarker", props: {}, children: [], }, ]); }); it("parses the plural spelling too", () => { const blocks = markdownToBlocks(""); expect(blocks).toEqual([ { type: "exampleMarker", props: {}, children: [], }, ]); }); it("round-trips an marker", () => { const markdown = ""; const blocks = markdownToBlocks(markdown); expect(blocksToMarkdown(blocks as CustomEditorBlock[])).toBe(markdown); }); it("parses a multi-line suite block with ordered fields", () => { const markdown = [ "", ].join("\n"); const blocks = markdownToBlocks(markdown); expect(blocks).toEqual([ { type: "testMeta", props: { metaKind: "suite", metaFields: JSON.stringify([ { key: "id", value: "@S12345678" }, { key: "emoji", value: "🔐" }, { key: "tags", value: "smoke, regression" }, { key: "assignee", value: "qa@example.com" }, ]), metaInline: false, }, children: [], }, ]); }); it("round-trips a multi-line suite block", () => { const markdown = [ "", ].join("\n"); const blocks = markdownToBlocks(markdown); expect(blocksToMarkdown(blocks as CustomEditorBlock[])).toBe(markdown); }); it("parses a YAML-list issues field without splitting URLs on ://", () => { const markdown = [ "", ].join("\n"); const blocks = markdownToBlocks(markdown); expect((blocks[0].props as any).metaFields).toBe( JSON.stringify([ { key: "id", value: "@T4ffddec3" }, { key: "type", value: "manual" }, { key: "issues", value: "https://github.com/testomatio/testomatio/issues/8963", }, ]), ); }); it("joins multiple YAML-list issues items with a comma", () => { const markdown = [ "", ].join("\n"); const blocks = markdownToBlocks(markdown); expect((blocks[0].props as any).metaFields).toBe( JSON.stringify([ { key: "id", value: "@T4ffddec3" }, { key: "issues", value: "https://github.com/testomatio/testomatio/issues/8963, https://github.com/testomatio/testomatio/issues/9001", }, ]), ); }); it("round-trips a YAML-list issues field (single item)", () => { const markdown = [ "", ].join("\n"); const blocks = markdownToBlocks(markdown); expect(blocksToMarkdown(blocks as CustomEditorBlock[])).toBe(markdown); }); it("round-trips a YAML-list issues field (multiple items)", () => { const markdown = [ "", ].join("\n"); const blocks = markdownToBlocks(markdown); expect(blocksToMarkdown(blocks as CustomEditorBlock[])).toBe(markdown); }); it("keeps fields that are no longer suggested (creator, shared)", () => { // `creator`/`shared` were dropped from the "+" menu suggestions, but whatever // the markdown already carries must still parse, render and round-trip. const markdown = [ "", ].join("\n"); const blocks = markdownToBlocks(markdown); expect((blocks[0].props as any).metaFields).toBe( JSON.stringify([ { key: "id", value: "@T4ffddec3" }, { key: "creator", value: "qa@example.com" }, { key: "shared", value: "true" }, { key: "issues", value: "https://github.com/testomatio/testomatio/issues/8963", }, ]), ); expect(blocksToMarkdown(blocks as CustomEditorBlock[])).toBe(markdown); }); it("ignores lines without a colon inside a metadata block", () => { const markdown = [ "", ].join("\n"); const blocks = markdownToBlocks(markdown); expect((blocks[0].props as any).metaFields).toBe( JSON.stringify([ { key: "id", value: "@T12345678" }, { key: "priority", value: "high" }, ]), ); }); it("serializes a one-liner that gained extra fields as a block", () => { const blocks: CustomEditorBlock[] = [ { id: "m1", type: "testMeta", props: { metaKind: "test", metaFields: JSON.stringify([ { key: "id", value: "@T12345678" }, { key: "priority", value: "high" }, ]), metaInline: true, } as any, content: undefined as any, children: [], }, ]; expect(blocksToMarkdown(blocks)).toBe( [""].join("\n"), ); }); it("skips fields with empty values when serializing", () => { const blocks: CustomEditorBlock[] = [ { id: "m2", type: "testMeta", props: { metaKind: "test", metaFields: JSON.stringify([ { key: "id", value: "@T12345678" }, { key: "priority", value: "high" }, { key: "tags", value: "" }, { key: "", value: "orphan" }, ]), metaInline: false, } as any, content: undefined as any, children: [], }, ]; expect(blocksToMarkdown(blocks)).toBe( [""].join("\n"), ); }); it("leaves a generic HTML comment as paragraph text", () => { const blocks = markdownToBlocks(""); expect(blocks[0].type).toBe("paragraph"); expect(blocksToMarkdown(blocks as CustomEditorBlock[])).toBe( "", ); }); }); describe("backticks and code spans (CommonMark)", () => { const URL = "{{baseURL}}/endpoint?query_param_one=value_one&query_param_two=value_two"; const inlineOf = (markdown: string) => { const blocks = markdownToBlocks(markdown); expect(blocks).toHaveLength(1); expect(blocks[0].type).toBe("paragraph"); return blocks[0].content; }; it.each([ ["single backticks", "`" + URL + "`"], ["double backticks", "``" + URL + "``"], ["triple backticks on one line", "```" + URL + "```"], ])("parses %s as one inline code span and round-trips losslessly", (_label, markdown) => { expect(inlineOf(markdown)).toEqual([ { type: "text", text: URL, styles: { code: true } }, ]); const serialized = blocksToMarkdown(markdownToBlocks(markdown) as CustomEditorBlock[]); expect(serialized).toBe("`" + URL + "`"); }); it("closes a code span only with a run of the same length", () => { expect(inlineOf("`` a ` b ``")).toEqual([ { type: "text", text: "a ` b", styles: { code: true } }, ]); }); it("strips one padding space so a span can start and end with a backtick", () => { const markdown = "`` `code` ``"; expect(inlineOf(markdown)).toEqual([ { type: "text", text: "`code`", styles: { code: true } }, ]); expect(blocksToMarkdown(markdownToBlocks(markdown) as CustomEditorBlock[])).toBe(markdown); }); it("keeps backslashes inside code spans literal", () => { for (const markdown of ["`a\\.b`", "`C:\\\\dir`", "`\\*not escaped\\*`"]) { const code = markdown.slice(1, -1); expect(inlineOf(markdown)).toEqual([ { type: "text", text: code, styles: { code: true } }, ]); expect(blocksToMarkdown(markdownToBlocks(markdown) as CustomEditorBlock[])).toBe(markdown); } }); it("keeps HTML-looking text inside code spans", () => { expect(inlineOf("`x`")).toEqual([ { type: "text", text: "x", styles: { code: true } }, ]); }); it("treats an unmatched backtick run as literal text", () => { expect(inlineOf("foo `unclosed")).toEqual([ { type: "text", text: "foo `unclosed", styles: {} }, ]); expect(inlineOf("``foo`bar")).toEqual([ { type: "text", text: "``foo`bar", styles: {} }, ]); }); it("lets code spans take precedence over emphasis delimiters", () => { expect(inlineOf("**bold `x**` y**")).toEqual([ { type: "text", text: "bold ", styles: { bold: true } }, { type: "text", text: "x**", styles: { bold: true, code: true } }, { type: "text", text: " y", styles: { bold: true } }, ]); expect(inlineOf("`a_b` and _c_")).toEqual([ { type: "text", text: "a_b", styles: { code: true } }, { type: "text", text: " and ", styles: {} }, { type: "text", text: "c", styles: { italic: true } }, ]); }); it("does not treat escaped markers as formatting", () => { expect(inlineOf("\\*not italic\\*")).toEqual([ { type: "text", text: "*not italic*", styles: {} }, ]); expect(inlineOf("\\`not code\\`")).toEqual([ { type: "text", text: "`not code`", styles: {} }, ]); }); it("does not italicize underscores inside words", () => { expect(inlineOf("snake_case_name and query_param_one")).toEqual([ { type: "text", text: "snake_case_name and query_param_one", styles: {} }, ]); expect(inlineOf("an _italic_ word")).toEqual([ { type: "text", text: "an ", styles: {} }, { type: "text", text: "italic", styles: { italic: true } }, { type: "text", text: " word", styles: {} }, ]); expect(inlineOf("2 * 3 * 4")).toEqual([ { type: "text", text: "2 * 3 * 4", styles: {} }, ]); }); it("closes a fence only with the same character and at least the same length", () => { const markdown = ["````md", "```js", "x", "```", "````", "after"].join("\n"); const blocks = markdownToBlocks(markdown); expect(blocks[0]).toEqual({ type: "codeBlock", props: { language: "md" }, content: [{ type: "text", text: ["```js", "x", "```"].join("\n"), styles: {} }], children: [], }); expect(blocks[1].type).toBe("paragraph"); expect(blocksToMarkdown(blocks as CustomEditorBlock[])).toBe(markdown); }); it("parses tilde fences", () => { const blocks = markdownToBlocks(["~~~python", "print(1)", "~~~"].join("\n")); expect(blocks).toEqual([ { type: "codeBlock", props: { language: "python" }, content: [{ type: "text", text: "print(1)", styles: {} }], children: [], }, ]); }); it("keeps backslashes inside code in step fields", () => { const markdown = [ "### Steps", "", "* Open ``" + URL + "`` and check `a\\.b`.", " *Expected*: Path `C:\\\\dir` is shown\\.", ].join("\n"); const step = markdownToBlocks(markdown).find((b) => b.type === "testStep"); expect(step?.props).toMatchObject({ stepTitle: "Open ``" + URL + "`` and check `a\\.b`.", expectedResult: "Path `C:\\\\dir` is shown.", }); }); });