testomatio-editor-blocks 0.1.2 → 0.2.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.
Files changed (36) hide show
  1. package/README.md +13 -6
  2. package/package/editor/blocks/markdown.d.ts +5 -0
  3. package/package/editor/blocks/markdown.js +160 -0
  4. package/package/editor/blocks/snippet.d.ts +38 -0
  5. package/package/editor/blocks/snippet.js +65 -0
  6. package/package/editor/blocks/step.d.ts +32 -0
  7. package/package/editor/blocks/step.js +97 -0
  8. package/package/editor/blocks/stepField.d.ts +26 -0
  9. package/package/editor/blocks/stepField.js +316 -0
  10. package/package/editor/customMarkdownConverter.js +111 -80
  11. package/package/editor/customSchema.d.ts +31 -45
  12. package/package/editor/customSchema.js +6 -616
  13. package/package/editor/snippetAutocomplete.d.ts +28 -0
  14. package/package/editor/snippetAutocomplete.js +94 -0
  15. package/package/editor/stepAutocomplete.d.ts +1 -1
  16. package/package/editor/stepAutocomplete.js +1 -1
  17. package/package/index.d.ts +4 -1
  18. package/package/index.js +4 -1
  19. package/package/styles.css +57 -0
  20. package/package.json +1 -1
  21. package/src/App.tsx +143 -41
  22. package/src/editor/blocks/blocks.test.ts +22 -0
  23. package/src/editor/blocks/markdown.ts +199 -0
  24. package/src/editor/blocks/snippet.tsx +109 -0
  25. package/src/editor/blocks/step.tsx +175 -0
  26. package/src/editor/blocks/stepField.tsx +487 -0
  27. package/src/editor/customMarkdownConverter.test.ts +121 -36
  28. package/src/editor/customMarkdownConverter.ts +128 -85
  29. package/src/editor/customSchema.tsx +6 -935
  30. package/src/editor/snippetAutocomplete.test.ts +54 -0
  31. package/src/editor/snippetAutocomplete.ts +133 -0
  32. package/src/editor/stepAutocomplete.test.ts +3 -3
  33. package/src/editor/stepAutocomplete.tsx +1 -1
  34. package/src/editor/styles.css +57 -0
  35. package/src/index.ts +4 -1
  36. package/src/editor/customSchema.test.ts +0 -47
