testomatio-editor-blocks 0.4.75 → 0.4.77

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.
@@ -3,6 +3,24 @@ export type MetaField = {
3
3
  value: string;
4
4
  };
5
5
  export declare function serializeMetaFields(fields: MetaField[]): string;
6
+ type TestEditorLike = {
7
+ document: any[];
8
+ getTextCursorPosition?: () => {
9
+ block?: {
10
+ id?: string;
11
+ };
12
+ };
13
+ insertBlocks: (blocks: any[], referenceId: string, placement: "before" | "after") => any[];
14
+ };
15
+ /**
16
+ * Insert a new test: a `testMeta` panel (labelled "NEW TEST" until it gets an id)
17
+ * seeded with default metadata — `type: manual`, `priority: normal`, and `labels`
18
+ * copied from the suite block — followed by an empty H2 heading for the test title.
19
+ *
20
+ * Inserts at the text cursor when the editor is focused, otherwise at the end of
21
+ * the document. Returns the title heading's block id (for focusing), or null.
22
+ */
23
+ export declare function addTestBlock(editor: TestEditorLike): string | null;
6
24
  export declare const testMetaBlock: {
7
25
  config: {
8
26
  readonly type: "testMeta";
@@ -35,3 +53,4 @@ export declare const testMetaBlock: {
35
53
  };
36
54
  }, any, import("@blocknote/core").InlineContentSchema, import("@blocknote/core").StyleSchema>;
37
55
  };
