testomatio-editor-blocks 0.4.65 → 0.4.67

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 (33) hide show
  1. package/package/editor/blocks/step.d.ts +5 -0
  2. package/package/editor/blocks/step.js +273 -213
  3. package/package/editor/blocks/stepHorizontalView.d.ts +1 -0
  4. package/package/editor/blocks/stepHorizontalView.js +2 -2
  5. package/package/editor/blocks/testMeta.d.ts +37 -0
  6. package/package/editor/blocks/testMeta.js +111 -0
  7. package/package/editor/blocks/useDeferredMount.d.ts +26 -0
  8. package/package/editor/blocks/useDeferredMount.js +54 -0
  9. package/package/editor/createMarkdownPasteHandler.js +56 -9
  10. package/package/editor/customMarkdownConverter.js +127 -10
  11. package/package/editor/customSchema.d.ts +32 -0
  12. package/package/editor/customSchema.js +2 -0
  13. package/package/editor/testMetaFields.d.ts +17 -0
  14. package/package/editor/testMetaFields.js +33 -0
  15. package/package/index.d.ts +2 -0
  16. package/package/index.js +2 -0
  17. package/package/styles.css +215 -0
  18. package/package.json +1 -1
  19. package/src/App.tsx +54 -15
  20. package/src/editor/blocks/step.tsx +198 -47
  21. package/src/editor/blocks/stepHorizontalView.tsx +3 -0
  22. package/src/editor/blocks/stepNumber.test.ts +39 -0
  23. package/src/editor/blocks/testMeta.tsx +242 -0
  24. package/src/editor/blocks/useDeferredMount.ts +66 -0
  25. package/src/editor/createMarkdownPasteHandler.test.ts +126 -0
  26. package/src/editor/createMarkdownPasteHandler.ts +60 -8
  27. package/src/editor/customMarkdownConverter.test.ts +135 -0
  28. package/src/editor/customMarkdownConverter.ts +125 -0
  29. package/src/editor/customSchema.tsx +2 -0
  30. package/src/editor/renderingPerf.test.ts +59 -0
  31. package/src/editor/styles.css +215 -0
  32. package/src/editor/testMetaFields.ts +53 -0
  33. package/src/index.ts +7 -0