@@ -0,0 +1,487 @@
1
+ import { useStepAutocomplete, type StepSuggestion } from "../stepAutocomplete";
2
+ import { type SnippetSuggestion } from "../snippetAutocomplete";
3
+ import { useStepImageUpload } from "../stepImageUpload";
4
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
5
+ import type { ClipboardEvent, ReactNode, ChangeEvent } from "react";
6
+ import {
7
+ escapeHtml,
8
+ escapeMarkdownText,
9
+ htmlToMarkdown,
10
+ markdownToHtml,
11
+ normalizePlainText,
12
+ } from "./markdown";
13
+
14
+ type Suggestion = StepSuggestion | SnippetSuggestion;
15
+
16
+ type StepFieldProps = {
17
+ label: string;
18
+ value: string;
19
+ placeholder: string;
20
+ onChange: (nextValue: string) => void;
21
+ autoFocus?: boolean;
22
+ multiline?: boolean;
23
+ enableAutocomplete?: boolean;
24
+ fieldName?: string;
25
+ suggestionFilter?: (suggestion: Suggestion) => boolean;
26
+ suggestionsOverride?: Suggestion[];
27
+ onSuggestionSelect?: (suggestion: Suggestion) => void;
28
+ readOnly?: boolean;
29
+ showSuggestionsOnFocus?: boolean;
30
+ enableImageUpload?: boolean;
31
+ onImageFile?: (file: File) => Promise<void> | void;
32
+ rightAction?: ReactNode;
33
+ showFormattingButtons?: boolean;
34
+ showImageButton?: boolean;
35
+ };
36
+
37
+ export function StepField({
38
+ label,
39
+ value,
40
+ placeholder,
41
+ onChange,
42
+ autoFocus,
43
+ multiline = false,
44
+ enableAutocomplete = false,
45
+ fieldName,
46
+ suggestionFilter,
47
+ suggestionsOverride,
48
+ onSuggestionSelect,
49
+ readOnly = false,
50
+ showSuggestionsOnFocus = false,
51
+ enableImageUpload = false,
52
+ onImageFile,
53
+ rightAction,
54
+ showFormattingButtons = false,
55
+ showImageButton = false,
56
+ }: StepFieldProps) {
57
+ const editorRef = useRef<HTMLDivElement>(null);
58
+ const [isFocused, setIsFocused] = useState(false);
59
+ const autoFocusRef = useRef(false);
60
+ const [plainTextValue, setPlainTextValue] = useState("");
61
+ const [activeSuggestionIndex, setActiveSuggestionIndex] = useState(0);
62
+ const [showAllSuggestions, setShowAllSuggestions] = useState(false);
63
+ const stepSuggestions = useStepAutocomplete();
64
+ const suggestions = suggestionsOverride ?? stepSuggestions;
65
+ const uploadImage = useStepImageUpload();
66
+ const fileInputRef = useRef<HTMLInputElement>(null);
67
+ const [isUploading, setIsUploading] = useState(false);
68
+ const normalizedQuery = normalizePlainText(plainTextValue);
69
+ const suggestionPool = useMemo(() => {
70
+ if (!suggestionFilter) {
71
+ return suggestions;
72
+ }
73
+ const filtered = suggestions.filter(suggestionFilter);
74
+ return filtered.length > 0 ? filtered : suggestions;
75
+ }, [suggestionFilter, suggestions]);
76
+ const filteredSuggestions = useMemo(() => {
77
+ if (!enableAutocomplete) {
78
+ return [];
79
+ }
80
+
81
+ const pool = showAllSuggestions || !normalizedQuery
82
+ ? suggestionPool
83
+ : suggestionPool.filter((item) => normalizePlainText(item.title).startsWith(normalizedQuery));
84
+
85
+ return pool.slice(0, 8);
86
+ }, [enableAutocomplete, normalizedQuery, showAllSuggestions, suggestionPool]);
87
+ const hasExactMatch = filteredSuggestions.some(
88
+ (item) => normalizePlainText(item.title) === normalizedQuery,
89
+ );
90
+ const shouldShowAutocomplete =
91
+ enableAutocomplete &&
92
+ isFocused &&
93
+ filteredSuggestions.length > 0 &&
94
+ (!hasExactMatch || showAllSuggestions) &&
95
+ (showAllSuggestions || normalizedQuery.length >= 1);
96
+ useEffect(() => {
97
+ setActiveSuggestionIndex(0);
98
+ }, [normalizedQuery, filteredSuggestions.length, showAllSuggestions]);
99
+
100
+ useEffect(() => {
101
+ if (normalizedQuery.length > 0) {
102
+ setShowAllSuggestions(false);
103
+ }
104
+ }, [normalizedQuery]);
105
+
106
+ useEffect(() => {
107
+ const element = editorRef.current;
108
+ if (!element || isFocused) {
109
+ return;
110
+ }
111
+
112
+ if (value.trim().length === 0) {
113
+ element.innerHTML = "";
114
+ setPlainTextValue("");
115
+ } else {
116
+ element.innerHTML = markdownToHtml(value);
117
+ setPlainTextValue(element.textContent ?? "");
118
+ }
119
+ }, [value, isFocused]);
120
+
121
+ const syncValue = useCallback(() => {
122
+ const element = editorRef.current;
123
+ if (!element) {
124
+ return;
125
+ }
126
+
127
+ const markdown = htmlToMarkdown(element.innerHTML);
128
+ if (markdown !== value) {
129
+ onChange(markdown);
130
+ }
131
+ setPlainTextValue(element.innerText ?? "");
132
+ if (!markdown && element.innerHTML !== "") {
133
+ element.innerHTML = "";
134
+ }
135
+ }, [onChange, value]);
136
+
137
+ useEffect(() => {
138
+ if (!autoFocus || autoFocusRef.current || !editorRef.current) {
139
+ return;
140
+ }
141
+
142
+ autoFocusRef.current = true;
143
+ const element = editorRef.current;
144
+ const focusElement = () => {
145
+ element.focus();
146
+ setIsFocused(true);
147
+ if (showSuggestionsOnFocus && enableAutocomplete) {
148
+ setShowAllSuggestions(true);
149
+ }
150
+ const selection = typeof window !== "undefined" ? window.getSelection?.() : null;
151
+ if (selection) {
152
+ selection.selectAllChildren(element);
153
+ selection.collapseToEnd();
154
+ }
155
+ };
156
+
157
+ if (typeof requestAnimationFrame === "function") {
158
+ const frame = requestAnimationFrame(focusElement);
159
+ return () => cancelAnimationFrame(frame);
160
+ }
161
+
162
+ const timeout = setTimeout(focusElement, 0);
163
+ return () => clearTimeout(timeout);
164
+ }, [autoFocus, enableAutocomplete, showSuggestionsOnFocus]);
165
+
166
+ const ensureCaretInEditor = useCallback(() => {
167
+ const element = editorRef.current;
168
+ if (!element) {
169
+ return false;
170
+ }
171
+
172
+ const selection = window.getSelection?.();
173
+ if (!selection) {
174
+ return false;
175
+ }
176
+
177
+ if (selection.rangeCount === 0 || !element.contains(selection.anchorNode)) {
178
+ const range = document.createRange();
179
+ range.selectNodeContents(element);
180
+ range.collapse(false);
181
+ selection.removeAllRanges();
182
+ selection.addRange(range);
183
+ }
184
+ element.focus();
185
+ return true;
186
+ }, []);
187
+
188
+ const handlePaste = useCallback(
189
+ async (event: ClipboardEvent<HTMLDivElement>) => {
190
+ if ((enableImageUpload && uploadImage) || onImageFile) {
191
+ const items = Array.from(event.clipboardData.items ?? []);
192
+ const imageItem = items.find((item) => item.kind === "file" && item.type.startsWith("image/"));
193
+ const file = imageItem?.getAsFile();
194
+ if (file) {
195
+ event.preventDefault();
196
+ if (onImageFile) {
197
+ await onImageFile(file);
198
+ return;
199
+ }
200
+ if (enableImageUpload && uploadImage) {
201
+ try {
202
+ const result = await uploadImage(file);
203
+ if (result?.url) {
204
+ ensureCaretInEditor();
205
+ const needsBreak = (editorRef.current?.innerHTML ?? "").trim().length > 0;
206
+ const imgHtml =
207
+ (needsBreak ? "<br />" : "") +
208
+ `<img src="${escapeHtml(result.url)}" alt="" class="bn-inline-image" contenteditable="false" draggable="false" />`;
209
+ document.execCommand("insertHTML", false, imgHtml);
210
+ syncValue();
211
+ }
212
+ } catch (error) {
213
+ console.error("Failed to upload image from paste", error);
214
+ }
215
+ return;
216
+ }
217
+ }
218
+ }
219
+
220
+ event.preventDefault();
221
+ const text = event.clipboardData?.getData("text/plain") ?? "";
222
+ const html = markdownToHtml(text);
223
+ ensureCaretInEditor();
224
+ document.execCommand("insertHTML", false, html);
225
+ syncValue();
226
+ },
227
+ [enableImageUpload, ensureCaretInEditor, onImageFile, syncValue, uploadImage],
228
+ );
229
+
230
+ const applySuggestion = useCallback(
231
+ (suggestion: Suggestion) => {
232
+ const escaped = escapeMarkdownText(suggestion.title);
233
+ onChange(escaped);
234
+ onSuggestionSelect?.(suggestion);
235
+ setPlainTextValue(suggestion.title);
236
+ setActiveSuggestionIndex(0);
237
+ setShowAllSuggestions(false);
238
+ if (editorRef.current) {
239
+ editorRef.current.innerHTML = markdownToHtml(escaped);
240
+ editorRef.current.focus();
241
+ const selection = typeof window !== "undefined" ? window.getSelection?.() : null;
242
+ if (selection && editorRef.current.firstChild) {
243
+ const range = document.createRange();
244
+ range.selectNodeContents(editorRef.current);
245
+ range.collapse(false);
246
+ selection.removeAllRanges();
247
+ selection.addRange(range);
248
+ }
249
+ }
250
+ },
251
+ [onChange, onSuggestionSelect],
252
+ );
253
+
254
+ return (
255
+ <div className="bn-step-field">
256
+ <div className="bn-step-field__top">
257
+ <span className="bn-step-field__label">
258
+ {label}
259
+ {enableAutocomplete && (
260
+ <button
261
+ type="button"
262
+ className="bn-step-toolbar__button"
263
+ onMouseDown={(event) => {
264
+ event.preventDefault();
265
+ setShowAllSuggestions(true);
266
+ editorRef.current?.focus();
267
+ }}
268
+ aria-label="Show suggestions"
269
+ tabIndex={-1}
270
+ >
271
+
272
+ </button>
273
+ )}
274
+ </span>
275
+ <div className="bn-step-toolbar" aria-label={`${label} controls`}>
276
+ {showFormattingButtons && (
277
+ <>
278
+ <button
279
+ type="button"
280
+ className="bn-step-toolbar__button"
281
+ onMouseDown={(event) => {
282
+ event.preventDefault();
283
+ editorRef.current?.focus();
284
+ document.execCommand("bold");
285
+ syncValue();
286
+ }}
287
+ aria-label="Bold"
288
+ tabIndex={-1}
289
+ >
290
+ B
291
+ </button>
292
+ <button
293
+ type="button"
294
+ className="bn-step-toolbar__button"
295
+ onMouseDown={(event) => {
296
+ event.preventDefault();
297
+ editorRef.current?.focus();
298
+ document.execCommand("italic");
299
+ syncValue();
300
+ }}
301
+ aria-label="Italic"
302
+ tabIndex={-1}
303
+ >
304
+ I
305
+ </button>
306
+ </>
307
+ )}
308
+ {enableImageUpload && uploadImage && showImageButton && (
309
+ <button
310
+ type="button"
311
+ className="bn-step-toolbar__button"
312
+ onMouseDown={(event) => {
313
+ event.preventDefault();
314
+ const input = fileInputRef.current;
315
+ if (input) {
316
+ input.click();
317
+ }
318
+ }}
319
+ aria-label="Insert image"
320
+ tabIndex={-1}
321
+ disabled={isUploading}
322
+ >
323
+ Img
324
+ </button>
325
+ )}
326
+ {rightAction}
327
+ </div>
328
+ </div>
329
+ {enableImageUpload && (
330
+ <input
331
+ ref={fileInputRef}
332
+ type="file"
333
+ accept="image/*"
334
+ style={{ display: "none" }}
335
+ onChange={async (event: ChangeEvent<HTMLInputElement>) => {
336
+ const file = event.target.files?.[0];
337
+ if (!file || !uploadImage) {
338
+ return;
339
+ }
340
+ try {
341
+ setIsUploading(true);
342
+ const response = await uploadImage(file);
343
+ if (response?.url) {
344
+ const element = editorRef.current;
345
+ if (element) {
346
+ const escapedUrl = escapeHtml(response.url);
347
+ const needsBreak = element.innerHTML.trim().length > 0;
348
+ const imgHtml =
349
+ (needsBreak ? "<br />" : "") +
350
+ `<img src="${escapedUrl}" alt="" class="bn-inline-image" contenteditable="false" draggable="false" />`;
351
+ element.focus();
352
+ ensureCaretInEditor();
353
+ document.execCommand("insertHTML", false, imgHtml);
354
+ syncValue();
355
+ }
356
+ }
357
+ } catch (error) {
358
+ console.error("Failed to upload image", error);
359
+ } finally {
360
+ setIsUploading(false);
361
+ event.target.value = "";
362
+ }
363
+ }}
364
+ />
365
+ )}
366
+ <div
367
+ ref={editorRef}
368
+ className="bn-step-editor"
369
+ suppressContentEditableWarning
370
+ data-placeholder={placeholder}
371
+ data-multiline={multiline ? "true" : "false"}
372
+ data-step-field={fieldName}
373
+ contentEditable={readOnly ? "false" : "true"}
374
+ onFocus={() => {
375
+ setIsFocused(true);
376
+ if (showSuggestionsOnFocus && enableAutocomplete) {
377
+ setShowAllSuggestions(true);
378
+ }
379
+ setPlainTextValue(editorRef.current?.innerText ?? "");
380
+ }}
381
+ onBlur={() => {
382
+ setIsFocused(false);
383
+ syncValue();
384
+ }}
385
+ onInput={readOnly ? undefined : syncValue}
386
+ onPaste={readOnly ? (event) => event.preventDefault() : handlePaste}
387
+ onKeyDown={(event) => {
388
+ if (readOnly) {
389
+ const allowedKeys = new Set([
390
+ "ArrowDown",
391
+ "ArrowUp",
392
+ "Enter",
393
+ "Tab",
394
+ ]);
395
+ const openKeys =
396
+ enableAutocomplete && (event.metaKey || event.ctrlKey) && (event.code === "Space" || event.key === "" || event.key === " ");
397
+ if (!allowedKeys.has(event.key) && !openKeys) {
398
+ event.preventDefault();
399
+ return;
400
+ }
401
+ }
402
+ if ((event.key === "a" || event.key === "A") && (event.metaKey || event.ctrlKey)) {
403
+ event.preventDefault();
404
+ const selection = window.getSelection?.();
405
+ const node = editorRef.current;
406
+ if (selection && node) {
407
+ const range = document.createRange();
408
+ range.selectNodeContents(node);
409
+ selection.removeAllRanges();
410
+ selection.addRange(range);
411
+ }
412
+ return;
413
+ }
414
+
415
+ if (enableAutocomplete && shouldShowAutocomplete) {
416
+ if (event.key === "ArrowDown") {
417
+ event.preventDefault();
418
+ setActiveSuggestionIndex((prev) =>
419
+ prev + 1 >= filteredSuggestions.length ? 0 : prev + 1,
420
+ );
421
+ return;
422
+ }
423
+ if (event.key === "ArrowUp") {
424
+ event.preventDefault();
425
+ setActiveSuggestionIndex((prev) =>
426
+ prev - 1 < 0 ? filteredSuggestions.length - 1 : prev - 1,
427
+ );
428
+ return;
429
+ }
430
+ if (event.key === "Enter" || event.key === "Tab") {
431
+ event.preventDefault();
432
+ const suggestion = filteredSuggestions[activeSuggestionIndex] ?? filteredSuggestions[0];
433
+ if (suggestion) {
434
+ applySuggestion(suggestion);
435
+ }
436
+ return;
437
+ }
438
+ }
439
+
440
+ if (enableAutocomplete && (event.metaKey || event.ctrlKey) && (event.code === "Space" || event.key === "" || event.key === " ")) {
441
+ event.preventDefault();
442
+ setShowAllSuggestions(true);
443
+ return;
444
+ }
445
+
446
+ if (event.key === "Enter") {
447
+ event.preventDefault();
448
+ if (multiline && event.shiftKey) {
449
+ document.execCommand("insertLineBreak");
450
+ document.execCommand("insertLineBreak");
451
+ } else {
452
+ document.execCommand("insertLineBreak");
453
+ }
454
+ syncValue();
455
+ }
456
+ }}
457
+ />
458
+ {shouldShowAutocomplete && (
459
+ <div className="bn-step-suggestions" role="listbox" aria-label={`${label} suggestions`}>
460
+ {filteredSuggestions.map((suggestion, index) => (
461
+ <button
462
+ type="button"
463
+ key={suggestion.id}
464
+ role="option"
465
+ aria-selected={index === activeSuggestionIndex}
466
+ className={
467
+ index === activeSuggestionIndex
468
+ ? "bn-step-suggestion bn-step-suggestion--active"
469
+ : "bn-step-suggestion"
470
+ }
471
+ onMouseDown={(event) => {
472
+ event.preventDefault();
473
+ applySuggestion(suggestion);
474
+ }}
475
+ tabIndex={-1}
476
+ >
477
+ <span className="bn-step-suggestion__title">{suggestion.title}</span>
478
+ {typeof suggestion.usageCount === "number" && suggestion.usageCount > 0 && (
479
+ <span className="bn-step-suggestion__meta">{suggestion.usageCount} uses</span>
480
+ )}
481
+ </button>
482
+ ))}
483
+ </div>
484
+ )}
485
+ </div>
486
+ );
487
+ }
@@ -101,6 +101,62 @@ describe("blocksToMarkdown", () => {
101
101
  );
102
102
  });
