testomatio-editor-blocks 0.4.46 → 0.4.48
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.d.ts +11 -1
- package/package/editor/blocks/step.js +56 -0
- package/package/editor/blocks/stepField.js +134 -12
- package/package/editor/customMarkdownConverter.js +8 -35
- package/package/index.d.ts +1 -1
- package/package/index.js +1 -1
- package/package/styles.css +14 -4
- package/package.json +1 -1
- package/src/App.tsx +27 -48
- package/src/editor/blocks/step.tsx +61 -1
- package/src/editor/blocks/stepField.tsx +141 -8
- package/src/editor/customMarkdownConverter.test.ts +113 -15
- package/src/editor/customMarkdownConverter.ts +8 -36
- package/src/editor/markdownToBlocks.test.ts +15 -3
- package/src/editor/styles.css +14 -4
- package/src/index.ts +1 -1
|
@@ -105,7 +105,7 @@ export function canInsertStepOrSnippet(
|
|
|
105
105
|
*/
|
|
106
106
|
export function addStepsBlock(editor: {
|
|
107
107
|
document: any[];
|
|
108
|
-
insertBlocks: (blocks: any[], referenceId: string, placement:
|
|
108
|
+
insertBlocks: (blocks: any[], referenceId: string, placement: "before" | "after") => any[];
|
|
109
109
|
}): string | null {
|
|
110
110
|
const allBlocks = editor.document;
|
|
111
111
|
const emptyStep = {
|
|
@@ -157,6 +157,66 @@ export function addStepsBlock(editor: {
|
|
|
157
157
|
return inserted?.[1]?.id ?? null;
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
/**
|
|
161
|
+
* Programmatically add an empty snippet block to the editor.
|
|
162
|
+
* - If a "Steps" heading exists, inserts after the last step/snippet under it.
|
|
163
|
+
* - Otherwise, appends a "Steps" heading + empty snippet at the end.
|
|
164
|
+
* Returns the inserted snippet's block ID (for focusing), or null.
|
|
165
|
+
*/
|
|
166
|
+
export function addSnippetBlock(editor: {
|
|
167
|
+
document: any[];
|
|
168
|
+
insertBlocks: (blocks: any[], referenceId: string, placement: "before" | "after") => any[];
|
|
169
|
+
}): string | null {
|
|
170
|
+
const allBlocks = editor.document;
|
|
171
|
+
const emptySnippet = {
|
|
172
|
+
type: "snippet" as const,
|
|
173
|
+
props: { snippetId: "", snippetTitle: "", snippetData: "", snippetExpectedResult: "" },
|
|
174
|
+
children: [],
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
let stepsHeadingIndex = -1;
|
|
178
|
+
for (let i = 0; i < allBlocks.length; i++) {
|
|
179
|
+
const b = allBlocks[i];
|
|
180
|
+
if (b.type !== "heading") continue;
|
|
181
|
+
const text = (Array.isArray(b.content) ? b.content : [])
|
|
182
|
+
.filter((n: any) => n.type === "text")
|
|
183
|
+
.map((n: any) => n.text ?? "")
|
|
184
|
+
.join("")
|
|
185
|
+
.trim()
|
|
186
|
+
.toLowerCase()
|
|
187
|
+
.replace(/[:\-–—]$/, "");
|
|
188
|
+
if (isStepsHeading(text)) {
|
|
189
|
+
stepsHeadingIndex = i;
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (stepsHeadingIndex >= 0) {
|
|
195
|
+
let lastIndex = stepsHeadingIndex;
|
|
196
|
+
for (let i = stepsHeadingIndex + 1; i < allBlocks.length; i++) {
|
|
197
|
+
const b = allBlocks[i];
|
|
198
|
+
if (b.type === "testStep" || b.type === "snippet") {
|
|
199
|
+
lastIndex = i;
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (isEmptyParagraph(b)) continue;
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
const inserted = editor.insertBlocks([emptySnippet], allBlocks[lastIndex].id, "after");
|
|
206
|
+
return inserted?.[0]?.id ?? null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const lastBlock = allBlocks[allBlocks.length - 1];
|
|
210
|
+
const stepsHeading = {
|
|
211
|
+
type: "heading" as const,
|
|
212
|
+
props: { level: 3 },
|
|
213
|
+
content: [{ type: "text" as const, text: "Steps" }],
|
|
214
|
+
children: [],
|
|
215
|
+
};
|
|
216
|
+
const inserted = editor.insertBlocks([stepsHeading, emptySnippet], lastBlock.id, "after");
|
|
217
|
+
return inserted?.[1]?.id ?? null;
|
|
218
|
+
}
|
|
219
|
+
|
|
160
220
|
export const stepBlock = createReactBlockSpec(
|
|
161
221
|
{
|
|
162
222
|
type: "testStep",
|
|
@@ -81,6 +81,22 @@ type ExtractedImage = {
|
|
|
81
81
|
|
|
82
82
|
type LinkMeta = { start: number; end: number; url: string };
|
|
83
83
|
type FormattingMeta = { start: number; end: number; type: "bold" | "italic" | "code" };
|
|
84
|
+
type FormatType = "bold" | "italic" | "code";
|
|
85
|
+
|
|
86
|
+
function getActiveFormats(
|
|
87
|
+
formatting: FormattingMeta[],
|
|
88
|
+
selStart: number,
|
|
89
|
+
selEnd: number,
|
|
90
|
+
): Set<FormatType> {
|
|
91
|
+
const active = new Set<FormatType>();
|
|
92
|
+
if (selStart === selEnd) return active;
|
|
93
|
+
for (const f of formatting) {
|
|
94
|
+
if (selStart < f.end && selEnd > f.start) {
|
|
95
|
+
active.add(f.type);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return active;
|
|
99
|
+
}
|
|
84
100
|
|
|
85
101
|
|
|
86
102
|
function stripInlineMarkdown(markdown: string): {
|
|
@@ -345,8 +361,6 @@ function getCaretRectInPreview(preview: HTMLElement, offset: number, textareaVal
|
|
|
345
361
|
}
|
|
346
362
|
|
|
347
363
|
function applyFormattingHighlights(preview: HTMLElement, formatting: FormattingMeta[], textareaValue?: string) {
|
|
348
|
-
if (formatting.length === 0) return;
|
|
349
|
-
|
|
350
364
|
// Remove previous formatting highlights
|
|
351
365
|
const existingBold = preview.querySelectorAll("strong.step-preview-bold");
|
|
352
366
|
for (let i = 0; i < existingBold.length; i++) {
|
|
@@ -386,6 +400,8 @@ function applyFormattingHighlights(preview: HTMLElement, formatting: FormattingM
|
|
|
386
400
|
// so the tree walker sees clean text nodes matching the original structure.
|
|
387
401
|
preview.normalize();
|
|
388
402
|
|
|
403
|
+
if (formatting.length === 0) return;
|
|
404
|
+
|
|
389
405
|
// OverType splits textarea lines into <div> elements, discarding the \n
|
|
390
406
|
// characters. Convert textarea-space positions (with \n) to preview-space
|
|
391
407
|
// positions (without \n) so we can find the correct text nodes.
|
|
@@ -632,10 +648,14 @@ export function StepField({
|
|
|
632
648
|
const linkSelectionRef = useRef<{ start: number; end: number; text: string } | null>(null);
|
|
633
649
|
const linksRef = useRef<LinkMeta[]>([]);
|
|
634
650
|
const formattingRef = useRef<FormattingMeta[]>([]);
|
|
651
|
+
const formattingUndoRef = useRef<Array<{ formatting: FormattingMeta[]; links: LinkMeta[] }>>([]);
|
|
652
|
+
const formattingRedoRef = useRef<Array<{ formatting: FormattingMeta[]; links: LinkMeta[] }>>([]);
|
|
635
653
|
const caretRef = useRef<HTMLDivElement | null>(null);
|
|
636
654
|
const prevTextRef = useRef("");
|
|
637
655
|
const isSyncingRef = useRef(false);
|
|
638
656
|
const [cursorLink, setCursorLink] = useState<LinkMeta | null>(null);
|
|
657
|
+
const [activeFormats, setActiveFormats] = useState<Set<FormatType>>(new Set());
|
|
658
|
+
const [linkActive, setLinkActive] = useState(false);
|
|
639
659
|
const Components = useComponentsContext();
|
|
640
660
|
const resolvedPlaceholder = placeholder ?? "";
|
|
641
661
|
|
|
@@ -658,6 +678,8 @@ export function StepField({
|
|
|
658
678
|
|
|
659
679
|
linksRef.current = adjustLinksForEdit(linksRef.current, editPos, delta);
|
|
660
680
|
formattingRef.current = adjustFormattingForEdit(formattingRef.current, editPos, delta);
|
|
681
|
+
formattingUndoRef.current = [];
|
|
682
|
+
formattingRedoRef.current = [];
|
|
661
683
|
prevTextRef.current = nextValue;
|
|
662
684
|
|
|
663
685
|
const markdown = buildFullMarkdown(nextValue, linksRef.current, formattingRef.current);
|
|
@@ -1024,22 +1046,45 @@ export function StepField({
|
|
|
1024
1046
|
textareaNode.focus();
|
|
1025
1047
|
|
|
1026
1048
|
const fmtType: "bold" | "italic" | "code" = action === "toggleBold" ? "bold" : action === "toggleCode" ? "code" : "italic";
|
|
1027
|
-
const
|
|
1028
|
-
const
|
|
1049
|
+
const rawStart = textareaNode.selectionStart ?? 0;
|
|
1050
|
+
const rawEnd = textareaNode.selectionEnd ?? 0;
|
|
1051
|
+
|
|
1052
|
+
// Trim leading/trailing whitespace from the selection so that
|
|
1053
|
+
// formatting markers wrap only the meaningful content.
|
|
1054
|
+
const selectedText = textareaNode.value.slice(rawStart, rawEnd);
|
|
1055
|
+
const leadingWs = selectedText.match(/^(\s*)/)?.[1].length ?? 0;
|
|
1056
|
+
const trailingWs = selectedText.match(/(\s*)$/)?.[1].length ?? 0;
|
|
1057
|
+
const start = rawStart + leadingWs;
|
|
1058
|
+
const end = rawEnd - trailingWs;
|
|
1059
|
+
|
|
1060
|
+
// If selection is all whitespace, nothing to format
|
|
1061
|
+
if (start >= end) return;
|
|
1029
1062
|
|
|
1030
1063
|
// Check if selection is already formatted
|
|
1031
1064
|
const existingIdx = formattingRef.current.findIndex(
|
|
1032
1065
|
(f) => f.type === fmtType && f.start <= start && f.end >= end,
|
|
1033
1066
|
);
|
|
1034
1067
|
|
|
1068
|
+
// Save current state for undo before modifying
|
|
1069
|
+
formattingUndoRef.current = [
|
|
1070
|
+
...formattingUndoRef.current,
|
|
1071
|
+
{ formatting: [...formattingRef.current], links: [...linksRef.current] },
|
|
1072
|
+
];
|
|
1073
|
+
formattingRedoRef.current = [];
|
|
1074
|
+
|
|
1035
1075
|
if (existingIdx !== -1) {
|
|
1036
1076
|
// Remove formatting
|
|
1037
1077
|
formattingRef.current = formattingRef.current.filter((_, i) => i !== existingIdx);
|
|
1038
1078
|
} else if (start !== end) {
|
|
1079
|
+
// Remove overlapping formatting of other types before applying new format
|
|
1080
|
+
formattingRef.current = formattingRef.current.filter(
|
|
1081
|
+
(f) => f.type === fmtType || f.start >= end || f.end <= start,
|
|
1082
|
+
);
|
|
1039
1083
|
// Add formatting for selection
|
|
1040
1084
|
formattingRef.current = [...formattingRef.current, { start, end, type: fmtType }];
|
|
1041
1085
|
} else {
|
|
1042
1086
|
// No selection — nothing to format
|
|
1087
|
+
formattingUndoRef.current = formattingUndoRef.current.slice(0, -1);
|
|
1043
1088
|
return;
|
|
1044
1089
|
}
|
|
1045
1090
|
|
|
@@ -1057,6 +1102,46 @@ export function StepField({
|
|
|
1057
1102
|
[textareaNode],
|
|
1058
1103
|
);
|
|
1059
1104
|
|
|
1105
|
+
const updateActiveFormats = useCallback(() => {
|
|
1106
|
+
if (!textareaNode) return;
|
|
1107
|
+
const selStart = textareaNode.selectionStart ?? 0;
|
|
1108
|
+
const selEnd = textareaNode.selectionEnd ?? 0;
|
|
1109
|
+
const next = getActiveFormats(formattingRef.current, selStart, selEnd);
|
|
1110
|
+
setActiveFormats((prev) => {
|
|
1111
|
+
if (prev.size === next.size && [...prev].every((t) => next.has(t))) return prev;
|
|
1112
|
+
return next;
|
|
1113
|
+
});
|
|
1114
|
+
const hasLink = selStart !== selEnd && linksRef.current.some((l) => selStart < l.end && selEnd > l.start);
|
|
1115
|
+
setLinkActive(hasLink);
|
|
1116
|
+
}, [textareaNode]);
|
|
1117
|
+
|
|
1118
|
+
useEffect(() => {
|
|
1119
|
+
if (!textareaNode) return;
|
|
1120
|
+
|
|
1121
|
+
const onSelectionChange = () => {
|
|
1122
|
+
if (document.activeElement === textareaNode) {
|
|
1123
|
+
updateActiveFormats();
|
|
1124
|
+
}
|
|
1125
|
+
};
|
|
1126
|
+
|
|
1127
|
+
const onBlur = () => {
|
|
1128
|
+
setActiveFormats(new Set());
|
|
1129
|
+
setLinkActive(false);
|
|
1130
|
+
};
|
|
1131
|
+
|
|
1132
|
+
document.addEventListener("selectionchange", onSelectionChange);
|
|
1133
|
+
textareaNode.addEventListener("keyup", updateActiveFormats);
|
|
1134
|
+
textareaNode.addEventListener("mouseup", updateActiveFormats);
|
|
1135
|
+
textareaNode.addEventListener("blur", onBlur);
|
|
1136
|
+
|
|
1137
|
+
return () => {
|
|
1138
|
+
document.removeEventListener("selectionchange", onSelectionChange);
|
|
1139
|
+
textareaNode.removeEventListener("keyup", updateActiveFormats);
|
|
1140
|
+
textareaNode.removeEventListener("mouseup", updateActiveFormats);
|
|
1141
|
+
textareaNode.removeEventListener("blur", onBlur);
|
|
1142
|
+
};
|
|
1143
|
+
}, [textareaNode, updateActiveFormats]);
|
|
1144
|
+
|
|
1060
1145
|
const linkPopoverRef = useRef<HTMLDivElement>(null);
|
|
1061
1146
|
|
|
1062
1147
|
// Close link popover on outside click
|
|
@@ -1310,6 +1395,54 @@ export function StepField({
|
|
|
1310
1395
|
handleToolbarAction("toggleCode");
|
|
1311
1396
|
return;
|
|
1312
1397
|
}
|
|
1398
|
+
if (event.key === "z" || event.key === "Z") {
|
|
1399
|
+
const undoStack = formattingUndoRef.current;
|
|
1400
|
+
if (undoStack.length > 0) {
|
|
1401
|
+
event.preventDefault();
|
|
1402
|
+
event.stopImmediatePropagation();
|
|
1403
|
+
formattingRedoRef.current = [
|
|
1404
|
+
...formattingRedoRef.current,
|
|
1405
|
+
{ formatting: [...formattingRef.current], links: [...linksRef.current] },
|
|
1406
|
+
];
|
|
1407
|
+
const prev = undoStack[undoStack.length - 1];
|
|
1408
|
+
formattingUndoRef.current = undoStack.slice(0, -1);
|
|
1409
|
+
formattingRef.current = prev.formatting;
|
|
1410
|
+
linksRef.current = prev.links;
|
|
1411
|
+
const instance = editorInstanceRef.current;
|
|
1412
|
+
if (instance) {
|
|
1413
|
+
const markdown = buildFullMarkdown(instance.getValue(), linksRef.current, formattingRef.current);
|
|
1414
|
+
onChangeRef.current?.(markdown);
|
|
1415
|
+
setPlainTextValue(markdownToPlainText(markdown));
|
|
1416
|
+
applyFormattingHighlights(instance.preview, formattingRef.current, instance.textarea?.value);
|
|
1417
|
+
applyLinkHighlights(instance.preview, linksRef.current);
|
|
1418
|
+
}
|
|
1419
|
+
return;
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
if (modKey && event.shiftKey && (event.key === "z" || event.key === "Z")) {
|
|
1424
|
+
const redoStack = formattingRedoRef.current;
|
|
1425
|
+
if (redoStack.length > 0) {
|
|
1426
|
+
event.preventDefault();
|
|
1427
|
+
event.stopImmediatePropagation();
|
|
1428
|
+
formattingUndoRef.current = [
|
|
1429
|
+
...formattingUndoRef.current,
|
|
1430
|
+
{ formatting: [...formattingRef.current], links: [...linksRef.current] },
|
|
1431
|
+
];
|
|
1432
|
+
const next = redoStack[redoStack.length - 1];
|
|
1433
|
+
formattingRedoRef.current = redoStack.slice(0, -1);
|
|
1434
|
+
formattingRef.current = next.formatting;
|
|
1435
|
+
linksRef.current = next.links;
|
|
1436
|
+
const instance = editorInstanceRef.current;
|
|
1437
|
+
if (instance) {
|
|
1438
|
+
const markdown = buildFullMarkdown(instance.getValue(), linksRef.current, formattingRef.current);
|
|
1439
|
+
onChangeRef.current?.(markdown);
|
|
1440
|
+
setPlainTextValue(markdownToPlainText(markdown));
|
|
1441
|
+
applyFormattingHighlights(instance.preview, formattingRef.current, instance.textarea?.value);
|
|
1442
|
+
applyLinkHighlights(instance.preview, linksRef.current);
|
|
1443
|
+
}
|
|
1444
|
+
return;
|
|
1445
|
+
}
|
|
1313
1446
|
}
|
|
1314
1447
|
|
|
1315
1448
|
if (enableAutocomplete && shouldShowAutocomplete) {
|
|
@@ -1489,7 +1622,7 @@ export function StepField({
|
|
|
1489
1622
|
<>
|
|
1490
1623
|
<button
|
|
1491
1624
|
type="button"
|
|
1492
|
-
className="bn-step-toolbar__button"
|
|
1625
|
+
className={`bn-step-toolbar__button${activeFormats.has("bold") ? " bn-step-toolbar__button--active" : ""}`}
|
|
1493
1626
|
data-tooltip="Bold"
|
|
1494
1627
|
onMouseDown={(event) => {
|
|
1495
1628
|
event.preventDefault();
|
|
@@ -1504,7 +1637,7 @@ export function StepField({
|
|
|
1504
1637
|
</button>
|
|
1505
1638
|
<button
|
|
1506
1639
|
type="button"
|
|
1507
|
-
className="bn-step-toolbar__button"
|
|
1640
|
+
className={`bn-step-toolbar__button${activeFormats.has("italic") ? " bn-step-toolbar__button--active" : ""}`}
|
|
1508
1641
|
data-tooltip="Italic"
|
|
1509
1642
|
onMouseDown={(event) => {
|
|
1510
1643
|
event.preventDefault();
|
|
@@ -1519,7 +1652,7 @@ export function StepField({
|
|
|
1519
1652
|
</button>
|
|
1520
1653
|
<button
|
|
1521
1654
|
type="button"
|
|
1522
|
-
className="bn-step-toolbar__button"
|
|
1655
|
+
className={`bn-step-toolbar__button${activeFormats.has("code") ? " bn-step-toolbar__button--active" : ""}`}
|
|
1523
1656
|
data-tooltip="Code"
|
|
1524
1657
|
onMouseDown={(event) => {
|
|
1525
1658
|
event.preventDefault();
|
|
@@ -1558,7 +1691,7 @@ export function StepField({
|
|
|
1558
1691
|
<Components.Generic.Popover.Trigger>
|
|
1559
1692
|
<button
|
|
1560
1693
|
type="button"
|
|
1561
|
-
className="bn-step-toolbar__button"
|
|
1694
|
+
className={`bn-step-toolbar__button${linkActive ? " bn-step-toolbar__button--active" : ""}`}
|
|
1562
1695
|
data-tooltip="Insert link"
|
|
1563
1696
|
onMouseDown={(event) => {
|
|
1564
1697
|
event.preventDefault();
|
|
@@ -704,11 +704,15 @@ describe("markdownToBlocks", () => {
|
|
|
704
704
|
|
|
705
705
|
it("parses test steps and test cases", () => {
|
|
706
706
|
const markdown = [
|
|
707
|
+
"### Steps",
|
|
708
|
+
"",
|
|
707
709
|
"* Open the Login page.",
|
|
708
710
|
" *Expected*: The Login page loads successfully.",
|
|
709
711
|
].join("\n");
|
|
710
712
|
|
|
711
|
-
|
|
713
|
+
const blocks = markdownToBlocks(markdown);
|
|
714
|
+
const stepBlocks = blocks.filter((b) => b.type === "testStep");
|
|
715
|
+
expect(stepBlocks).toEqual([
|
|
712
716
|
{
|
|
713
717
|
type: "testStep",
|
|
714
718
|
props: {
|
|
@@ -723,9 +727,11 @@ describe("markdownToBlocks", () => {
|
|
|
723
727
|
});
|
|
724
728
|
|
|
725
729
|
it("parses a step with empty title but with step data", () => {
|
|
726
|
-
const markdown = ["* ", " Navigate to the page"].join("\n");
|
|
730
|
+
const markdown = ["### Steps", "", "* ", " Navigate to the page"].join("\n");
|
|
727
731
|
|
|
728
|
-
|
|
732
|
+
const blocks = markdownToBlocks(markdown);
|
|
733
|
+
const stepBlocks = blocks.filter((b) => b.type === "testStep");
|
|
734
|
+
expect(stepBlocks).toEqual([
|
|
729
735
|
{
|
|
730
736
|
type: "testStep",
|
|
731
737
|
props: {
|
|
@@ -741,6 +747,13 @@ describe("markdownToBlocks", () => {
|
|
|
741
747
|
|
|
742
748
|
it("round-trips a title-less step with data", () => {
|
|
743
749
|
const blocks: CustomEditorBlock[] = [
|
|
750
|
+
{
|
|
751
|
+
id: "h1",
|
|
752
|
+
type: "heading",
|
|
753
|
+
props: { level: 3, textColor: "default", backgroundColor: "default", textAlignment: "left" as const },
|
|
754
|
+
content: [{ type: "text" as const, text: "Steps", styles: {} }],
|
|
755
|
+
children: [],
|
|
756
|
+
},
|
|
744
757
|
{
|
|
745
758
|
id: "s1",
|
|
746
759
|
type: "testStep",
|
|
@@ -757,7 +770,8 @@ describe("markdownToBlocks", () => {
|
|
|
757
770
|
|
|
758
771
|
const md = blocksToMarkdown(blocks);
|
|
759
772
|
const parsed = markdownToBlocks(md);
|
|
760
|
-
|
|
773
|
+
const stepBlocks = parsed.filter((b) => b.type === "testStep");
|
|
774
|
+
expect(stepBlocks).toEqual([
|
|
761
775
|
{
|
|
762
776
|
type: "testStep",
|
|
763
777
|
props: {
|
|
@@ -773,12 +787,16 @@ describe("markdownToBlocks", () => {
|
|
|
773
787
|
|
|
774
788
|
it("parses snippet markdown into snippet blocks", () => {
|
|
775
789
|
const markdown = [
|
|
790
|
+
"### Steps",
|
|
791
|
+
"",
|
|
776
792
|
"<!-- begin snippet #501 -->",
|
|
777
793
|
"Run the seeder",
|
|
778
794
|
"<!-- end snippet #501 -->",
|
|
779
795
|
].join("\n");
|
|
780
796
|
|
|
781
|
-
|
|
797
|
+
const blocks = markdownToBlocks(markdown);
|
|
798
|
+
const snippetBlocks = blocks.filter((b) => b.type === "snippet");
|
|
799
|
+
expect(snippetBlocks).toEqual([
|
|
782
800
|
{
|
|
783
801
|
type: "snippet",
|
|
784
802
|
props: {
|
|
@@ -794,13 +812,17 @@ describe("markdownToBlocks", () => {
|
|
|
794
812
|
|
|
795
813
|
it("parses snippet markdown with space between # and ID", () => {
|
|
796
814
|
const markdown = [
|
|
815
|
+
"### Steps",
|
|
816
|
+
"",
|
|
797
817
|
"<!-- begin snippet # 22289 -->",
|
|
798
818
|
"* Fill `<Email>` with correct registered email",
|
|
799
819
|
"* Verify that the update to the target is successful",
|
|
800
820
|
"<!-- end snippet # 22289 -->",
|
|
801
821
|
].join("\n");
|
|
802
822
|
|
|
803
|
-
|
|
823
|
+
const blocks = markdownToBlocks(markdown);
|
|
824
|
+
const snippetBlocks = blocks.filter((b) => b.type === "snippet");
|
|
825
|
+
expect(snippetBlocks).toEqual([
|
|
804
826
|
{
|
|
805
827
|
type: "snippet",
|
|
806
828
|
props: {
|
|
@@ -816,6 +838,8 @@ describe("markdownToBlocks", () => {
|
|
|
816
838
|
|
|
817
839
|
it("parses snippet bodies and ignores nested snippet markers", () => {
|
|
818
840
|
const markdown = [
|
|
841
|
+
"### Steps",
|
|
842
|
+
"",
|
|
819
843
|
"<!-- begin snippet #888 -->",
|
|
820
844
|
"Prep DB",
|
|
821
845
|
"<!-- begin snippet #ignored -->",
|
|
@@ -824,7 +848,9 @@ describe("markdownToBlocks", () => {
|
|
|
824
848
|
"<!-- end snippet #888 -->",
|
|
825
849
|
].join("\n");
|
|
826
850
|
|
|
827
|
-
|
|
851
|
+
const blocks = markdownToBlocks(markdown);
|
|
852
|
+
const snippetBlocks = blocks.filter((b) => b.type === "snippet");
|
|
853
|
+
expect(snippetBlocks).toEqual([
|
|
828
854
|
{
|
|
829
855
|
type: "snippet",
|
|
830
856
|
props: {
|
|
@@ -940,6 +966,8 @@ describe("markdownToBlocks", () => {
|
|
|
940
966
|
|
|
941
967
|
it("parses step data containing code fences, blank lines, and images", () => {
|
|
942
968
|
const markdown = [
|
|
969
|
+
"### Steps",
|
|
970
|
+
"",
|
|
943
971
|
"* Step 2: Update an order status.",
|
|
944
972
|
" ```",
|
|
945
973
|
" SQL CREATE bnbmnbm mnbmb mm",
|
|
@@ -969,7 +997,9 @@ describe("markdownToBlocks", () => {
|
|
|
969
997
|
"",
|
|
970
998
|
].join("\n");
|
|
971
999
|
|
|
972
|
-
|
|
1000
|
+
const blocks = markdownToBlocks(markdown);
|
|
1001
|
+
const stepBlocks = blocks.filter((b) => b.type === "testStep");
|
|
1002
|
+
expect(stepBlocks).toEqual([
|
|
973
1003
|
{
|
|
974
1004
|
type: "testStep",
|
|
975
1005
|
props: {
|
|
@@ -1104,12 +1134,16 @@ describe("markdownToBlocks", () => {
|
|
|
1104
1134
|
|
|
1105
1135
|
it("parses expected result prefixes with emphasis", () => {
|
|
1106
1136
|
const markdown = [
|
|
1137
|
+
"### Steps",
|
|
1138
|
+
"",
|
|
1107
1139
|
"* Open the form.",
|
|
1108
1140
|
" **Expected:** The form opens.",
|
|
1109
1141
|
" Expected: Fields are empty.",
|
|
1110
1142
|
].join("\n");
|
|
1111
1143
|
|
|
1112
|
-
|
|
1144
|
+
const blocks = markdownToBlocks(markdown);
|
|
1145
|
+
const stepBlocks = blocks.filter((b) => b.type === "testStep");
|
|
1146
|
+
expect(stepBlocks).toEqual([
|
|
1113
1147
|
{
|
|
1114
1148
|
type: "testStep",
|
|
1115
1149
|
props: {
|
|
@@ -1125,13 +1159,17 @@ describe("markdownToBlocks", () => {
|
|
|
1125
1159
|
|
|
1126
1160
|
it("parses test step with data", () => {
|
|
1127
1161
|
const markdown = [
|
|
1162
|
+
"### Steps",
|
|
1163
|
+
"",
|
|
1128
1164
|
"* Navigate to login",
|
|
1129
1165
|
" Open browser",
|
|
1130
1166
|
" Go to login page",
|
|
1131
1167
|
" *Expected*: Login form visible",
|
|
1132
1168
|
].join("\n");
|
|
1133
1169
|
|
|
1134
|
-
|
|
1170
|
+
const blocks = markdownToBlocks(markdown);
|
|
1171
|
+
const stepBlocks = blocks.filter((b) => b.type === "testStep");
|
|
1172
|
+
expect(stepBlocks).toEqual([
|
|
1135
1173
|
{
|
|
1136
1174
|
type: "testStep",
|
|
1137
1175
|
props: {
|
|
@@ -1147,13 +1185,17 @@ describe("markdownToBlocks", () => {
|
|
|
1147
1185
|
|
|
1148
1186
|
it("parses unindented step data between the title and expected result", () => {
|
|
1149
1187
|
const markdown = [
|
|
1188
|
+
"### Steps",
|
|
1189
|
+
"",
|
|
1150
1190
|
"* Prepare test fixtures",
|
|
1151
1191
|
"Collect user accounts from staging.",
|
|
1152
1192
|
"Reset passwords for all test accounts.",
|
|
1153
1193
|
"*Expected*: Test accounts are ready for execution.",
|
|
1154
1194
|
].join("\n");
|
|
1155
1195
|
|
|
1156
|
-
|
|
1196
|
+
const blocks = markdownToBlocks(markdown);
|
|
1197
|
+
const stepBlocks = blocks.filter((b) => b.type === "testStep");
|
|
1198
|
+
expect(stepBlocks).toEqual([
|
|
1157
1199
|
{
|
|
1158
1200
|
type: "testStep",
|
|
1159
1201
|
props: {
|
|
@@ -1169,11 +1211,15 @@ describe("markdownToBlocks", () => {
|
|
|
1169
1211
|
|
|
1170
1212
|
it("parses expected result containing a markdown image", () => {
|
|
1171
1213
|
const markdown = [
|
|
1214
|
+
"### Steps",
|
|
1215
|
+
"",
|
|
1172
1216
|
"* Display the generated report.",
|
|
1173
1217
|
" *Expected*: ",
|
|
1174
1218
|
].join("\n");
|
|
1175
1219
|
|
|
1176
|
-
|
|
1220
|
+
const blocks = markdownToBlocks(markdown);
|
|
1221
|
+
const stepBlocks = blocks.filter((b) => b.type === "testStep");
|
|
1222
|
+
expect(stepBlocks).toEqual([
|
|
1177
1223
|
{
|
|
1178
1224
|
type: "testStep",
|
|
1179
1225
|
props: {
|
|
@@ -1201,17 +1247,26 @@ describe("markdownToBlocks", () => {
|
|
|
1201
1247
|
},
|
|
1202
1248
|
]);
|
|
1203
1249
|
|
|
1204
|
-
expect(markdownRoundTrip).toBe(
|
|
1250
|
+
expect(markdownRoundTrip).toBe(
|
|
1251
|
+
[
|
|
1252
|
+
"* Display the generated report.",
|
|
1253
|
+
" *Expected*: ",
|
|
1254
|
+
].join("\n"),
|
|
1255
|
+
);
|
|
1205
1256
|
});
|
|
1206
1257
|
|
|
1207
1258
|
it("parses expected result with short expected label and image", () => {
|
|
1208
1259
|
const markdown = [
|
|
1260
|
+
"### Steps",
|
|
1261
|
+
"",
|
|
1209
1262
|
"* Should open login screen",
|
|
1210
1263
|
" *Expected*: Login should look like this",
|
|
1211
1264
|
" ",
|
|
1212
1265
|
].join("\n");
|
|
1213
1266
|
|
|
1214
|
-
|
|
1267
|
+
const blocks = markdownToBlocks(markdown);
|
|
1268
|
+
const stepBlocks = blocks.filter((b) => b.type === "testStep");
|
|
1269
|
+
expect(stepBlocks).toEqual([
|
|
1215
1270
|
{
|
|
1216
1271
|
type: "testStep",
|
|
1217
1272
|
props: {
|
|
@@ -1755,11 +1810,15 @@ describe("markdownToBlocks", () => {
|
|
|
1755
1810
|
|
|
1756
1811
|
it("parses expected result lines written with bold 'Expected Result' prefix for compatibility", () => {
|
|
1757
1812
|
const markdown = [
|
|
1813
|
+
"### Steps",
|
|
1814
|
+
"",
|
|
1758
1815
|
"* Step 1: Send a chat message to the user.",
|
|
1759
1816
|
"**Expected Result**: The user receives a real-time notification for the chat message.",
|
|
1760
1817
|
].join("\n");
|
|
1761
1818
|
|
|
1762
|
-
|
|
1819
|
+
const blocks = markdownToBlocks(markdown);
|
|
1820
|
+
const stepBlocks = blocks.filter((b) => b.type === "testStep");
|
|
1821
|
+
expect(stepBlocks).toEqual([
|
|
1763
1822
|
{
|
|
1764
1823
|
type: "testStep",
|
|
1765
1824
|
props: {
|
|
@@ -2407,3 +2466,42 @@ describe("video/audio block serialization", () => {
|
|
|
2407
2466
|
expect(md).toBe("[](https://example.com/sound.mp3)");
|
|
2408
2467
|
});
|
|
2409
2468
|
});
|
|
2469
|
+
|
|
2470
|
+
describe("steps require Steps heading", () => {
|
|
2471
|
+
it("does not parse bullet items with Expected markers as steps without a Steps heading", () => {
|
|
2472
|
+
const markdown = [
|
|
2473
|
+
"### Preconditions",
|
|
2474
|
+
"",
|
|
2475
|
+
"* Open the Login page.",
|
|
2476
|
+
" *Expected*: The Login page loads successfully.",
|
|
2477
|
+
].join("\n");
|
|
2478
|
+
|
|
2479
|
+
const blocks = markdownToBlocks(markdown);
|
|
2480
|
+
expect(blocks.filter((b) => b.type === "testStep")).toHaveLength(0);
|
|
2481
|
+
expect(blocks.filter((b) => b.type === "bulletListItem")).toHaveLength(1);
|
|
2482
|
+
});
|
|
2483
|
+
|
|
2484
|
+
it("does not parse snippet comments as snippet blocks without a Steps heading", () => {
|
|
2485
|
+
const markdown = [
|
|
2486
|
+
"<!-- begin snippet #501 -->",
|
|
2487
|
+
"Run the seeder",
|
|
2488
|
+
"<!-- end snippet #501 -->",
|
|
2489
|
+
].join("\n");
|
|
2490
|
+
|
|
2491
|
+
const blocks = markdownToBlocks(markdown);
|
|
2492
|
+
expect(blocks.filter((b) => b.type === "snippet")).toHaveLength(0);
|
|
2493
|
+
});
|
|
2494
|
+
|
|
2495
|
+
it("parses bullet items as steps when preceded by a Steps heading", () => {
|
|
2496
|
+
const markdown = [
|
|
2497
|
+
"### Steps",
|
|
2498
|
+
"",
|
|
2499
|
+
"* next 22",
|
|
2500
|
+
].join("\n");
|
|
2501
|
+
|
|
2502
|
+
const blocks = markdownToBlocks(markdown);
|
|
2503
|
+
const stepBlocks = blocks.filter((b) => b.type === "testStep");
|
|
2504
|
+
expect(stepBlocks).toHaveLength(1);
|
|
2505
|
+
expect((stepBlocks[0].props as any).stepTitle).toBe("next 22");
|
|
2506
|
+
});
|
|
2507
|
+
});
|
|
@@ -834,14 +834,9 @@ function parseList(
|
|
|
834
834
|
break;
|
|
835
835
|
}
|
|
836
836
|
|
|
837
|
-
// Only try to parse as testStep for top-level items
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
if (indentLevel === 0 && (allowEmptySteps || isLikelyStep(lines, index))) {
|
|
841
|
-
const looksLikeTestStep = listType === "bullet" ||
|
|
842
|
-
(listType === "numbered" && (
|
|
843
|
-
allowEmptySteps || isLikelyStep(lines, index)
|
|
844
|
-
));
|
|
837
|
+
// Only try to parse as testStep for top-level items under a Steps heading
|
|
838
|
+
if (indentLevel === 0 && allowEmptySteps) {
|
|
839
|
+
const looksLikeTestStep = listType === "bullet" || listType === "numbered";
|
|
845
840
|
|
|
846
841
|
if (looksLikeTestStep) {
|
|
847
842
|
const nextStep = parseTestStep(lines, index, allowEmptySteps);
|
|
@@ -890,31 +885,6 @@ function parseList(
|
|
|
890
885
|
return { items, nextIndex: index };
|
|
891
886
|
}
|
|
892
887
|
|
|
893
|
-
function isLikelyStep(lines: string[], index: number): boolean {
|
|
894
|
-
// Look ahead to see if there's indented content or expected result
|
|
895
|
-
// Look ahead through subsequent lines for expected result markers or indented content
|
|
896
|
-
for (let i = index + 1; i < lines.length; i++) {
|
|
897
|
-
const line = lines[i];
|
|
898
|
-
const trimmed = line.trim();
|
|
899
|
-
|
|
900
|
-
// Stop at blank lines
|
|
901
|
-
if (!trimmed) break;
|
|
902
|
-
|
|
903
|
-
// Check for indented content (step data) first — indented lines indicate a test step
|
|
904
|
-
const hasIndent = /^\s{2,}/.test(line);
|
|
905
|
-
if (hasIndent) return true;
|
|
906
|
-
|
|
907
|
-
// Stop at new list items, headings, or other block-level elements (only if not indented)
|
|
908
|
-
if (/^[-*+](\s|$)/.test(trimmed) || /^\d+[.)]\s/.test(trimmed)) break;
|
|
909
|
-
if (trimmed.startsWith("#") || trimmed.startsWith(">") || trimmed.startsWith("|") || trimmed.startsWith("```") || trimmed.startsWith(":::")) break;
|
|
910
|
-
|
|
911
|
-
// Check for expected result markers
|
|
912
|
-
if (EXPECTED_LABEL_REGEX.test(trimmed)) return true;
|
|
913
|
-
if (trimmed.match(/^\*[^*]*expected/i)) return true;
|
|
914
|
-
}
|
|
915
|
-
|
|
916
|
-
return false;
|
|
917
|
-
}
|
|
918
888
|
|
|
919
889
|
function parseTestStep(
|
|
920
890
|
lines: string[],
|
|
@@ -1453,15 +1423,17 @@ export function markdownToBlocks(markdown: string, options?: MarkdownToBlocksOpt
|
|
|
1453
1423
|
continue;
|
|
1454
1424
|
}
|
|
1455
1425
|
|
|
1456
|
-
const snippetWrapper =
|
|
1426
|
+
const snippetWrapper = stepsHeadingLevel !== null
|
|
1427
|
+
? parseSnippetWrapper(lines, index)
|
|
1428
|
+
: null;
|
|
1457
1429
|
if (snippetWrapper) {
|
|
1458
1430
|
blocks.push(snippetWrapper.block);
|
|
1459
1431
|
index = snippetWrapper.nextIndex;
|
|
1460
1432
|
continue;
|
|
1461
1433
|
}
|
|
1462
1434
|
|
|
1463
|
-
const stepLikeBlock =
|
|
1464
|
-
? parseTestStep(lines, index,
|
|
1435
|
+
const stepLikeBlock = stepsHeadingLevel !== null
|
|
1436
|
+
? parseTestStep(lines, index, true)
|
|
1465
1437
|
: null;
|
|
1466
1438
|
if (stepLikeBlock) {
|
|
1467
1439
|
blocks.push(stepLikeBlock.block);
|