56
+ export {};
@@ -26,6 +26,70 @@ function parseMetaFields(raw) {
26
26
  export function serializeMetaFields(fields) {
27
27
  return JSON.stringify(fields);
28
28
  }
29
+ /** Reads the `labels` value from the first suite `testMeta` block, if any. */
30
+ function suiteLabelsFromDocument(document) {
31
+ var _a, _b;
32
+ for (const block of document) {
33
+ if ((block === null || block === void 0 ? void 0 : block.type) !== "testMeta")
34
+ continue;
35
+ if (((_a = block.props) === null || _a === void 0 ? void 0 : _a.metaKind) !== "suite")
36
+ continue;
37
+ const fields = parseMetaFields((_b = block.props) === null || _b === void 0 ? void 0 : _b.metaFields);
38
+ const labels = fields.find((f) => f.key.trim().toLowerCase() === "labels");
39
+ if (labels)
40
+ return labels.value;
41
+ }
42
+ return "";
43
+ }
44
+ /**
45
+ * Insert a new test: a `testMeta` panel (labelled "NEW TEST" until it gets an id)
46
+ * seeded with default metadata — `type: manual`, `priority: normal`, and `labels`
47
+ * copied from the suite block — followed by an empty H2 heading for the test title.
48
+ *
49
+ * Inserts at the text cursor when the editor is focused, otherwise at the end of
50
+ * the document. Returns the title heading's block id (for focusing), or null.
51
+ */
52
+ export function addTestBlock(editor) {
53
+ var _a, _b, _c, _d, _e;
54
+ const fields = [
55
+ { key: "type", value: "manual" },
56
+ { key: "priority", value: "normal" },
57
+ { key: "labels", value: suiteLabelsFromDocument(editor.document) },
58
+ ];
59
+ const metaBlock = {
60
+ type: "testMeta",
61
+ props: {
62
+ metaKind: "test",
63
+ metaFields: serializeMetaFields(fields),
64
+ metaInline: false,
65
+ },
66
+ children: [],
67
+ };
68
+ const titleHeading = {
69
+ type: "heading",
70
+ props: { level: 2 },
71
+ content: [],
72
+ children: [],
73
+ };
74
+ // Prefer the text cursor position — BlockNote keeps it in the editor state even
75
+ // after the editor blurs (e.g. when a toolbar button is clicked), so the test
76
+ // lands where the user left the caret. Fall back to the document end only when
77
+ // there is no cursor at all.
78
+ const docs = editor.document;
79
+ let referenceId;
80
+ try {
81
+ referenceId = (_b = (_a = editor.getTextCursorPosition) === null || _a === void 0 ? void 0 : _a.call(editor).block) === null || _b === void 0 ? void 0 : _b.id;
82
+ }
83
+ catch {
84
+ referenceId = undefined;
85
+ }
86
+ if (!referenceId)
87
+ referenceId = (_c = docs[docs.length - 1]) === null || _c === void 0 ? void 0 : _c.id;
88
+ if (!referenceId)
89
+ return null;
90
+ const inserted = editor.insertBlocks([metaBlock, titleHeading], referenceId, "after");
91
+ return (_e = (_d = inserted === null || inserted === void 0 ? void 0 : inserted[1]) === null || _d === void 0 ? void 0 : _d.id) !== null && _e !== void 0 ? _e : null;
92
+ }
29
93
  function AddFieldMenu({ kind, usedKeys, onPick }) {
30
94
  const [isOpen, setIsOpen] = useState(false);
31
95
  const containerRef = useRef(null);
@@ -122,6 +186,6 @@ export const testMetaBlock = createReactBlockSpec({
122
186
  // so it's immediately editable. `expanded` is UI-only state — never
123
187
  // serialized.
124
188
  const [expanded, setExpanded] = useState(() => fields.length === 0);
125
- return (_jsxs("div", { className: `bn-testmeta${expanded ? " bn-testmeta--expanded" : " bn-testmeta--collapsed"}`, "data-block-id": block.id, "data-kind": kind, contentEditable: false, suppressContentEditableWarning: true, draggable: false, children: [_jsxs("div", { className: "bn-testmeta__header", children: [_jsx("span", { className: "bn-testmeta__label", children: kind.toUpperCase() }), (idField === null || idField === void 0 ? void 0 : idField.value) && _jsx("span", { className: "bn-testmeta__id", children: idField.value }), !expanded && (_jsx("button", { type: "button", className: "bn-testmeta__summary", title: summaryText || "No metadata yet", onClick: () => setExpanded(true), children: summaryText || _jsx("span", { className: "bn-testmeta__summary--empty", children: "No metadata" }) })), _jsxs("div", { className: "bn-testmeta__actions", children: [expanded && _jsx(AddFieldMenu, { kind: kind, usedKeys: usedKeys, onPick: handleAddField }), _jsx("button", { type: "button", className: `bn-testmeta__toggle${expanded ? " bn-testmeta__toggle--expanded" : ""}`, "aria-expanded": expanded, "aria-label": expanded ? "Collapse metadata" : "Expand metadata", title: expanded ? "Collapse" : "Expand", onClick: () => setExpanded((prev) => !prev), children: _jsx("svg", { width: "14", height: "14", viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true", children: _jsx("path", { d: "M4 6L8 10L12 6", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round" }) }) })] })] }), expanded && editableFields.length > 0 && (_jsx("div", { className: "bn-testmeta__rows", children: editableFields.map(({ field, index }) => (_jsxs("div", { className: "bn-testmeta__row", children: [_jsx("input", { className: "bn-testmeta__key bn-testmeta__key--input", type: "text", value: field.key, placeholder: "key", spellCheck: false, onChange: (e) => handleKeyChange(index, e.target.value) }), _jsx("input", { className: "bn-testmeta__value", type: "text", value: field.value, placeholder: "value", spellCheck: false, onChange: (e) => handleValueChange(index, e.target.value) }), _jsx("button", { type: "button", className: "bn-testmeta__remove", "aria-label": "Remove field", title: "Remove field", onClick: () => handleRemove(index), children: "\u00D7" })] }, index))) }))] }));
189
+ return (_jsxs("div", { className: `bn-testmeta${expanded ? " bn-testmeta--expanded" : " bn-testmeta--collapsed"}`, "data-block-id": block.id, "data-kind": kind, contentEditable: false, suppressContentEditableWarning: true, draggable: false, children: [_jsxs("div", { className: "bn-testmeta__header", children: [_jsx("span", { className: "bn-testmeta__label", children: kind === "test" && !(idField === null || idField === void 0 ? void 0 : idField.value) ? "NEW TEST" : kind.toUpperCase() }), (idField === null || idField === void 0 ? void 0 : idField.value) && _jsx("span", { className: "bn-testmeta__id", children: idField.value }), !expanded && (_jsx("button", { type: "button", className: "bn-testmeta__summary", title: summaryText || "No metadata yet", onClick: () => setExpanded(true), children: summaryText || _jsx("span", { className: "bn-testmeta__summary--empty", children: "No metadata" }) })), _jsxs("div", { className: "bn-testmeta__actions", children: [expanded && _jsx(AddFieldMenu, { kind: kind, usedKeys: usedKeys, onPick: handleAddField }), _jsx("button", { type: "button", className: `bn-testmeta__toggle${expanded ? " bn-testmeta__toggle--expanded" : ""}`, "aria-expanded": expanded, "aria-label": expanded ? "Collapse metadata" : "Expand metadata", title: expanded ? "Collapse" : "Expand", onClick: () => setExpanded((prev) => !prev), children: _jsx("svg", { width: "14", height: "14", viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true", children: _jsx("path", { d: "M4 6L8 10L12 6", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round" }) }) })] })] }), expanded && editableFields.length > 0 && (_jsx("div", { className: "bn-testmeta__rows", children: editableFields.map(({ field, index }) => (_jsxs("div", { className: "bn-testmeta__row", children: [_jsx("input", { className: "bn-testmeta__key bn-testmeta__key--input", type: "text", value: field.key, placeholder: "key", spellCheck: false, onChange: (e) => handleKeyChange(index, e.target.value) }), _jsx("input", { className: "bn-testmeta__value", type: "text", value: field.value, placeholder: "value", spellCheck: false, onChange: (e) => handleValueChange(index, e.target.value) }), _jsx("button", { type: "button", className: "bn-testmeta__remove", "aria-label": "Remove field", title: "Remove field", onClick: () => handleRemove(index), children: "\u00D7" })] }, index))) }))] }));
126
190
  },
