", 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",
"```",
"",
].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",
" ```",
" ",
" *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 ",
].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("");
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",
" ```",
" ",
"**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",
"```",
"",
].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",
" ```",
" ",
" *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",
"",
" ",
].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.",
"",
"[](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",
"",
"[](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.",
"",
"[](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*: ",
].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: "",
listStyle: "bullet",
},
children: [],
},
]);
const markdownRoundTrip = blocksToMarkdown([
{
id: "step-image",
type: "testStep",
props: {
stepTitle: "Display the generated report.",
stepData: "",
expectedResult: "",
listStyle: "bullet",
},
content: undefined,
children: [],
},
]);
expect(markdownRoundTrip).toBe(
[
"* Display the generated report.",
" *Expected result*: ",
].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",
" ",
].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",
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",
listStyle: "bullet",
},
content: undefined,
children: [],
},
]);
expect(roundTrip).toBe(
[
"* Should open login screen",
" *Expected result*: Login should look like this",
" ",
].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.",
"",
"",
"",
"",
].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("");
expect(roundTripMarkdown).toContain("");
});
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.",
"",
"",
"",
"",
].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.",
"",
"",
"",
"",
].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("");
expect(roundTripMarkdown).toContain("");
});
it("handles images with multiple blank lines between them", () => {
const markdown = `


`;
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("");
expect(roundTripMarkdown).toContain("");
});
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.


`;
const blocks = markdownToBlocks(markdown);
// Test round-trip conversion
const roundTripMarkdown = blocksToMarkdown(blocks as CustomEditorBlock[]);
// Check that both images are preserved
expect(roundTripMarkdown).toContain("");
expect(roundTripMarkdown).toContain("");
// 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 = "";
const blocks = markdownToBlocks(markdown);
const roundTripMarkdown = blocksToMarkdown(blocks as CustomEditorBlock[]);
expect(roundTripMarkdown).toContain("");
});
it("parses image with size suffix =WIDTHxHEIGHT", () => {
const markdown = "";
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 = "";
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("");
});
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("");
expect(markdown).not.toContain("=");
});
it("round-trips image with size preserving width", () => {
const markdown = "";
const blocks = markdownToBlocks(markdown);
const roundTripMarkdown = blocksToMarkdown(blocks as CustomEditorBlock[]);
expect(roundTripMarkdown).toContain("");
});
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.


`;
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("[](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("[](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("[](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 = "[](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 = "[](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 = "";
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("[](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 = "[](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("[](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("[](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.",
});
});
});