testomatio-editor-blocks 0.4.71 → 0.4.73
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package/editor/blocks/testMeta.js +7 -4
- package/package/editor/customMarkdownConverter.js +21 -6
- package/package/editor/tagBadge.d.ts +35 -0
- package/package/editor/tagBadge.js +114 -0
- package/package/index.d.ts +1 -0
- package/package/index.js +1 -0
- package/package/styles.css +24 -0
- package/package.json +1 -1
- package/src/App.tsx +2 -0
- package/src/editor/blocks/testMeta.tsx +7 -4
- package/src/editor/customMarkdownConverter.test.ts +47 -0
- package/src/editor/customMarkdownConverter.ts +23 -5
- package/src/editor/styles.css +24 -0
- package/src/editor/tagBadge.test.ts +75 -0
- package/src/editor/tagBadge.ts +143 -0
- package/src/index.ts +8 -0
|
@@ -115,10 +115,13 @@ export const testMetaBlock = createReactBlockSpec({
|
|
|
115
115
|
.filter(({ field }) => field.key.trim().length > 0 && field.value.trim().length > 0)
|
|
116
116
|
.map(({ field }) => `${field.key}: ${field.value}`)
|
|
117
117
|
.join(" · ");
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
|
|
118
|
+
// Default to the compact (collapsed) view whenever the block carries any
|
|
119
|
+
// field — this keeps suite blocks compact too, even when their fields have
|
|
120
|
+
// no value yet (e.g. a seeded `emoji:` row) and so produce an empty
|
|
121
|
+
// summary. Only a genuinely empty block (no fields at all) starts expanded
|
|
122
|
+
// so it's immediately editable. `expanded` is UI-only state — never
|
|
123
|
+
// serialized.
|
|
124
|
+
const [expanded, setExpanded] = useState(() => fields.length === 0);
|
|
122
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))) }))] }));
|
|
123
126
|
},
|
|
124
127
|
});
|
|
@@ -387,13 +387,21 @@ function serializeBlock(block, ctx, orderedIndex, stepIndex) {
|
|
|
387
387
|
}
|
|
388
388
|
if (stepData.length > 0) {
|
|
389
389
|
const dataLines = stepData.split(/\r?\n/);
|
|
390
|
+
let insideCodeFence = false;
|
|
390
391
|
dataLines.forEach((dataLine) => {
|
|
391
392
|
const trimmedLine = dataLine.trim();
|
|
392
|
-
if (trimmedLine.length
|
|
393
|
-
lines.push(` ${escapeStepContent(trimmedLine)}`);
|
|
394
|
-
}
|
|
395
|
-
else {
|
|
393
|
+
if (trimmedLine.length === 0) {
|
|
396
394
|
lines.push(" ");
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
// Don't escape dots inside fenced code blocks (or on the fence lines
|
|
398
|
+
// themselves) — Markdown ignores backslash escapes there, so `\.`
|
|
399
|
+
// would render literally.
|
|
400
|
+
const isFence = trimmedLine.startsWith("```");
|
|
401
|
+
const content = insideCodeFence || isFence ? trimmedLine : escapeStepContent(trimmedLine);
|
|
402
|
+
lines.push(` ${content}`);
|
|
403
|
+
if (isFence) {
|
|
404
|
+
insideCodeFence = !insideCodeFence;
|
|
397
405
|
}
|
|
398
406
|
});
|
|
399
407
|
}
|
|
@@ -401,16 +409,23 @@ function serializeBlock(block, ctx, orderedIndex, stepIndex) {
|
|
|
401
409
|
if (normalizedExpected.length > 0) {
|
|
402
410
|
const expectedLines = normalizedExpected.split(/\r?\n/);
|
|
403
411
|
const label = "*Expected result*";
|
|
412
|
+
let insideCodeFence = false;
|
|
404
413
|
expectedLines.forEach((expectedLine, index) => {
|
|
405
414
|
const trimmedLine = expectedLine.trim();
|
|
406
415
|
if (trimmedLine.length === 0) {
|
|
407
416
|
return;
|
|
408
417
|
}
|
|
418
|
+
// As with step data, leave dots untouched inside fenced code blocks.
|
|
419
|
+
const isFence = trimmedLine.startsWith("```");
|
|
420
|
+
const content = insideCodeFence || isFence ? trimmedLine : escapeStepContent(trimmedLine);
|
|
409
421
|
if (index === 0) {
|
|
410
|
-
lines.push(` ${label}: ${
|
|
422
|
+
lines.push(` ${label}: ${content}`);
|
|
411
423
|
}
|
|
412
424
|
else {
|
|
413
|
-
lines.push(` ${
|
|
425
|
+
lines.push(` ${content}`);
|
|
426
|
+
}
|
|
427
|
+
if (isFence) {
|
|
428
|
+
insideCodeFence = !insideCodeFence;
|
|
414
429
|
}
|
|
415
430
|
});
|
|
416
431
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { BlockNoteExtension } from "@blocknote/core";
|
|
2
|
+
/** Matches a tag (capture group 1) preceded by start-of-string or whitespace. */
|
|
3
|
+
export declare const TAGS_DETECT_REGEXP: RegExp;
|
|
4
|
+
export interface TagMatch {
|
|
5
|
+
/** Offset of the `@` within the scanned string (the leading space is excluded). */
|
|
6
|
+
start: number;
|
|
7
|
+
/** Offset just past the last character of the tag. */
|
|
8
|
+
end: number;
|
|
9
|
+
/** The matched tag text, including the leading `@`. */
|
|
10
|
+
tag: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Find all `@tag` tokens inside a plain string and return their offsets.
|
|
14
|
+
* Pure and DOM-free so it can be unit-tested directly.
|
|
15
|
+
*/
|
|
16
|
+
export declare function detectTags(text: string): TagMatch[];
|
|
17
|
+
/**
|
|
18
|
+
* BlockNote extension that renders `@tags` inside headings as badges.
|
|
19
|
+
*
|
|
20
|
+
* Editor extensions are supplied at editor-creation time and cannot be carried
|
|
21
|
+
* by the schema, so consumers must add this to their `useCreateBlockNote` call:
|
|
22
|
+
*
|
|
23
|
+
* ```ts
|
|
24
|
+
* useCreateBlockNote({
|
|
25
|
+
* schema: customSchema,
|
|
26
|
+
* extensions: [tagBadgeExtension()],
|
|
27
|
+
* });
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
export declare class TagBadgeExtension extends BlockNoteExtension {
|
|
31
|
+
static key(): string;
|
|
32
|
+
constructor();
|
|
33
|
+
}
|
|
34
|
+
/** Factory for the `extensions` option of `useCreateBlockNote`. */
|
|
35
|
+
export declare const tagBadgeExtension: () => TagBadgeExtension;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { BlockNoteExtension } from "@blocknote/core";
|
|
2
|
+
import { Plugin, PluginKey } from "prosemirror-state";
|
|
3
|
+
import { Decoration, DecorationSet } from "prosemirror-view";
|
|
4
|
+
/**
|
|
5
|
+
* Tags are tokens that start with `@` and may contain alphanumerics plus a few
|
|
6
|
+
* symbols (`= - _ ( ) . : &`), e.g. `@smoke`, `@severity:high`, `@T1234abcd`.
|
|
7
|
+
*
|
|
8
|
+
* This is a faithful JS port of the backend (Ruby) tag regexes:
|
|
9
|
+
* TAG_PREFIX_REGEXP = /(?:^|[ \t])/
|
|
10
|
+
* TAG_ALLOWED_SYMBOLS_REGEXP = /[\w\d\=\-\_\(\)\.\:\&]*[\w\d\)]/
|
|
11
|
+
* TAGS_DETECT_REGEXP = /(?:^|[ \t])(\@<allowed>)/
|
|
12
|
+
*
|
|
13
|
+
* `\w` already covers `[0-9_]`, so the symbol class is kept minimal while
|
|
14
|
+
* staying faithful to the allowed characters. A tag must be preceded by the
|
|
15
|
+
* start of the string or whitespace — so email-like text (`user@example.com`)
|
|
16
|
+
* is correctly ignored.
|
|
17
|
+
*/
|
|
18
|
+
const TAG_ALLOWED_SYMBOLS = String.raw `[\w=\-_().:&]*[\w)]`;
|
|
19
|
+
/** Matches a tag (capture group 1) preceded by start-of-string or whitespace. */
|
|
20
|
+
export const TAGS_DETECT_REGEXP = new RegExp(String.raw `(?:^|[ \t])(@${TAG_ALLOWED_SYMBOLS})`, "g");
|
|
21
|
+
/**
|
|
22
|
+
* Find all `@tag` tokens inside a plain string and return their offsets.
|
|
23
|
+
* Pure and DOM-free so it can be unit-tested directly.
|
|
24
|
+
*/
|
|
25
|
+
export function detectTags(text) {
|
|
26
|
+
const matches = [];
|
|
27
|
+
// Use a fresh regex each call so the shared `lastIndex` state never leaks
|
|
28
|
+
// between invocations.
|
|
29
|
+
const regexp = new RegExp(TAGS_DETECT_REGEXP.source, "g");
|
|
30
|
+
let match;
|
|
31
|
+
while ((match = regexp.exec(text)) !== null) {
|
|
32
|
+
const tag = match[1];
|
|
33
|
+
// The match may include a leading space/tab consumed by the prefix; the tag
|
|
34
|
+
// itself starts that many characters into the overall match.
|
|
35
|
+
const start = match.index + (match[0].length - tag.length);
|
|
36
|
+
matches.push({ start, end: start + tag.length, tag });
|
|
37
|
+
// Defensive guard against a zero-length match looping forever (a tag always
|
|
38
|
+
// starts with `@`, so this should never trigger).
|
|
39
|
+
if (regexp.lastIndex === match.index) {
|
|
40
|
+
regexp.lastIndex++;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return matches;
|
|
44
|
+
}
|
|
45
|
+
const tagBadgePluginKey = new PluginKey("testomatioTagBadge");
|
|
46
|
+
/**
|
|
47
|
+
* Build inline decorations for every `@tag` found inside heading blocks. Tags
|
|
48
|
+
* are only *painted* — the underlying text is untouched, so markdown
|
|
49
|
+
* serialization round-trips unchanged.
|
|
50
|
+
*/
|
|
51
|
+
function buildHeadingTagDecorations(doc) {
|
|
52
|
+
const decorations = [];
|
|
53
|
+
doc.descendants((node, pos) => {
|
|
54
|
+
if (node.type.name !== "heading") {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
// Walk the heading's inline children so positions stay correct even when it
|
|
58
|
+
// contains links or inline images alongside text.
|
|
59
|
+
node.forEach((child, offset) => {
|
|
60
|
+
if (!child.isText || !child.text) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
// `pos + 1` steps inside the heading content node; `offset` is the child's
|
|
64
|
+
// position relative to the node's content.
|
|
65
|
+
const base = pos + 1 + offset;
|
|
66
|
+
for (const { start, end } of detectTags(child.text)) {
|
|
67
|
+
decorations.push(Decoration.inline(base + start, base + end, {
|
|
68
|
+
class: "bn-tag-badge",
|
|
69
|
+
}));
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
// Headings only hold inline content; no need to descend further.
|
|
73
|
+
return false;
|
|
74
|
+
});
|
|
75
|
+
return DecorationSet.create(doc, decorations);
|
|
76
|
+
}
|
|
77
|
+
function tagBadgePlugin() {
|
|
78
|
+
return new Plugin({
|
|
79
|
+
key: tagBadgePluginKey,
|
|
80
|
+
state: {
|
|
81
|
+
init: (_config, state) => buildHeadingTagDecorations(state.doc),
|
|
82
|
+
apply: (tr, value) => tr.docChanged ? buildHeadingTagDecorations(tr.doc) : value,
|
|
83
|
+
},
|
|
84
|
+
props: {
|
|
85
|
+
decorations(state) {
|
|
86
|
+
return tagBadgePluginKey.getState(state);
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* BlockNote extension that renders `@tags` inside headings as badges.
|
|
93
|
+
*
|
|
94
|
+
* Editor extensions are supplied at editor-creation time and cannot be carried
|
|
95
|
+
* by the schema, so consumers must add this to their `useCreateBlockNote` call:
|
|
96
|
+
*
|
|
97
|
+
* ```ts
|
|
98
|
+
* useCreateBlockNote({
|
|
99
|
+
* schema: customSchema,
|
|
100
|
+
* extensions: [tagBadgeExtension()],
|
|
101
|
+
* });
|
|
102
|
+
* ```
|
|
103
|
+
*/
|
|
104
|
+
export class TagBadgeExtension extends BlockNoteExtension {
|
|
105
|
+
static key() {
|
|
106
|
+
return "tagBadge";
|
|
107
|
+
}
|
|
108
|
+
constructor() {
|
|
109
|
+
super();
|
|
110
|
+
this.addProsemirrorPlugin(tagBadgePlugin());
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/** Factory for the `extensions` option of `useCreateBlockNote`. */
|
|
114
|
+
export const tagBadgeExtension = () => new TagBadgeExtension();
|
package/package/index.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ export { snippetBlock } from "./editor/blocks/snippet";
|
|
|
4
4
|
export { testMetaBlock } 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
|
+
export { tagBadgeExtension, TagBadgeExtension, TAGS_DETECT_REGEXP, detectTags, type TagMatch, } from "./editor/tagBadge";
|
|
7
8
|
export { blocksToMarkdown, markdownToBlocks, type CustomEditorBlock, type CustomPartialBlock, type MarkdownToBlocksOptions, } from "./editor/customMarkdownConverter";
|
|
8
9
|
export { useStepAutocomplete, parseStepsFromJsonApi, setStepsFetcher, type StepSuggestion, type StepJsonApiDocument, type StepJsonApiResource, } from "./editor/stepAutocomplete";
|
|
9
10
|
export { useStepImageUpload, setImageUploadHandler, type StepImageUploadHandler, } from "./editor/stepImageUpload";
|
package/package/index.js
CHANGED
|
@@ -4,6 +4,7 @@ export { snippetBlock } from "./editor/blocks/snippet";
|
|
|
4
4
|
export { testMetaBlock } from "./editor/blocks/testMeta";
|
|
5
5
|
export { setMetaFieldSuggestions, getMetaFieldSuggestions, } from "./editor/testMetaFields";
|
|
6
6
|
export { markdownToHtml, htmlToMarkdown } from "./editor/blocks/markdown";
|
|
7
|
+
export { tagBadgeExtension, TagBadgeExtension, TAGS_DETECT_REGEXP, detectTags, } from "./editor/tagBadge";
|
|
7
8
|
export { blocksToMarkdown, markdownToBlocks, } from "./editor/customMarkdownConverter";
|
|
8
9
|
export { useStepAutocomplete, parseStepsFromJsonApi, setStepsFetcher, } from "./editor/stepAutocomplete";
|
|
9
10
|
export { useStepImageUpload, setImageUploadHandler, } from "./editor/stepImageUpload";
|
package/package/styles.css
CHANGED
|
@@ -723,6 +723,30 @@ html.dark .bn-teststep__view-toggle--compact svg {
|
|
|
723
723
|
--prev-level: 18px;
|
|
724
724
|
}
|
|
725
725
|
|
|
726
|
+
/* ============================================
|
|
727
|
+
TAG BADGES IN HEADINGS
|
|
728
|
+
`@tag` tokens inside headings are painted as compact badges by a ProseMirror
|
|
729
|
+
inline decoration (see src/editor/tagBadge.ts). The text stays editable; only
|
|
730
|
+
the styling is applied.
|
|
731
|
+
============================================ */
|
|
732
|
+
.testomatio-editor [data-content-type="heading"] .bn-tag-badge {
|
|
733
|
+
font-size: 0.78em;
|
|
734
|
+
font-weight: 500;
|
|
735
|
+
line-height: 1.2;
|
|
736
|
+
background: var(--bg-muted);
|
|
737
|
+
color: var(--text-muted);
|
|
738
|
+
border: 1px solid var(--border-light);
|
|
739
|
+
border-radius: 6px;
|
|
740
|
+
padding: 0.05em 0.4em;
|
|
741
|
+
vertical-align: middle;
|
|
742
|
+
white-space: nowrap;
|
|
743
|
+
}
|
|
744
|
+
html.dark .testomatio-editor [data-content-type="heading"] .bn-tag-badge {
|
|
745
|
+
background: rgba(255, 255, 255, 0.08);
|
|
746
|
+
color: rgba(255, 255, 255, 0.6);
|
|
747
|
+
border-color: rgba(255, 255, 255, 0.12);
|
|
748
|
+
}
|
|
749
|
+
|
|
726
750
|
/* ============================================
|
|
727
751
|
TEST / SUITE METADATA BLOCK
|
|
728
752
|
Dimmed card for `<!-- test ... -->` / `<!-- suite ... -->` comments.
|
package/package.json
CHANGED
package/src/App.tsx
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
} from "./editor/customMarkdownConverter";
|
|
21
21
|
import { createMarkdownPasteHandler } from "./editor/createMarkdownPasteHandler";
|
|
22
22
|
import { customSchema, type CustomEditor } from "./editor/customSchema";
|
|
23
|
+
import { tagBadgeExtension } from "./editor/tagBadge";
|
|
23
24
|
import { setStepsFetcher, type StepJsonApiDocument } from "./editor/stepAutocomplete";
|
|
24
25
|
import { setSnippetFetcher, type SnippetJsonApiDocument } from "./editor/snippetAutocomplete";
|
|
25
26
|
import { setImageUploadHandler } from "./editor/stepImageUpload";
|
|
@@ -404,6 +405,7 @@ function CustomSlashMenu() {
|
|
|
404
405
|
function App() {
|
|
405
406
|
const editor = useCreateBlockNote({
|
|
406
407
|
schema: customSchema,
|
|
408
|
+
extensions: [tagBadgeExtension()],
|
|
407
409
|
pasteHandler: createMarkdownPasteHandler(markdownToBlocks),
|
|
408
410
|
uploadFile: async (file: File) => {
|
|
409
411
|
const url = `https://placehold.co/600x400?text=${encodeURIComponent(file.name)}`;
|
|
@@ -196,10 +196,13 @@ export const testMetaBlock = createReactBlockSpec(
|
|
|
196
196
|
.filter(({ field }) => field.key.trim().length > 0 && field.value.trim().length > 0)
|
|
197
197
|
.map(({ field }) => `${field.key}: ${field.value}`)
|
|
198
198
|
.join(" · ");
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
|
|
199
|
+
// Default to the compact (collapsed) view whenever the block carries any
|
|
200
|
+
// field — this keeps suite blocks compact too, even when their fields have
|
|
201
|
+
// no value yet (e.g. a seeded `emoji:` row) and so produce an empty
|
|
202
|
+
// summary. Only a genuinely empty block (no fields at all) starts expanded
|
|
203
|
+
// so it's immediately editable. `expanded` is UI-only state — never
|
|
204
|
+
// serialized.
|
|
205
|
+
const [expanded, setExpanded] = useState(() => fields.length === 0);
|
|
203
206
|
|
|
204
207
|
return (
|
|
205
208
|
<div
|
|
@@ -1249,6 +1249,53 @@ describe("markdownToBlocks", () => {
|
|
|
1249
1249
|
);
|
|
1250
1250
|
});
|
|
1251
1251
|
|
|
1252
|
+
it("does not escape dots inside fenced code blocks in step data", () => {
|
|
1253
|
+
const stepData = [
|
|
1254
|
+
"```",
|
|
1255
|
+
"curl 'https://stable.testomat.io/api/runs/54?page=1&entry=' \\",
|
|
1256
|
+
" -H 'accept-language: en-US,en;q=0.9' \\",
|
|
1257
|
+
" -b 'gclau=1.1.1681594017.1781077572;'",
|
|
1258
|
+
"```",
|
|
1259
|
+
].join("\n");
|
|
1260
|
+
|
|
1261
|
+
const markdown = blocksToMarkdown([
|
|
1262
|
+
{
|
|
1263
|
+
id: "step1",
|
|
1264
|
+
type: "testStep",
|
|
1265
|
+
props: {
|
|
1266
|
+
stepTitle: "Run the request.",
|
|
1267
|
+
stepData,
|
|
1268
|
+
expectedResult: "",
|
|
1269
|
+
listStyle: "bullet",
|
|
1270
|
+
},
|
|
1271
|
+
content: undefined,
|
|
1272
|
+
children: [],
|
|
1273
|
+
},
|
|
1274
|
+
]);
|
|
1275
|
+
|
|
1276
|
+
expect(markdown).toBe(
|
|
1277
|
+
[
|
|
1278
|
+
// Title outside the fence is still escaped.
|
|
1279
|
+
"* Run the request\\.",
|
|
1280
|
+
// Dots inside the fence stay literal.
|
|
1281
|
+
" ```",
|
|
1282
|
+
" curl 'https://stable.testomat.io/api/runs/54?page=1&entry=' \\",
|
|
1283
|
+
" -H 'accept-language: en-US,en;q=0.9' \\",
|
|
1284
|
+
" -b 'gclau=1.1.1681594017.1781077572;'",
|
|
1285
|
+
" ```",
|
|
1286
|
+
].join("\n"),
|
|
1287
|
+
);
|
|
1288
|
+
|
|
1289
|
+
// Round-trip stays stable.
|
|
1290
|
+
const roundTrip = blocksToMarkdown(
|
|
1291
|
+
markdownToBlocks(["### Steps", "", markdown].join("\n")) as CustomEditorBlock[],
|
|
1292
|
+
);
|
|
1293
|
+
expect(roundTrip).toContain(
|
|
1294
|
+
" curl 'https://stable.testomat.io/api/runs/54?page=1&entry=' \\",
|
|
1295
|
+
);
|
|
1296
|
+
expect(roundTrip).toContain(" -b 'gclau=1.1.1681594017.1781077572;'");
|
|
1297
|
+
});
|
|
1298
|
+
|
|
1252
1299
|
it("does not include content after a blank line in step data", () => {
|
|
1253
1300
|
const markdown = [
|
|
1254
1301
|
"### Steps",
|
|
@@ -469,12 +469,22 @@ function serializeBlock(
|
|
|
469
469
|
|
|
470
470
|
if (stepData.length > 0) {
|
|
471
471
|
const dataLines = stepData.split(/\r?\n/);
|
|
472
|
+
let insideCodeFence = false;
|
|
472
473
|
dataLines.forEach((dataLine: string) => {
|
|
473
474
|
const trimmedLine = dataLine.trim();
|
|
474
|
-
if (trimmedLine.length
|
|
475
|
-
lines.push(` ${escapeStepContent(trimmedLine)}`);
|
|
476
|
-
} else {
|
|
475
|
+
if (trimmedLine.length === 0) {
|
|
477
476
|
lines.push(" ");
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
// Don't escape dots inside fenced code blocks (or on the fence lines
|
|
480
|
+
// themselves) — Markdown ignores backslash escapes there, so `\.`
|
|
481
|
+
// would render literally.
|
|
482
|
+
const isFence = trimmedLine.startsWith("```");
|
|
483
|
+
const content =
|
|
484
|
+
insideCodeFence || isFence ? trimmedLine : escapeStepContent(trimmedLine);
|
|
485
|
+
lines.push(` ${content}`);
|
|
486
|
+
if (isFence) {
|
|
487
|
+
insideCodeFence = !insideCodeFence;
|
|
478
488
|
}
|
|
479
489
|
});
|
|
480
490
|
}
|
|
@@ -483,16 +493,24 @@ function serializeBlock(
|
|
|
483
493
|
if (normalizedExpected.length > 0) {
|
|
484
494
|
const expectedLines = normalizedExpected.split(/\r?\n/);
|
|
485
495
|
const label = "*Expected result*";
|
|
496
|
+
let insideCodeFence = false;
|
|
486
497
|
expectedLines.forEach((expectedLine: string, index: number) => {
|
|
487
498
|
const trimmedLine = expectedLine.trim();
|
|
488
499
|
if (trimmedLine.length === 0) {
|
|
489
500
|
return;
|
|
490
501
|
}
|
|
491
502
|
|
|
503
|
+
// As with step data, leave dots untouched inside fenced code blocks.
|
|
504
|
+
const isFence = trimmedLine.startsWith("```");
|
|
505
|
+
const content =
|
|
506
|
+
insideCodeFence || isFence ? trimmedLine : escapeStepContent(trimmedLine);
|
|
492
507
|
if (index === 0) {
|
|
493
|
-
lines.push(` ${label}: ${
|
|
508
|
+
lines.push(` ${label}: ${content}`);
|
|
494
509
|
} else {
|
|
495
|
-
lines.push(` ${
|
|
510
|
+
lines.push(` ${content}`);
|
|
511
|
+
}
|
|
512
|
+
if (isFence) {
|
|
513
|
+
insideCodeFence = !insideCodeFence;
|
|
496
514
|
}
|
|
497
515
|
});
|
|
498
516
|
}
|
package/src/editor/styles.css
CHANGED
|
@@ -723,6 +723,30 @@ html.dark .bn-teststep__view-toggle--compact svg {
|
|
|
723
723
|
--prev-level: 18px;
|
|
724
724
|
}
|
|
725
725
|
|
|
726
|
+
/* ============================================
|
|
727
|
+
TAG BADGES IN HEADINGS
|
|
728
|
+
`@tag` tokens inside headings are painted as compact badges by a ProseMirror
|
|
729
|
+
inline decoration (see src/editor/tagBadge.ts). The text stays editable; only
|
|
730
|
+
the styling is applied.
|
|
731
|
+
============================================ */
|
|
732
|
+
.testomatio-editor [data-content-type="heading"] .bn-tag-badge {
|
|
733
|
+
font-size: 0.78em;
|
|
734
|
+
font-weight: 500;
|
|
735
|
+
line-height: 1.2;
|
|
736
|
+
background: var(--bg-muted);
|
|
737
|
+
color: var(--text-muted);
|
|
738
|
+
border: 1px solid var(--border-light);
|
|
739
|
+
border-radius: 6px;
|
|
740
|
+
padding: 0.05em 0.4em;
|
|
741
|
+
vertical-align: middle;
|
|
742
|
+
white-space: nowrap;
|
|
743
|
+
}
|
|
744
|
+
html.dark .testomatio-editor [data-content-type="heading"] .bn-tag-badge {
|
|
745
|
+
background: rgba(255, 255, 255, 0.08);
|
|
746
|
+
color: rgba(255, 255, 255, 0.6);
|
|
747
|
+
border-color: rgba(255, 255, 255, 0.12);
|
|
748
|
+
}
|
|
749
|
+
|
|
726
750
|
/* ============================================
|
|
727
751
|
TEST / SUITE METADATA BLOCK
|
|
728
752
|
Dimmed card for `<!-- test ... -->` / `<!-- suite ... -->` comments.
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { detectTags } from "./tagBadge";
|
|
3
|
+
|
|
4
|
+
describe("detectTags", () => {
|
|
5
|
+
it("detects a single tag at the start of the string", () => {
|
|
6
|
+
expect(detectTags("@smoke")).toEqual([{ start: 0, end: 6, tag: "@smoke" }]);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("detects a tag that follows a space", () => {
|
|
10
|
+
// "Login flow @smoke" — the @ is at index 11.
|
|
11
|
+
expect(detectTags("Login flow @smoke")).toEqual([
|
|
12
|
+
{ start: 11, end: 17, tag: "@smoke" },
|
|
13
|
+
]);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("detects multiple tags in one title", () => {
|
|
17
|
+
const matches = detectTags("Login flow @smoke @regression");
|
|
18
|
+
expect(matches).toEqual([
|
|
19
|
+
{ start: 11, end: 17, tag: "@smoke" },
|
|
20
|
+
{ start: 18, end: 29, tag: "@regression" },
|
|
21
|
+
]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("detects tags that contain allowed symbols", () => {
|
|
25
|
+
expect(detectTags("@severity:high")).toEqual([
|
|
26
|
+
{ start: 0, end: 14, tag: "@severity:high" },
|
|
27
|
+
]);
|
|
28
|
+
expect(detectTags("@T1234abcd")).toEqual([
|
|
29
|
+
{ start: 0, end: 10, tag: "@T1234abcd" },
|
|
30
|
+
]);
|
|
31
|
+
expect(detectTags("@a-b_c")).toEqual([{ start: 0, end: 6, tag: "@a-b_c" }]);
|
|
32
|
+
expect(detectTags("@group(1)")).toEqual([
|
|
33
|
+
{ start: 0, end: 9, tag: "@group(1)" },
|
|
34
|
+
]);
|
|
35
|
+
expect(detectTags("@key=value")).toEqual([
|
|
36
|
+
{ start: 0, end: 10, tag: "@key=value" },
|
|
37
|
+
]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("detects a tag after a tab character", () => {
|
|
41
|
+
expect(detectTags("title\t@flaky")).toEqual([
|
|
42
|
+
{ start: 6, end: 12, tag: "@flaky" },
|
|
43
|
+
]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("does not match an email address", () => {
|
|
47
|
+
// The `@` is preceded by a word character, not start/whitespace.
|
|
48
|
+
expect(detectTags("Contact user@example.com")).toEqual([]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("does not match a bare @ with no body", () => {
|
|
52
|
+
expect(detectTags("just an @ symbol")).toEqual([]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("does not match @ in the middle of a word", () => {
|
|
56
|
+
expect(detectTags("foo@bar")).toEqual([]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("returns an empty array for text without tags", () => {
|
|
60
|
+
expect(detectTags("A plain heading title")).toEqual([]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("ignores a trailing dot that is not a valid tag terminator", () => {
|
|
64
|
+
// The tag must end with a word char or `)`, so the trailing `.` is excluded.
|
|
65
|
+
expect(detectTags("end of @smoke.")).toEqual([
|
|
66
|
+
{ start: 7, end: 13, tag: "@smoke" },
|
|
67
|
+
]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("does not keep stale regex state across calls", () => {
|
|
71
|
+
// Guards against a shared lastIndex leaking between invocations.
|
|
72
|
+
expect(detectTags("@one")).toEqual([{ start: 0, end: 4, tag: "@one" }]);
|
|
73
|
+
expect(detectTags("@two")).toEqual([{ start: 0, end: 4, tag: "@two" }]);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { BlockNoteExtension } from "@blocknote/core";
|
|
2
|
+
import type { Node as PMNode } from "prosemirror-model";
|
|
3
|
+
import { Plugin, PluginKey } from "prosemirror-state";
|
|
4
|
+
import { Decoration, DecorationSet } from "prosemirror-view";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Tags are tokens that start with `@` and may contain alphanumerics plus a few
|
|
8
|
+
* symbols (`= - _ ( ) . : &`), e.g. `@smoke`, `@severity:high`, `@T1234abcd`.
|
|
9
|
+
*
|
|
10
|
+
* This is a faithful JS port of the backend (Ruby) tag regexes:
|
|
11
|
+
* TAG_PREFIX_REGEXP = /(?:^|[ \t])/
|
|
12
|
+
* TAG_ALLOWED_SYMBOLS_REGEXP = /[\w\d\=\-\_\(\)\.\:\&]*[\w\d\)]/
|
|
13
|
+
* TAGS_DETECT_REGEXP = /(?:^|[ \t])(\@<allowed>)/
|
|
14
|
+
*
|
|
15
|
+
* `\w` already covers `[0-9_]`, so the symbol class is kept minimal while
|
|
16
|
+
* staying faithful to the allowed characters. A tag must be preceded by the
|
|
17
|
+
* start of the string or whitespace — so email-like text (`user@example.com`)
|
|
18
|
+
* is correctly ignored.
|
|
19
|
+
*/
|
|
20
|
+
const TAG_ALLOWED_SYMBOLS = String.raw`[\w=\-_().:&]*[\w)]`;
|
|
21
|
+
|
|
22
|
+
/** Matches a tag (capture group 1) preceded by start-of-string or whitespace. */
|
|
23
|
+
export const TAGS_DETECT_REGEXP = new RegExp(
|
|
24
|
+
String.raw`(?:^|[ \t])(@${TAG_ALLOWED_SYMBOLS})`,
|
|
25
|
+
"g",
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
export interface TagMatch {
|
|
29
|
+
/** Offset of the `@` within the scanned string (the leading space is excluded). */
|
|
30
|
+
start: number;
|
|
31
|
+
/** Offset just past the last character of the tag. */
|
|
32
|
+
end: number;
|
|
33
|
+
/** The matched tag text, including the leading `@`. */
|
|
34
|
+
tag: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Find all `@tag` tokens inside a plain string and return their offsets.
|
|
39
|
+
* Pure and DOM-free so it can be unit-tested directly.
|
|
40
|
+
*/
|
|
41
|
+
export function detectTags(text: string): TagMatch[] {
|
|
42
|
+
const matches: TagMatch[] = [];
|
|
43
|
+
// Use a fresh regex each call so the shared `lastIndex` state never leaks
|
|
44
|
+
// between invocations.
|
|
45
|
+
const regexp = new RegExp(TAGS_DETECT_REGEXP.source, "g");
|
|
46
|
+
let match: RegExpExecArray | null;
|
|
47
|
+
while ((match = regexp.exec(text)) !== null) {
|
|
48
|
+
const tag = match[1];
|
|
49
|
+
// The match may include a leading space/tab consumed by the prefix; the tag
|
|
50
|
+
// itself starts that many characters into the overall match.
|
|
51
|
+
const start = match.index + (match[0].length - tag.length);
|
|
52
|
+
matches.push({ start, end: start + tag.length, tag });
|
|
53
|
+
// Defensive guard against a zero-length match looping forever (a tag always
|
|
54
|
+
// starts with `@`, so this should never trigger).
|
|
55
|
+
if (regexp.lastIndex === match.index) {
|
|
56
|
+
regexp.lastIndex++;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return matches;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const tagBadgePluginKey = new PluginKey<DecorationSet>("testomatioTagBadge");
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Build inline decorations for every `@tag` found inside heading blocks. Tags
|
|
66
|
+
* are only *painted* — the underlying text is untouched, so markdown
|
|
67
|
+
* serialization round-trips unchanged.
|
|
68
|
+
*/
|
|
69
|
+
function buildHeadingTagDecorations(doc: PMNode): DecorationSet {
|
|
70
|
+
const decorations: Decoration[] = [];
|
|
71
|
+
|
|
72
|
+
doc.descendants((node, pos) => {
|
|
73
|
+
if (node.type.name !== "heading") {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Walk the heading's inline children so positions stay correct even when it
|
|
78
|
+
// contains links or inline images alongside text.
|
|
79
|
+
node.forEach((child, offset) => {
|
|
80
|
+
if (!child.isText || !child.text) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
// `pos + 1` steps inside the heading content node; `offset` is the child's
|
|
84
|
+
// position relative to the node's content.
|
|
85
|
+
const base = pos + 1 + offset;
|
|
86
|
+
for (const { start, end } of detectTags(child.text)) {
|
|
87
|
+
decorations.push(
|
|
88
|
+
Decoration.inline(base + start, base + end, {
|
|
89
|
+
class: "bn-tag-badge",
|
|
90
|
+
}),
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Headings only hold inline content; no need to descend further.
|
|
96
|
+
return false;
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
return DecorationSet.create(doc, decorations);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function tagBadgePlugin(): Plugin<DecorationSet> {
|
|
103
|
+
return new Plugin<DecorationSet>({
|
|
104
|
+
key: tagBadgePluginKey,
|
|
105
|
+
state: {
|
|
106
|
+
init: (_config, state) => buildHeadingTagDecorations(state.doc),
|
|
107
|
+
apply: (tr, value) =>
|
|
108
|
+
tr.docChanged ? buildHeadingTagDecorations(tr.doc) : value,
|
|
109
|
+
},
|
|
110
|
+
props: {
|
|
111
|
+
decorations(state) {
|
|
112
|
+
return tagBadgePluginKey.getState(state);
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* BlockNote extension that renders `@tags` inside headings as badges.
|
|
120
|
+
*
|
|
121
|
+
* Editor extensions are supplied at editor-creation time and cannot be carried
|
|
122
|
+
* by the schema, so consumers must add this to their `useCreateBlockNote` call:
|
|
123
|
+
*
|
|
124
|
+
* ```ts
|
|
125
|
+
* useCreateBlockNote({
|
|
126
|
+
* schema: customSchema,
|
|
127
|
+
* extensions: [tagBadgeExtension()],
|
|
128
|
+
* });
|
|
129
|
+
* ```
|
|
130
|
+
*/
|
|
131
|
+
export class TagBadgeExtension extends BlockNoteExtension {
|
|
132
|
+
static key() {
|
|
133
|
+
return "tagBadge";
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
constructor() {
|
|
137
|
+
super();
|
|
138
|
+
this.addProsemirrorPlugin(tagBadgePlugin());
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Factory for the `extensions` option of `useCreateBlockNote`. */
|
|
143
|
+
export const tagBadgeExtension = () => new TagBadgeExtension();
|
package/src/index.ts
CHANGED
|
@@ -15,6 +15,14 @@ export {
|
|
|
15
15
|
} from "./editor/testMetaFields";
|
|
16
16
|
export { markdownToHtml, htmlToMarkdown } from "./editor/blocks/markdown";
|
|
17
17
|
|
|
18
|
+
export {
|
|
19
|
+
tagBadgeExtension,
|
|
20
|
+
TagBadgeExtension,
|
|
21
|
+
TAGS_DETECT_REGEXP,
|
|
22
|
+
detectTags,
|
|
23
|
+
type TagMatch,
|
|
24
|
+
} from "./editor/tagBadge";
|
|
25
|
+
|
|
18
26
|
export {
|
|
19
27
|
blocksToMarkdown,
|
|
20
28
|
markdownToBlocks,
|