testomatio-editor-blocks 0.4.47 → 0.4.49

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.
@@ -22,7 +22,17 @@ export declare function canInsertStepOrSnippet(editor: {
22
22
  */
23
23
  export declare function addStepsBlock(editor: {
24
24
  document: any[];
25
- insertBlocks: (blocks: any[], referenceId: string, placement: string) => any[];
25
+ insertBlocks: (blocks: any[], referenceId: string, placement: "before" | "after") => any[];
26
+ }): string | null;
27
+ /**
28
+ * Programmatically add an empty snippet block to the editor.
29
+ * - If a "Steps" heading exists, inserts after the last step/snippet under it.
30
+ * - Otherwise, appends a "Steps" heading + empty snippet at the end.
31
+ * Returns the inserted snippet's block ID (for focusing), or null.
32
+ */
33
+ export declare function addSnippetBlock(editor: {
34
+ document: any[];
35
+ insertBlocks: (blocks: any[], referenceId: string, placement: "before" | "after") => any[];
26
36
  }): string | null;
27
37
  export declare const stepBlock: {
28
38
  config: {
@@ -142,6 +142,62 @@ export function addStepsBlock(editor) {
142
142
  const inserted = editor.insertBlocks([stepsHeading, emptyStep], lastBlock.id, "after");
143
143
  return (_d = (_c = inserted === null || inserted === void 0 ? void 0 : inserted[1]) === null || _c === void 0 ? void 0 : _c.id) !== null && _d !== void 0 ? _d : null;
144
144
  }
145
+ /**
146
+ * Programmatically add an empty snippet block to the editor.
147
+ * - If a "Steps" heading exists, inserts after the last step/snippet under it.
148
+ * - Otherwise, appends a "Steps" heading + empty snippet at the end.
149
+ * Returns the inserted snippet's block ID (for focusing), or null.
150
+ */
151
+ export function addSnippetBlock(editor) {
152
+ var _a, _b, _c, _d;
153
+ const allBlocks = editor.document;
154
+ const emptySnippet = {
155
+ type: "snippet",
156
+ props: { snippetId: "", snippetTitle: "", snippetData: "", snippetExpectedResult: "" },
157
+ children: [],
158
+ };
159
+ let stepsHeadingIndex = -1;
160
+ for (let i = 0; i < allBlocks.length; i++) {
161
+ const b = allBlocks[i];
162
+ if (b.type !== "heading")
163
+ continue;
164
+ const text = (Array.isArray(b.content) ? b.content : [])
165
+ .filter((n) => n.type === "text")
166
+ .map((n) => { var _a; return (_a = n.text) !== null && _a !== void 0 ? _a : ""; })
167
+ .join("")
168
+ .trim()
169
+ .toLowerCase()
170
+ .replace(/[:\-–—]$/, "");
171
+ if (isStepsHeading(text)) {
172
+ stepsHeadingIndex = i;
173
+ break;
174
+ }
175
+ }
176
+ if (stepsHeadingIndex >= 0) {
177
+ let lastIndex = stepsHeadingIndex;
178
+ for (let i = stepsHeadingIndex + 1; i < allBlocks.length; i++) {
179
+ const b = allBlocks[i];
180
+ if (b.type === "testStep" || b.type === "snippet") {
181
+ lastIndex = i;
182
+ continue;
183
+ }
184
+ if (isEmptyParagraph(b))
185
+ continue;
186
+ break;
187
+ }
188
+ const inserted = editor.insertBlocks([emptySnippet], allBlocks[lastIndex].id, "after");
189
+ return (_b = (_a = inserted === null || inserted === void 0 ? void 0 : inserted[0]) === null || _a === void 0 ? void 0 : _a.id) !== null && _b !== void 0 ? _b : null;
190
+ }
191
+ const lastBlock = allBlocks[allBlocks.length - 1];
192
+ const stepsHeading = {
193
+ type: "heading",
194
+ props: { level: 3 },
195
+ content: [{ type: "text", text: "Steps" }],
196
+ children: [],
197
+ };
198
+ const inserted = editor.insertBlocks([stepsHeading, emptySnippet], lastBlock.id, "after");
199
+ return (_d = (_c = inserted === null || inserted === void 0 ? void 0 : inserted[1]) === null || _c === void 0 ? void 0 : _c.id) !== null && _d !== void 0 ? _d : null;
200
+ }
145
201
  export const stepBlock = createReactBlockSpec({
146
202
  type: "testStep",
147
203
  content: "none",
@@ -880,6 +880,8 @@ export function StepField({ label, showLabel = true, labelToggle, labelAction, p
880
880
  formattingRef.current = formattingRef.current.filter((_, i) => i !== existingIdx);
881
881
  }
882
882
  else if (start !== end) {
883
+ // Remove overlapping formatting of other types before applying new format
884
+ formattingRef.current = formattingRef.current.filter((f) => f.type === fmtType || f.start >= end || f.end <= start);
883
885
  // Add formatting for selection
884
886
  formattingRef.current = [...formattingRef.current, { start, end, type: fmtType }];
885
887
  }
@@ -685,12 +685,9 @@ function parseList(lines, startIndex, listType, indentLevel, allowEmptySteps = f
685
685
  if (detectedType !== listType) {
686
686
  break;
687
687
  }
688
- // Only try to parse as testStep for top-level items (indentLevel === 0)
689
- // Under a Steps heading (allowEmptySteps=true): always try for both bullet and numbered
690
- // Outside Steps heading: only if the item looks like a test step (has Expected markers or indented data)
691
- if (indentLevel === 0 && (allowEmptySteps || isLikelyStep(lines, index))) {
692
- const looksLikeTestStep = listType === "bullet" ||
693
- (listType === "numbered" && (allowEmptySteps || isLikelyStep(lines, index)));
688
+ // Only try to parse as testStep for top-level items under a Steps heading
689
+ if (indentLevel === 0 && allowEmptySteps) {
690
+ const looksLikeTestStep = listType === "bullet" || listType === "numbered";
694
691
  if (looksLikeTestStep) {
695
692
  const nextStep = parseTestStep(lines, index, allowEmptySteps);
696
693
  if (nextStep) {
@@ -736,32 +733,6 @@ function parseList(lines, startIndex, listType, indentLevel, allowEmptySteps = f
736
733
  }
737
734
  return { items, nextIndex: index };
738
735
  }
739
- function isLikelyStep(lines, index) {
740
- // Look ahead to see if there's indented content or expected result
741
- // Look ahead through subsequent lines for expected result markers or indented content
742
- for (let i = index + 1; i < lines.length; i++) {
743
- const line = lines[i];
744
- const trimmed = line.trim();
745
- // Stop at blank lines
746
- if (!trimmed)
747
- break;
748
- // Check for indented content (step data) first — indented lines indicate a test step
749
- const hasIndent = /^\s{2,}/.test(line);
750
- if (hasIndent)
751
- return true;
752
- // Stop at new list items, headings, or other block-level elements (only if not indented)
753
- if (/^[-*+](\s|$)/.test(trimmed) || /^\d+[.)]\s/.test(trimmed))
754
- break;
755
- if (trimmed.startsWith("#") || trimmed.startsWith(">") || trimmed.startsWith("|") || trimmed.startsWith("```") || trimmed.startsWith(":::"))
756
- break;
757
- // Check for expected result markers
758
- if (EXPECTED_LABEL_REGEX.test(trimmed))
759
- return true;
760
- if (trimmed.match(/^\*[^*]*expected/i))
761
- return true;
762
- }
763
- return false;
764
- }
765
736
  function parseTestStep(lines, index, allowEmpty = false, snippetId) {
766
737
  const current = lines[index];
767
738
  const trimmed = current.trim();
@@ -805,6 +776,7 @@ function parseTestStep(lines, index, allowEmpty = false, snippetId) {
805
776
  let expectedResult = "";
806
777
  let next = index + 1;
807
778
  let inExpectedResult = false;
779
+ let blankLineSeenOutsideCodeBlock = false;
808
780
  const stepIndent = current.length - current.trimStart().length;
809
781
  while (next < lines.length) {
810
782
  const line = lines[next];
@@ -818,6 +790,7 @@ function parseTestStep(lines, index, allowEmpty = false, snippetId) {
818
790
  }
819
791
  else {
820
792
  stepDataLines.push("");
793
+ blankLineSeenOutsideCodeBlock = true;
821
794
  }
822
795
  }
823
796
  next += 1;
@@ -923,6 +896,10 @@ function parseTestStep(lines, index, allowEmpty = false, snippetId) {
923
896
  next += 1;
924
897
  continue;
925
898
  }
899
+ // After a blank line outside a code block, stop adding to step data
900
+ if (blankLineSeenOutsideCodeBlock) {
901
+ break;
902
+ }
926
903
  if (STEP_DATA_LINE_REGEX.test(rawTrimmed)) {
927
904
  const content = unescapeMarkdown(rawTrimmed);
928
905
  stepDataLines.push(content);
@@ -1225,14 +1202,16 @@ export function markdownToBlocks(markdown, options) {
1225
1202
  }
1226
1203
  continue;
1227
1204
  }
1228
- const snippetWrapper = parseSnippetWrapper(lines, index);
1205
+ const snippetWrapper = stepsHeadingLevel !== null
1206
+ ? parseSnippetWrapper(lines, index)
1207
+ : null;
1229
1208
  if (snippetWrapper) {
1230
1209
  blocks.push(snippetWrapper.block);
1231
1210
  index = snippetWrapper.nextIndex;
1232
1211
  continue;
1233
1212
  }
1234
- const stepLikeBlock = (stepsHeadingLevel !== null || isLikelyStep(lines, index))
1235
- ? parseTestStep(lines, index, stepsHeadingLevel !== null)
1213
+ const stepLikeBlock = stepsHeadingLevel !== null
1214
+ ? parseTestStep(lines, index, true)
1236
1215
  : null;
1237
1216
  if (stepLikeBlock) {
1238
1217
  blocks.push(stepLikeBlock.block);
@@ -1,5 +1,5 @@
1
1
  export { customSchema, type CustomSchema, type CustomBlock, type CustomEditor, } from "./editor/customSchema";
2
- export { stepBlock, canInsertStepOrSnippet, isStepsHeading, addStepsBlock } from "./editor/blocks/step";
2
+ export { stepBlock, canInsertStepOrSnippet, isStepsHeading, addStepsBlock, addSnippetBlock } from "./editor/blocks/step";
3
3
  export { snippetBlock } from "./editor/blocks/snippet";
4
4
  export { markdownToHtml, htmlToMarkdown } from "./editor/blocks/markdown";
5
5
  export { blocksToMarkdown, markdownToBlocks, type CustomEditorBlock, type CustomPartialBlock, type MarkdownToBlocksOptions, } from "./editor/customMarkdownConverter";
package/package/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export { customSchema, } from "./editor/customSchema";
2
- export { stepBlock, canInsertStepOrSnippet, isStepsHeading, addStepsBlock } from "./editor/blocks/step";
2
+ export { stepBlock, canInsertStepOrSnippet, isStepsHeading, addStepsBlock, addSnippetBlock } from "./editor/blocks/step";
3
3
  export { snippetBlock } from "./editor/blocks/snippet";
4
4
  export { markdownToHtml, htmlToMarkdown } from "./editor/blocks/markdown";
5
5
  export { blocksToMarkdown, markdownToBlocks, } from "./editor/customMarkdownConverter";
@@ -1339,6 +1339,11 @@ html.dark .bn-step-image-preview__content {
1339
1339
  pointer-events: none;
1340
1340
  }
1341
1341
 
1342
+ .bn-suggestion-menu .mantine-Badge-label {
1343
+ text-transform: none;
1344
+ font-size: var(--badge-fz-sm);
1345
+ }
1346
+
1342
1347
  .bn-suggestion-icon {
1343
1348
  display: inline-flex;
1344
1349
  align-items: center;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testomatio-editor-blocks",
3
- "version": "0.4.47",
3
+ "version": "0.4.49",
4
4
  "description": "Custom BlockNote schema, markdown conversion helpers, and UI for Testomatio-style test cases and steps.",
5
5
  "type": "module",
6
6
  "main": "./package/index.js",
package/src/App.tsx CHANGED
@@ -15,13 +15,14 @@ import {
15
15
  blocksToMarkdown,
16
16
  markdownToBlocks,
17
17
  type CustomEditorBlock,
18
- type CustomPartialBlock,
18
+
19
19
  } from "./editor/customMarkdownConverter";
20
20
  import { createMarkdownPasteHandler } from "./editor/createMarkdownPasteHandler";
21
21
  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, addStepsBlock, addSnippetBlock } from "./editor/blocks/step";
25
26
  import "./App.css";
26
27
 
27
28
  const focusStepField = (
@@ -291,7 +292,18 @@ function CustomSlashMenu() {
291
292
  }
292
293
 
293
294
  const getItems = async (query: string) => {
294
- const defaultItems = getDefaultReactSlashMenuItems(editor);
295
+ const isMac =
296
+ typeof navigator !== "undefined" &&
297
+ (/Mac/.test(navigator.platform) ||
298
+ (/AppleWebKit/.test(navigator.userAgent) &&
299
+ /Mobile\/\w+/.test(navigator.userAgent)));
300
+
301
+ const defaultItems = getDefaultReactSlashMenuItems(editor).map((item) => {
302
+ if (item.badge && isMac) {
303
+ return { ...item, badge: item.badge.replace("Alt", "Option") };
304
+ }
305
+ return item;
306
+ });
295
307
 
296
308
  const stepItem = {
297
309
  key: "test_step" as any,
@@ -334,7 +346,12 @@ function CustomSlashMenu() {
334
346
  },
335
347
  };
336
348
 
337
- return filterSuggestionItems([...defaultItems, stepItem, snippetItem], query);
349
+ const currentBlock = editor.getTextCursorPosition().block;
350
+ const canInsert = canInsertStepOrSnippet(editor, currentBlock.id);
351
+ const items = canInsert
352
+ ? [...defaultItems, stepItem, snippetItem]
353
+ : defaultItems;
354
+ return filterSuggestionItems(items, query);
338
355
  };
339
356
 
340
357
  return <SuggestionMenuController triggerCharacter="/" getItems={getItems} />;
@@ -451,52 +468,14 @@ function App() {
451
468
  };
452
469
  }, [editor, uploadStepImage]);
453
470
 
454
- const createTestStepBlock = useMemo<() => CustomPartialBlock>(() => {
455
- return () => ({
456
- type: "testStep",
457
- props: {
458
- stepTitle: "",
459
- stepData: "",
460
- expectedResult: "",
461
- },
462
- children: [],
463
- });
464
- }, []);
465
-
466
- const createSnippetBlock = useMemo<() => CustomPartialBlock>(() => {
467
- return () => ({
468
- type: "snippet",
469
- props: {
470
- snippetId: "",
471
- snippetTitle: "",
472
- snippetData: "",
473
- snippetExpectedResult: "",
474
- },
475
- children: [],
476
- });
477
- }, []);
478
-
479
- const insertBlockAfterSelection = (createBlock: () => CustomPartialBlock, focusFieldName?: string) => {
480
- const selection = editor.getSelection();
481
- const selectedBlocks = selection?.blocks ?? [];
482
- const selectedBlock = selectedBlocks[selectedBlocks.length - 1];
483
- const documentBlocks = editor.document;
484
- const fallbackBlock = documentBlocks[documentBlocks.length - 1];
485
- const referenceId = selectedBlock?.id ?? fallbackBlock?.id;
486
-
487
- if (!referenceId) {
488
- return;
489
- }
490
-
491
- const inserted = editor.insertBlocks([createBlock()], referenceId, "after");
492
- const firstInserted = inserted[0];
493
- if (firstInserted && focusFieldName) {
494
- focusStepField(editor, firstInserted.id, focusFieldName);
495
- }
471
+ const insertTestStep = () => {
472
+ const id = addStepsBlock(editor);
473
+ if (id) focusStepField(editor, id, "title");
474
+ };
475
+ const insertSnippet = () => {
476
+ const id = addSnippetBlock(editor);
477
+ if (id) focusStepField(editor, id, "snippet-title");
496
478
  };
497
-
498
- const insertTestStep = () => insertBlockAfterSelection(createTestStepBlock, "title");
499
- const insertSnippet = () => insertBlockAfterSelection(createSnippetBlock, "snippet-title");
500
479
 
501
480
  const handleCopyMarkdown = async () => {
502
481
  if (conversionError) {
@@ -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: string) => any[];
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",
@@ -1076,6 +1076,10 @@ export function StepField({
1076
1076
  // Remove formatting
1077
1077
  formattingRef.current = formattingRef.current.filter((_, i) => i !== existingIdx);
1078
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
+ );
1079
1083
  // Add formatting for selection
1080
1084
  formattingRef.current = [...formattingRef.current, { start, end, type: fmtType }];
1081
1085
  } else {
@@ -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
- expect(markdownToBlocks(markdown)).toEqual([
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
- expect(markdownToBlocks(markdown)).toEqual([
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
- expect(parsed).toEqual([
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
- expect(markdownToBlocks(markdown)).toEqual([
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
- expect(markdownToBlocks(markdown)).toEqual([
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
- expect(markdownToBlocks(markdown)).toEqual([
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
  "![](/attachments/HMhkVtlDrO.png)",
970
998
  ].join("\n");
971
999
 
972
- expect(markdownToBlocks(markdown)).toEqual([
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: {
@@ -1016,6 +1046,26 @@ describe("markdownToBlocks", () => {
1016
1046
  );
1017
1047
  });
1018
1048
 
1049
+ it("does not include content after a blank line in step data", () => {
1050
+ const markdown = [
1051
+ "### Steps",
1052
+ "",
1053
+ "* step",
1054
+ " expected",
1055
+ "",
1056
+ " ![](http://localhost:3000/attachments/mlbix3nOJa.png =303x*)",
1057
+ ].join("\n");
1058
+
1059
+ const blocks = markdownToBlocks(markdown);
1060
+ const stepBlocks = blocks.filter((b) => b.type === "testStep");
1061
+ expect(stepBlocks).toHaveLength(1);
1062
+ expect(stepBlocks[0].props).toMatchObject({
1063
+ stepTitle: "step",
1064
+ stepData: "expected",
1065
+ expectedResult: "",
1066
+ });
1067
+ });
1068
+
1019
1069
  it("parses bullet lists written with asterisk markers", () => {
1020
1070
  const markdown = [
1021
1071
  "### Preconditions",
@@ -1104,12 +1154,16 @@ describe("markdownToBlocks", () => {
1104
1154
 
1105
1155
  it("parses expected result prefixes with emphasis", () => {
1106
1156
  const markdown = [
1157
+ "### Steps",
1158
+ "",
1107
1159
  "* Open the form.",
1108
1160
  " **Expected:** The form opens.",
1109
1161
  " Expected: Fields are empty.",
1110
1162
  ].join("\n");
1111
1163
 
1112
- expect(markdownToBlocks(markdown)).toEqual([
1164
+ const blocks = markdownToBlocks(markdown);
1165
+ const stepBlocks = blocks.filter((b) => b.type === "testStep");
1166
+ expect(stepBlocks).toEqual([
1113
1167
  {
1114
1168
  type: "testStep",
1115
1169
  props: {
@@ -1125,13 +1179,17 @@ describe("markdownToBlocks", () => {
1125
1179
 
1126
1180
  it("parses test step with data", () => {
1127
1181
  const markdown = [
1182
+ "### Steps",
1183
+ "",
1128
1184
  "* Navigate to login",
1129
1185
  " Open browser",
1130
1186
  " Go to login page",
1131
1187
  " *Expected*: Login form visible",
1132
1188
  ].join("\n");
1133
1189
 
1134
- expect(markdownToBlocks(markdown)).toEqual([
1190
+ const blocks = markdownToBlocks(markdown);
1191
+ const stepBlocks = blocks.filter((b) => b.type === "testStep");
1192
+ expect(stepBlocks).toEqual([
1135
1193
  {
1136
1194
  type: "testStep",
1137
1195
  props: {
@@ -1147,13 +1205,17 @@ describe("markdownToBlocks", () => {
1147
1205
 
1148
1206
  it("parses unindented step data between the title and expected result", () => {
1149
1207
  const markdown = [
1208
+ "### Steps",
1209
+ "",
1150
1210
  "* Prepare test fixtures",
1151
1211
  "Collect user accounts from staging.",
1152
1212
  "Reset passwords for all test accounts.",
1153
1213
  "*Expected*: Test accounts are ready for execution.",
1154
1214
  ].join("\n");
1155
1215
 
1156
- expect(markdownToBlocks(markdown)).toEqual([
1216
+ const blocks = markdownToBlocks(markdown);
1217
+ const stepBlocks = blocks.filter((b) => b.type === "testStep");
1218
+ expect(stepBlocks).toEqual([
1157
1219
  {
1158
1220
  type: "testStep",
1159
1221
  props: {
@@ -1169,11 +1231,15 @@ describe("markdownToBlocks", () => {
1169
1231
 
1170
1232
  it("parses expected result containing a markdown image", () => {
1171
1233
  const markdown = [
1234
+ "### Steps",
1235
+ "",
1172
1236
  "* Display the generated report.",
1173
1237
  " *Expected*: ![](/attachments/report.png)",
1174
1238
  ].join("\n");
1175
1239
 
1176
- expect(markdownToBlocks(markdown)).toEqual([
1240
+ const blocks = markdownToBlocks(markdown);
1241
+ const stepBlocks = blocks.filter((b) => b.type === "testStep");
1242
+ expect(stepBlocks).toEqual([
1177
1243
  {
1178
1244
  type: "testStep",
1179
1245
  props: {
@@ -1201,17 +1267,26 @@ describe("markdownToBlocks", () => {
1201
1267
  },
1202
1268
  ]);
1203
1269
 
1204
- expect(markdownRoundTrip).toBe(markdown);
1270
+ expect(markdownRoundTrip).toBe(
1271
+ [
1272
+ "* Display the generated report.",
1273
+ " *Expected*: ![](/attachments/report.png)",
1274
+ ].join("\n"),
1275
+ );
1205
1276
  });
1206
1277
 
1207
1278
  it("parses expected result with short expected label and image", () => {
1208
1279
  const markdown = [
1280
+ "### Steps",
1281
+ "",
1209
1282
  "* Should open login screen",
1210
1283
  " *Expected*: Login should look like this",
1211
1284
  " ![](/login.png)",
1212
1285
  ].join("\n");
1213
1286
 
1214
- expect(markdownToBlocks(markdown)).toEqual([
1287
+ const blocks = markdownToBlocks(markdown);
1288
+ const stepBlocks = blocks.filter((b) => b.type === "testStep");
1289
+ expect(stepBlocks).toEqual([
1215
1290
  {
1216
1291
  type: "testStep",
1217
1292
  props: {
@@ -1755,11 +1830,15 @@ describe("markdownToBlocks", () => {
1755
1830
 
1756
1831
  it("parses expected result lines written with bold 'Expected Result' prefix for compatibility", () => {
1757
1832
  const markdown = [
1833
+ "### Steps",
1834
+ "",
1758
1835
  "* Step 1: Send a chat message to the user.",
1759
1836
  "**Expected Result**: The user receives a real-time notification for the chat message.",
1760
1837
  ].join("\n");
1761
1838
 
1762
- expect(markdownToBlocks(markdown)).toEqual([
1839
+ const blocks = markdownToBlocks(markdown);
1840
+ const stepBlocks = blocks.filter((b) => b.type === "testStep");
1841
+ expect(stepBlocks).toEqual([
1763
1842
  {
1764
1843
  type: "testStep",
1765
1844
  props: {
@@ -2407,3 +2486,42 @@ describe("video/audio block serialization", () => {
2407
2486
  expect(md).toBe("[![sound.mp3](/images/file-type-icons/file.svg)](https://example.com/sound.mp3)");
2408
2487
  });
2409
2488
  });
2489
+
2490
+ describe("steps require Steps heading", () => {
2491
+ it("does not parse bullet items with Expected markers as steps without a Steps heading", () => {
2492
+ const markdown = [
2493
+ "### Preconditions",
2494
+ "",
2495
+ "* Open the Login page.",
2496
+ " *Expected*: The Login page loads successfully.",
2497
+ ].join("\n");
2498
+
2499
+ const blocks = markdownToBlocks(markdown);
2500
+ expect(blocks.filter((b) => b.type === "testStep")).toHaveLength(0);
2501
+ expect(blocks.filter((b) => b.type === "bulletListItem")).toHaveLength(1);
2502
+ });
2503
+
2504
+ it("does not parse snippet comments as snippet blocks without a Steps heading", () => {
2505
+ const markdown = [
2506
+ "<!-- begin snippet #501 -->",
2507
+ "Run the seeder",
2508
+ "<!-- end snippet #501 -->",
2509
+ ].join("\n");
2510
+
2511
+ const blocks = markdownToBlocks(markdown);
2512
+ expect(blocks.filter((b) => b.type === "snippet")).toHaveLength(0);
2513
+ });
2514
+
2515
+ it("parses bullet items as steps when preceded by a Steps heading", () => {
2516
+ const markdown = [
2517
+ "### Steps",
2518
+ "",
2519
+ "* next 22",
2520
+ ].join("\n");
2521
+
2522
+ const blocks = markdownToBlocks(markdown);
2523
+ const stepBlocks = blocks.filter((b) => b.type === "testStep");
2524
+ expect(stepBlocks).toHaveLength(1);
2525
+ expect((stepBlocks[0].props as any).stepTitle).toBe("next 22");
2526
+ });
2527
+ });
@@ -834,14 +834,9 @@ function parseList(
834
834
  break;
835
835
  }
836
836
 
837
- // Only try to parse as testStep for top-level items (indentLevel === 0)
838
- // Under a Steps heading (allowEmptySteps=true): always try for both bullet and numbered
839
- // Outside Steps heading: only if the item looks like a test step (has Expected markers or indented data)
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[],
@@ -971,6 +941,7 @@ function parseTestStep(
971
941
  let expectedResult = "";
972
942
  let next = index + 1;
973
943
  let inExpectedResult = false;
944
+ let blankLineSeenOutsideCodeBlock = false;
974
945
  const stepIndent = current.length - current.trimStart().length;
975
946
 
976
947
  while (next < lines.length) {
@@ -985,6 +956,7 @@ function parseTestStep(
985
956
  expectedResult += "\n";
986
957
  } else {
987
958
  stepDataLines.push("");
959
+ blankLineSeenOutsideCodeBlock = true;
988
960
  }
989
961
  }
990
962
  next += 1;
@@ -1096,6 +1068,11 @@ function parseTestStep(
1096
1068
  continue;
1097
1069
  }
1098
1070
 
1071
+ // After a blank line outside a code block, stop adding to step data
1072
+ if (blankLineSeenOutsideCodeBlock) {
1073
+ break;
1074
+ }
1075
+
1099
1076
  if (STEP_DATA_LINE_REGEX.test(rawTrimmed)) {
1100
1077
  const content = unescapeMarkdown(rawTrimmed);
1101
1078
  stepDataLines.push(content);
@@ -1453,15 +1430,17 @@ export function markdownToBlocks(markdown: string, options?: MarkdownToBlocksOpt
1453
1430
  continue;
1454
1431
  }
1455
1432
 
1456
- const snippetWrapper = parseSnippetWrapper(lines, index);
1433
+ const snippetWrapper = stepsHeadingLevel !== null
1434
+ ? parseSnippetWrapper(lines, index)
1435
+ : null;
1457
1436
  if (snippetWrapper) {
1458
1437
  blocks.push(snippetWrapper.block);
1459
1438
  index = snippetWrapper.nextIndex;
1460
1439
  continue;
1461
1440
  }
1462
1441
 
1463
- const stepLikeBlock = (stepsHeadingLevel !== null || isLikelyStep(lines, index))
1464
- ? parseTestStep(lines, index, stepsHeadingLevel !== null)
1442
+ const stepLikeBlock = stepsHeadingLevel !== null
1443
+ ? parseTestStep(lines, index, true)
1465
1444
  : null;
1466
1445
  if (stepLikeBlock) {
1467
1446
  blocks.push(stepLikeBlock.block);
@@ -10,11 +10,15 @@ const baseProps = {
10
10
  describe("markdownToBlocks", () => {
11
11
  it("parses test steps and test cases", () => {
12
12
  const markdown = [
13
+ "### Steps",
14
+ "",
13
15
  "* Open the Login page.",
14
16
  " *Expected*: The Login page loads successfully.",
15
17
  ].join("\n");
16
18
 
17
- expect(markdownToBlocks(markdown)).toEqual([
19
+ const blocks = markdownToBlocks(markdown);
20
+ const stepBlocks = blocks.filter((b) => b.type === "testStep");
21
+ expect(stepBlocks).toEqual([
18
22
  {
19
23
  type: "testStep",
20
24
  props: {
@@ -30,12 +34,16 @@ describe("markdownToBlocks", () => {
30
34
 
31
35
  it("parses snippet markdown into snippet blocks", () => {
32
36
  const markdown = [
37
+ "### Steps",
38
+ "",
33
39
  "<!-- begin snippet #501 -->",
34
40
  "Run the seeder",
35
41
  "<!-- end snippet #501 -->",
36
42
  ].join("\n");
37
43
 
38
- expect(markdownToBlocks(markdown)).toEqual([
44
+ const blocks = markdownToBlocks(markdown);
45
+ const snippetBlocks = blocks.filter((b) => b.type === "snippet");
46
+ expect(snippetBlocks).toEqual([
39
47
  {
40
48
  type: "snippet",
41
49
  props: {
@@ -51,6 +59,8 @@ describe("markdownToBlocks", () => {
51
59
 
52
60
  it("parses snippet bodies and ignores nested snippet markers", () => {
53
61
  const markdown = [
62
+ "### Steps",
63
+ "",
54
64
  "<!-- begin snippet #888 -->",
55
65
  "Prep DB",
56
66
  "<!-- begin snippet #ignored -->",
@@ -59,7 +69,9 @@ describe("markdownToBlocks", () => {
59
69
  "<!-- end snippet #888 -->",
60
70
  ].join("\n");
61
71
 
62
- expect(markdownToBlocks(markdown)).toEqual([
72
+ const blocks = markdownToBlocks(markdown);
73
+ const snippetBlocks = blocks.filter((b) => b.type === "snippet");
74
+ expect(snippetBlocks).toEqual([
63
75
  {
64
76
  type: "snippet",
65
77
  props: {
@@ -1339,6 +1339,11 @@ html.dark .bn-step-image-preview__content {
1339
1339
  pointer-events: none;
1340
1340
  }
1341
1341
 
1342
+ .bn-suggestion-menu .mantine-Badge-label {
1343
+ text-transform: none;
1344
+ font-size: var(--badge-fz-sm);
1345
+ }
1346
+
1342
1347
  .bn-suggestion-icon {
1343
1348
  display: inline-flex;
1344
1349
  align-items: center;
package/src/index.ts CHANGED
@@ -4,7 +4,7 @@ export {
4
4
  type CustomBlock,
5
5
  type CustomEditor,
6
6
  } from "./editor/customSchema";
7
- export { stepBlock, canInsertStepOrSnippet, isStepsHeading, addStepsBlock } from "./editor/blocks/step";
7
+ export { stepBlock, canInsertStepOrSnippet, isStepsHeading, addStepsBlock, addSnippetBlock } from "./editor/blocks/step";
8
8
  export { snippetBlock } from "./editor/blocks/snippet";
9
9
  export { markdownToHtml, htmlToMarkdown } from "./editor/blocks/markdown";
10
10