103
103
 
104
+ it("serializes a snippet block with prefixed title", () => {
105
+ const blocks: CustomEditorBlock[] = [
106
+ {
107
+ id: "sn1",
108
+ type: "snippet",
109
+ props: {
110
+ snippetId: "501",
111
+ snippetTitle: "Open the login page",
112
+ snippetData: "Navigate to /login",
113
+ snippetExpectedResult: "Login form renders",
114
+ },
115
+ content: undefined,
116
+ children: [],
117
+ },
118
+ ];
119
+
120
+ expect(blocksToMarkdown(blocks)).toBe(
121
+ [
122
+ "<!-- begin snippet #501 -->",
123
+ "Navigate to /login",
124
+ "<!-- end snippet #501 -->",
125
+ ].join("\n"),
126
+ );
127
+ });
128
+
129
+ it("serializes snippet bodies without duplicating wrapper comments", () => {
130
+ const blocks: CustomEditorBlock[] = [
131
+ {
132
+ id: "sn2",
133
+ type: "snippet",
134
+ props: {
135
+ snippetId: "777",
136
+ snippetTitle: "Has inline wrappers",
137
+ snippetData: [
138
+ "<!-- begin snippet #777 -->",
139
+ "Line 1",
140
+ "Line 2",
141
+ "<!-- end snippet #777 -->",
142
+ ].join("\n"),
143
+ snippetExpectedResult: "",
144
+ },
145
+ content: undefined,
146
+ children: [],
147
+ },
148
+ ];
149
+
150
+ expect(blocksToMarkdown(blocks)).toBe(
151
+ [
152
+ "<!-- begin snippet #777 -->",
153
+ "Line 1",
154
+ "Line 2",
155
+ "<!-- end snippet #777 -->",
156
+ ].join("\n"),
157
+ );
158
+ });
159
+
104
160
  it("keeps inline formatting inside step fields", () => {
105
161
  const blocks: CustomEditorBlock[] = [
106
162
  {
@@ -196,32 +252,6 @@ describe("blocksToMarkdown", () => {
196
252
  );
197
253
  });
198
254
 
199
- it("exports the custom test case block", () => {
200
- const blocks: CustomEditorBlock[] = [
201
- {
202
- id: "tc1",
203
- type: "testCase",
204
- props: {
205
- ...baseProps,
206
- status: "ready",
207
- reference: "QA-7",
208
- },
209
- content: [
210
- {
211
- type: "text",
212
- text: "Run the smoke tests.",
213
- styles: {},
214
- },
215
- ],
216
- children: [],
217
- },
218
- ];
219
-
220
- expect(blocksToMarkdown(blocks)).toBe(
221
- ":::test-case status=\"ready\" reference=\"QA-7\"\nRun the smoke tests.\n:::",
222
- );
223
- });
224
-
225
255
  it("serializes tables", () => {
226
256
  const blocks: CustomEditorBlock[] = [
227
257
  {
@@ -297,10 +327,6 @@ describe("markdownToBlocks", () => {
297
327
  const markdown = [
298
328
  "* Open the Login page.",
299
329
  " *Expected*: The Login page loads successfully.",
300
- "",
301
- ":::test-case status=\"ready\" reference=\"QA-7\"",
302
- "Run the smoke tests.",
303
- ":::",
304
330
  ].join("\n");
305
331
 
306
332
  expect(markdownToBlocks(markdown)).toEqual([
@@ -313,17 +339,76 @@ describe("markdownToBlocks", () => {
313
339
  },
314
340
  children: [],
315
341
  },
342
+ ]);
343
+ });
344
+
345
+ it("parses snippet markdown into snippet blocks", () => {
346
+ const markdown = [
347
+ "<!-- begin snippet #501 -->",
348
+ "Run the seeder",
349
+ "<!-- end snippet #501 -->",
350
+ ].join("\n");
351
+
352
+ expect(markdownToBlocks(markdown)).toEqual([
316
353
  {
317
- type: "testCase",
354
+ type: "snippet",
318
355
  props: {
319
- ...baseProps,
320
- status: "ready",
321
- reference: "QA-7",
356
+ snippetId: "501",
357
+ snippetTitle: "",
358
+ snippetData: "Run the seeder",
359
+ snippetExpectedResult: "",
322
360
  },
323
- content: [{ type: "text", text: "Run the smoke tests.", styles: {} }],
324
361
  children: [],
325
362
  },
326
- ]);
363
+ ]);
364
+ });
365
+
366
+ it("parses snippet bodies and ignores nested snippet markers", () => {
367
+ const markdown = [
368
+ "<!-- begin snippet #888 -->",
369
+ "Prep DB",
370
+ "<!-- begin snippet #ignored -->",
371
+ "Do not keep this marker",
372
+ "<!-- end snippet #ignored -->",
373
+ "<!-- end snippet #888 -->",
374
+ ].join("\n");
375
+
376
+ expect(markdownToBlocks(markdown)).toEqual([
377
+ {
378
+ type: "snippet",
379
+ props: {
380
+ snippetId: "888",
381
+ snippetTitle: "",
382
+ snippetData: "Prep DB\nDo not keep this marker",
383
+ snippetExpectedResult: "",
384
+ },
385
+ children: [],
386
+ },
387
+ ]);
388
+
389
+ const roundTrip = blocksToMarkdown([
390
+ {
391
+ id: "sn888",
392
+ type: "snippet",
393
+ props: {
394
+ snippetId: "888",
395
+ snippetTitle: "",
396
+ snippetData: "Prep DB\nDo not keep this marker",
397
+ snippetExpectedResult: "",
398
+ },
399
+ content: undefined,
400
+ children: [],
401
+ },
402
+ ]);
403
+
404
+ expect(roundTrip).toBe(
405
+ [
406
+ "<!-- begin snippet #888 -->",
407
+ "Prep DB",
408
+ "Do not keep this marker",
409
+ "<!-- end snippet #888 -->",
410
+ ].join("\n"),
411
+ );
327
412
  });
328
413
 
329
414
  it("parses step lists with inline expected results label", () => {