@@ -0,0 +1,242 @@
1
+ import { createReactBlockSpec } from "@blocknote/react";
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
+ import type { ChangeEvent } from "react";
4
+ import { getMetaFieldSuggestions } from "../testMetaFields";
5
+
6
+ export type MetaField = { key: string; value: string };
7
+
8
+ const ID_KEYS = new Set(["id"]);
9
+
10
+ function parseMetaFields(raw: unknown): MetaField[] {
11
+ if (typeof raw !== "string" || raw.trim().length === 0) {
12
+ return [];
13
+ }
14
+ try {
15
+ const parsed = JSON.parse(raw);
16
+ if (!Array.isArray(parsed)) {
17
+ return [];
18
+ }
19
+ return parsed
20
+ .filter((item) => item && typeof item === "object")
21
+ .map((item) => ({
22
+ key: typeof item.key === "string" ? item.key : "",
23
+ value: typeof item.value === "string" ? item.value : "",
24
+ }));
25
+ } catch {
26
+ return [];
27
+ }
28
+ }
29
+
30
+ export function serializeMetaFields(fields: MetaField[]): string {
31
+ return JSON.stringify(fields);
32
+ }
33
+
34
+ type AddFieldMenuProps = {
35
+ kind: "test" | "suite";
36
+ usedKeys: string[];
37
+ onPick: (key: string) => void;
38
+ };
39
+
40
+ function AddFieldMenu({ kind, usedKeys, onPick }: AddFieldMenuProps) {
41
+ const [isOpen, setIsOpen] = useState(false);
42
+ const containerRef = useRef<HTMLDivElement>(null);
43
+
44
+ useEffect(() => {
45
+ if (!isOpen) return;
46
+ const handleMouseDown = (event: MouseEvent) => {
47
+ if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
48
+ setIsOpen(false);
49
+ }
50
+ };
51
+ document.addEventListener("mousedown", handleMouseDown);
52
+ return () => document.removeEventListener("mousedown", handleMouseDown);
53
+ }, [isOpen]);
54
+
55
+ const available = useMemo(() => {
56
+ const used = new Set(usedKeys.map((k) => k.trim().toLowerCase()));
57
+ return getMetaFieldSuggestions(kind).filter((s) => !used.has(s.key.trim().toLowerCase()));
58
+ }, [kind, usedKeys]);
59
+
60
+ const pick = useCallback(
61
+ (key: string) => {
62
+ onPick(key);
63
+ setIsOpen(false);
64
+ },
65
+ [onPick],
66
+ );
67
+
68
+ return (
69
+ <div className="bn-testmeta__add-wrap" ref={containerRef}>
70
+ <button
71
+ type="button"
72
+ className="bn-testmeta__add"
73
+ aria-label="Add field"
74
+ title="Add field"
75
+ onClick={() => setIsOpen((prev) => !prev)}
76
+ >
77
+ +
78
+ </button>
79
+ {isOpen && (
80
+ <div className="bn-testmeta__menu" role="listbox">
81
+ {available.map((suggestion) => (
82
+ <button
83
+ type="button"
84
+ key={suggestion.key}
85
+ role="option"
86
+ className="bn-testmeta__menu-item"
87
+ onMouseDown={(event) => {
88
+ event.preventDefault();
89
+ pick(suggestion.key);
90
+ }}
91
+ >
92
+ {suggestion.label ?? suggestion.key}
93
+ </button>
94
+ ))}
95
+ <button
96
+ type="button"
97
+ className="bn-testmeta__menu-item bn-testmeta__menu-item--custom"
98
+ onMouseDown={(event) => {
99
+ event.preventDefault();
100
+ pick("");
101
+ }}
102
+ >
103
+ Custom field…
104
+ </button>
105
+ </div>
106
+ )}
107
+ </div>
108
+ );
109
+ }
110
+
111
+ export const testMetaBlock = createReactBlockSpec(
112
+ {
113
+ type: "testMeta",
114
+ content: "none",
115
+ propSchema: {
116
+ // "test" | "suite" — which keyword the comment opened with.
117
+ metaKind: {
118
+ default: "test",
119
+ },
120
+ // JSON-encoded MetaField[] so insertion order is preserved.
121
+ metaFields: {
122
+ default: "[]",
123
+ },
124
+ // true when the source comment was a one-liner (`<!-- test id: @T.. -->`).
125
+ metaInline: {
126
+ default: false,
127
+ },
128
+ },
129
+ },
130
+ {
131
+ render: ({ block, editor }) => {
132
+ const kind = (block.props.metaKind as string) === "suite" ? "suite" : "test";
133
+ const fields = useMemo(
134
+ () => parseMetaFields(block.props.metaFields),
135
+ [block.props.metaFields],
136
+ );
137
+
138
+ const commitFields = useCallback(
139
+ (next: MetaField[]) => {
140
+ editor.updateBlock(block.id, {
141
+ props: { metaFields: serializeMetaFields(next) } as any,
142
+ });
143
+ },
144
+ [block.id, editor],
145
+ );
146
+
147
+ const handleValueChange = useCallback(
148
+ (index: number, value: string) => {
149
+ const next = fields.map((field, i) =>
150
+ i === index ? { ...field, value } : field,
151
+ );
152
+ commitFields(next);
153
+ },
154
+ [fields, commitFields],
155
+ );
156
+
157
+ const handleKeyChange = useCallback(
158
+ (index: number, key: string) => {
159
+ const next = fields.map((field, i) =>
160
+ i === index ? { ...field, key } : field,
161
+ );
162
+ commitFields(next);
163
+ },
164
+ [fields, commitFields],
165
+ );
166
+
167
+ const handleRemove = useCallback(
168
+ (index: number) => {
169
+ commitFields(fields.filter((_, i) => i !== index));
170
+ },
171
+ [fields, commitFields],
172
+ );
173
+
174
+ const handleAddField = useCallback(
175
+ (key: string) => {
176
+ commitFields([...fields, { key, value: "" }]);
177
+ },
178
+ [fields, commitFields],
179
+ );
180
+
181
+ const usedKeys = useMemo(() => fields.map((f) => f.key), [fields]);
182
+
183
+ // The read-only `id` is shown inline on the header line next to the kind
184
+ // label; every other field renders as an editable row below.
185
+ const idField = fields.find((f) => ID_KEYS.has(f.key.trim().toLowerCase()));
186
+ const editableFields = fields
187
+ .map((field, index) => ({ field, index }))
188
+ .filter(({ field }) => !ID_KEYS.has(field.key.trim().toLowerCase()));
189
+
190
+ return (
191
+ <div
192
+ className="bn-testmeta"
193
+ data-block-id={block.id}
194
+ data-kind={kind}
195
+ contentEditable={false}
196
+ suppressContentEditableWarning
197
+ draggable={false}
198
+ >
199
+ <div className="bn-testmeta__header">
200
+ <span className="bn-testmeta__label">{kind.toUpperCase()}</span>
201
+ {idField?.value && <span className="bn-testmeta__id">{idField.value}</span>}
202
+ <AddFieldMenu kind={kind} usedKeys={usedKeys} onPick={handleAddField} />
203
+ </div>
204
+
205
+ {editableFields.length > 0 && (
206
+ <div className="bn-testmeta__rows">
207
+ {editableFields.map(({ field, index }) => (
208
+ <div className="bn-testmeta__row" key={index}>
209
+ <input
210
+ className="bn-testmeta__key bn-testmeta__key--input"
211
+ type="text"
212
+ value={field.key}
213
+ placeholder="key"
214
+ spellCheck={false}
215
+ onChange={(e: ChangeEvent<HTMLInputElement>) => handleKeyChange(index, e.target.value)}
216
+ />
217
+ <input
218
+ className="bn-testmeta__value"
219
+ type="text"
220
+ value={field.value}
221
+ placeholder="value"
222
+ spellCheck={false}
223
+ onChange={(e: ChangeEvent<HTMLInputElement>) => handleValueChange(index, e.target.value)}
224
+ />
225
+ <button
226
+ type="button"
227
+ className="bn-testmeta__remove"
228
+ aria-label="Remove field"
229
+ title="Remove field"
230
+ onClick={() => handleRemove(index)}
231
+ >
232
+ ×
233
+ </button>
234
+ </div>
235
+ ))}
236
+ </div>
237
+ )}
238
+ </div>
239
+ );
240
+ },
241
+ },
242
+ );
@@ -0,0 +1,66 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+
3
+ /**
4
+ * Defers mounting of expensive block content until the element is at (or near)
5
+ * the viewport. Heavy blocks (e.g. test steps that each spin up an OverType
6
+ * editor) render a cheap placeholder first; the real interactive content is
7
+ * mounted only once the block scrolls into view. This keeps pasting/loading a
8
+ * large document fast — only the visible steps pay the editor-init cost up
9
+ * front, the rest are upgraded lazily as the user scrolls.
10
+ *
11
+ * Returns a ref to attach to the wrapper element and a boolean that flips to
12
+ * `true` once (and stays true — we never tear an editor back down).
13
+ *
14
+ * `activate(focus)` lets the caller upgrade eagerly on interaction. Passing
15
+ * `focus: true` (a click/focus on the placeholder) records that the freshly
16
+ * mounted content should take focus, so a single click on a preview starts
17
+ * editing. Passive activation (hover pre-warm, scroll-into-view) leaves focus
18
+ * alone via `shouldFocusOnActivate === false`.
19
+ */
20
+ export function useDeferredMount<T extends HTMLElement>(
21
+ options: { rootMargin?: string; initiallyActive?: boolean } = {},
22
+ ): {
23
+ ref: React.RefObject<T | null>;
24
+ active: boolean;
25
+ activate: (focus?: boolean) => void;
26
+ shouldFocusOnActivate: boolean;
27
+ } {
28
+ const { rootMargin = "300px 0px", initiallyActive = false } = options;
29
+ const ref = useRef<T>(null);
30
+ const [active, setActive] = useState(initiallyActive);
31
+ const activeRef = useRef(active);
32
+ activeRef.current = active;
33
+ const focusOnActivateRef = useRef(false);
34
+
35
+ const activate = (focus = false) => {
36
+ if (activeRef.current) return;
37
+ if (focus) focusOnActivateRef.current = true;
38
+ setActive(true);
39
+ };
40
+
41
+ useEffect(() => {
42
+ if (activeRef.current) return;
43
+ const el = ref.current;
44
+ if (!el) return;
45
+
46
+ // Environments without IntersectionObserver (or SSR) just mount eagerly.
47
+ if (typeof IntersectionObserver === "undefined") {
48
+ setActive(true);
49
+ return;
50
+ }
51
+
52
+ const observer = new IntersectionObserver(
53
+ (entries) => {
54
+ if (entries.some((entry) => entry.isIntersecting)) {
55
+ setActive(true);
56
+ observer.disconnect();
57
+ }
58
+ },
59
+ { rootMargin },
60
+ );
61
+ observer.observe(el);
62
+ return () => observer.disconnect();
63
+ }, [rootMargin]);
64
+
65
+ return { ref, active, activate, shouldFocusOnActivate: focusOnActivateRef.current };
66
+ }
@@ -0,0 +1,126 @@
1
+ import { describe, it, expect, vi, afterEach } from "vitest";
2
+ import { createMarkdownPasteHandler } from "./createMarkdownPasteHandler";
3
+
4
+ // These tests lock in the rendering-performance contract of the paste handler:
5
+ // a large paste must NOT build the whole document in one synchronous shot
6
+ // (which froze the editor for ~1.6s on a 1000-block document). Only a small
7
+ // first chunk is inserted synchronously; the rest is streamed in deferred
8
+ // (idle/timeout) batches. They assert behaviour, not wall-clock, so they are
9
+ // deterministic and CI-safe.
10
+
11
+ type Recorded = { content: { text: string }[]; id?: string };
12
+
13
+ function makeBlocks(n: number) {
14
+ return Array.from({ length: n }, (_, i) => ({
15
+ type: "paragraph",
16
+ content: [{ type: "text", text: `line ${i}`, styles: {} }],
17
+ }));
18
+ }
19
+
20
+ function makeEditor() {
21
+ let idSeq = 0;
22
+ const inserted: Recorded[] = [];
23
+ const assignIds = (blocks: any[]) => blocks.map((b) => ({ ...b, id: `b${idSeq++}` }));
24
+
25
+ const editor: any = {
26
+ document: [{ id: "cursor", type: "paragraph", content: [] }],
27
+ getSelection: () => ({ blocks: [] }),
28
+ getTextCursorPosition: () => ({ block: editor.document[0] }),
29
+ replaceBlocks: vi.fn((_ids: string[], blocks: any[]) => {
30
+ const withIds = assignIds(blocks);
31
+ inserted.push(...withIds);
32
+ return { insertedBlocks: withIds, removedBlocks: [] };
33
+ }),
34
+ insertBlocks: vi.fn((blocks: any[]) => {
35
+ const withIds = assignIds(blocks);
36
+ inserted.push(...withIds);
37
+ return withIds;
38
+ }),
39
+ focus: vi.fn(),
40
+ };
41
+ return { editor, inserted };
42
+ }
43
+
44
+ function makeEvent(text: string): any {
45
+ return {
46
+ clipboardData: {
47
+ types: ["text/plain"],
48
+ getData: (type: string) => (type === "text/plain" ? text : ""),
49
+ },
50
+ };
51
+ }
52
+
53
+ const defaultPasteHandler = vi.fn(() => true);
54
+
55
+ afterEach(() => {
56
+ vi.useRealTimers();
57
+ vi.clearAllMocks();
58
+ });
59
+
60
+ describe("createMarkdownPasteHandler — chunked rendering", () => {
61
+ it("inserts a small paste in a single synchronous transaction", () => {
62
+ const N = 20;
63
+ const handler = createMarkdownPasteHandler(() => makeBlocks(N) as any);
64
+ const { editor, inserted } = makeEditor();
65
+
66
+ vi.useFakeTimers();
67
+ const result = handler({
68
+ event: makeEvent("a\nb\nc"),
69
+ editor,
70
+ defaultPasteHandler,
71
+ });
72
+
73
+ expect(result).toBe(true);
74
+ expect(inserted.length).toBe(N); // everything inserted up front
75
+ expect(editor.replaceBlocks).toHaveBeenCalledTimes(1);
76
+ expect(editor.insertBlocks).not.toHaveBeenCalled(); // nothing deferred
77
+ expect(vi.getTimerCount()).toBe(0); // no background work scheduled
78
+ });
79
+
80
+ it("does NOT render a large paste synchronously — only a bounded first chunk", () => {
81
+ const N = 1000;
82
+ const handler = createMarkdownPasteHandler(() => makeBlocks(N) as any);
83
+ const { editor, inserted } = makeEditor();
84
+
85
+ vi.useFakeTimers();
86
+ handler({ event: makeEvent("big\npaste"), editor, defaultPasteHandler });
87
+
88
+ const syncCount = inserted.length;
89
+ expect(syncCount).toBeGreaterThan(0);
90
+ expect(syncCount).toBeLessThan(N); // the whole doc was NOT built synchronously
91
+ expect(syncCount).toBeLessThanOrEqual(100); // first chunk stays small
92
+ expect(editor.replaceBlocks).toHaveBeenCalledTimes(1);
93
+ expect(vi.getTimerCount()).toBeGreaterThan(0); // remainder is scheduled, not run
94
+ });
95
+
96
+ it("eventually streams in every block exactly once and in order", () => {
97
+ const N = 1000;
98
+ const handler = createMarkdownPasteHandler(() => makeBlocks(N) as any);
99
+ const { editor, inserted } = makeEditor();
100
+
101
+ vi.useFakeTimers();
102
+ handler({ event: makeEvent("big\npaste"), editor, defaultPasteHandler });
103
+ vi.runAllTimers(); // flush all deferred batches
104
+
105
+ expect(inserted.length).toBe(N);
106
+ inserted.forEach((block, i) => {
107
+ expect(block.content[0].text).toBe(`line ${i}`);
108
+ });
109
+
110
+ // Every background batch is bounded — no single batch rebuilds the doc.
111
+ for (const call of editor.insertBlocks.mock.calls) {
112
+ expect((call[0] as any[]).length).toBeLessThanOrEqual(100);
113
+ }
114
+ });
115
+
116
+ it("delegates to the default handler when the converter yields nothing", () => {
117
+ const handler = createMarkdownPasteHandler(() => [] as any);
118
+ const { editor } = makeEditor();
119
+
120
+ const result = handler({ event: makeEvent("x"), editor, defaultPasteHandler });
121
+
122
+ expect(result).toBe(true);
123
+ expect(defaultPasteHandler).toHaveBeenCalled();
124
+ expect(editor.replaceBlocks).not.toHaveBeenCalled();
125
+ });
126
+ });
@@ -12,6 +12,25 @@ type PasteHandlerContext = {
12
12
 
13
13
  const BLOCK_MARKDOWN_PREFIX = /^(\s*)(#{1,6}\s|[-*+]\s|\d+[.)]\s|>\s|```|~~~|\||!\[)/;
14
14
 
15
+ // For large pastes, only the first chunk is inserted synchronously (enough to
16
+ // fill the viewport); the remaining blocks are streamed in during idle time so
17
+ // the editor stays responsive and the user sees content immediately instead of
18
+ // the main thread freezing while a thousand-block document is built at once.
19
+ const CHUNK_THRESHOLD = 150;
20
+ const FIRST_CHUNK = 50;
21
+ const REST_CHUNK = 40;
22
+
23
+ type ScheduleFn = (cb: () => void) => void;
24
+
25
+ const scheduleIdle: ScheduleFn =
26
+ typeof window !== "undefined" && typeof (window as any).requestIdleCallback === "function"
27
+ ? (cb) => (window as any).requestIdleCallback(() => cb(), { timeout: 200 })
28
+ : (cb) => setTimeout(cb, 0);
29
+
30
+ function lastBlockId(blocks: Array<{ id?: string }>): string | undefined {
31
+ return blocks.length ? blocks[blocks.length - 1]?.id : undefined;
32
+ }
33
+
15
34
  function isInlineOnlyPaste(plainText: string, parsedBlocks: CustomPartialBlock[]): boolean {
16
35
  if (parsedBlocks.length !== 1) return false;
17
36
  const [block] = parsedBlocks;
@@ -55,18 +74,51 @@ export function createMarkdownPasteHandler(
55
74
  ?.map((block: any) => block.id)
56
75
  .filter((id: unknown): id is string => Boolean(id)) ?? [];
57
76
 
58
- if (selectedIds.length > 0) {
59
- editor.replaceBlocks(selectedIds, parsedBlocks);
60
- } else {
77
+ // Insert the initial set of blocks at the paste location, returning the
78
+ // inserted blocks so subsequent chunks can be appended after the last one.
79
+ const insertInitial = (
80
+ blocksToInsert: CustomPartialBlock[],
81
+ ): Array<{ id?: string }> | null => {
82
+ if (selectedIds.length > 0) {
83
+ return editor.replaceBlocks(selectedIds, blocksToInsert).insertedBlocks;
84
+ }
61
85
  const cursorBlock = editor.getTextCursorPosition().block;
62
86
  if (cursorBlock) {
63
- editor.replaceBlocks([cursorBlock.id], parsedBlocks);
64
- } else if (editor.document.length > 0) {
87
+ return editor.replaceBlocks([cursorBlock.id], blocksToInsert).insertedBlocks;
88
+ }
89
+ if (editor.document.length > 0) {
65
90
  const reference = editor.document[editor.document.length - 1];
66
- editor.insertBlocks(parsedBlocks, reference.id, "after");
67
- } else {
68
- return defaultPasteHandler();
91
+ return editor.insertBlocks(blocksToInsert, reference.id, "after");
69
92
  }
93
+ return null;
94
+ };
95
+
96
+ if (parsedBlocks.length <= CHUNK_THRESHOLD) {
97
+ // Small paste: insert everything in one transaction (original behaviour).
98
+ if (insertInitial(parsedBlocks) === null) return defaultPasteHandler();
99
+ } else {
100
+ // Large paste: render the first screenful now, stream the rest in idle
101
+ // time so the main thread is never blocked building the whole document.
102
+ const firstChunk = parsedBlocks.slice(0, FIRST_CHUNK);
103
+ const rest = parsedBlocks.slice(FIRST_CHUNK);
104
+ const inserted = insertInitial(firstChunk);
105
+ if (inserted === null) return defaultPasteHandler();
106
+
107
+ let anchorId = lastBlockId(inserted);
108
+ let cursor = 0;
109
+ const pump = () => {
110
+ if (!anchorId || cursor >= rest.length) return;
111
+ const batch = rest.slice(cursor, cursor + REST_CHUNK);
112
+ cursor += REST_CHUNK;
113
+ try {
114
+ const insertedBatch = editor.insertBlocks(batch, anchorId, "after");
115
+ anchorId = lastBlockId(insertedBatch) ?? anchorId;
116
+ } catch {
117
+ return; // stop streaming on any structural error
118
+ }
119
+ if (cursor < rest.length) scheduleIdle(pump);
120
+ };
121
+ scheduleIdle(pump);
70
122
  }
71
123
 
72
124
  editor.focus();
@@ -3053,3 +3053,138 @@ describe("blank line <-> empty paragraph mapping", () => {
3053
3053
  expect(blocksToMarkdown(blocks as CustomEditorBlock[])).toBe(markdown);
3054
3054
  });
3055
3055
  });
3056
+
3057
+ describe("test/suite metadata comments", () => {
3058
+ it("parses a one-liner test comment into a testMeta block", () => {
3059
+ const blocks = markdownToBlocks("<!-- test id: @T12345678 -->");
3060
+ expect(blocks).toEqual([
3061
+ {
3062
+ type: "testMeta",
3063
+ props: {
3064
+ metaKind: "test",
3065
+ metaFields: JSON.stringify([{ key: "id", value: "@T12345678" }]),
3066
+ metaInline: true,
3067
+ },
3068
+ children: [],
3069
+ },
3070
+ ]);
3071
+ });
3072
+
3073
+ it("round-trips a one-liner test comment", () => {
3074
+ const markdown = "<!-- test id: @T12345678 -->";
3075
+ const blocks = markdownToBlocks(markdown);
3076
+ expect(blocksToMarkdown(blocks as CustomEditorBlock[])).toBe(markdown);
3077
+ });
3078
+
3079
+ it("parses a multi-line suite block with ordered fields", () => {
3080
+ const markdown = [
3081
+ "<!-- suite",
3082
+ "id: @S12345678",
3083
+ "emoji: 🔐",
3084
+ "tags: smoke, regression",
3085
+ "assignee: qa@example.com",
3086
+ "-->",
3087
+ ].join("\n");
3088
+ const blocks = markdownToBlocks(markdown);
3089
+ expect(blocks).toEqual([
3090
+ {
3091
+ type: "testMeta",
3092
+ props: {
3093
+ metaKind: "suite",
3094
+ metaFields: JSON.stringify([
3095
+ { key: "id", value: "@S12345678" },
3096
+ { key: "emoji", value: "🔐" },
3097
+ { key: "tags", value: "smoke, regression" },
3098
+ { key: "assignee", value: "qa@example.com" },
3099
+ ]),
3100
+ metaInline: false,
3101
+ },
3102
+ children: [],
3103
+ },
3104
+ ]);
3105
+ });
3106
+
3107
+ it("round-trips a multi-line suite block", () => {
3108
+ const markdown = [
3109
+ "<!-- suite",
3110
+ "id: @S12345678",
3111
+ "emoji: 🔐",
3112
+ "tags: smoke, regression",
3113
+ "assignee: qa@example.com",
3114
+ "-->",
3115
+ ].join("\n");
3116
+ const blocks = markdownToBlocks(markdown);
3117
+ expect(blocksToMarkdown(blocks as CustomEditorBlock[])).toBe(markdown);
3118
+ });
3119
+
3120
+ it("ignores lines without a colon inside a metadata block", () => {
3121
+ const markdown = [
3122
+ "<!-- test",
3123
+ "id: @T12345678",
3124
+ "this line is ignored",
3125
+ "priority: high",
3126
+ "-->",
3127
+ ].join("\n");
3128
+ const blocks = markdownToBlocks(markdown);
3129
+ expect((blocks[0].props as any).metaFields).toBe(
3130
+ JSON.stringify([
3131
+ { key: "id", value: "@T12345678" },
3132
+ { key: "priority", value: "high" },
3133
+ ]),
3134
+ );
3135
+ });
3136
+
3137
+ it("serializes a one-liner that gained extra fields as a block", () => {
3138
+ const blocks: CustomEditorBlock[] = [
3139
+ {
3140
+ id: "m1",
3141
+ type: "testMeta",
3142
+ props: {
3143
+ metaKind: "test",
3144
+ metaFields: JSON.stringify([
3145
+ { key: "id", value: "@T12345678" },
3146
+ { key: "priority", value: "high" },
3147
+ ]),
3148
+ metaInline: true,
3149
+ } as any,
3150
+ content: undefined as any,
3151
+ children: [],
3152
+ },
3153
+ ];
3154
+ expect(blocksToMarkdown(blocks)).toBe(
3155
+ ["<!-- test", "id: @T12345678", "priority: high", "-->"].join("\n"),
3156
+ );
3157
+ });
3158
+
3159
+ it("skips fields with empty values when serializing", () => {
3160
+ const blocks: CustomEditorBlock[] = [
3161
+ {
3162
+ id: "m2",
3163
+ type: "testMeta",
3164
+ props: {
3165
+ metaKind: "test",
3166
+ metaFields: JSON.stringify([
3167
+ { key: "id", value: "@T12345678" },
3168
+ { key: "priority", value: "high" },
3169
+ { key: "tags", value: "" },
3170
+ { key: "", value: "orphan" },
3171
+ ]),
3172
+ metaInline: false,
3173
+ } as any,
3174
+ content: undefined as any,
3175
+ children: [],
3176
+ },
3177
+ ];
3178
+ expect(blocksToMarkdown(blocks)).toBe(
3179
+ ["<!-- test", "id: @T12345678", "priority: high", "-->"].join("\n"),
3180
+ );
3181
+ });
3182
+
3183
+ it("leaves a generic HTML comment as paragraph text", () => {
3184
+ const blocks = markdownToBlocks("<!-- ai/agent generated description -->");
3185
+ expect(blocks[0].type).toBe("paragraph");
3186
+ expect(blocksToMarkdown(blocks as CustomEditorBlock[])).toBe(
3187
+ "<!-- ai/agent generated description -->",
3188
+ );
3189
+ });
3190
+ });