Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 152 additions & 0 deletions src/editor/customMarkdownConverter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -849,6 +849,86 @@ describe("markdownToBlocks", () => {
]);
});

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");

Expand Down Expand Up @@ -1332,6 +1412,25 @@ describe("markdownToBlocks", () => {
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",
Expand Down Expand Up @@ -2503,6 +2602,59 @@ describe("markdownToBlocks", () => {
// Most importantly: should not have a standalone "!" at the end
expect(roundTripMarkdown).not.toMatch(/\n!\s*$/);
});

it("parses a single-line fenced code block", () => {
const markdown = "```{{baseURL}}/endpoint?query_param_one=value_one&query_param_two=value_two```";
const blocks = markdownToBlocks(markdown);
expect(blocks).toEqual([
{
type: "codeBlock",
props: { language: "" },
content: [
{
type: "text",
text: "{{baseURL}}/endpoint?query_param_one=value_one&query_param_two=value_two",
styles: {},
},
],
children: [],
},
]);
});

it("parses an empty single-line fence without swallowing following lines", () => {
const markdown = ["``````", "next line"].join("\n");
const blocks = markdownToBlocks(markdown);
expect(blocks).toHaveLength(2);
expect(blocks[0]).toEqual({
type: "codeBlock",
props: { language: "" },
content: undefined,
children: [],
});
expect(blocks[1].type).toBe("paragraph");
});

it("normalizes a single-line fenced code block to multi-line on round-trip", () => {
const markdown = "```hello world```";
const blocks = markdownToBlocks(markdown);
expect(blocksToMarkdown(blocks as CustomEditorBlock[])).toBe(
["```", "hello world", "```"].join("\n"),
);
});

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", () => {
Expand Down
58 changes: 45 additions & 13 deletions src/editor/customMarkdownConverter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -845,6 +845,11 @@ function parseList(
): ListParseResult {
const items: CustomPartialBlock[] = [];
let index = startIndex;
// The minimum leading-space count for items at this list level. Initialized to
// the parent's expected indent, but updated to the first item's actual indent
// so a uniformly indented list stays flat instead of nesting under itself.
let baseIndent = indentLevel * 2;
let firstItemSeen = false;

while (index < lines.length) {
const rawLine = lines[index];
Expand All @@ -864,7 +869,7 @@ function parseList(
}
const nextLine = lines[lookahead];
const nextIndent = countIndent(nextLine);
if (nextIndent < indentLevel * 2) {
if (nextIndent < baseIndent) {
break;
}
const nextType = detectListType(nextLine.trim());
Expand All @@ -877,14 +882,17 @@ function parseList(

let indent = countIndent(rawLine);

if (indent < indentLevel * 2) {
if (indent < baseIndent) {
break;
}

// Check if this line should be parsed as nested content
// Only go deeper if indent is at least 2 more than the next level's expected indent
const nextLevelExpectedIndent = (indentLevel + 1) * 2;
if (indent >= nextLevelExpectedIndent && items.length > 0) {
if (!firstItemSeen) {
baseIndent = indent;
firstItemSeen = true;
}

// Only go deeper if indent is at least 2 more than this list's base indent
if (indent >= baseIndent + 2 && items.length > 0) {
const lastItem = items.at(-1);
if (!lastItem) {
break;
Expand Down Expand Up @@ -1063,11 +1071,18 @@ function parseTestStep(
break;
}

// Check for expected result labels with different formatting
const expectedMatch = rawTrimmed.match(EXPECTED_LABEL_REGEX);
const expectedStarMatch = rawTrimmed.match(/^\*expected\s*\*:\s*(.*)$/i) ||
rawTrimmed.match(/^\*expected\*:\s*(.*)$/i) ||
rawTrimmed.match(/^\*{1,2}expected\s*:\*{1,2}\s*(.*)$/i);
// Check for expected result labels with different formatting.
// Strip an optional leading bullet marker so patterns like
// "* *Expected*: ..." (a sub-bullet with an italic Expected label) are
// recognized via the same matchers as the bare label forms.
const bulletPrefixMatch = rawTrimmed.match(/^[*-]\s+/);
const lineForExpectedCheck = bulletPrefixMatch
? rawTrimmed.slice(bulletPrefixMatch[0].length)
: rawTrimmed;
const expectedMatch = lineForExpectedCheck.match(EXPECTED_LABEL_REGEX);
const expectedStarMatch = lineForExpectedCheck.match(/^\*expected\s*\*:\s*(.*)$/i) ||
lineForExpectedCheck.match(/^\*expected\*:\s*(.*)$/i) ||
lineForExpectedCheck.match(/^\*{1,2}expected\s*:\*{1,2}\s*(.*)$/i);

if (expectedMatch || expectedStarMatch) {
inExpectedResult = true;
Expand All @@ -1076,7 +1091,7 @@ function parseTestStep(
if (expectedStarMatch) {
content = (expectedStarMatch[1] || '').trim();
} else {
content = rawTrimmed.slice(expectedMatch![0].length).trim();
content = lineForExpectedCheck.slice(expectedMatch![0].length).trim();
}

// Add the content (if any) from this line
Expand Down Expand Up @@ -1258,7 +1273,24 @@ function parseCodeBlock(lines: string[], index: number): { block: CustomPartialB
return null;
}

const language = trimmed.slice(3).trim();
const afterOpening = trimmed.slice(3);
const closeMatch = afterOpening.match(/```\s*$/);
if (closeMatch) {
const content = afterOpening.slice(0, afterOpening.length - closeMatch[0].length);
return {
block: {
type: "codeBlock",
props: { language: "" },
content: content.length
? [{ type: "text", text: content, styles: {} }]
: undefined,
children: [],
},
nextIndex: index + 1,
};
}

const language = afterOpening.trim();
const body: string[] = [];
let next = index + 1;
while (next < lines.length && !lines[next].startsWith("```") ) {
Expand Down
Loading