diff --git a/src/roundtrip.ts b/src/roundtrip.ts index 6d4a3e1..83bebdd 100644 --- a/src/roundtrip.ts +++ b/src/roundtrip.ts @@ -66,13 +66,15 @@ export function stripBlockIds(node: any): any { return node; } -interface ParsedArgs { +export interface ParsedArgs { fixture?: string; page?: string; corpus?: string; } -function parseArgs(argv: string[]): ParsedArgs { +// Exported (R-Cfg-1) so the CLI arg parsing — the lookahead for --corpus and the +// `argv[++i]` value consumption — is unit-testable in isolation. +export function parseArgs(argv: string[]): ParsedArgs { const args: ParsedArgs = {}; for (let i = 0; i < argv.length; i++) { const a = argv[i]; diff --git a/test/auth-utils.test.ts b/test/auth-utils.test.ts index b35b6a7..dd1c097 100644 --- a/test/auth-utils.test.ts +++ b/test/auth-utils.test.ts @@ -125,6 +125,17 @@ describe('getCollabToken', () => { expect(captured.message).toContain('Failed to get collab token: 403'); }); + it('returns undefined when the response carries no token on either path', async () => { + // Neither data.data.token nor data.token is present. The source does + // `data.data?.token || data.token`, so both are undefined and it returns + // undefined rather than throwing (documented behaviour / latent bug, §7). + mock.onPost(`${BASE_URL}/auth/collab-token`).reply(200, { data: {} }); + + const token = await getCollabToken(BASE_URL, API_TOKEN); + + expect(token).toBeUndefined(); + }); + it('rethrows a non-axios error unwrapped (status untouched)', async () => { const boom = new Error('plain failure'); // A handler that throws a non-axios error from inside the request lifecycle. @@ -217,6 +228,17 @@ describe('performLogin', () => { ).rejects.toThrow('No Set-Cookie header found in login response'); }); + it('throws "No authToken cookie" for an EMPTY set-cookie array (distinct from missing header)', async () => { + // An empty array is TRUTHY, so it passes the `!cookies` missing-header guard + // and reaches the `.find(...)` — which returns undefined, so the distinct + // "No authToken cookie found" error fires (not the missing-header one). + mock.onPost(`${BASE_URL}/auth/login`).reply(200, {}, { 'set-cookie': [] }); + + await expect( + performLogin(BASE_URL, 'user@example.com', 'pw'), + ).rejects.toThrow('No authToken cookie found in login response'); + }); + describe('error logging (DEBUG gating)', () => { let errorSpy: ReturnType; diff --git a/test/canonicalize-extra.test.ts b/test/canonicalize-extra.test.ts new file mode 100644 index 0000000..e2103f8 --- /dev/null +++ b/test/canonicalize-extra.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, it } from 'vitest'; +import fc from 'fast-check'; +// Barrel import (R-Infra alias resolves this to packages/docmost-client/src so +// coverage measures the real source, not stale dist). +import { canonicalizeContent, docsCanonicallyEqual } from 'docmost-client'; + +// --------------------------------------------------------------------------- +// Gaps NOT covered by canonicalize.test.ts (test-strategy report §2 diff): +// - the *.align family (drawio/excalidraw/video/youtube/embed): a "center" +// default is dropped, a non-default value is kept; +// - comment.resolved: TRUE is PRESERVED (only resolved:false is normalized); +// - link.target / link.rel NON-default values are kept; +// - property: canonicalizeContent is a fixpoint, docsCanonicallyEqual is +// reflexive and symmetric. +// The base file already covers id-stripping, null-drop, link/comment/orderedList +// default-drop, key-order insensitivity, and a real-diff negative — not re-added. +// --------------------------------------------------------------------------- + +describe('canonicalizeContent — *.align default family', () => { + // Every diagram/media node whose schema `align` defaults to "center". + const alignTypes = ['drawio', 'excalidraw', 'video', 'youtube', 'embed']; + + for (const type of alignTypes) { + it(`${type}: align "center" (the schema default) is dropped`, () => { + const out = canonicalizeContent({ + type, + attrs: { id: 'n-1', src: '/x', align: 'center' }, + }); + // align==default removed; the meaningful src survives. + expect(out.attrs).toEqual({ src: '/x' }); + }); + + it(`${type}: a NON-default align (e.g. "right") is kept`, () => { + const out = canonicalizeContent({ + type, + attrs: { id: 'n-1', src: '/x', align: 'right' }, + }); + expect(out.attrs).toEqual({ src: '/x', align: 'right' }); + }); + } + + it('image align is NOT in KNOWN_DEFAULTS: a non-null align survives, null is dropped', () => { + // image.align defaults to null, so it is handled by the null-drop rule and + // a real value ("left") must be kept (no spurious default match). + const kept = canonicalizeContent({ + type: 'image', + attrs: { id: 'i-1', src: '/a.png', align: 'left' }, + }); + expect(kept.attrs).toEqual({ src: '/a.png', align: 'left' }); + // An image with align:"center" must KEEP it (center is NOT a default for + // image, only for the diagram/media family) — guards against over-matching. + const center = canonicalizeContent({ + type: 'image', + attrs: { id: 'i-2', src: '/b.png', align: 'center' }, + }); + expect(center.attrs).toEqual({ src: '/b.png', align: 'center' }); + }); +}); + +describe('canonicalizeContent — comment.resolved:true preserved (SPEC §11 L66)', () => { + it('keeps resolved:true (a legitimate change, not a default to normalize away)', () => { + const out = canonicalizeContent({ + type: 'text', + text: 'anchored', + marks: [{ type: 'comment', attrs: { commentId: 'cmt-1', resolved: true } }], + }); + // resolved:true is NON-default; it must survive alongside the commentId so a + // resolve-vs-unresolved divergence is not falsely reported as equal. + expect(out.marks).toEqual([ + { type: 'comment', attrs: { commentId: 'cmt-1', resolved: true } }, + ]); + }); + + it('a resolved:true comment is NOT canonically equal to an unresolved one', () => { + const resolved = { + type: 'text', + text: 'x', + marks: [{ type: 'comment', attrs: { commentId: 'c', resolved: true } }], + }; + const open = { + type: 'text', + text: 'x', + marks: [{ type: 'comment', attrs: { commentId: 'c' } }], + }; + expect(docsCanonicallyEqual(resolved, open)).toBe(false); + }); +}); + +describe('canonicalizeContent — link non-default target/rel kept', () => { + it('keeps a NON-default link.target (e.g. "_self")', () => { + const out = canonicalizeContent({ + type: 'text', + text: 'l', + marks: [{ type: 'link', attrs: { href: 'https://e.com', target: '_self' } }], + }); + // _self != the "_blank" default, so target must survive. + expect(out.marks).toEqual([ + { type: 'link', attrs: { href: 'https://e.com', target: '_self' } }, + ]); + }); + + it('keeps a NON-default link.rel', () => { + const out = canonicalizeContent({ + type: 'text', + text: 'l', + marks: [{ type: 'link', attrs: { href: 'https://e.com', rel: 'nofollow' } }], + }); + expect(out.marks).toEqual([ + { type: 'link', attrs: { href: 'https://e.com', rel: 'nofollow' } }, + ]); + }); +}); + +// --------------------------------------------------------------------------- +// Property-based oracle checks (SPEC §11). The generated trees mix node/mark +// types, ids, null attrs, known-default attrs and meaningful attrs, so the +// invariants are exercised across the whole canonicalization surface. +// --------------------------------------------------------------------------- + +// An attribute value: a meaningful value, a null/undefined, a block id, or a +// known schema default — so pruning, id-drop, null-drop and default-drop all +// fire during shrinking. +const attrValueArb = fc.oneof( + fc.string({ minLength: 1, maxLength: 6 }), + fc.integer({ min: 0, max: 9 }), + fc.boolean(), + fc.constant(null), +); + +// A recursive ProseMirror-ish node arbitrary (bounded depth) with type, attrs +// (incl. an id and possibly a known default), optional marks and content. +const nodeArb: fc.Arbitrary = fc.letrec((tie) => ({ + node: fc.record( + { + type: fc.constantFrom( + 'paragraph', + 'heading', + 'orderedList', + 'drawio', + 'video', + 'text', + ), + text: fc.option(fc.string({ minLength: 0, maxLength: 5 }), { nil: undefined }), + attrs: fc.option( + fc.dictionary( + fc.constantFrom('id', 'level', 'start', 'align', 'src', 'indent', 'keep'), + attrValueArb, + { maxKeys: 4 }, + ), + { nil: undefined }, + ), + marks: fc.option( + fc.array( + fc.record({ + type: fc.constantFrom('bold', 'link', 'comment'), + attrs: fc.option( + fc.dictionary( + fc.constantFrom('href', 'target', 'rel', 'commentId', 'resolved'), + fc.oneof(attrValueArb, fc.constant('_blank')), + { maxKeys: 3 }, + ), + { nil: undefined }, + ), + }), + { maxLength: 2 }, + ), + { nil: undefined }, + ), + content: fc.option(fc.array(tie('node'), { maxLength: 2 }), { nil: undefined }), + }, + { requiredKeys: ['type'] }, + ), +})).node; + +describe('canonicalizeContent — property invariants (SPEC §11 oracle)', () => { + it('is a fixpoint: f(f(x)) === f(x)', () => { + fc.assert( + fc.property(nodeArb, (node) => { + const once = canonicalizeContent(node); + const twice = canonicalizeContent(once); + // The canonical form must already be stable under a second pass. + expect(twice).toEqual(once); + }), + { numRuns: 300 }, + ); + }); + + it('docsCanonicallyEqual is reflexive: equal(x, x) is always true', () => { + fc.assert( + fc.property(nodeArb, (node) => { + expect(docsCanonicallyEqual(node, node)).toBe(true); + }), + { numRuns: 300 }, + ); + }); + + it('docsCanonicallyEqual is symmetric: equal(a, b) === equal(b, a)', () => { + fc.assert( + fc.property(nodeArb, nodeArb, (a, b) => { + expect(docsCanonicallyEqual(a, b)).toBe(docsCanonicallyEqual(b, a)); + }), + { numRuns: 300 }, + ); + }); +}); diff --git a/test/diff.test.ts b/test/diff.test.ts index 3e195a4..79ea155 100644 --- a/test/diff.test.ts +++ b/test/diff.test.ts @@ -264,6 +264,24 @@ describe('diffDocs', () => { expect(result.summary).toEqual({ inserted: 0, deleted: 0, blocksChanged: 0 }); }); + it('still computes integrity (images/tables/callouts/footnotes) in the coarse-fallback branch', () => { + // Regression guard: integrity is computed BEFORE the try/catch, so a + // pathological pair that forces the fallback must NOT zero the integrity + // counts. The unknown node forces the precise path to throw (fellBack). + const oldDoc = doc(image(), callout(), table(), para('a [1]'), { type: '___nope' }); + const newDoc = doc(image(), image(), table(), para('b [2] [3]')); + const result = diffDocs(oldDoc, newDoc); + + // The fallback was taken... + expect(result.markdown).toContain('coarse block-level diff shown.'); + // ...yet every integrity tuple is the real count, not [0,0]. + expect(result.integrity.images).toEqual([1, 2]); + expect(result.integrity.callouts).toEqual([1, 0]); + expect(result.integrity.tables).toEqual([1, 1]); + // Footnote markers are counted from both docs even under the fallback. + expect(result.integrity.footnoteMarkers).toEqual([[1], [2, 3]]); + }); + it('reports both a deletion and an insertion in the fallback path', () => { const oldDoc = doc(para('old paragraph'), { type: '___nope' }); const newDoc = doc(para('new paragraph')); diff --git a/test/divergence.test.ts b/test/divergence.test.ts index b00ffd5..bda7efa 100644 --- a/test/divergence.test.ts +++ b/test/divergence.test.ts @@ -37,4 +37,42 @@ describe('firstDivergence', () => { expect(d!.a).toBe('here'); expect(d!.b).toBeUndefined(); }); + + // --- branches NOT covered above (report §2) --- + + it('reports a type mismatch (string vs number) at the current path', () => { + const d = firstDivergence({ k: '1' }, { k: 1 }); + expect(d).toEqual({ path: '$.k', a: '1', b: 1 }); + }); + + it('reports null-vs-object as a divergence (typeof object both, but one is null)', () => { + // typeof null === 'object', so this exercises the explicit `a===null` guard. + const d = firstDivergence({ k: null }, { k: { nested: true } }); + expect(d).toEqual({ path: '$.k', a: null, b: { nested: true } }); + }); + + it('reports array-vs-object as a divergence (aIsArr !== bIsArr)', () => { + const d = firstDivergence({ k: [1, 2] }, { k: { 0: 1, 1: 2 } }); + expect(d).not.toBeNull(); + expect(d!.path).toBe('$.k'); + expect(Array.isArray(d!.a)).toBe(true); + expect(Array.isArray(d!.b)).toBe(false); + }); + + it('returns null for two equal primitives (the a === b fast path)', () => { + expect(firstDivergence(7, 7)).toBeNull(); + expect(firstDivergence('same', 'same')).toBeNull(); + expect(firstDivergence(null, null)).toBeNull(); + expect(firstDivergence(true, true)).toBeNull(); + }); + + it('reports a key present only in b (a-side undefined)', () => { + // The key union scans both objects, so a key only in `b` is detected with + // the a-side value undefined. + const d = firstDivergence({ shared: 1 }, { shared: 1, extra: 'b-only' }); + expect(d).not.toBeNull(); + expect(d!.path).toBe('$.extra'); + expect(d!.a).toBeUndefined(); + expect(d!.b).toBe('b-only'); + }); }); diff --git a/test/docmost-schema-closures.test.ts b/test/docmost-schema-closures.test.ts new file mode 100644 index 0000000..c257813 --- /dev/null +++ b/test/docmost-schema-closures.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest'; +import { generateJSON } from '@tiptap/html'; +// Import the extension list DIRECTLY from src (NOT the barrel, which pulls in +// collaboration.ts and mutates global DOM at import time). +import { docmostExtensions } from '../packages/docmost-client/src/lib/docmost-schema.js'; + +// --------------------------------------------------------------------------- +// These exercise the schema's parse-CLOSURES (getAttrs / parseHTML), which are +// otherwise uncalled by the declarative-spec tests (docmost-schema.test.ts only +// covers sanitizeCssColor / clampCalloutType). generateJSON(html, extensions) +// runs the real HTML -> ProseMirror import the way markdownToProseMirror does. +// +// Report §2 gaps (prosemirror-schema): +// - TextStyle.getAttrs: a colored imports as a textStyle mark, but a +// comment span (data-comment-id) and a mention span (data-type=mention) are +// NOT swallowed (their anchors must survive — SPEC §3); +// - Highlight: background-color is read into the color attr (its own path, +// distinct from textStyle); +// - Column.width: data-width="N" is parseFloat'd to a NUMBER (string->number). +// --------------------------------------------------------------------------- + +/** Import an HTML fragment and return the doc's first paragraph's first inline. */ +function firstInline(html: string): any { + const doc = generateJSON(html, docmostExtensions); + return doc.content[0].content[0]; +} + +describe('TextStyle.getAttrs (colored span import)', () => { + it('imports a plain colored as a textStyle mark with the color', () => { + const inline = firstInline('

red

'); + expect(inline.text).toBe('red'); + expect(inline.marks).toEqual([ + { type: 'textStyle', attrs: { color: '#ff0000' } }, + ]); + }); + + it('does NOT swallow a comment span (data-comment-id) — it stays a comment mark', () => { + const inline = firstInline('

x

'); + // The comment anchor must survive: getAttrs returns false for this span, so + // the Comment mark claims it instead of textStyle (no silent drop, SPEC §3). + const markTypes = (inline.marks ?? []).map((m: any) => m.type); + expect(markTypes).toContain('comment'); + expect(markTypes).not.toContain('textStyle'); + const comment = inline.marks.find((m: any) => m.type === 'comment'); + expect(comment.attrs.commentId).toBe('cid-9'); + }); + + it('a comment span that ALSO carries a color is still a comment (data-comment-id guard wins)', () => { + const inline = firstInline( + '

x

', + ); + const markTypes = (inline.marks ?? []).map((m: any) => m.type); + expect(markTypes).toContain('comment'); + // textStyle.getAttrs short-circuits on data-comment-id, so the color span is + // NOT additionally claimed as a textStyle mark. + expect(markTypes).not.toContain('textStyle'); + }); + + it('does NOT swallow a mention span (data-type=mention) — it stays a mention node', () => { + const inline = firstInline( + '

@Alice

', + ); + expect(inline.type).toBe('mention'); + expect(inline.attrs.id).toBe('m1'); + expect(inline.attrs.label).toBe('Alice'); + }); +}); + +describe('Highlight background-color guard', () => { + it('reads background-color into the highlight color attr', () => { + const inline = firstInline( + '

h

', + ); + const hl = (inline.marks ?? []).find((m: any) => m.type === 'highlight'); + expect(hl).toBeDefined(); + expect(hl.attrs.color).toBe('#00ff00'); + }); + + it('a bare with no background-color imports with color null', () => { + const inline = firstInline('

h

'); + const hl = (inline.marks ?? []).find((m: any) => m.type === 'highlight'); + expect(hl).toBeDefined(); + expect(hl.attrs.color).toBeNull(); + }); + + it('reads data-color and runs it through the sanitizeCssColor guard', () => { + // The data-color attribute path bypasses the browser CSS parser and hits + // the guarded parseHTML directly, so a well-formed color passes verbatim... + const ok = firstInline('

h

'); + const okHl = (ok.marks ?? []).find((m: any) => m.type === 'highlight'); + expect(okHl.attrs.color).toBe('#abcdef'); + + // ...and a CSS-injection breakout payload is rejected, leaving color null. + const bad = firstInline('

h

'); + const badHl = (bad.marks ?? []).find((m: any) => m.type === 'highlight'); + expect(badHl.attrs.color).toBeNull(); + }); +}); + +describe('Column.width parseFloat (string -> number)', () => { + it('parses data-width="42.5" into the NUMBER 42.5 (not the string)', () => { + const doc = generateJSON( + '

c

', + docmostExtensions, + ); + const column = doc.content[0].content[0]; + expect(column.type).toBe('column'); + expect(column.attrs.width).toBe(42.5); + expect(typeof column.attrs.width).toBe('number'); + }); + + it('a column without data-width imports with width null', () => { + const doc = generateJSON( + '

c

', + docmostExtensions, + ); + const column = doc.content[0].content[0]; + expect(column.attrs.width).toBeNull(); + }); +}); diff --git a/test/fixtures/corpus/08-details.json b/test/fixtures/corpus/08-details.json new file mode 100644 index 0000000..74c2668 --- /dev/null +++ b/test/fixtures/corpus/08-details.json @@ -0,0 +1,15 @@ +{ + "type": "doc", + "content": [ + { + "type": "details", + "attrs": { "open": false }, + "content": [ + { "type": "detailsSummary", "content": [{ "type": "text", "text": "Click to expand" }] }, + { "type": "detailsContent", "content": [ + { "type": "paragraph", "content": [{ "type": "text", "text": "Hidden body paragraph." }] } + ]} + ] + } + ] +} diff --git a/test/fixtures/corpus/09-columns.json b/test/fixtures/corpus/09-columns.json new file mode 100644 index 0000000..49c0abe --- /dev/null +++ b/test/fixtures/corpus/09-columns.json @@ -0,0 +1,17 @@ +{ + "type": "doc", + "content": [ + { + "type": "columns", + "attrs": { "layout": "two", "widthMode": "normal" }, + "content": [ + { "type": "column", "attrs": { "width": 50 }, "content": [ + { "type": "paragraph", "content": [{ "type": "text", "text": "Left column." }] } + ]}, + { "type": "column", "attrs": { "width": 50 }, "content": [ + { "type": "paragraph", "content": [{ "type": "text", "text": "Right column." }] } + ]} + ] + } + ] +} diff --git a/test/fixtures/corpus/10-mention-in-heading.json b/test/fixtures/corpus/10-mention-in-heading.json new file mode 100644 index 0000000..18fb060 --- /dev/null +++ b/test/fixtures/corpus/10-mention-in-heading.json @@ -0,0 +1,13 @@ +{ + "type": "doc", + "content": [ + { + "type": "heading", + "attrs": { "level": 2 }, + "content": [ + { "type": "text", "text": "Notes for " }, + { "type": "mention", "attrs": { "id": "m-2", "label": "Bob", "entityType": "user", "entityId": "u-2", "slugId": "s-2", "creatorId": "c-2" } } + ] + } + ] +} diff --git a/test/json-edit.test.ts b/test/json-edit.test.ts index 95a1383..6e88839 100644 --- a/test/json-edit.test.ts +++ b/test/json-edit.test.ts @@ -284,5 +284,46 @@ describe('applyTextEdits', () => { { find: 'gamma', replacements: 1 }, ]); }); + + it('a LATE edit failure leaves the INPUT document unmodified (no partial mutation)', () => { + // The first edit is valid but a later one is absent and throws. Because the + // function mutates only a deep copy and throws before returning, the + // caller's input must be byte-identical afterwards (no partial apply leaks). + const doc = singleParagraph('alpha beta'); + const snapshot = structuredClone(doc); + expect(() => + applyTextEdits(doc, [ + { find: 'alpha', replace: 'A' }, // would succeed + { find: 'MISSING', replace: 'X' }, // throws "text not found" + ]), + ).toThrow(/text not found/); + // Input doc is untouched: the successful first edit was applied only to the + // internal copy, which was discarded when the second edit threw. + expect(doc).toEqual(snapshot); + }); + }); + + describe('find === replace and self-containing replaceAll', () => { + it('a find === replace edit is a no-op on the text but still reports a replacement', () => { + const doc = singleParagraph('hello world'); + const { doc: result, results } = applyTextEdits(doc, [ + { find: 'world', replace: 'world' }, + ]); + expect(result.content[0].content[0].text).toBe('hello world'); + // The replacement still counts (it spliced the same value back in). + expect(results).toEqual([{ find: 'world', replacements: 1 }]); + }); + + it('replaceAll where the replacement CONTAINS the find ("a" -> "aa") does not re-scan', () => { + // split().join() replaces all original occurrences in one pass; it must NOT + // re-match the inserted "aa" (which would loop). "a a a" -> "aa aa aa". + const doc = singleParagraph('a a a'); + const { doc: result, results } = applyTextEdits(doc, [ + { find: 'a', replace: 'aa', replaceAll: true }, + ]); + expect(result.content[0].content[0].text).toBe('aa aa aa'); + // Exactly the 3 original occurrences are counted, not 6. + expect(results).toEqual([{ find: 'a', replacements: 3 }]); + }); }); }); diff --git a/test/markdown-converter-golden.test.ts b/test/markdown-converter-golden.test.ts new file mode 100644 index 0000000..6a0c2e7 --- /dev/null +++ b/test/markdown-converter-golden.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, it } from 'vitest'; +// Import DIRECTLY from src (NOT the docmost-client barrel, which pulls in +// collaboration.ts and mutates global DOM at import time). +import { convertProseMirrorToMarkdown } from '../packages/docmost-client/src/lib/markdown-converter.js'; + +// markdown-converter.ts is the weakest pure module (report §2). These golden +// tests close the gaps the base markdown-converter.test.ts leaves open: +// columns/column wrapper, embed/audio/pdf (used to emit nothing), drawio/ +// excalidraw data-align presence rule, the remaining inline-mark matrix, +// paragraph.textAlign, subpages + unknown-in-container fallback, escaping +// idempotence, table-cell pipe/newline sanitization, and empty/single-column +// tables. Cases already asserted in the base file are NOT repeated. + +const doc = (...nodes: any[]) => ({ type: 'doc', content: nodes }); +const c = (node: any) => convertProseMirrorToMarkdown(doc(node)); +const text = (t: string, marks?: any[]) => + marks ? { type: 'text', text: t, marks } : { type: 'text', text: t }; +const para = (...inline: any[]) => ({ type: 'paragraph', content: inline }); + +describe('columns / column (raw-HTML layout wrapper)', () => { + it('wraps a multi-column layout as nested data-type divs with the children inside (regression: children unwrapped)', () => { + const out = c({ + type: 'columns', + attrs: { layout: 'two' }, + content: [ + { type: 'column', attrs: { width: 50 }, content: [para(text('L'))] }, + { type: 'column', content: [para(text('R'))] }, + ], + }); + expect(out).toBe( + '
' + + '

L

' + + '

R

' + + '
', + ); + }); + + it('omits the default widthMode "normal" but emits a non-default one', () => { + const normal = c({ + type: 'columns', + attrs: { layout: 'two', widthMode: 'normal' }, + content: [{ type: 'column', content: [para(text('x'))] }], + }); + expect(normal).not.toContain('data-width-mode'); + const wide = c({ + type: 'columns', + attrs: { layout: 'two', widthMode: 'full' }, + content: [{ type: 'column', content: [para(text('x'))] }], + }); + expect(wide).toContain('data-width-mode="full"'); + }); +}); + +describe('embed / audio / pdf (previously emitted nothing — invisible regression)', () => { + it('embed emits div[data-type="embed"] with src/provider', () => { + expect(c({ type: 'embed', attrs: { src: 'https://x.com/e', provider: 'iframe' } })).toBe( + '
', + ); + }); + + it('audio emits a div-wrapped