127
191
  });
@@ -379,7 +379,20 @@ function serializeBlock(block, ctx, orderedIndex, stepIndex) {
379
379
  return lines;
380
380
  }
381
381
  lines.push(`<!-- ${kind}`);
382
- fields.forEach((field) => lines.push(`${field.key}: ${field.value}`));
382
+ fields.forEach((field) => {
383
+ // List-valued keys (e.g. `issues`) are written back as a YAML list to
384
+ // preserve the backend's format. The single panel field holds the items
385
+ // comma-separated, so split them back out into ` - item` lines.
386
+ if (LIST_META_KEYS.has(field.key.trim().toLowerCase())) {
387
+ const items = field.value.split(",").map((s) => s.trim()).filter(Boolean);
388
+ if (items.length) {
389
+ lines.push(`${field.key}:`);
390
+ items.forEach((item) => lines.push(` - ${item}`));
391
+ return;
392
+ }
393
+ }
394
+ lines.push(`${field.key}: ${field.value}`);
395
+ });
383
396
  lines.push("-->");
384
397
  return lines;
385
398
  }
@@ -1210,12 +1223,26 @@ function parseParagraph(lines, index) {
1210
1223
  };
1211
1224
  }
1212
1225
  const META_COMMENT_OPEN_REGEX = /^<!--\s*(test|suite)(?=\s|-->|$)/i;
