testomatio-editor-blocks 0.2.3 → 0.4.0
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/markdown.d.ts +5 -0
- package/package/editor/blocks/markdown.js +30 -5
- package/package/editor/blocks/snippet.js +127 -2
- package/package/editor/blocks/step.js +82 -15
- package/package/editor/blocks/stepField.d.ts +7 -3
- package/package/editor/blocks/stepField.js +395 -230
- package/package/editor/blocks/useAutoResize.d.ts +8 -0
- package/package/editor/blocks/useAutoResize.js +31 -0
- package/package/editor/customMarkdownConverter.js +150 -21
- package/package/styles.css +327 -71
- package/package.json +5 -2
- package/src/App.tsx +1 -1
- package/src/editor/blocks/markdown.ts +35 -5
- package/src/editor/blocks/snippet.tsx +202 -26
- package/src/editor/blocks/step.tsx +132 -36
- package/src/editor/blocks/stepField.tsx +552 -267
- package/src/editor/blocks/useAutoResize.ts +44 -0
- package/src/editor/customMarkdownConverter.test.ts +114 -2
- package/src/editor/customMarkdownConverter.ts +166 -19
- package/src/editor/customSchema.test.ts +35 -0
- package/src/editor/markdownToBlocks.test.ts +119 -0
- package/src/editor/styles.css +342 -71
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
|
|
3
|
+
type Options = {
|
|
4
|
+
textarea: HTMLTextAreaElement | null;
|
|
5
|
+
multiline?: boolean;
|
|
6
|
+
minRows?: number;
|
|
7
|
+
maxRows?: number;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function useAutoResize({ textarea, multiline = false, minRows = 2, maxRows = 12 }: Options) {
|
|
11
|
+
const frameRef = useRef<number>(0);
|
|
12
|
+
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
if (!textarea || !multiline) {
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const resize = () => {
|
|
19
|
+
textarea.style.height = "auto";
|
|
20
|
+
const lineHeight = parseFloat(getComputedStyle(textarea).lineHeight || "16");
|
|
21
|
+
const minHeight = lineHeight * minRows;
|
|
22
|
+
const maxHeight = lineHeight * maxRows;
|
|
23
|
+
|
|
24
|
+
textarea.style.height = `${Math.min(Math.max(textarea.scrollHeight, minHeight), maxHeight)}px`;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const observer = new MutationObserver(resize);
|
|
28
|
+
observer.observe(textarea, { childList: true, characterData: true, subtree: true });
|
|
29
|
+
|
|
30
|
+
const handleInput = () => {
|
|
31
|
+
cancelAnimationFrame(frameRef.current ?? 0);
|
|
32
|
+
frameRef.current = requestAnimationFrame(resize);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
textarea.addEventListener("input", handleInput);
|
|
36
|
+
resize();
|
|
37
|
+
|
|
38
|
+
return () => {
|
|
39
|
+
observer.disconnect();
|
|
40
|
+
textarea.removeEventListener("input", handleInput);
|
|
41
|
+
cancelAnimationFrame(frameRef.current ?? 0);
|
|
42
|
+
};
|
|
43
|
+
}, [textarea, multiline, minRows, maxRows]);
|
|
44
|
+
}
|
|
@@ -157,6 +157,21 @@ describe("blocksToMarkdown", () => {
|
|
|
157
157
|
);
|
|
158
158
|
});
|
|
159
159
|
|
|
160
|
+
it("cleans escaped formatting markers when toggling styles repeatedly", () => {
|
|
161
|
+
const blocks: CustomEditorBlock[] = [
|
|
162
|
+
{
|
|
163
|
+
id: "esc1",
|
|
164
|
+
type: "paragraph",
|
|
165
|
+
props: baseProps,
|
|
166
|
+
content: [{ type: "text", text: "text", styles: { bold: true, italic: true } }],
|
|
167
|
+
children: [],
|
|
168
|
+
},
|
|
169
|
+
];
|
|
170
|
+
|
|
171
|
+
const markdown = blocksToMarkdown(blocks);
|
|
172
|
+
expect(markdown).toBe("***text***");
|
|
173
|
+
});
|
|
174
|
+
|
|
160
175
|
it("keeps inline formatting inside step fields", () => {
|
|
161
176
|
const blocks: CustomEditorBlock[] = [
|
|
162
177
|
{
|
|
@@ -474,7 +489,7 @@ describe("markdownToBlocks", () => {
|
|
|
474
489
|
props: {
|
|
475
490
|
stepTitle: "Step 4: Verify that the notifications are displayed correctly in the application's notification panel.",
|
|
476
491
|
stepData: "",
|
|
477
|
-
expectedResult: "All notifications (chat message, order update, file received) are listed in the notification panel with the correct information (e.g., timestamp, message content)
|
|
492
|
+
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",
|
|
478
493
|
},
|
|
479
494
|
children: [],
|
|
480
495
|
},
|
|
@@ -647,7 +662,7 @@ describe("markdownToBlocks", () => {
|
|
|
647
662
|
props: {
|
|
648
663
|
stepTitle: "Open the form.",
|
|
649
664
|
stepData: "",
|
|
650
|
-
expectedResult: "
|
|
665
|
+
expectedResult: "** The form opens.\nFields are empty.",
|
|
651
666
|
},
|
|
652
667
|
children: [],
|
|
653
668
|
},
|
|
@@ -773,6 +788,52 @@ describe("markdownToBlocks", () => {
|
|
|
773
788
|
);
|
|
774
789
|
});
|
|
775
790
|
|
|
791
|
+
it("parses steps under a Steps heading even when expected results are missing", () => {
|
|
792
|
+
const markdown = [
|
|
793
|
+
"### Steps",
|
|
794
|
+
"",
|
|
795
|
+
"* Pass onboarding as mobile user",
|
|
796
|
+
"* Navigate to More tab -≻ My Profile -≻ Log into the app with user from preconditions",
|
|
797
|
+
" *Expected:* Upsell SS screen is displayed",
|
|
798
|
+
"* Close SS",
|
|
799
|
+
" *Expected:* My Course and More tab are displayed",
|
|
800
|
+
].join("\n");
|
|
801
|
+
|
|
802
|
+
const blocks = markdownToBlocks(markdown);
|
|
803
|
+
const stepBlocks = blocks.filter((block) => block.type === "testStep");
|
|
804
|
+
|
|
805
|
+
expect(stepBlocks).toEqual([
|
|
806
|
+
{
|
|
807
|
+
type: "testStep",
|
|
808
|
+
props: {
|
|
809
|
+
stepTitle: "Pass onboarding as mobile user",
|
|
810
|
+
stepData: "",
|
|
811
|
+
expectedResult: "",
|
|
812
|
+
},
|
|
813
|
+
children: [],
|
|
814
|
+
},
|
|
815
|
+
{
|
|
816
|
+
type: "testStep",
|
|
817
|
+
props: {
|
|
818
|
+
stepTitle:
|
|
819
|
+
"Navigate to More tab -≻ My Profile -≻ Log into the app with user from preconditions",
|
|
820
|
+
stepData: "",
|
|
821
|
+
expectedResult: "* Upsell SS screen is displayed",
|
|
822
|
+
},
|
|
823
|
+
children: [],
|
|
824
|
+
},
|
|
825
|
+
{
|
|
826
|
+
type: "testStep",
|
|
827
|
+
props: {
|
|
828
|
+
stepTitle: "Close SS",
|
|
829
|
+
stepData: "",
|
|
830
|
+
expectedResult: "* My Course and More tab are displayed",
|
|
831
|
+
},
|
|
832
|
+
children: [],
|
|
833
|
+
},
|
|
834
|
+
]);
|
|
835
|
+
});
|
|
836
|
+
|
|
776
837
|
it("round-trips simple blocks", () => {
|
|
777
838
|
const blocks: CustomEditorBlock[] = [
|
|
778
839
|
{
|
|
@@ -865,4 +926,55 @@ describe("markdownToBlocks", () => {
|
|
|
865
926
|
},
|
|
866
927
|
]);
|
|
867
928
|
});
|
|
929
|
+
|
|
930
|
+
it("parses multiple Expected blocks within a single test step", () => {
|
|
931
|
+
const markdown = [
|
|
932
|
+
"### Steps",
|
|
933
|
+
"",
|
|
934
|
+
"* Swipe Back",
|
|
935
|
+
"* Check UI of Sleep score info screen",
|
|
936
|
+
" - Back button",
|
|
937
|
+
" Header: Sleep Score Info",
|
|
938
|
+
" 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.",
|
|
939
|
+
" *Expected:* - 1st block:",
|
|
940
|
+
" *Expected:* - 2nd block:",
|
|
941
|
+
" *Expected:* - 3d block:",
|
|
942
|
+
"* Tap 'Back' button",
|
|
943
|
+
].join("\n");
|
|
944
|
+
|
|
945
|
+
const blocks = markdownToBlocks(markdown);
|
|
946
|
+
const stepBlocks = blocks.filter((block) => block.type === "testStep");
|
|
947
|
+
|
|
948
|
+
expect(stepBlocks).toHaveLength(3);
|
|
949
|
+
|
|
950
|
+
expect(stepBlocks[0]).toEqual({
|
|
951
|
+
type: "testStep",
|
|
952
|
+
props: {
|
|
953
|
+
stepTitle: "Swipe Back",
|
|
954
|
+
stepData: "",
|
|
955
|
+
expectedResult: "",
|
|
956
|
+
},
|
|
957
|
+
children: [],
|
|
958
|
+
});
|
|
959
|
+
|
|
960
|
+
expect(stepBlocks[1]).toEqual({
|
|
961
|
+
type: "testStep",
|
|
962
|
+
props: {
|
|
963
|
+
stepTitle: "Check UI of Sleep score info screen",
|
|
964
|
+
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.",
|
|
965
|
+
expectedResult: "* - 1st block:\n* - 2nd block:\n* - 3d block:",
|
|
966
|
+
},
|
|
967
|
+
children: [],
|
|
968
|
+
});
|
|
969
|
+
|
|
970
|
+
expect(stepBlocks[2]).toEqual({
|
|
971
|
+
type: "testStep",
|
|
972
|
+
props: {
|
|
973
|
+
stepTitle: "Tap 'Back' button",
|
|
974
|
+
stepData: "",
|
|
975
|
+
expectedResult: "",
|
|
976
|
+
},
|
|
977
|
+
children: [],
|
|
978
|
+
});
|
|
979
|
+
});
|
|
868
980
|
});
|
|
@@ -206,6 +206,26 @@ function inlineToMarkdown(content: CustomEditorBlock["content"]): string {
|
|
|
206
206
|
.join("");
|
|
207
207
|
}
|
|
208
208
|
|
|
209
|
+
function inlineContentToPlainText(content: CustomEditorBlock["content"]): string {
|
|
210
|
+
if (!Array.isArray(content)) {
|
|
211
|
+
return "";
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return (content as any[])
|
|
215
|
+
.map((node: any) => {
|
|
216
|
+
if (node && typeof node === "object") {
|
|
217
|
+
if (typeof node.text === "string") {
|
|
218
|
+
return node.text;
|
|
219
|
+
}
|
|
220
|
+
if (Array.isArray(node.content)) {
|
|
221
|
+
return inlineContentToPlainText(node.content);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return "";
|
|
225
|
+
})
|
|
226
|
+
.join("");
|
|
227
|
+
}
|
|
228
|
+
|
|
209
229
|
function serializeChildren(block: CustomEditorBlock, ctx: MarkdownContext): string[] {
|
|
210
230
|
if (!block.children?.length) {
|
|
211
231
|
return [];
|
|
@@ -667,6 +687,7 @@ function parseList(
|
|
|
667
687
|
startIndex: number,
|
|
668
688
|
listType: "bullet" | "numbered" | "check",
|
|
669
689
|
indentLevel: number,
|
|
690
|
+
allowEmptySteps = false,
|
|
670
691
|
): ListParseResult {
|
|
671
692
|
const items: CustomPartialBlock[] = [];
|
|
672
693
|
let index = startIndex;
|
|
@@ -681,15 +702,15 @@ function parseList(
|
|
|
681
702
|
}
|
|
682
703
|
|
|
683
704
|
let indent = countIndent(rawLine);
|
|
684
|
-
|
|
685
|
-
if (indent > baseIndent && indent <= baseIndent + 1) {
|
|
686
|
-
indent = baseIndent;
|
|
687
|
-
}
|
|
705
|
+
|
|
688
706
|
if (indent < indentLevel * 2) {
|
|
689
707
|
break;
|
|
690
708
|
}
|
|
691
709
|
|
|
692
|
-
if
|
|
710
|
+
// Check if this line should be parsed as nested content
|
|
711
|
+
// Only go deeper if indent is at least 2 more than the next level's expected indent
|
|
712
|
+
const nextLevelExpectedIndent = (indentLevel + 1) * 2;
|
|
713
|
+
if (indent >= nextLevelExpectedIndent) {
|
|
693
714
|
const lastItem = items.at(-1);
|
|
694
715
|
if (!lastItem) {
|
|
695
716
|
break;
|
|
@@ -698,7 +719,18 @@ function parseList(
|
|
|
698
719
|
if (!nestedType) {
|
|
699
720
|
break;
|
|
700
721
|
}
|
|
701
|
-
const nested = parseList(
|
|
722
|
+
const nested = parseList(
|
|
723
|
+
lines,
|
|
724
|
+
index,
|
|
725
|
+
nestedType,
|
|
726
|
+
indentLevel + 1,
|
|
727
|
+
allowEmptySteps,
|
|
728
|
+
);
|
|
729
|
+
// If nested parsing made no progress, skip this line to avoid infinite loop
|
|
730
|
+
if (nested.nextIndex === index) {
|
|
731
|
+
index += 1;
|
|
732
|
+
continue;
|
|
733
|
+
}
|
|
702
734
|
lastItem.children = [...(lastItem.children ?? []), ...nested.items];
|
|
703
735
|
index = nested.nextIndex;
|
|
704
736
|
continue;
|
|
@@ -709,6 +741,17 @@ function parseList(
|
|
|
709
741
|
break;
|
|
710
742
|
}
|
|
711
743
|
|
|
744
|
+
// Only try to parse as testStep for top-level bullet items (indentLevel === 0)
|
|
745
|
+
// Nested bullets within numbered lists should remain as regular bulletListItem
|
|
746
|
+
if (listType === "bullet" && indentLevel === 0) {
|
|
747
|
+
const nextStep = parseTestStep(lines, index, allowEmptySteps);
|
|
748
|
+
if (nextStep) {
|
|
749
|
+
items.push(nextStep.block);
|
|
750
|
+
index = nextStep.nextIndex;
|
|
751
|
+
continue;
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
712
755
|
if (listType === "check") {
|
|
713
756
|
const checkMatch = trimmed.match(/^[-*+]\s+\[([xX\s])\]\s+(.*)$/);
|
|
714
757
|
const checked = (checkMatch?.[1] ?? "").toLowerCase() === "x";
|
|
@@ -749,6 +792,7 @@ function parseList(
|
|
|
749
792
|
function parseTestStep(
|
|
750
793
|
lines: string[],
|
|
751
794
|
index: number,
|
|
795
|
+
allowEmpty = false,
|
|
752
796
|
snippetId?: string,
|
|
753
797
|
): { block: CustomPartialBlock; nextIndex: number } | null {
|
|
754
798
|
const current = lines[index];
|
|
@@ -781,6 +825,7 @@ function parseTestStep(
|
|
|
781
825
|
let expectedResult = "";
|
|
782
826
|
let next = index + 1;
|
|
783
827
|
let inExpectedResult = false;
|
|
828
|
+
let foundFirstExpected = false;
|
|
784
829
|
|
|
785
830
|
while (next < lines.length) {
|
|
786
831
|
const line = lines[next];
|
|
@@ -788,8 +833,12 @@ function parseTestStep(
|
|
|
788
833
|
const rawTrimmed = line.trim();
|
|
789
834
|
|
|
790
835
|
if (!rawTrimmed) {
|
|
791
|
-
if (stepDataLines.length > 0) {
|
|
792
|
-
|
|
836
|
+
if (stepDataLines.length > 0 || inExpectedResult) {
|
|
837
|
+
if (inExpectedResult) {
|
|
838
|
+
expectedResult += "\n";
|
|
839
|
+
} else {
|
|
840
|
+
stepDataLines.push("");
|
|
841
|
+
}
|
|
793
842
|
}
|
|
794
843
|
next += 1;
|
|
795
844
|
continue;
|
|
@@ -812,21 +861,68 @@ function parseTestStep(
|
|
|
812
861
|
break;
|
|
813
862
|
}
|
|
814
863
|
|
|
815
|
-
|
|
864
|
+
// Check for expected result labels with different formatting
|
|
865
|
+
const expectedMatch = rawTrimmed.match(EXPECTED_LABEL_REGEX);
|
|
866
|
+
const expectedStarMatch = rawTrimmed.match(/^\*expected\s*\*:\s*(.*)$/i) ||
|
|
867
|
+
rawTrimmed.match(/^\*expected\*:\s*(.*)$/i);
|
|
868
|
+
|
|
869
|
+
if (expectedMatch || expectedStarMatch) {
|
|
870
|
+
foundFirstExpected = true;
|
|
816
871
|
inExpectedResult = true;
|
|
817
|
-
const
|
|
818
|
-
|
|
872
|
+
const label = expectedMatch ? expectedMatch[0] : (expectedStarMatch ? expectedStarMatch[0] : '');
|
|
873
|
+
let content = rawTrimmed.slice(label.length).trim();
|
|
874
|
+
|
|
875
|
+
// Add the content (if any) from this line
|
|
876
|
+
if (content) {
|
|
877
|
+
const expectedContent = unescapeMarkdown(content);
|
|
878
|
+
if (expectedResult.length > 0) {
|
|
879
|
+
expectedResult += "\n" + expectedContent;
|
|
880
|
+
} else {
|
|
881
|
+
expectedResult = expectedContent;
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
next += 1;
|
|
885
|
+
continue;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
// Check for lines that start with * and contain Expected (but don't match the above patterns)
|
|
889
|
+
if (rawTrimmed.match(/^\*[^*]*expected/i)) {
|
|
890
|
+
foundFirstExpected = true;
|
|
891
|
+
inExpectedResult = true;
|
|
892
|
+
// Remove the leading * and trim
|
|
893
|
+
let content = rawTrimmed.slice(1).trim();
|
|
894
|
+
// Remove any "Expected:" prefix
|
|
895
|
+
content = content.replace(/^expected\s*:?\s*/i, '').trim();
|
|
896
|
+
|
|
897
|
+
const expectedContent = unescapeMarkdown(content);
|
|
898
|
+
if (expectedResult.length > 0) {
|
|
899
|
+
expectedResult += "\n" + expectedContent;
|
|
900
|
+
} else {
|
|
901
|
+
expectedResult = expectedContent;
|
|
902
|
+
}
|
|
819
903
|
next += 1;
|
|
820
904
|
continue;
|
|
821
905
|
}
|
|
822
906
|
|
|
823
907
|
if (rawTrimmed.startsWith("```")) {
|
|
824
|
-
|
|
908
|
+
if (inExpectedResult) {
|
|
909
|
+
if (expectedResult.length > 0) {
|
|
910
|
+
expectedResult += "\n" + unescapeMarkdown(rawTrimmed);
|
|
911
|
+
} else {
|
|
912
|
+
expectedResult = unescapeMarkdown(rawTrimmed);
|
|
913
|
+
}
|
|
914
|
+
} else {
|
|
915
|
+
stepDataLines.push(unescapeMarkdown(rawTrimmed));
|
|
916
|
+
}
|
|
825
917
|
next += 1;
|
|
826
918
|
while (next < lines.length) {
|
|
827
919
|
const fenceLine = lines[next];
|
|
828
920
|
const fenceTrimmed = fenceLine.trim();
|
|
829
|
-
|
|
921
|
+
if (inExpectedResult) {
|
|
922
|
+
expectedResult += "\n" + unescapeMarkdown(fenceTrimmed);
|
|
923
|
+
} else {
|
|
924
|
+
stepDataLines.push(unescapeMarkdown(fenceTrimmed));
|
|
925
|
+
}
|
|
830
926
|
next += 1;
|
|
831
927
|
if (fenceTrimmed.startsWith("```")) {
|
|
832
928
|
break;
|
|
@@ -836,8 +932,24 @@ function parseTestStep(
|
|
|
836
932
|
}
|
|
837
933
|
|
|
838
934
|
if (inExpectedResult) {
|
|
839
|
-
|
|
840
|
-
|
|
935
|
+
// After finding the first expected result, indented lines are part of it
|
|
936
|
+
if (hasIndent) {
|
|
937
|
+
const expectedContent = unescapeMarkdown(rawTrimmed);
|
|
938
|
+
if (expectedResult.length > 0) {
|
|
939
|
+
expectedResult += "\n" + expectedContent;
|
|
940
|
+
} else {
|
|
941
|
+
expectedResult = expectedContent;
|
|
942
|
+
}
|
|
943
|
+
} else if (foundFirstExpected && rawTrimmed.startsWith("*") && !rawTrimmed.startsWith("* ")) {
|
|
944
|
+
// Non-indented lines starting with single * (not list item) are likely more expected results
|
|
945
|
+
// Remove the leading * and treat the rest as content
|
|
946
|
+
const expectedContent = unescapeMarkdown(rawTrimmed.slice(1).trim());
|
|
947
|
+
if (expectedResult.length > 0) {
|
|
948
|
+
expectedResult += "\n" + expectedContent;
|
|
949
|
+
} else {
|
|
950
|
+
expectedResult = expectedContent;
|
|
951
|
+
}
|
|
952
|
+
}
|
|
841
953
|
next += 1;
|
|
842
954
|
continue;
|
|
843
955
|
}
|
|
@@ -849,6 +961,14 @@ function parseTestStep(
|
|
|
849
961
|
continue;
|
|
850
962
|
}
|
|
851
963
|
|
|
964
|
+
// If we have indent and the line doesn't match other patterns, treat it as step data
|
|
965
|
+
if (hasIndent) {
|
|
966
|
+
const content = unescapeMarkdown(rawTrimmed);
|
|
967
|
+
stepDataLines.push(content);
|
|
968
|
+
next += 1;
|
|
969
|
+
continue;
|
|
970
|
+
}
|
|
971
|
+
|
|
852
972
|
break;
|
|
853
973
|
}
|
|
854
974
|
|
|
@@ -857,7 +977,12 @@ function parseTestStep(
|
|
|
857
977
|
.join("\n")
|
|
858
978
|
.trim();
|
|
859
979
|
|
|
860
|
-
if (
|
|
980
|
+
if (
|
|
981
|
+
!isLikelyStep &&
|
|
982
|
+
!expectedResult &&
|
|
983
|
+
stepDataLines.length === 0 &&
|
|
984
|
+
!(allowEmpty && titleWithPlaceholders.length > 0)
|
|
985
|
+
) {
|
|
861
986
|
return null;
|
|
862
987
|
}
|
|
863
988
|
|
|
@@ -1095,6 +1220,7 @@ export function markdownToBlocks(markdown: string): CustomPartialBlock[] {
|
|
|
1095
1220
|
const lines = normalized.split("\n");
|
|
1096
1221
|
const blocks: CustomPartialBlock[] = [];
|
|
1097
1222
|
let index = 0;
|
|
1223
|
+
let stepsHeadingLevel: number | null = null;
|
|
1098
1224
|
|
|
1099
1225
|
while (index < lines.length) {
|
|
1100
1226
|
const line = lines[index];
|
|
@@ -1110,7 +1236,7 @@ export function markdownToBlocks(markdown: string): CustomPartialBlock[] {
|
|
|
1110
1236
|
continue;
|
|
1111
1237
|
}
|
|
1112
1238
|
|
|
1113
|
-
const stepLikeBlock = parseTestStep(lines, index);
|
|
1239
|
+
const stepLikeBlock = parseTestStep(lines, index, stepsHeadingLevel !== null);
|
|
1114
1240
|
if (stepLikeBlock) {
|
|
1115
1241
|
blocks.push(stepLikeBlock.block);
|
|
1116
1242
|
index = stepLikeBlock.nextIndex;
|
|
@@ -1126,7 +1252,22 @@ export function markdownToBlocks(markdown: string): CustomPartialBlock[] {
|
|
|
1126
1252
|
|
|
1127
1253
|
const heading = parseHeading(lines, index);
|
|
1128
1254
|
if (heading) {
|
|
1129
|
-
|
|
1255
|
+
const headingBlock = heading.block;
|
|
1256
|
+
const headingLevel = (headingBlock.props as any)?.level ?? 3;
|
|
1257
|
+
const headingText = inlineContentToPlainText(headingBlock.content as any);
|
|
1258
|
+
const normalizedHeading = headingText.trim().toLowerCase();
|
|
1259
|
+
|
|
1260
|
+
if (normalizedHeading === "steps") {
|
|
1261
|
+
stepsHeadingLevel = headingLevel;
|
|
1262
|
+
} else if (
|
|
1263
|
+
stepsHeadingLevel !== null &&
|
|
1264
|
+
headingLevel <= stepsHeadingLevel &&
|
|
1265
|
+
normalizedHeading.length > 0
|
|
1266
|
+
) {
|
|
1267
|
+
stepsHeadingLevel = null;
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
blocks.push(headingBlock);
|
|
1130
1271
|
index = heading.nextIndex;
|
|
1131
1272
|
continue;
|
|
1132
1273
|
}
|
|
@@ -1147,7 +1288,13 @@ export function markdownToBlocks(markdown: string): CustomPartialBlock[] {
|
|
|
1147
1288
|
|
|
1148
1289
|
const listType = detectListType(line.trim());
|
|
1149
1290
|
if (listType) {
|
|
1150
|
-
const { items, nextIndex } = parseList(
|
|
1291
|
+
const { items, nextIndex } = parseList(
|
|
1292
|
+
lines,
|
|
1293
|
+
index,
|
|
1294
|
+
listType,
|
|
1295
|
+
0,
|
|
1296
|
+
stepsHeadingLevel !== null,
|
|
1297
|
+
);
|
|
1151
1298
|
blocks.push(...items);
|
|
1152
1299
|
index = nextIndex;
|
|
1153
1300
|
continue;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { __markdownTestUtils } from "./customSchema";
|
|
3
|
+
|
|
4
|
+
describe("customSchema markdown helpers", () => {
|
|
5
|
+
it("renders markdown images as <img> tags and round-trips back to markdown", () => {
|
|
6
|
+
const markdown = "";
|
|
7
|
+
const html = __markdownTestUtils.markdownToHtml(markdown);
|
|
8
|
+
|
|
9
|
+
expect(html).toContain('<img src="/attachments/example.png" alt="" class="bn-inline-image" contenteditable="false" draggable="false" />');
|
|
10
|
+
expect(__markdownTestUtils.htmlToMarkdown(html)).toBe(markdown);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("handles images mixed with surrounding text", () => {
|
|
14
|
+
const markdown = [
|
|
15
|
+
"Success screenshot:",
|
|
16
|
+
"",
|
|
17
|
+
"Please archive it.",
|
|
18
|
+
].join("\n");
|
|
19
|
+
|
|
20
|
+
const html = __markdownTestUtils.markdownToHtml(markdown);
|
|
21
|
+
expect(html).toContain('<img src="/attachments/success.png" alt="" class="bn-inline-image" contenteditable="false" draggable="false" />');
|
|
22
|
+
expect(__markdownTestUtils.htmlToMarkdown(html)).toBe(markdown);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("renders expected-result style markdown with an image", () => {
|
|
26
|
+
const markdown = [
|
|
27
|
+
"Login should look like this",
|
|
28
|
+
"",
|
|
29
|
+
].join("\n");
|
|
30
|
+
|
|
31
|
+
const html = __markdownTestUtils.markdownToHtml(markdown);
|
|
32
|
+
expect(html).toContain('<img src="/login.png" alt="" class="bn-inline-image" contenteditable="false" draggable="false" />');
|
|
33
|
+
expect(__markdownTestUtils.htmlToMarkdown(html)).toBe(markdown);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { markdownToBlocks } from "./customMarkdownConverter";
|
|
3
|
+
|
|
4
|
+
const baseProps = {
|
|
5
|
+
textAlignment: "left" as const,
|
|
6
|
+
textColor: "default" as const,
|
|
7
|
+
backgroundColor: "default" as const,
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
describe("markdownToBlocks", () => {
|
|
11
|
+
it("parses test steps and test cases", () => {
|
|
12
|
+
const markdown = [
|
|
13
|
+
"* Open the Login page.",
|
|
14
|
+
" *Expected*: The Login page loads successfully.",
|
|
15
|
+
].join("\n");
|
|
16
|
+
|
|
17
|
+
expect(markdownToBlocks(markdown)).toEqual([
|
|
18
|
+
{
|
|
19
|
+
type: "testStep",
|
|
20
|
+
props: {
|
|
21
|
+
stepTitle: "Open the Login page.",
|
|
22
|
+
stepData: "",
|
|
23
|
+
expectedResult: "The Login page loads successfully.",
|
|
24
|
+
},
|
|
25
|
+
children: [],
|
|
26
|
+
},
|
|
27
|
+
]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("parses snippet markdown into snippet blocks", () => {
|
|
31
|
+
const markdown = [
|
|
32
|
+
"<!-- begin snippet #501 -->",
|
|
33
|
+
"Run the seeder",
|
|
34
|
+
"<!-- end snippet #501 -->",
|
|
35
|
+
].join("\n");
|
|
36
|
+
|
|
37
|
+
expect(markdownToBlocks(markdown)).toEqual([
|
|
38
|
+
{
|
|
39
|
+
type: "snippet",
|
|
40
|
+
props: {
|
|
41
|
+
snippetId: "501",
|
|
42
|
+
snippetTitle: "",
|
|
43
|
+
snippetData: "Run the seeder",
|
|
44
|
+
snippetExpectedResult: "",
|
|
45
|
+
},
|
|
46
|
+
children: [],
|
|
47
|
+
},
|
|
48
|
+
]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("parses snippet bodies and ignores nested snippet markers", () => {
|
|
52
|
+
const markdown = [
|
|
53
|
+
"<!-- begin snippet #888 -->",
|
|
54
|
+
"Prep DB",
|
|
55
|
+
"<!-- begin snippet #ignored -->",
|
|
56
|
+
"Do not keep this marker",
|
|
57
|
+
"<!-- end snippet #ignored -->",
|
|
58
|
+
"<!-- end snippet #888 -->",
|
|
59
|
+
].join("\n");
|
|
60
|
+
|
|
61
|
+
expect(markdownToBlocks(markdown)).toEqual([
|
|
62
|
+
{
|
|
63
|
+
type: "snippet",
|
|
64
|
+
props: {
|
|
65
|
+
snippetId: "888",
|
|
66
|
+
snippetTitle: "",
|
|
67
|
+
snippetData: "Prep DB\nDo not keep this marker",
|
|
68
|
+
snippetExpectedResult: "",
|
|
69
|
+
},
|
|
70
|
+
children: [],
|
|
71
|
+
},
|
|
72
|
+
]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// Adding just a few more critical tests to keep the file small and focused
|
|
76
|
+
it("parses step lists with inline expected results label", () => {
|
|
77
|
+
const markdown = [
|
|
78
|
+
"## Test Description: Real-time notifications (chat, order updates, file received)",
|
|
79
|
+
"",
|
|
80
|
+
"This test case verifies the functionality of real-time notifications for chat messages, order updates, and file receipts within the application.",
|
|
81
|
+
"",
|
|
82
|
+
"### Preconditions",
|
|
83
|
+
"",
|
|
84
|
+
"### Steps",
|
|
85
|
+
"",
|
|
86
|
+
"* Step 1: Send a chat message to the user.",
|
|
87
|
+
"**Expected**: The user receives a real-time notification for the chat message.",
|
|
88
|
+
"* Step 2: Update an order status.",
|
|
89
|
+
"**Expected**: The user receives a real-time notification for the order update.",
|
|
90
|
+
].join("\n");
|
|
91
|
+
|
|
92
|
+
const blocks = markdownToBlocks(markdown);
|
|
93
|
+
const stepBlocks = blocks.filter((block) => block.type === "testStep");
|
|
94
|
+
|
|
95
|
+
expect(stepBlocks).toHaveLength(2);
|
|
96
|
+
expect(stepBlocks[0]).toEqual({
|
|
97
|
+
type: "testStep",
|
|
98
|
+
props: {
|
|
99
|
+
stepTitle: "Step 1: Send a chat message to the user.",
|
|
100
|
+
stepData: "",
|
|
101
|
+
expectedResult: "The user receives a real-time notification for the chat message.",
|
|
102
|
+
},
|
|
103
|
+
children: [],
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("round-trips simple blocks", () => {
|
|
108
|
+
const markdown = "Simple paragraph text";
|
|
109
|
+
const blocks = markdownToBlocks(markdown);
|
|
110
|
+
|
|
111
|
+
expect(blocks).toHaveLength(1);
|
|
112
|
+
expect(blocks[0]).toEqual({
|
|
113
|
+
type: "paragraph",
|
|
114
|
+
props: baseProps,
|
|
115
|
+
content: [{ type: "text", text: "Simple paragraph text", styles: {} }],
|
|
116
|
+
children: [],
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
});
|