testomatio-editor-blocks 0.3.0 → 0.4.1
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/snippet.js +42 -23
- package/package/editor/blocks/step.js +166 -35
- package/package/editor/blocks/stepField.d.ts +9 -1
- package/package/editor/blocks/stepField.js +664 -34
- package/package/editor/blocks/stepHorizontalView.d.ts +14 -0
- package/package/editor/blocks/stepHorizontalView.js +7 -0
- package/package/editor/blocks/useAutoResize.d.ts +8 -0
- package/package/editor/blocks/useAutoResize.js +31 -0
- package/package/editor/customMarkdownConverter.d.ts +1 -0
- package/package/editor/customMarkdownConverter.js +260 -31
- package/package/styles.css +706 -130
- package/package.json +9 -2
- package/src/App.tsx +1 -1
- package/src/editor/blocks/markdown.ts +27 -7
- package/src/editor/blocks/snippet.tsx +117 -61
- package/src/editor/blocks/step.tsx +325 -87
- package/src/editor/blocks/stepField.tsx +1396 -299
- package/src/editor/blocks/stepHorizontalView.tsx +90 -0
- package/src/editor/blocks/useAutoResize.ts +44 -0
- package/src/editor/customMarkdownConverter.test.ts +542 -3
- package/src/editor/customMarkdownConverter.ts +310 -36
- package/src/editor/customSchema.test.ts +35 -0
- package/src/editor/markdownToBlocks.test.ts +119 -0
- package/src/editor/styles.css +827 -128
|
@@ -61,7 +61,7 @@ const headingPrefixes: Record<number, string> = {
|
|
|
61
61
|
const SPECIAL_CHAR_REGEX = /([*_`~\[\]()<>\\])/g;
|
|
62
62
|
const HTML_SPAN_REGEX = /<\/?span[^>]*>/g;
|
|
63
63
|
const HTML_UNDERLINE_REGEX = /<\/?u>/g;
|
|
64
|
-
const EXPECTED_LABEL_REGEX = /^(?:[*_`]*\s*)?(expected(?:\s+result)?)\s*(?:[*_`]*\s*)
|
|
64
|
+
const EXPECTED_LABEL_REGEX = /^(?:[*_`]*\s*)?(expected(?:\s+result)?)\s*(?:[*_`]*\s*)?\s*[:\-–—]\s*/i;
|
|
65
65
|
// Matches any non-empty line that falls between the step title and the expected result line.
|
|
66
66
|
const STEP_DATA_LINE_REGEX =
|
|
67
67
|
/^(?!\s*(?:[*_`]*\s*)?(?:expected(?:\s+result)?)\b).+/i;
|
|
@@ -185,22 +185,62 @@ function inlineToMarkdown(content: CustomEditorBlock["content"]): string {
|
|
|
185
185
|
return "";
|
|
186
186
|
}
|
|
187
187
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
if (isStyledTextInlineContent(item)) {
|
|
191
|
-
return applyTextStyles(escapeMarkdown(item.text), item.styles);
|
|
192
|
-
}
|
|
188
|
+
const result: string[] = [];
|
|
189
|
+
let i = 0;
|
|
193
190
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
191
|
+
while (i < content.length) {
|
|
192
|
+
const item = content[i];
|
|
193
|
+
|
|
194
|
+
if (isStyledTextInlineContent(item)) {
|
|
195
|
+
// Check if this is a "!" followed by a link (image syntax)
|
|
196
|
+
if (item.text === "!" && i + 1 < content.length && isLinkInlineContent(content[i + 1])) {
|
|
197
|
+
const link = content[i + 1] as any;
|
|
198
|
+
const inner = inlineToMarkdown(link.content);
|
|
199
|
+
const safeHref = escapeMarkdown(link.href);
|
|
200
|
+
result.push(``);
|
|
201
|
+
i += 2;
|
|
202
|
+
continue;
|
|
198
203
|
}
|
|
204
|
+
result.push(applyTextStyles(escapeMarkdown(item.text), item.styles));
|
|
205
|
+
i += 1;
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
199
208
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
209
|
+
if (isLinkInlineContent(item)) {
|
|
210
|
+
const inner = inlineToMarkdown(item.content);
|
|
211
|
+
const safeHref = escapeMarkdown(item.href);
|
|
212
|
+
result.push(`[${inner}](${safeHref})`);
|
|
213
|
+
i += 1;
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (Array.isArray((item as any).content)) {
|
|
218
|
+
result.push(inlineToMarkdown((item as any).content));
|
|
219
|
+
i += 1;
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
i += 1;
|
|
224
|
+
}
|
|
203
225
|
|
|
226
|
+
return result.join("");
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function inlineContentToPlainText(content: CustomEditorBlock["content"]): string {
|
|
230
|
+
if (!Array.isArray(content)) {
|
|
231
|
+
return "";
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return (content as any[])
|
|
235
|
+
.map((node: any) => {
|
|
236
|
+
if (node && typeof node === "object") {
|
|
237
|
+
if (typeof node.text === "string") {
|
|
238
|
+
return node.text;
|
|
239
|
+
}
|
|
240
|
+
if (Array.isArray(node.content)) {
|
|
241
|
+
return inlineContentToPlainText(node.content);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
204
244
|
return "";
|
|
205
245
|
})
|
|
206
246
|
.join("");
|
|
@@ -593,10 +633,13 @@ function parseInlineMarkdown(text: string): EditorInline[] {
|
|
|
593
633
|
pushPlain();
|
|
594
634
|
const label = cleaned.slice(i + 1, endLabel);
|
|
595
635
|
const href = cleaned.slice(startLink + 1, endLink);
|
|
636
|
+
const parsedLabel = parseInlineMarkdown(label);
|
|
637
|
+
// Ensure link content is never undefined - if empty, add empty text
|
|
638
|
+
const linkContent = parsedLabel.length > 0 ? parsedLabel : [{ type: "text", text: "", styles: {} }];
|
|
596
639
|
result.push({
|
|
597
640
|
type: "link",
|
|
598
641
|
href: unescapeMarkdown(href),
|
|
599
|
-
content:
|
|
642
|
+
content: linkContent,
|
|
600
643
|
} as any);
|
|
601
644
|
i = endLink + 1;
|
|
602
645
|
continue;
|
|
@@ -667,6 +710,7 @@ function parseList(
|
|
|
667
710
|
startIndex: number,
|
|
668
711
|
listType: "bullet" | "numbered" | "check",
|
|
669
712
|
indentLevel: number,
|
|
713
|
+
allowEmptySteps = false,
|
|
670
714
|
): ListParseResult {
|
|
671
715
|
const items: CustomPartialBlock[] = [];
|
|
672
716
|
let index = startIndex;
|
|
@@ -681,15 +725,15 @@ function parseList(
|
|
|
681
725
|
}
|
|
682
726
|
|
|
683
727
|
let indent = countIndent(rawLine);
|
|
684
|
-
|
|
685
|
-
if (indent > baseIndent && indent <= baseIndent + 1) {
|
|
686
|
-
indent = baseIndent;
|
|
687
|
-
}
|
|
728
|
+
|
|
688
729
|
if (indent < indentLevel * 2) {
|
|
689
730
|
break;
|
|
690
731
|
}
|
|
691
732
|
|
|
692
|
-
if
|
|
733
|
+
// Check if this line should be parsed as nested content
|
|
734
|
+
// Only go deeper if indent is at least 2 more than the next level's expected indent
|
|
735
|
+
const nextLevelExpectedIndent = (indentLevel + 1) * 2;
|
|
736
|
+
if (indent >= nextLevelExpectedIndent) {
|
|
693
737
|
const lastItem = items.at(-1);
|
|
694
738
|
if (!lastItem) {
|
|
695
739
|
break;
|
|
@@ -698,7 +742,18 @@ function parseList(
|
|
|
698
742
|
if (!nestedType) {
|
|
699
743
|
break;
|
|
700
744
|
}
|
|
701
|
-
const nested = parseList(
|
|
745
|
+
const nested = parseList(
|
|
746
|
+
lines,
|
|
747
|
+
index,
|
|
748
|
+
nestedType,
|
|
749
|
+
indentLevel + 1,
|
|
750
|
+
allowEmptySteps,
|
|
751
|
+
);
|
|
752
|
+
// If nested parsing made no progress, skip this line to avoid infinite loop
|
|
753
|
+
if (nested.nextIndex === index) {
|
|
754
|
+
index += 1;
|
|
755
|
+
continue;
|
|
756
|
+
}
|
|
702
757
|
lastItem.children = [...(lastItem.children ?? []), ...nested.items];
|
|
703
758
|
index = nested.nextIndex;
|
|
704
759
|
continue;
|
|
@@ -709,6 +764,27 @@ function parseList(
|
|
|
709
764
|
break;
|
|
710
765
|
}
|
|
711
766
|
|
|
767
|
+
// Only try to parse as testStep for top-level items (indentLevel === 0)
|
|
768
|
+
// when we're under a Steps heading AND the list type is bullet
|
|
769
|
+
// Numbered lists under Steps heading are only parsed as test steps if they look like test steps
|
|
770
|
+
if (indentLevel === 0 && allowEmptySteps) {
|
|
771
|
+
// For bullet lists, always try to parse as test steps
|
|
772
|
+
// For numbered lists, only try if they have step-like characteristics
|
|
773
|
+
const looksLikeTestStep = listType === "bullet" ||
|
|
774
|
+
(listType === "numbered" && (
|
|
775
|
+
isLikelyStep(lines, index)
|
|
776
|
+
));
|
|
777
|
+
|
|
778
|
+
if (looksLikeTestStep) {
|
|
779
|
+
const nextStep = parseTestStep(lines, index, allowEmptySteps);
|
|
780
|
+
if (nextStep) {
|
|
781
|
+
items.push(nextStep.block);
|
|
782
|
+
index = nextStep.nextIndex;
|
|
783
|
+
continue;
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
712
788
|
if (listType === "check") {
|
|
713
789
|
const checkMatch = trimmed.match(/^[-*+]\s+\[([xX\s])\]\s+(.*)$/);
|
|
714
790
|
const checked = (checkMatch?.[1] ?? "").toLowerCase() === "x";
|
|
@@ -746,18 +822,56 @@ function parseList(
|
|
|
746
822
|
return { items, nextIndex: index };
|
|
747
823
|
}
|
|
748
824
|
|
|
825
|
+
function isLikelyStep(lines: string[], index: number): boolean {
|
|
826
|
+
// Look ahead to see if there's indented content or expected result
|
|
827
|
+
if (index + 1 >= lines.length) return false;
|
|
828
|
+
|
|
829
|
+
const nextLine = lines[index + 1];
|
|
830
|
+
const hasIndent = /^\s{2,}/.test(nextLine);
|
|
831
|
+
|
|
832
|
+
// Check if the next line contains expected result markers
|
|
833
|
+
const nextTrimmed = nextLine.trim();
|
|
834
|
+
const hasExpectedResult = EXPECTED_LABEL_REGEX.test(nextTrimmed);
|
|
835
|
+
|
|
836
|
+
// Only consider it a test step if:
|
|
837
|
+
// 1. It has an expected result, OR
|
|
838
|
+
// 2. The next line is indented but doesn't start with a numbered or bullet list
|
|
839
|
+
if (hasExpectedResult) return true;
|
|
840
|
+
if (hasIndent && !/^\d+[.)]/.test(nextTrimmed) && !/^[-*+]/.test(nextTrimmed)) return true;
|
|
841
|
+
|
|
842
|
+
return false;
|
|
843
|
+
}
|
|
844
|
+
|
|
749
845
|
function parseTestStep(
|
|
750
846
|
lines: string[],
|
|
751
847
|
index: number,
|
|
848
|
+
allowEmpty = false,
|
|
752
849
|
snippetId?: string,
|
|
753
850
|
): { block: CustomPartialBlock; nextIndex: number } | null {
|
|
754
851
|
const current = lines[index];
|
|
755
852
|
const trimmed = current.trim();
|
|
756
|
-
|
|
853
|
+
const isBullet = trimmed.startsWith("* ") || trimmed.startsWith("- ");
|
|
854
|
+
const isNumbered = /^\d+[.)]\s+/.test(trimmed);
|
|
855
|
+
|
|
856
|
+
if (!isBullet && !isNumbered) {
|
|
757
857
|
return null;
|
|
758
858
|
}
|
|
759
859
|
|
|
760
|
-
|
|
860
|
+
// For numbered lists, only parse as test steps if called from parseList with allowEmpty=true
|
|
861
|
+
// The first call to parseTestStep from markdownToBlocks uses allowEmpty=stepsHeadingLevel !== null
|
|
862
|
+
// which should be false unless we're under a Steps heading
|
|
863
|
+
if (isNumbered && !allowEmpty) {
|
|
864
|
+
return null;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
let rawTitle: string;
|
|
868
|
+
if (isBullet) {
|
|
869
|
+
rawTitle = unescapeMarkdown(trimmed.slice(2)).trim();
|
|
870
|
+
} else {
|
|
871
|
+
// For numbered lists, remove the number and delimiter
|
|
872
|
+
rawTitle = unescapeMarkdown(trimmed.replace(/^\d+[.)]\s+/, "")).trim();
|
|
873
|
+
}
|
|
874
|
+
|
|
761
875
|
let blockType: "testStep" | "snippet" = "testStep";
|
|
762
876
|
const snippetMatch = rawTitle.match(/^snippet\s*[:\-–—]?\s*(.*)$/i);
|
|
763
877
|
if (snippetMatch) {
|
|
@@ -788,8 +902,12 @@ function parseTestStep(
|
|
|
788
902
|
const rawTrimmed = line.trim();
|
|
789
903
|
|
|
790
904
|
if (!rawTrimmed) {
|
|
791
|
-
if (stepDataLines.length > 0) {
|
|
792
|
-
|
|
905
|
+
if (stepDataLines.length > 0 || inExpectedResult) {
|
|
906
|
+
if (inExpectedResult) {
|
|
907
|
+
expectedResult += "\n";
|
|
908
|
+
} else {
|
|
909
|
+
stepDataLines.push("");
|
|
910
|
+
}
|
|
793
911
|
}
|
|
794
912
|
next += 1;
|
|
795
913
|
continue;
|
|
@@ -812,21 +930,66 @@ function parseTestStep(
|
|
|
812
930
|
break;
|
|
813
931
|
}
|
|
814
932
|
|
|
815
|
-
|
|
933
|
+
// Check for expected result labels with different formatting
|
|
934
|
+
const expectedMatch = rawTrimmed.match(EXPECTED_LABEL_REGEX);
|
|
935
|
+
const expectedStarMatch = rawTrimmed.match(/^\*expected\s*\*:\s*(.*)$/i) ||
|
|
936
|
+
rawTrimmed.match(/^\*expected\*:\s*(.*)$/i);
|
|
937
|
+
|
|
938
|
+
if (expectedMatch || expectedStarMatch) {
|
|
816
939
|
inExpectedResult = true;
|
|
817
|
-
const
|
|
818
|
-
|
|
940
|
+
const label = expectedMatch ? expectedMatch[0] : (expectedStarMatch ? expectedStarMatch[0] : '');
|
|
941
|
+
let content = rawTrimmed.slice(label.length).trim();
|
|
942
|
+
|
|
943
|
+
// Add the content (if any) from this line
|
|
944
|
+
if (content) {
|
|
945
|
+
const expectedContent = unescapeMarkdown(content);
|
|
946
|
+
if (expectedResult.length > 0) {
|
|
947
|
+
expectedResult += "\n" + expectedContent;
|
|
948
|
+
} else {
|
|
949
|
+
expectedResult = expectedContent;
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
next += 1;
|
|
953
|
+
continue;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
// Check for lines that start with * and contain Expected (but don't match the above patterns)
|
|
957
|
+
if (rawTrimmed.match(/^\*[^*]*expected/i)) {
|
|
958
|
+
inExpectedResult = true;
|
|
959
|
+
// Remove the leading * and trim
|
|
960
|
+
let content = rawTrimmed.slice(1).trim();
|
|
961
|
+
// Remove any "Expected:" prefix
|
|
962
|
+
content = content.replace(/^expected\s*:?\s*/i, '').trim();
|
|
963
|
+
|
|
964
|
+
const expectedContent = unescapeMarkdown(content);
|
|
965
|
+
if (expectedResult.length > 0) {
|
|
966
|
+
expectedResult += "\n" + expectedContent;
|
|
967
|
+
} else {
|
|
968
|
+
expectedResult = expectedContent;
|
|
969
|
+
}
|
|
819
970
|
next += 1;
|
|
820
971
|
continue;
|
|
821
972
|
}
|
|
822
973
|
|
|
823
974
|
if (rawTrimmed.startsWith("```")) {
|
|
824
|
-
|
|
975
|
+
if (inExpectedResult) {
|
|
976
|
+
if (expectedResult.length > 0) {
|
|
977
|
+
expectedResult += "\n" + unescapeMarkdown(rawTrimmed);
|
|
978
|
+
} else {
|
|
979
|
+
expectedResult = unescapeMarkdown(rawTrimmed);
|
|
980
|
+
}
|
|
981
|
+
} else {
|
|
982
|
+
stepDataLines.push(unescapeMarkdown(rawTrimmed));
|
|
983
|
+
}
|
|
825
984
|
next += 1;
|
|
826
985
|
while (next < lines.length) {
|
|
827
986
|
const fenceLine = lines[next];
|
|
828
987
|
const fenceTrimmed = fenceLine.trim();
|
|
829
|
-
|
|
988
|
+
if (inExpectedResult) {
|
|
989
|
+
expectedResult += "\n" + unescapeMarkdown(fenceTrimmed);
|
|
990
|
+
} else {
|
|
991
|
+
stepDataLines.push(unescapeMarkdown(fenceTrimmed));
|
|
992
|
+
}
|
|
830
993
|
next += 1;
|
|
831
994
|
if (fenceTrimmed.startsWith("```")) {
|
|
832
995
|
break;
|
|
@@ -836,8 +999,15 @@ function parseTestStep(
|
|
|
836
999
|
}
|
|
837
1000
|
|
|
838
1001
|
if (inExpectedResult) {
|
|
839
|
-
|
|
840
|
-
|
|
1002
|
+
// After finding the first expected result, indented lines are part of it
|
|
1003
|
+
if (hasIndent) {
|
|
1004
|
+
const expectedContent = unescapeMarkdown(rawTrimmed);
|
|
1005
|
+
if (expectedResult.length > 0) {
|
|
1006
|
+
expectedResult += "\n" + expectedContent;
|
|
1007
|
+
} else {
|
|
1008
|
+
expectedResult = expectedContent;
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
841
1011
|
next += 1;
|
|
842
1012
|
continue;
|
|
843
1013
|
}
|
|
@@ -849,6 +1019,14 @@ function parseTestStep(
|
|
|
849
1019
|
continue;
|
|
850
1020
|
}
|
|
851
1021
|
|
|
1022
|
+
// If we have indent and the line doesn't match other patterns, treat it as step data
|
|
1023
|
+
if (hasIndent) {
|
|
1024
|
+
const content = unescapeMarkdown(rawTrimmed);
|
|
1025
|
+
stepDataLines.push(content);
|
|
1026
|
+
next += 1;
|
|
1027
|
+
continue;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
852
1030
|
break;
|
|
853
1031
|
}
|
|
854
1032
|
|
|
@@ -857,7 +1035,12 @@ function parseTestStep(
|
|
|
857
1035
|
.join("\n")
|
|
858
1036
|
.trim();
|
|
859
1037
|
|
|
860
|
-
if (
|
|
1038
|
+
if (
|
|
1039
|
+
!isLikelyStep &&
|
|
1040
|
+
!expectedResult &&
|
|
1041
|
+
stepDataLines.length === 0 &&
|
|
1042
|
+
!(allowEmpty && titleWithPlaceholders.length > 0)
|
|
1043
|
+
) {
|
|
861
1044
|
return null;
|
|
862
1045
|
}
|
|
863
1046
|
|
|
@@ -1090,11 +1273,81 @@ function parseSnippetWrapper(
|
|
|
1090
1273
|
};
|
|
1091
1274
|
}
|
|
1092
1275
|
|
|
1276
|
+
// Post-process blocks to fix malformed image blocks
|
|
1277
|
+
export function fixMalformedImageBlocks(blocks: CustomPartialBlock[]): CustomPartialBlock[] {
|
|
1278
|
+
const result: CustomPartialBlock[] = [];
|
|
1279
|
+
let i = 0;
|
|
1280
|
+
|
|
1281
|
+
while (i < blocks.length) {
|
|
1282
|
+
const current = blocks[i];
|
|
1283
|
+
const next = blocks[i + 1];
|
|
1284
|
+
|
|
1285
|
+
// Skip empty paragraphs
|
|
1286
|
+
if (
|
|
1287
|
+
current.type === "paragraph" &&
|
|
1288
|
+
(!current.content || !Array.isArray(current.content) || current.content.length === 0)
|
|
1289
|
+
) {
|
|
1290
|
+
i += 1;
|
|
1291
|
+
continue;
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
// Check if current is a paragraph with just "!" - this is definitely a malformed image
|
|
1295
|
+
if (
|
|
1296
|
+
current.type === "paragraph" &&
|
|
1297
|
+
current.content &&
|
|
1298
|
+
Array.isArray(current.content) &&
|
|
1299
|
+
current.content.length === 1 &&
|
|
1300
|
+
(current.content[0] as any)?.type === "text" &&
|
|
1301
|
+
(current.content[0] as any)?.text === "!"
|
|
1302
|
+
) {
|
|
1303
|
+
// This is a malformed image block, skip it entirely
|
|
1304
|
+
// The full image was likely parsed as  but got corrupted
|
|
1305
|
+
i += 1;
|
|
1306
|
+
continue;
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
// Check if current is a paragraph with just "!" and next is an empty paragraph
|
|
1310
|
+
if (
|
|
1311
|
+
current.type === "paragraph" &&
|
|
1312
|
+
next?.type === "paragraph" &&
|
|
1313
|
+
current.content &&
|
|
1314
|
+
Array.isArray(current.content) &&
|
|
1315
|
+
current.content.length === 1 &&
|
|
1316
|
+
(current.content[0] as any)?.type === "text" &&
|
|
1317
|
+
(current.content[0] as any)?.text === "!" &&
|
|
1318
|
+
(!next.content || !Array.isArray(next.content) || next.content.length === 0)
|
|
1319
|
+
) {
|
|
1320
|
+
// This looks like a malformed image, skip both blocks
|
|
1321
|
+
i += 2;
|
|
1322
|
+
continue;
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
// Check if current has "!" but no link
|
|
1326
|
+
if (
|
|
1327
|
+
current.type === "paragraph" &&
|
|
1328
|
+
current.content &&
|
|
1329
|
+
Array.isArray(current.content) &&
|
|
1330
|
+
current.content.some((item: any) => item.type === "text" && item.text === "!") &&
|
|
1331
|
+
!current.content.some((item: any) => item.type === "link")
|
|
1332
|
+
) {
|
|
1333
|
+
// Skip malformed image block
|
|
1334
|
+
i += 1;
|
|
1335
|
+
continue;
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
result.push(current);
|
|
1339
|
+
i += 1;
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
return result;
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1093
1345
|
export function markdownToBlocks(markdown: string): CustomPartialBlock[] {
|
|
1094
1346
|
const normalized = markdown.replace(/\r\n/g, "\n");
|
|
1095
1347
|
const lines = normalized.split("\n");
|
|
1096
1348
|
const blocks: CustomPartialBlock[] = [];
|
|
1097
1349
|
let index = 0;
|
|
1350
|
+
let stepsHeadingLevel: number | null = null;
|
|
1098
1351
|
|
|
1099
1352
|
while (index < lines.length) {
|
|
1100
1353
|
const line = lines[index];
|
|
@@ -1110,7 +1363,7 @@ export function markdownToBlocks(markdown: string): CustomPartialBlock[] {
|
|
|
1110
1363
|
continue;
|
|
1111
1364
|
}
|
|
1112
1365
|
|
|
1113
|
-
const stepLikeBlock = parseTestStep(lines, index);
|
|
1366
|
+
const stepLikeBlock = parseTestStep(lines, index, stepsHeadingLevel !== null);
|
|
1114
1367
|
if (stepLikeBlock) {
|
|
1115
1368
|
blocks.push(stepLikeBlock.block);
|
|
1116
1369
|
index = stepLikeBlock.nextIndex;
|
|
@@ -1126,7 +1379,22 @@ export function markdownToBlocks(markdown: string): CustomPartialBlock[] {
|
|
|
1126
1379
|
|
|
1127
1380
|
const heading = parseHeading(lines, index);
|
|
1128
1381
|
if (heading) {
|
|
1129
|
-
|
|
1382
|
+
const headingBlock = heading.block;
|
|
1383
|
+
const headingLevel = (headingBlock.props as any)?.level ?? 3;
|
|
1384
|
+
const headingText = inlineContentToPlainText(headingBlock.content as any);
|
|
1385
|
+
const normalizedHeading = headingText.trim().toLowerCase();
|
|
1386
|
+
|
|
1387
|
+
if (normalizedHeading.replace(/[:\-–—]$/, "") === "steps") {
|
|
1388
|
+
stepsHeadingLevel = headingLevel;
|
|
1389
|
+
} else if (
|
|
1390
|
+
stepsHeadingLevel !== null &&
|
|
1391
|
+
headingLevel <= stepsHeadingLevel &&
|
|
1392
|
+
normalizedHeading.length > 0
|
|
1393
|
+
) {
|
|
1394
|
+
stepsHeadingLevel = null;
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
blocks.push(headingBlock);
|
|
1130
1398
|
index = heading.nextIndex;
|
|
1131
1399
|
continue;
|
|
1132
1400
|
}
|
|
@@ -1147,7 +1415,13 @@ export function markdownToBlocks(markdown: string): CustomPartialBlock[] {
|
|
|
1147
1415
|
|
|
1148
1416
|
const listType = detectListType(line.trim());
|
|
1149
1417
|
if (listType) {
|
|
1150
|
-
const { items, nextIndex } = parseList(
|
|
1418
|
+
const { items, nextIndex } = parseList(
|
|
1419
|
+
lines,
|
|
1420
|
+
index,
|
|
1421
|
+
listType,
|
|
1422
|
+
0,
|
|
1423
|
+
stepsHeadingLevel !== null,
|
|
1424
|
+
);
|
|
1151
1425
|
blocks.push(...items);
|
|
1152
1426
|
index = nextIndex;
|
|
1153
1427
|
continue;
|
|
@@ -1158,7 +1432,7 @@ export function markdownToBlocks(markdown: string): CustomPartialBlock[] {
|
|
|
1158
1432
|
index = paragraph.nextIndex;
|
|
1159
1433
|
}
|
|
1160
1434
|
|
|
1161
|
-
return blocks;
|
|
1435
|
+
return fixMalformedImageBlocks(blocks);
|
|
1162
1436
|
}
|
|
1163
1437
|
|
|
1164
1438
|
function splitTableRow(line: string): string[] {
|
|
@@ -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
|
+
});
|