1226
+ // Keys whose value is a YAML-style list (`key:` followed by indented `- item`
1227
+ // lines) in the testomat.io comment format — e.g. `issues` holding URLs. These
1228
+ // round-trip back to a list on serialize; everything else stays a flat line.
1229
+ const LIST_META_KEYS = new Set(["issues"]);
1213
1230
  function metaFieldsFromBody(bodyLines) {
1214
1231
  const fields = [];
1215
1232
  for (const raw of bodyLines) {
1216
1233
  const line = raw.trim();
1217
1234
  if (!line)
1218
1235
  continue;
1236
+ // YAML list item: belongs to the most recent `key:` field. The whole item
1237
+ // (e.g. `https://github.com/...`) is kept verbatim — because we catch the
1238
+ // list line before the colon check, URLs with `://` are never split.
1239
+ if (line === "-" || line.startsWith("- ")) {
1240
+ const item = line.slice(1).trim();
1241
+ const current = fields[fields.length - 1];
1242
+ if (current && item)
1243
+ current.items.push(item);
1244
+ continue;
1245
+ }
1219
1246
  const colon = line.indexOf(":");
1220
1247
  // "Each line is `key: value`; lines without `:` are ignored."
1221
1248
  if (colon === -1)
@@ -1224,9 +1251,13 @@ function metaFieldsFromBody(bodyLines) {
1224
1251
  const value = line.slice(colon + 1).trim();
1225
1252
  if (!key)
1226
1253
  continue;
1227
- fields.push({ key, value });
1254
+ fields.push({ key, value, items: [] });
1228
1255
  }
1229
- return fields;
1256
+ // A field with collected list items → value is the items joined by ", ".
1257
+ return fields.map(({ key, value, items }) => ({
1258
+ key,
1259
+ value: items.length ? items.join(", ") : value,
1260
+ }));
1230
1261
  }
1231
1262
  function parseMetaComment(lines, index) {
1232
1263
  const first = lines[index];
@@ -1,7 +1,7 @@
1
1
  export { customSchema, type CustomSchema, type CustomBlock, type CustomEditor, } from "./editor/customSchema";
2
2
  export { stepBlock, canInsertStepOrSnippet, isStepsHeading, addStepsBlock, addSnippetBlock } from "./editor/blocks/step";
3
3
  export { snippetBlock } from "./editor/blocks/snippet";
4
- export { testMetaBlock } from "./editor/blocks/testMeta";
4
+ export { testMetaBlock, addTestBlock } from "./editor/blocks/testMeta";
5
5
  export { setMetaFieldSuggestions, getMetaFieldSuggestions, type MetaFieldSuggestion, type MetaFieldSuggestionsConfig, } from "./editor/testMetaFields";
6
6
  export { markdownToHtml, htmlToMarkdown } from "./editor/blocks/markdown";
7
7
  export { tagBadgeExtension, TagBadgeExtension, TAGS_DETECT_REGEXP, detectTags, type TagMatch, } from "./editor/tagBadge";
package/package/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  export { customSchema, } from "./editor/customSchema";
2
2
  export { stepBlock, canInsertStepOrSnippet, isStepsHeading, addStepsBlock, addSnippetBlock } from "./editor/blocks/step";
3
3
  export { snippetBlock } from "./editor/blocks/snippet";
4
- export { testMetaBlock } from "./editor/blocks/testMeta";
4
+ export { testMetaBlock, addTestBlock } from "./editor/blocks/testMeta";
5
5
  export { setMetaFieldSuggestions, getMetaFieldSuggestions, } from "./editor/testMetaFields";
6
6
  export { markdownToHtml, htmlToMarkdown } from "./editor/blocks/markdown";
7
7
  export { tagBadgeExtension, TagBadgeExtension, TAGS_DETECT_REGEXP, detectTags, } from "./editor/tagBadge";
@@ -998,6 +998,19 @@ html.dark .testomatio-editor [data-content-type="heading"] .bn-tag-badge {
998
998
  color: var(--text-muted);
999
999
  }
1000
1000
 
1001
+ /*
1002
+ * The "new test" title heading is inserted directly after a testMeta panel, so it
1003
+ * is the block-outer immediately following the one containing a testMeta block.
1004
+ * Override BlockNote's injected "Heading" placeholder just for that heading.
1005
+ * !important beats the dynamically injected placeholder rule.
1006
+ */
1007
+ .bn-block-outer:has(.bn-block-content[data-content-type="testMeta"])
1008
+ + .bn-block-outer
1009
+ .bn-block-content[data-content-type="heading"]
1010
+ .bn-inline-content:has(> .ProseMirror-trailingBreak:only-child)::before {
1011
+ content: "Enter test title" !important;
1012
+ }
1013
+
1001
1014
  .bn-snippet-dropdown {
1002
1015
  position: relative;
1003
1016
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testomatio-editor-blocks",
3
- "version": "0.4.75",
3
+ "version": "0.4.77",
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
@@ -25,6 +25,7 @@ import { setStepsFetcher, type StepJsonApiDocument } from "./editor/stepAutocomp
25
25
  import { setSnippetFetcher, type SnippetJsonApiDocument } from "./editor/snippetAutocomplete";
26
26
  import { setImageUploadHandler } from "./editor/stepImageUpload";
27
27
  import { canInsertStepOrSnippet, addStepsBlock, addSnippetBlock } from "./editor/blocks/step";
28
+ import { addTestBlock } from "./editor/blocks/testMeta";
28
29
  import "./App.css";
29
30
 
30
31
  const focusStepField = (
@@ -356,14 +357,8 @@ function CustomSlashMenu() {
356
357
  icon: <span className="bn-suggestion-icon">@T</span>,
357
358
  aliases: ["test", "metadata", "meta", "test id"],
358
359
  onItemClick: () => {
359
- insertOrUpdateBlock(editor, {
360
- type: "testMeta",
361
- props: {
362
- metaKind: "test",
363
- metaFields: "[]",
364
- metaInline: false,
365
- },
366
- });
360
+ const headingId = addTestBlock(editor);
361
+ focusStepField(editor, headingId ?? undefined);
367
362
  },
368
363
  };
369
364
 
@@ -554,6 +549,10 @@ function App() {
554
549
  const id = addSnippetBlock(editor);
555
550
  if (id) focusStepField(editor, id, "snippet-title");
556
551
  };
552
+ const insertTest = () => {
553
+ const id = addTestBlock(editor);
554
+ if (id) focusStepField(editor, id);
555
+ };
557
556
 
558
557
  const handleCopyMarkdown = async () => {
559
558
  if (conversionError) {
@@ -645,6 +644,13 @@ function App() {
645
644
  >
646
645
  Insert Snippet
647
646
  </button>
647
+ <button
648
+ type="button"
649
+ className="app__action app__action--ghost"
650
+ onClick={insertTest}
651
+ >
652
+ Insert Test
653
+ </button>
648
654
  <button
649
655
  type="button"
650
656
  className="app__dark-toggle"
@@ -31,6 +31,77 @@ export function serializeMetaFields(fields: MetaField[]): string {
31
31
  return JSON.stringify(fields);
32
32
  }
33
33
 
34
+ type TestEditorLike = {
35
+ document: any[];
36
+ getTextCursorPosition?: () => { block?: { id?: string } };
37
+ insertBlocks: (
38
+ blocks: any[],
39
+ referenceId: string,
40
+ placement: "before" | "after",
41
+ ) => any[];
42
+ };
43
+
44
+ /** Reads the `labels` value from the first suite `testMeta` block, if any. */
45
+ function suiteLabelsFromDocument(document: any[]): string {
46
+ for (const block of document) {
47
+ if (block?.type !== "testMeta") continue;
48
+ if ((block.props as any)?.metaKind !== "suite") continue;
49
+ const fields = parseMetaFields((block.props as any)?.metaFields);
50
+ const labels = fields.find((f) => f.key.trim().toLowerCase() === "labels");
51
+ if (labels) return labels.value;
52
+ }
53
+ return "";
54
+ }
55
+
56
+ /**
57
+ * Insert a new test: a `testMeta` panel (labelled "NEW TEST" until it gets an id)
58
+ * seeded with default metadata — `type: manual`, `priority: normal`, and `labels`
59
+ * copied from the suite block — followed by an empty H2 heading for the test title.
60
+ *
61
+ * Inserts at the text cursor when the editor is focused, otherwise at the end of
62
+ * the document. Returns the title heading's block id (for focusing), or null.
63
+ */
64
+ export function addTestBlock(editor: TestEditorLike): string | null {
65
+ const fields: MetaField[] = [
66
+ { key: "type", value: "manual" },
67
+ { key: "priority", value: "normal" },
68
+ { key: "labels", value: suiteLabelsFromDocument(editor.document) },
69
+ ];
70
+
71
+ const metaBlock = {
72
+ type: "testMeta" as const,
73
+ props: {
74
+ metaKind: "test",
75
+ metaFields: serializeMetaFields(fields),
76
+ metaInline: false,
77
+ },
78
+ children: [],
79
+ };
80
+ const titleHeading = {
81
+ type: "heading" as const,
82
+ props: { level: 2 },
83
+ content: [],
84
+ children: [],
85
+ };
86
+
87
+ // Prefer the text cursor position — BlockNote keeps it in the editor state even
88
+ // after the editor blurs (e.g. when a toolbar button is clicked), so the test
89
+ // lands where the user left the caret. Fall back to the document end only when
90
+ // there is no cursor at all.
91
+ const docs = editor.document;
92
+ let referenceId: string | undefined;
93
+ try {
94
+ referenceId = editor.getTextCursorPosition?.().block?.id;
95
+ } catch {
96
+ referenceId = undefined;
97
+ }
98
+ if (!referenceId) referenceId = docs[docs.length - 1]?.id;
99
+ if (!referenceId) return null;
100
+
101
+ const inserted = editor.insertBlocks([metaBlock, titleHeading], referenceId, "after");
102
+ return inserted?.[1]?.id ?? null;
103
+ }
104
+
34
105
  type AddFieldMenuProps = {
35
106
  kind: "test" | "suite";
36
107
  usedKeys: string[];
@@ -214,7 +285,9 @@ export const testMetaBlock = createReactBlockSpec(
214
285
  draggable={false}
215
286
  >
216
287
  <div className="bn-testmeta__header">
217
- <span className="bn-testmeta__label">{kind.toUpperCase()}</span>
288
+ <span className="bn-testmeta__label">
289
+ {kind === "test" && !idField?.value ? "NEW TEST" : kind.toUpperCase()}
290
+ </span>
218
291
  {idField?.value && <span className="bn-testmeta__id">{idField.value}</span>}
219
292
  {!expanded && (
220
293
  <button
@@ -3223,6 +3223,77 @@ describe("test/suite metadata comments", () => {
3223
3223
  expect(blocksToMarkdown(blocks as CustomEditorBlock[])).toBe(markdown);
3224
3224
  });
3225
3225
 
3226
+ it("parses a YAML-list issues field without splitting URLs on ://", () => {
3227
+ const markdown = [
3228
+ "<!-- test",
3229
+ "id: @T4ffddec3",
3230
+ "type: manual",
3231
+ "issues:",
3232
+ " - https://github.com/testomatio/testomatio/issues/8963",
3233
+ "-->",
3234
+ ].join("\n");
3235
+ const blocks = markdownToBlocks(markdown);
3236
+ expect((blocks[0].props as any).metaFields).toBe(
3237
+ JSON.stringify([
3238
+ { key: "id", value: "@T4ffddec3" },
3239
+ { key: "type", value: "manual" },
3240
+ {
3241
+ key: "issues",
3242
+ value: "https://github.com/testomatio/testomatio/issues/8963",
3243
+ },
3244
+ ]),
3245
+ );
3246
+ });
3247
+
3248
+ it("joins multiple YAML-list issues items with a comma", () => {
3249
+ const markdown = [
3250
+ "<!-- test",
3251
+ "id: @T4ffddec3",
3252
+ "issues:",
3253
+ " - https://github.com/testomatio/testomatio/issues/8963",
3254
+ " - https://github.com/testomatio/testomatio/issues/9001",
3255
+ "-->",
3256
+ ].join("\n");
3257
+ const blocks = markdownToBlocks(markdown);
3258
+ expect((blocks[0].props as any).metaFields).toBe(
3259
+ JSON.stringify([
3260
+ { key: "id", value: "@T4ffddec3" },
3261
+ {
3262
+ key: "issues",
3263
+ value:
3264
+ "https://github.com/testomatio/testomatio/issues/8963, https://github.com/testomatio/testomatio/issues/9001",
3265
+ },
3266
+ ]),
3267
+ );
3268
+ });
3269
+
3270
+ it("round-trips a YAML-list issues field (single item)", () => {
3271
+ const markdown = [
3272
+ "<!-- test",
3273
+ "id: @T4ffddec3",
3274
+ "type: manual",
3275
+ "priority: normal",
3276
+ "issues:",
3277
+ " - https://github.com/testomatio/testomatio/issues/8963",
3278
+ "-->",
3279
+ ].join("\n");
3280
+ const blocks = markdownToBlocks(markdown);
3281
+ expect(blocksToMarkdown(blocks as CustomEditorBlock[])).toBe(markdown);
3282
+ });
3283
+
3284
+ it("round-trips a YAML-list issues field (multiple items)", () => {
3285
+ const markdown = [
3286
+ "<!-- test",
3287
+ "id: @T4ffddec3",
3288
+ "issues:",
3289
+ " - https://github.com/testomatio/testomatio/issues/8963",
3290
+ " - https://github.com/testomatio/testomatio/issues/9001",
3291
+ "-->",
3292
+ ].join("\n");
3293
+ const blocks = markdownToBlocks(markdown);
3294
+ expect(blocksToMarkdown(blocks as CustomEditorBlock[])).toBe(markdown);
3295
+ });
3296
+
3226
3297
  it("ignores lines without a colon inside a metadata block", () => {
3227
3298
  const markdown = [
3228
3299
  "<!-- test",
@@ -451,7 +451,20 @@ function serializeBlock(
451
451
  }
452
452
 
453
453
  lines.push(`<!-- ${kind}`);
454
- fields.forEach((field) => lines.push(`${field.key}: ${field.value}`));
454
+ fields.forEach((field) => {
455
+ // List-valued keys (e.g. `issues`) are written back as a YAML list to
456
+ // preserve the backend's format. The single panel field holds the items
457
+ // comma-separated, so split them back out into ` - item` lines.
458
+ if (LIST_META_KEYS.has(field.key.trim().toLowerCase())) {
459
+ const items = field.value.split(",").map((s) => s.trim()).filter(Boolean);
460
+ if (items.length) {
461
+ lines.push(`${field.key}:`);
462
+ items.forEach((item) => lines.push(` - ${item}`));
463
+ return;
464
+ }
465
+ }
466
+ lines.push(`${field.key}: ${field.value}`);
467
+ });
455
468
  lines.push("-->");
456
469
  return lines;
457
470
  }
@@ -1422,20 +1435,41 @@ function parseParagraph(lines: string[], index: number): { block: CustomPartialB
1422
1435
 
1423
1436
  const META_COMMENT_OPEN_REGEX = /^<!--\s*(test|suite)(?=\s|-->|$)/i;
1424
1437
 
1438
+ // Keys whose value is a YAML-style list (`key:` followed by indented `- item`
1439
+ // lines) in the testomat.io comment format — e.g. `issues` holding URLs. These
1440
+ // round-trip back to a list on serialize; everything else stays a flat line.
1441
+ const LIST_META_KEYS = new Set(["issues"]);
1442
+
1425
1443
  function metaFieldsFromBody(bodyLines: string[]): { key: string; value: string }[] {
1426
- const fields: { key: string; value: string }[] = [];
1444
+ const fields: { key: string; value: string; items: string[] }[] = [];
1427
1445
  for (const raw of bodyLines) {
1428
1446
  const line = raw.trim();
1429
1447
  if (!line) continue;
1448
+
1449
+ // YAML list item: belongs to the most recent `key:` field. The whole item
1450
+ // (e.g. `https://github.com/...`) is kept verbatim — because we catch the
1451
+ // list line before the colon check, URLs with `://` are never split.
1452
+ if (line === "-" || line.startsWith("- ")) {
1453
+ const item = line.slice(1).trim();
1454
+ const current = fields[fields.length - 1];
1455
+ if (current && item) current.items.push(item);
1456
+ continue;
1457
+ }
1458
+
1430
1459
  const colon = line.indexOf(":");
1431
1460
  // "Each line is `key: value`; lines without `:` are ignored."
1432
1461
  if (colon === -1) continue;
1433
1462
  const key = line.slice(0, colon).trim();
1434
1463
  const value = line.slice(colon + 1).trim();
1435
1464
  if (!key) continue;
1436
- fields.push({ key, value });
1465
+ fields.push({ key, value, items: [] });
1437
1466
  }
1438
- return fields;
1467
+
1468
+ // A field with collected list items → value is the items joined by ", ".
1469
+ return fields.map(({ key, value, items }) => ({
1470
+ key,
1471
+ value: items.length ? items.join(", ") : value,
1472
+ }));
1439
1473
  }
1440
1474
 
1441
1475
  function parseMetaComment(
@@ -998,6 +998,19 @@ html.dark .testomatio-editor [data-content-type="heading"] .bn-tag-badge {
998
998
  color: var(--text-muted);
999
999
  }
1000
1000
 
1001
+ /*
1002
+ * The "new test" title heading is inserted directly after a testMeta panel, so it
1003
+ * is the block-outer immediately following the one containing a testMeta block.
1004
+ * Override BlockNote's injected "Heading" placeholder just for that heading.
1005
+ * !important beats the dynamically injected placeholder rule.
1006
+ */
1007
+ .bn-block-outer:has(.bn-block-content[data-content-type="testMeta"])
1008
+ + .bn-block-outer
1009
+ .bn-block-content[data-content-type="heading"]
1010
+ .bn-inline-content:has(> .ProseMirror-trailingBreak:only-child)::before {
1011
+ content: "Enter test title" !important;
1012
+ }
1013
+
1001
1014
  .bn-snippet-dropdown {
1002
1015
  position: relative;
1003
1016
  }
package/src/index.ts CHANGED
@@ -6,7 +6,7 @@ export {
6
6
  } from "./editor/customSchema";
7
7
  export { stepBlock, canInsertStepOrSnippet, isStepsHeading, addStepsBlock, addSnippetBlock } from "./editor/blocks/step";
8
8
  export { snippetBlock } from "./editor/blocks/snippet";
9
- export { testMetaBlock } from "./editor/blocks/testMeta";
9
+ export { testMetaBlock, addTestBlock } from "./editor/blocks/testMeta";
10
10
  export {
11
11
  setMetaFieldSuggestions,
12
12
  getMetaFieldSuggestions,