testomatio-editor-blocks 0.4.35 → 0.4.37
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package/editor/blocks/step.js +0 -29
- package/package/editor/customMarkdownConverter.js +43 -24
- package/package.json +1 -1
- package/src/App.tsx +2 -7
- package/src/editor/blocks/step.tsx +0 -37
- package/src/editor/customMarkdownConverter.test.ts +226 -3
- package/src/editor/customMarkdownConverter.ts +44 -24
|
@@ -193,31 +193,6 @@ export const stepBlock = createReactBlockSpec({
|
|
|
193
193
|
}
|
|
194
194
|
return count;
|
|
195
195
|
}, [block.id, documentVersion, editor.document]);
|
|
196
|
-
// Check if there is a preceding "Steps" heading
|
|
197
|
-
const hasStepsHeading = useMemo(() => {
|
|
198
|
-
const allBlocks = editor.document;
|
|
199
|
-
const blockIndex = allBlocks.findIndex((b) => b.id === block.id);
|
|
200
|
-
if (blockIndex < 0)
|
|
201
|
-
return false;
|
|
202
|
-
for (let i = blockIndex - 1; i >= 0; i--) {
|
|
203
|
-
const b = allBlocks[i];
|
|
204
|
-
if (b.type === "testStep" || b.type === "snippet" || isEmptyParagraph(b)) {
|
|
205
|
-
continue;
|
|
206
|
-
}
|
|
207
|
-
if (b.type === "heading") {
|
|
208
|
-
const text = (Array.isArray(b.content) ? b.content : [])
|
|
209
|
-
.filter((n) => n.type === "text")
|
|
210
|
-
.map((n) => { var _a; return (_a = n.text) !== null && _a !== void 0 ? _a : ""; })
|
|
211
|
-
.join("")
|
|
212
|
-
.trim()
|
|
213
|
-
.toLowerCase()
|
|
214
|
-
.replace(/[:\-–—]$/, "");
|
|
215
|
-
return isStepsHeading(text);
|
|
216
|
-
}
|
|
217
|
-
return false;
|
|
218
|
-
}
|
|
219
|
-
return false;
|
|
220
|
-
}, [block.id, documentVersion, editor.document]);
|
|
221
196
|
useEditorChange(() => {
|
|
222
197
|
setDocumentVersion((version) => version + 1);
|
|
223
198
|
}, editor);
|
|
@@ -362,10 +337,6 @@ export const stepBlock = createReactBlockSpec({
|
|
|
362
337
|
}, [expectedHasContent, isExpectedVisible]);
|
|
363
338
|
const canToggleData = !dataHasContent;
|
|
364
339
|
const canToggleExpected = !expectedHasContent;
|
|
365
|
-
// Render as plain text when not under a "Steps" heading
|
|
366
|
-
if (!hasStepsHeading) {
|
|
367
|
-
return (_jsxs("div", { className: "bn-teststep-plain", "data-block-id": block.id, children: [_jsx("span", { children: stepTitle || "(empty step)" }), stepData ? _jsx("span", { className: "bn-teststep-plain__data", children: stepData }) : null, expectedResult ? _jsx("span", { className: "bn-teststep-plain__expected", children: expectedResult }) : null] }));
|
|
368
|
-
}
|
|
369
340
|
if (viewMode === "horizontal") {
|
|
370
341
|
return (_jsx(StepHorizontalView, { blockId: block.id, stepNumber: stepNumber, stepValue: combinedStepValue, expectedResult: expectedResult, onStepChange: handleCombinedStepChange, onExpectedChange: handleExpectedChange, onInsertNextStep: handleInsertNextStep, onFieldFocus: handleFieldFocus, viewToggle: _jsx("button", { type: "button", className: "bn-teststep__view-toggle bn-teststep__view-toggle--horizontal", "data-tooltip": "Switch step view", "aria-label": "Switch step view", onClick: handleToggleView, children: _jsxs("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true", children: [_jsx("mask", { id: "mask-toggle", style: { maskType: "alpha" }, maskUnits: "userSpaceOnUse", x: "0", y: "0", width: "16", height: "16", children: _jsx("rect", { width: "16", height: "16", fill: "#D9D9D9" }) }), _jsx("g", { mask: "url(#mask-toggle)", children: _jsx("path", { d: "M12.6667 2C13.0333 2 13.3472 2.13056 13.6083 2.39167C13.8694 2.65278 14 2.96667 14 3.33333L14 12.6667C14 13.0333 13.8694 13.3472 13.6083 13.6083C13.3472 13.8694 13.0333 14 12.6667 14L10 14C9.63333 14 9.31944 13.8694 9.05833 13.6083C8.79722 13.3472 8.66667 13.0333 8.66667 12.6667L8.66667 3.33333C8.66667 2.96667 8.79722 2.65278 9.05833 2.39167C9.31945 2.13055 9.63333 2 10 2L12.6667 2ZM6 2C6.36667 2 6.68056 2.13055 6.94167 2.39167C7.20278 2.65278 7.33333 2.96667 7.33333 3.33333L7.33333 12.6667C7.33333 13.0333 7.20278 13.3472 6.94167 13.6083C6.68055 13.8694 6.36667 14 6 14L3.33333 14C2.96667 14 2.65278 13.8694 2.39167 13.6083C2.13056 13.3472 2 13.0333 2 12.6667L2 3.33333C2 2.96667 2.13056 2.65278 2.39167 2.39167C2.65278 2.13055 2.96667 2 3.33333 2L6 2ZM3.33333 12.6667L6 12.6667L6 3.33333L3.33333 3.33333L3.33333 12.6667Z", fill: "currentColor" }) })] }) }) }));
|
|
371
342
|
}
|
|
@@ -80,7 +80,6 @@ function unescapeMarkdown(text) {
|
|
|
80
80
|
return stripHtmlWrappers(text).replace(/\\([*_`~\[\]()<>\\])/g, "$1");
|
|
81
81
|
}
|
|
82
82
|
function applyTextStyles(text, styles) {
|
|
83
|
-
var _a;
|
|
84
83
|
if (!styles) {
|
|
85
84
|
return text;
|
|
86
85
|
}
|
|
@@ -116,11 +115,22 @@ function applyTextStyles(text, styles) {
|
|
|
116
115
|
suffix: "</span>",
|
|
117
116
|
});
|
|
118
117
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
118
|
+
// Split on newlines so that style markers wrap each line individually.
|
|
119
|
+
// This prevents <br/> (inserted by table cell formatting) from being
|
|
120
|
+
// trapped inside markers like **bold<br/>text**.
|
|
121
|
+
const segments = result.split("\n");
|
|
122
|
+
const wrapped = segments.map((segment) => {
|
|
123
|
+
var _a;
|
|
124
|
+
if (!segment)
|
|
125
|
+
return segment;
|
|
126
|
+
let s = segment;
|
|
127
|
+
for (const wrapper of wrappers) {
|
|
128
|
+
const suffix = (_a = wrapper.suffix) !== null && _a !== void 0 ? _a : wrapper.prefix;
|
|
129
|
+
s = `${wrapper.prefix}${s}${suffix}`;
|
|
130
|
+
}
|
|
131
|
+
return s;
|
|
132
|
+
});
|
|
133
|
+
return wrapped.join("\n");
|
|
124
134
|
}
|
|
125
135
|
function inlineToMarkdown(content) {
|
|
126
136
|
if (!content || !Array.isArray(content)) {
|
|
@@ -309,17 +319,17 @@ function serializeBlock(block, ctx, orderedIndex, stepIndex) {
|
|
|
309
319
|
}
|
|
310
320
|
return flattenWithBlankLine(lines, true);
|
|
311
321
|
}
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
}
|
|
322
|
+
const normalizedTitle = stepTitle
|
|
323
|
+
.split(/\r?\n/)
|
|
324
|
+
.map((segment) => segment.trim())
|
|
325
|
+
.filter((segment) => segment.length > 0)
|
|
326
|
+
.join(" ");
|
|
327
|
+
const normalizedExpectedForCheck = stripExpectedPrefix(expectedResult).trim();
|
|
328
|
+
const hasContent = stepData.length > 0 || normalizedExpectedForCheck.length > 0;
|
|
329
|
+
if (normalizedTitle.length > 0 || hasContent) {
|
|
330
|
+
const listStyle = (_m = block.props.listStyle) !== null && _m !== void 0 ? _m : "bullet";
|
|
331
|
+
const prefix = listStyle === "ordered" ? `${(stepIndex !== null && stepIndex !== void 0 ? stepIndex : 0) + 1}.` : "*";
|
|
332
|
+
lines.push(normalizedTitle.length > 0 ? `${prefix} ${normalizedTitle}` : `${prefix} `);
|
|
323
333
|
}
|
|
324
334
|
if (stepData.length > 0) {
|
|
325
335
|
const dataLines = stepData.split(/\r?\n/);
|
|
@@ -416,7 +426,16 @@ function serializeBlock(block, ctx, orderedIndex, stepIndex) {
|
|
|
416
426
|
const formatCell = (value) => {
|
|
417
427
|
if (!value.length)
|
|
418
428
|
return " ";
|
|
419
|
-
|
|
429
|
+
// Handle newlines inside backtick code spans:
|
|
430
|
+
// close code before <br>, reopen after
|
|
431
|
+
let result = value.replace(/`([^`]*)`/g, (match) => {
|
|
432
|
+
if (!match.includes("\n"))
|
|
433
|
+
return match;
|
|
434
|
+
const inner = match.slice(1, -1);
|
|
435
|
+
const parts = inner.split("\n");
|
|
436
|
+
return parts.map((p) => (p ? `\`${p}\`` : "")).join("<br>");
|
|
437
|
+
});
|
|
438
|
+
return result.replace(/\n/g, "<br>");
|
|
420
439
|
};
|
|
421
440
|
const toAlignmentToken = (alignment) => {
|
|
422
441
|
switch (alignment) {
|
|
@@ -606,7 +625,7 @@ function detectListType(trimmed) {
|
|
|
606
625
|
if (/^\d+[.)]\s+/.test(trimmed)) {
|
|
607
626
|
return "numbered";
|
|
608
627
|
}
|
|
609
|
-
if (/^[-*+]\s
|
|
628
|
+
if (/^[-*+](\s+|$)/.test(trimmed)) {
|
|
610
629
|
return "bullet";
|
|
611
630
|
}
|
|
612
631
|
return null;
|
|
@@ -695,7 +714,7 @@ function parseList(lines, startIndex, listType, indentLevel, allowEmptySteps = f
|
|
|
695
714
|
}
|
|
696
715
|
else {
|
|
697
716
|
const bulletMatch = trimmed.match(/^[-*+]\s+(.*)$/);
|
|
698
|
-
const text = (_d = bulletMatch === null || bulletMatch === void 0 ? void 0 : bulletMatch[1]) !== null && _d !== void 0 ? _d : trimmed.slice(2);
|
|
717
|
+
const text = (_d = bulletMatch === null || bulletMatch === void 0 ? void 0 : bulletMatch[1]) !== null && _d !== void 0 ? _d : (trimmed.length <= 1 ? "" : trimmed.slice(2));
|
|
699
718
|
items.push({
|
|
700
719
|
type: "bulletListItem",
|
|
701
720
|
props: cloneBaseProps(),
|
|
@@ -721,7 +740,7 @@ function isLikelyStep(lines, index) {
|
|
|
721
740
|
if (hasIndent)
|
|
722
741
|
return true;
|
|
723
742
|
// Stop at new list items, headings, or other block-level elements (only if not indented)
|
|
724
|
-
if (/^[-*+]\s/.test(trimmed) || /^\d+[.)]\s/.test(trimmed))
|
|
743
|
+
if (/^[-*+](\s|$)/.test(trimmed) || /^\d+[.)]\s/.test(trimmed))
|
|
725
744
|
break;
|
|
726
745
|
if (trimmed.startsWith("#") || trimmed.startsWith(">") || trimmed.startsWith("|") || trimmed.startsWith("```") || trimmed.startsWith(":::"))
|
|
727
746
|
break;
|
|
@@ -736,7 +755,7 @@ function isLikelyStep(lines, index) {
|
|
|
736
755
|
function parseTestStep(lines, index, allowEmpty = false, snippetId) {
|
|
737
756
|
const current = lines[index];
|
|
738
757
|
const trimmed = current.trim();
|
|
739
|
-
const isBullet = trimmed.startsWith("* ") || trimmed.startsWith("- ");
|
|
758
|
+
const isBullet = trimmed.startsWith("* ") || trimmed.startsWith("- ") || trimmed === "*" || trimmed === "-";
|
|
740
759
|
const isNumbered = /^\d+[.)]\s+/.test(trimmed);
|
|
741
760
|
if (!isBullet && !isNumbered) {
|
|
742
761
|
return null;
|
|
@@ -749,7 +768,7 @@ function parseTestStep(lines, index, allowEmpty = false, snippetId) {
|
|
|
749
768
|
}
|
|
750
769
|
let rawTitle;
|
|
751
770
|
if (isBullet) {
|
|
752
|
-
rawTitle = unescapeMarkdown(trimmed.slice(2)).trim();
|
|
771
|
+
rawTitle = unescapeMarkdown((trimmed.startsWith("* ") || trimmed.startsWith("- ")) ? trimmed.slice(2) : trimmed.slice(1)).trim();
|
|
753
772
|
}
|
|
754
773
|
else {
|
|
755
774
|
// For numbered lists, remove the number and delimiter
|
|
@@ -793,7 +812,7 @@ function parseTestStep(lines, index, allowEmpty = false, snippetId) {
|
|
|
793
812
|
continue;
|
|
794
813
|
}
|
|
795
814
|
const isNumberedStep = NUMBERED_STEP_REGEX.test(rawTrimmed);
|
|
796
|
-
const isNewStep = (!hasIndent && (rawTrimmed.startsWith("* ") || rawTrimmed.startsWith("- "))) ||
|
|
815
|
+
const isNewStep = (!hasIndent && (rawTrimmed.startsWith("* ") || rawTrimmed.startsWith("- ") || rawTrimmed === "*" || rawTrimmed === "-")) ||
|
|
797
816
|
(!hasIndent && isNumberedStep);
|
|
798
817
|
if (isNewStep) {
|
|
799
818
|
break;
|
package/package.json
CHANGED
package/src/App.tsx
CHANGED
|
@@ -22,7 +22,6 @@ import { customSchema, type CustomEditor } from "./editor/customSchema";
|
|
|
22
22
|
import { setStepsFetcher, type StepJsonApiDocument } from "./editor/stepAutocomplete";
|
|
23
23
|
import { setSnippetFetcher, type SnippetJsonApiDocument } from "./editor/snippetAutocomplete";
|
|
24
24
|
import { setImageUploadHandler } from "./editor/stepImageUpload";
|
|
25
|
-
import { canInsertStepOrSnippet } from "./editor/blocks/step";
|
|
26
25
|
import "./App.css";
|
|
27
26
|
|
|
28
27
|
const focusStepField = (
|
|
@@ -335,11 +334,7 @@ function CustomSlashMenu() {
|
|
|
335
334
|
},
|
|
336
335
|
};
|
|
337
336
|
|
|
338
|
-
|
|
339
|
-
const canInsert = cursorBlock ? canInsertStepOrSnippet(editor, cursorBlock.id) : false;
|
|
340
|
-
const customItems = canInsert ? [stepItem, snippetItem] : [];
|
|
341
|
-
|
|
342
|
-
return filterSuggestionItems([...defaultItems, ...customItems], query);
|
|
337
|
+
return filterSuggestionItems([...defaultItems, stepItem, snippetItem], query);
|
|
343
338
|
};
|
|
344
339
|
|
|
345
340
|
return <SuggestionMenuController triggerCharacter="/" getItems={getItems} />;
|
|
@@ -489,7 +484,7 @@ function App() {
|
|
|
489
484
|
const fallbackBlock = documentBlocks[documentBlocks.length - 1];
|
|
490
485
|
const referenceId = selectedBlock?.id ?? fallbackBlock?.id;
|
|
491
486
|
|
|
492
|
-
if (!referenceId
|
|
487
|
+
if (!referenceId) {
|
|
493
488
|
return;
|
|
494
489
|
}
|
|
495
490
|
|
|
@@ -212,32 +212,6 @@ export const stepBlock = createReactBlockSpec(
|
|
|
212
212
|
return count;
|
|
213
213
|
}, [block.id, documentVersion, editor.document]);
|
|
214
214
|
|
|
215
|
-
// Check if there is a preceding "Steps" heading
|
|
216
|
-
const hasStepsHeading = useMemo(() => {
|
|
217
|
-
const allBlocks = editor.document;
|
|
218
|
-
const blockIndex = allBlocks.findIndex((b) => b.id === block.id);
|
|
219
|
-
if (blockIndex < 0) return false;
|
|
220
|
-
|
|
221
|
-
for (let i = blockIndex - 1; i >= 0; i--) {
|
|
222
|
-
const b = allBlocks[i];
|
|
223
|
-
if (b.type === "testStep" || b.type === "snippet" || isEmptyParagraph(b)) {
|
|
224
|
-
continue;
|
|
225
|
-
}
|
|
226
|
-
if (b.type === "heading") {
|
|
227
|
-
const text = (Array.isArray(b.content) ? b.content : [])
|
|
228
|
-
.filter((n: any) => n.type === "text")
|
|
229
|
-
.map((n: any) => n.text ?? "")
|
|
230
|
-
.join("")
|
|
231
|
-
.trim()
|
|
232
|
-
.toLowerCase()
|
|
233
|
-
.replace(/[:\-–—]$/, "");
|
|
234
|
-
return isStepsHeading(text);
|
|
235
|
-
}
|
|
236
|
-
return false;
|
|
237
|
-
}
|
|
238
|
-
return false;
|
|
239
|
-
}, [block.id, documentVersion, editor.document]);
|
|
240
|
-
|
|
241
215
|
useEditorChange(() => {
|
|
242
216
|
setDocumentVersion((version) => version + 1);
|
|
243
217
|
}, editor);
|
|
@@ -417,17 +391,6 @@ export const stepBlock = createReactBlockSpec(
|
|
|
417
391
|
const canToggleData = !dataHasContent;
|
|
418
392
|
const canToggleExpected = !expectedHasContent;
|
|
419
393
|
|
|
420
|
-
// Render as plain text when not under a "Steps" heading
|
|
421
|
-
if (!hasStepsHeading) {
|
|
422
|
-
return (
|
|
423
|
-
<div className="bn-teststep-plain" data-block-id={block.id}>
|
|
424
|
-
<span>{stepTitle || "(empty step)"}</span>
|
|
425
|
-
{stepData ? <span className="bn-teststep-plain__data">{stepData}</span> : null}
|
|
426
|
-
{expectedResult ? <span className="bn-teststep-plain__expected">{expectedResult}</span> : null}
|
|
427
|
-
</div>
|
|
428
|
-
);
|
|
429
|
-
}
|
|
430
|
-
|
|
431
394
|
if (viewMode === "horizontal") {
|
|
432
395
|
return (
|
|
433
396
|
<StepHorizontalView
|
|
@@ -105,6 +105,67 @@ describe("blocksToMarkdown", () => {
|
|
|
105
105
|
);
|
|
106
106
|
});
|
|
107
107
|
|
|
108
|
+
it("serializes a step with empty title but with stepData", () => {
|
|
109
|
+
const blocks: CustomEditorBlock[] = [
|
|
110
|
+
{
|
|
111
|
+
id: "s1",
|
|
112
|
+
type: "testStep",
|
|
113
|
+
props: {
|
|
114
|
+
stepTitle: "",
|
|
115
|
+
stepData: "Navigate to the page",
|
|
116
|
+
expectedResult: "",
|
|
117
|
+
listStyle: "bullet",
|
|
118
|
+
},
|
|
119
|
+
content: undefined,
|
|
120
|
+
children: [],
|
|
121
|
+
},
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
expect(blocksToMarkdown(blocks)).toBe(
|
|
125
|
+
["* ", " Navigate to the page"].join("\n"),
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("serializes a step with empty title but with expectedResult", () => {
|
|
130
|
+
const blocks: CustomEditorBlock[] = [
|
|
131
|
+
{
|
|
132
|
+
id: "s1",
|
|
133
|
+
type: "testStep",
|
|
134
|
+
props: {
|
|
135
|
+
stepTitle: "",
|
|
136
|
+
stepData: "",
|
|
137
|
+
expectedResult: "Login form visible",
|
|
138
|
+
listStyle: "bullet",
|
|
139
|
+
},
|
|
140
|
+
content: undefined,
|
|
141
|
+
children: [],
|
|
142
|
+
},
|
|
143
|
+
];
|
|
144
|
+
|
|
145
|
+
expect(blocksToMarkdown(blocks)).toBe(
|
|
146
|
+
["* ", " *Expected*: Login form visible"].join("\n"),
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("drops completely empty step (no title, no data, no expected)", () => {
|
|
151
|
+
const blocks: CustomEditorBlock[] = [
|
|
152
|
+
{
|
|
153
|
+
id: "s1",
|
|
154
|
+
type: "testStep",
|
|
155
|
+
props: {
|
|
156
|
+
stepTitle: "",
|
|
157
|
+
stepData: "",
|
|
158
|
+
expectedResult: "",
|
|
159
|
+
listStyle: "bullet",
|
|
160
|
+
},
|
|
161
|
+
content: undefined,
|
|
162
|
+
children: [],
|
|
163
|
+
},
|
|
164
|
+
];
|
|
165
|
+
|
|
166
|
+
expect(blocksToMarkdown(blocks)).toBe("");
|
|
167
|
+
});
|
|
168
|
+
|
|
108
169
|
it("serializes a snippet block with prefixed title", () => {
|
|
109
170
|
const blocks: CustomEditorBlock[] = [
|
|
110
171
|
{
|
|
@@ -377,7 +438,120 @@ describe("blocksToMarkdown", () => {
|
|
|
377
438
|
[
|
|
378
439
|
"| Steps | Expected Results |",
|
|
379
440
|
"| --- | --- |",
|
|
380
|
-
"| line1<br
|
|
441
|
+
"| line1<br>line2 | ok |",
|
|
442
|
+
].join("\n"),
|
|
443
|
+
);
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
it("serializes table cell with code style and newline placing br outside backticks", () => {
|
|
447
|
+
const blocks: CustomEditorBlock[] = [
|
|
448
|
+
{
|
|
449
|
+
id: "tbl-code",
|
|
450
|
+
type: "table",
|
|
451
|
+
props: { textColor: "default" },
|
|
452
|
+
content: {
|
|
453
|
+
type: "tableContent",
|
|
454
|
+
columnWidths: [undefined, undefined],
|
|
455
|
+
headerRows: 1,
|
|
456
|
+
rows: [
|
|
457
|
+
{
|
|
458
|
+
cells: [
|
|
459
|
+
{
|
|
460
|
+
type: "tableCell",
|
|
461
|
+
props: cellProps,
|
|
462
|
+
content: [{ type: "text", text: "A", styles: {} }],
|
|
463
|
+
},
|
|
464
|
+
{
|
|
465
|
+
type: "tableCell",
|
|
466
|
+
props: cellProps,
|
|
467
|
+
content: [{ type: "text", text: "B", styles: {} }],
|
|
468
|
+
},
|
|
469
|
+
],
|
|
470
|
+
},
|
|
471
|
+
{
|
|
472
|
+
cells: [
|
|
473
|
+
{
|
|
474
|
+
type: "tableCell",
|
|
475
|
+
props: cellProps,
|
|
476
|
+
content: [
|
|
477
|
+
{ type: "text", text: "code", styles: { code: true } },
|
|
478
|
+
{ type: "text", text: "\nnext line", styles: {} },
|
|
479
|
+
],
|
|
480
|
+
},
|
|
481
|
+
{
|
|
482
|
+
type: "tableCell",
|
|
483
|
+
props: cellProps,
|
|
484
|
+
content: [{ type: "text", text: "ok", styles: {} }],
|
|
485
|
+
},
|
|
486
|
+
],
|
|
487
|
+
},
|
|
488
|
+
],
|
|
489
|
+
},
|
|
490
|
+
children: [],
|
|
491
|
+
},
|
|
492
|
+
];
|
|
493
|
+
|
|
494
|
+
expect(blocksToMarkdown(blocks)).toBe(
|
|
495
|
+
[
|
|
496
|
+
"| A | B |",
|
|
497
|
+
"| --- | --- |",
|
|
498
|
+
"| `code`<br>next line | ok |",
|
|
499
|
+
].join("\n"),
|
|
500
|
+
);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it("serializes table cells with styled text and newlines without trapping <br/> inside markers", () => {
|
|
504
|
+
const blocks: CustomEditorBlock[] = [
|
|
505
|
+
{
|
|
506
|
+
id: "tbl3",
|
|
507
|
+
type: "table",
|
|
508
|
+
props: { textColor: "default" },
|
|
509
|
+
content: {
|
|
510
|
+
type: "tableContent",
|
|
511
|
+
columnWidths: [undefined, undefined],
|
|
512
|
+
headerRows: 1,
|
|
513
|
+
rows: [
|
|
514
|
+
{
|
|
515
|
+
cells: [
|
|
516
|
+
{
|
|
517
|
+
type: "tableCell",
|
|
518
|
+
props: cellProps,
|
|
519
|
+
content: [{ type: "text", text: "Col A", styles: {} }],
|
|
520
|
+
},
|
|
521
|
+
{
|
|
522
|
+
type: "tableCell",
|
|
523
|
+
props: cellProps,
|
|
524
|
+
content: [{ type: "text", text: "Col B", styles: {} }],
|
|
525
|
+
},
|
|
526
|
+
],
|
|
527
|
+
},
|
|
528
|
+
{
|
|
529
|
+
cells: [
|
|
530
|
+
{
|
|
531
|
+
type: "tableCell",
|
|
532
|
+
props: cellProps,
|
|
533
|
+
content: [{ type: "text", text: "ok", styles: {} }],
|
|
534
|
+
},
|
|
535
|
+
{
|
|
536
|
+
type: "tableCell",
|
|
537
|
+
props: cellProps,
|
|
538
|
+
content: [
|
|
539
|
+
{ type: "text", text: "opened\nnewline", styles: { bold: true } },
|
|
540
|
+
],
|
|
541
|
+
},
|
|
542
|
+
],
|
|
543
|
+
},
|
|
544
|
+
],
|
|
545
|
+
},
|
|
546
|
+
children: [],
|
|
547
|
+
},
|
|
548
|
+
];
|
|
549
|
+
|
|
550
|
+
expect(blocksToMarkdown(blocks)).toBe(
|
|
551
|
+
[
|
|
552
|
+
"| Col A | Col B |",
|
|
553
|
+
"| --- | --- |",
|
|
554
|
+
"| ok | **opened**<br>**newline** |",
|
|
381
555
|
].join("\n"),
|
|
382
556
|
);
|
|
383
557
|
});
|
|
@@ -419,6 +593,55 @@ describe("markdownToBlocks", () => {
|
|
|
419
593
|
]);
|
|
420
594
|
});
|
|
421
595
|
|
|
596
|
+
it("parses a step with empty title but with step data", () => {
|
|
597
|
+
const markdown = ["* ", " Navigate to the page"].join("\n");
|
|
598
|
+
|
|
599
|
+
expect(markdownToBlocks(markdown)).toEqual([
|
|
600
|
+
{
|
|
601
|
+
type: "testStep",
|
|
602
|
+
props: {
|
|
603
|
+
stepTitle: "",
|
|
604
|
+
stepData: "Navigate to the page",
|
|
605
|
+
expectedResult: "",
|
|
606
|
+
listStyle: "bullet",
|
|
607
|
+
},
|
|
608
|
+
children: [],
|
|
609
|
+
},
|
|
610
|
+
]);
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
it("round-trips a title-less step with data", () => {
|
|
614
|
+
const blocks: CustomEditorBlock[] = [
|
|
615
|
+
{
|
|
616
|
+
id: "s1",
|
|
617
|
+
type: "testStep",
|
|
618
|
+
props: {
|
|
619
|
+
stepTitle: "",
|
|
620
|
+
stepData: "Open the browser",
|
|
621
|
+
expectedResult: "Page loads",
|
|
622
|
+
listStyle: "bullet",
|
|
623
|
+
},
|
|
624
|
+
content: undefined,
|
|
625
|
+
children: [],
|
|
626
|
+
},
|
|
627
|
+
];
|
|
628
|
+
|
|
629
|
+
const md = blocksToMarkdown(blocks);
|
|
630
|
+
const parsed = markdownToBlocks(md);
|
|
631
|
+
expect(parsed).toEqual([
|
|
632
|
+
{
|
|
633
|
+
type: "testStep",
|
|
634
|
+
props: {
|
|
635
|
+
stepTitle: "",
|
|
636
|
+
stepData: "Open the browser",
|
|
637
|
+
expectedResult: "Page loads",
|
|
638
|
+
listStyle: "bullet",
|
|
639
|
+
},
|
|
640
|
+
children: [],
|
|
641
|
+
},
|
|
642
|
+
]);
|
|
643
|
+
});
|
|
644
|
+
|
|
422
645
|
it("parses snippet markdown into snippet blocks", () => {
|
|
423
646
|
const markdown = [
|
|
424
647
|
"<!-- begin snippet #501 -->",
|
|
@@ -1270,7 +1493,7 @@ describe("markdownToBlocks", () => {
|
|
|
1270
1493
|
const markdown = [
|
|
1271
1494
|
"| A | B |",
|
|
1272
1495
|
"| --- | --- |",
|
|
1273
|
-
"| line1<br
|
|
1496
|
+
"| line1<br>line2 | ok |",
|
|
1274
1497
|
].join("\n");
|
|
1275
1498
|
|
|
1276
1499
|
const blocks = markdownToBlocks(markdown);
|
|
@@ -1364,7 +1587,7 @@ describe("markdownToBlocks", () => {
|
|
|
1364
1587
|
];
|
|
1365
1588
|
|
|
1366
1589
|
const markdown = blocksToMarkdown(blocks);
|
|
1367
|
-
expect(markdown).toContain("first<br
|
|
1590
|
+
expect(markdown).toContain("first<br>second<br>third");
|
|
1368
1591
|
|
|
1369
1592
|
const parsed = markdownToBlocks(markdown);
|
|
1370
1593
|
const row = (parsed[0] as any).content.rows[1];
|
|
@@ -174,12 +174,21 @@ function applyTextStyles(text: string, styles: EditorStyles | undefined): string
|
|
|
174
174
|
});
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
177
|
+
// Split on newlines so that style markers wrap each line individually.
|
|
178
|
+
// This prevents <br/> (inserted by table cell formatting) from being
|
|
179
|
+
// trapped inside markers like **bold<br/>text**.
|
|
180
|
+
const segments = result.split("\n");
|
|
181
|
+
const wrapped = segments.map((segment) => {
|
|
182
|
+
if (!segment) return segment;
|
|
183
|
+
let s = segment;
|
|
184
|
+
for (const wrapper of wrappers) {
|
|
185
|
+
const suffix = wrapper.suffix ?? wrapper.prefix;
|
|
186
|
+
s = `${wrapper.prefix}${s}${suffix}`;
|
|
187
|
+
}
|
|
188
|
+
return s;
|
|
189
|
+
});
|
|
181
190
|
|
|
182
|
-
return
|
|
191
|
+
return wrapped.join("\n");
|
|
183
192
|
}
|
|
184
193
|
|
|
185
194
|
function inlineToMarkdown(content: CustomEditorBlock["content"]): string {
|
|
@@ -392,18 +401,19 @@ function serializeBlock(
|
|
|
392
401
|
return flattenWithBlankLine(lines, true);
|
|
393
402
|
}
|
|
394
403
|
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
}
|
|
404
|
+
const normalizedTitle = stepTitle
|
|
405
|
+
.split(/\r?\n/)
|
|
406
|
+
.map((segment: string) => segment.trim())
|
|
407
|
+
.filter((segment: string) => segment.length > 0)
|
|
408
|
+
.join(" ");
|
|
409
|
+
|
|
410
|
+
const normalizedExpectedForCheck = stripExpectedPrefix(expectedResult).trim();
|
|
411
|
+
const hasContent = stepData.length > 0 || normalizedExpectedForCheck.length > 0;
|
|
412
|
+
|
|
413
|
+
if (normalizedTitle.length > 0 || hasContent) {
|
|
414
|
+
const listStyle = (block.props as any).listStyle ?? "bullet";
|
|
415
|
+
const prefix = listStyle === "ordered" ? `${(stepIndex ?? 0) + 1}.` : "*";
|
|
416
|
+
lines.push(normalizedTitle.length > 0 ? `${prefix} ${normalizedTitle}` : `${prefix} `);
|
|
407
417
|
}
|
|
408
418
|
|
|
409
419
|
if (stepData.length > 0) {
|
|
@@ -515,7 +525,15 @@ function serializeBlock(
|
|
|
515
525
|
const formattedRows = rows.map(normalizeRow);
|
|
516
526
|
const formatCell = (value: string) => {
|
|
517
527
|
if (!value.length) return " ";
|
|
518
|
-
|
|
528
|
+
// Handle newlines inside backtick code spans:
|
|
529
|
+
// close code before <br>, reopen after
|
|
530
|
+
let result = value.replace(/`([^`]*)`/g, (match) => {
|
|
531
|
+
if (!match.includes("\n")) return match;
|
|
532
|
+
const inner = match.slice(1, -1);
|
|
533
|
+
const parts = inner.split("\n");
|
|
534
|
+
return parts.map((p) => (p ? `\`${p}\`` : "")).join("<br>");
|
|
535
|
+
});
|
|
536
|
+
return result.replace(/\n/g, "<br>");
|
|
519
537
|
};
|
|
520
538
|
const toAlignmentToken = (alignment: string) => {
|
|
521
539
|
switch (alignment) {
|
|
@@ -734,7 +752,7 @@ function detectListType(trimmed: string): "bullet" | "numbered" | "check" | null
|
|
|
734
752
|
if (/^\d+[.)]\s+/.test(trimmed)) {
|
|
735
753
|
return "numbered";
|
|
736
754
|
}
|
|
737
|
-
if (/^[-*+]\s
|
|
755
|
+
if (/^[-*+](\s+|$)/.test(trimmed)) {
|
|
738
756
|
return "bullet";
|
|
739
757
|
}
|
|
740
758
|
return null;
|
|
@@ -850,7 +868,7 @@ function parseList(
|
|
|
850
868
|
});
|
|
851
869
|
} else {
|
|
852
870
|
const bulletMatch = trimmed.match(/^[-*+]\s+(.*)$/);
|
|
853
|
-
const text = bulletMatch?.[1] ?? trimmed.slice(2);
|
|
871
|
+
const text = bulletMatch?.[1] ?? (trimmed.length <= 1 ? "" : trimmed.slice(2));
|
|
854
872
|
items.push({
|
|
855
873
|
type: "bulletListItem",
|
|
856
874
|
props: cloneBaseProps(),
|
|
@@ -880,7 +898,7 @@ function isLikelyStep(lines: string[], index: number): boolean {
|
|
|
880
898
|
if (hasIndent) return true;
|
|
881
899
|
|
|
882
900
|
// Stop at new list items, headings, or other block-level elements (only if not indented)
|
|
883
|
-
if (/^[-*+]\s/.test(trimmed) || /^\d+[.)]\s/.test(trimmed)) break;
|
|
901
|
+
if (/^[-*+](\s|$)/.test(trimmed) || /^\d+[.)]\s/.test(trimmed)) break;
|
|
884
902
|
if (trimmed.startsWith("#") || trimmed.startsWith(">") || trimmed.startsWith("|") || trimmed.startsWith("```") || trimmed.startsWith(":::")) break;
|
|
885
903
|
|
|
886
904
|
// Check for expected result markers
|
|
@@ -899,7 +917,7 @@ function parseTestStep(
|
|
|
899
917
|
): { block: CustomPartialBlock; nextIndex: number } | null {
|
|
900
918
|
const current = lines[index];
|
|
901
919
|
const trimmed = current.trim();
|
|
902
|
-
const isBullet = trimmed.startsWith("* ") || trimmed.startsWith("- ");
|
|
920
|
+
const isBullet = trimmed.startsWith("* ") || trimmed.startsWith("- ") || trimmed === "*" || trimmed === "-";
|
|
903
921
|
const isNumbered = /^\d+[.)]\s+/.test(trimmed);
|
|
904
922
|
|
|
905
923
|
if (!isBullet && !isNumbered) {
|
|
@@ -915,7 +933,9 @@ function parseTestStep(
|
|
|
915
933
|
|
|
916
934
|
let rawTitle: string;
|
|
917
935
|
if (isBullet) {
|
|
918
|
-
rawTitle = unescapeMarkdown(
|
|
936
|
+
rawTitle = unescapeMarkdown(
|
|
937
|
+
(trimmed.startsWith("* ") || trimmed.startsWith("- ")) ? trimmed.slice(2) : trimmed.slice(1)
|
|
938
|
+
).trim();
|
|
919
939
|
} else {
|
|
920
940
|
// For numbered lists, remove the number and delimiter
|
|
921
941
|
rawTitle = unescapeMarkdown(trimmed.replace(/^\d+[.)]\s+/, "")).trim();
|
|
@@ -963,7 +983,7 @@ function parseTestStep(
|
|
|
963
983
|
}
|
|
964
984
|
const isNumberedStep = NUMBERED_STEP_REGEX.test(rawTrimmed);
|
|
965
985
|
const isNewStep =
|
|
966
|
-
(!hasIndent && (rawTrimmed.startsWith("* ") || rawTrimmed.startsWith("- "))) ||
|
|
986
|
+
(!hasIndent && (rawTrimmed.startsWith("* ") || rawTrimmed.startsWith("- ") || rawTrimmed === "*" || rawTrimmed === "-")) ||
|
|
967
987
|
(!hasIndent && isNumberedStep);
|
|
968
988
|
|
|
969
989
|
if (isNewStep) {
|