// Generates the ProseMirror-shaped Y.XmlFragment fixtures that define the // wire contract for core/src/fragment.rs. These are the contract: never // edit a fixture to make a test pass. A deliberate rule change regenerates // them in the same commit. import * as Y from 'yjs' import { writeFileSync, mkdirSync } from 'node:fs' // The client id is pinned so the bytes are reproducible. Options:: // with_guid_and_client_id on the Rust side pins the same pair; nothing // in this pipeline may draw entropy (see the plan's Global Constraints). function docWith(build) { const doc = new Y.Doc() doc.clientID = 1 build(doc.getXmlFragment('prosemirror')) return Y.encodeStateAsUpdate(doc) } function el(name, attrs, ...children) { const e = new Y.XmlElement(name) for (const [k, v] of Object.entries(attrs ?? {})) e.setAttribute(k, v) if (children.length) e.insert(0, children) return e } function text(s, marks) { const t = new Y.XmlText() t.insert(0, s, marks ?? undefined) return t } mkdirSync('vectors/fragment', { recursive: true }) writeFileSync('vectors/fragment/simple.bin', docWith(f => { f.insert(0, [ el('paragraph', null, text('First paragraph.')), el('paragraph', null, text('Second paragraph.')), ]) })) writeFileSync('vectors/fragment/marks.bin', docWith(f => { f.insert(0, [ el('paragraph', null, text('Plain then '), text('bold', { strong: {} }), text(' then '), text('italic', { em: {} }), text('.')), ]) })) writeFileSync('vectors/fragment/nested.bin', docWith(f => { f.insert(0, [ el('heading', { level: '2' }, text('A heading')), el('bullet_list', null, el('list_item', null, el('paragraph', null, text('One'))), el('list_item', null, el('paragraph', null, text('Two')))), ]) })) // styled.bin covers what the formatting bar can produce that the three // fixtures above cannot: the font and size marks (core/src/fragment.rs's // MARKS table), a run carrying two marks at once, and the two block // shapes — blockquote and ordered_list — that had no fixture before the // export paths needed to render them. The marks are attribute-free on // purpose; see that table's own comment for why a mark never carries a // value. writeFileSync('vectors/fragment/styled.bin', docWith(f => { f.insert(0, [ el('paragraph', null, text('sans', { font_sans: {} }), text(' mono', { font_mono: {} }), text(' large', { size_lg: {} }), text(' both', { font_sans: {}, strong: {} })), el('blockquote', null, el('paragraph', null, text('Quoted.'))), el('ordered_list', null, el('list_item', null, el('paragraph', null, text('First'))), el('list_item', null, el('paragraph', null, text('Second')))), ]) })) // fonts.bin covers the font marks on their own, and exists because // serif became sayable. Until sans took the default over, "serif" was // spelled as the ABSENCE of a mark — there was no font_serif — so the // vocabulary styled.bin pins had no way to express the face most // documents were actually in. This fixture pins the whole of it: an // unmarked run, which carries no span whatever the default happens to // be, and one run per face. // // A fixture of its own, rather than three more runs inside styled.bin. // These vectors are the wire contract, not an output of it: styled.bin's // bytes are what a dozen assertions in core/tests/fragment.rs and // internal/docs already compare against, and regenerating it would // quietly change what every one of them means. Adding a vector changes // nothing that already exists. writeFileSync('vectors/fragment/fonts.bin', docWith(f => { f.insert(0, [ el('paragraph', null, text('Unmarked'), text(' serif', { font_serif: {} }), text(' sans', { font_sans: {} }), text(' mono', { font_mono: {} })), ]) })) // aligned.bin covers the first block-level formatting: a paragraph's // `align` attribute. Unlike a mark, alignment belongs to the block, so // it is a real Yjs XML attribute on the element — which is exactly why // it needs a fixture of its own. y-prosemirror stores node attributes // this way (createTypeFromElementNode: setAttribute for every non-null // attr), so this is what an aligned document ACTUALLY contains on the // wire, and core/src/fragment.rs has to read it back from there rather // than from anyone's description of it. // // The first paragraph carries no attribute at all, which is the whole // point of left being the default: the editor's schema stores null, // y-prosemirror skips null, and an unaligned document is byte-identical // to one written before alignment existed. The last one says "left" out // loud, which a paste or a future build can produce and which must // render as no class rather than as a class of its own. writeFileSync('vectors/fragment/aligned.bin', docWith(f => { f.insert(0, [ el('paragraph', null, text('Left by default.')), el('paragraph', { align: 'center' }, text('Centred.')), el('paragraph', { align: 'right' }, text('Right.')), el('paragraph', { align: 'left' }, text('Left, said out loud.')), ]) })) // indented.bin covers the second block attribute, and it exists as its // own vector for a reason aligned.bin did not have: indent's value is a // NUMBER, where align's is a string from a closed set of three. // // That difference is the thing this fixture is here to pin. A string // attribute can only be handled one way — look it up in a table, drop // anything not in it — so aligned.bin's job was to prove the table is // consulted. A number has to be parsed and clamped instead, which is a // second mechanism with a second set of ways to be wrong, and the // wrong-looking cases are exactly the ones no editor will ever produce: // a level past the maximum, a zero, a value that is not a number at // all. POST /d/{sqid}/updates takes raw Yjs bytes, so every one of them // is reachable by a client that never went through the schema, and the // renderer's output goes into the page unescaped. // // So the last three paragraphs here are not decoration. "99" must // render clamped to the deepest level rather than as docs-indent-99; // "0" must render as no class at all, the same as no attribute; // "3; drop" must fail to parse and render flush left, never reaching // attribute position. A build that interpolated the attribute straight // into a class name would pass every test written against the first // three paragraphs and fail these. // // The first paragraph carries no attribute, for aligned.bin's own // reason: an un-indented document must be byte-identical to one written // before indent existed. // // The heading is here because headings are indentable and are not // alignable — a real asymmetry in the schema, and one that a fixture // covering only paragraphs would let a renderer get wrong in silence. writeFileSync('vectors/fragment/indented.bin', docWith(f => { f.insert(0, [ el('paragraph', null, text('Flush left, no attribute.')), el('paragraph', { indent: '1' }, text('One step.')), el('paragraph', { indent: '8' }, text('The deepest step.')), el('heading', { level: '2', indent: '2' }, text('An indented heading.')), el('paragraph', { align: 'center', indent: '3' }, text('Centred and indented.')), el('paragraph', { indent: '99' }, text('Past the maximum; clamps.')), el('paragraph', { indent: '0' }, text('Zero is no indent.')), el('paragraph', { indent: '3; drop' }, text('Not a number at all.')), // The four values JavaScript's Number() accepts and a Rust parse // does not. They are here because the two sides disagreed about // every one of them for a review cycle — the editor rendered them // indented and the read view rendered them flush left — and the // agreement is now the thing being pinned rather than the clamp. el('paragraph', { indent: '400' }, text('Too large for a u8; still clamps.')), el('paragraph', { indent: ' 3' }, text('Leading space is not a number.')), el('paragraph', { indent: '3.0' }, text('Nor is a decimal point.')), el('paragraph', { indent: '0x3' }, text('Nor is hex.')), ]) })) // sizes.bin covers the point ladder, and the three legacy marks // alongside it. // // styled.bin already pins size_sm/lg/xl, so this could have been read as // redundant. It is not: the legacy marks are pinned there as the sizes // this editor WRITES, and here as the sizes it must go on UNDERSTANDING // after it stopped writing them. A change that dropped them from MARKS // would leave styled.bin's assertions to be "fixed" by regenerating it; // this fixture states the property directly, so the same change fails // with a message about documents in the wild. // // The mixed run at the end is the case neither renderer can produce and // POST /d/{sqid}/updates can: a run carrying a ladder mark AND a legacy // one. Both this build and internal/export/pdf.go resolve it the same // way — ladder first, in a fixed order — because a document must not // measure one size on screen and another on paper, and an arbitrary but // stable answer is what determinism needs. writeFileSync('vectors/fragment/sizes.bin', docWith(f => { f.insert(0, [ el('paragraph', null, text('Body'), text(' eight', { size_8: {} }), text(' fourteen', { size_14: {} }), text(' seventy-two', { size_72: {} })), el('paragraph', null, text('Small', { size_sm: {} }), text(' Large', { size_lg: {} }), text(' Extra', { size_xl: {} })), el('paragraph', null, text('Both at once', { size_lg: {}, size_24: {} })), ]) })) // --- trap-containment vectors --- // // core/tests/wasm_trap_containment.rs mutation-fuzzes real fixtures and // checks a control document's content survives untouched, reading it // back through the wasm ABI's docs_core_text_len/docs_core_text_read. // Those now read fragment_text (root "prosemirror"), not text_content // (root "content") — see core/src/abi.rs's text_string doc comment — // so that test needs fragment-shaped fixtures of its own rather than // fixtures/data's Y.Text ones (which read back as "" through that ABI // now). These four mirror fixtures/data's hello/unicode/formatted/ // concurrent in content and purpose (plain ASCII, surrogate pairs and // combining marks, inline formatting, a concurrent merge) so the same // coverage that fuzzing wants — plain text, non-ASCII byte lengths, // format-op decoding, CRDT merge structure — survives the move, just // wrapped in a single top-level each instead of a bare // Y.Text. // // plainTextOf mirrors core/src/fragment.rs's fragment_text: walk the // tree, concatenate each Y.XmlText's own rendering, and join top-level // block texts with a blank line. // // Uses each Y.XmlText's own toString(), not toDelta() — verified // against the real Rust output, not assumed: fragment_text calls // yrs's XmlTextRef::get_string(txn), and yrs's own get_string embeds // formatting as inline pseudo-tags exactly the way Y.XmlText.toString() // does in JS ("plain bold plain", not "plain bold // plain") — this is XmlText's own "plain text" convention in both // implementations, not the HTML fragment_html builds via its own MARKS // table. A first version of this generator used toDelta() (mark-free) // and it disagreed with the real Rust output on the very first run — // core/tests/wasm_trap_containment.rs caught it immediately, which is // the whole reason this fixture set exists. function plainTextOf(doc) { const f = doc.getXmlFragment('prosemirror') const isBlock = (name) => ['paragraph', 'heading', 'list_item', 'blockquote'].includes(name) const blocks = [] let cur = '' function walk(node) { if (node instanceof Y.XmlText) { cur += node.toString() } else if (node instanceof Y.XmlElement) { for (const child of node.toArray()) walk(child) if (isBlock(node.nodeName) && cur.length > 0) { blocks.push(cur) cur = '' } } } for (const child of f.toArray()) walk(child) if (cur.length > 0) blocks.push(cur) return blocks.join('\n\n') } // emitTrap writes both halves a fixture needs: the update bytes, and // (unlike docWith's three vectors above, which core/tests/fragment.rs // asserts against with hand-written literals) an expected.json next to // it, the same shape fixtures/generate.mjs writes for fixtures/data — // wasm_trap_containment.rs reads that "text" field the same way // wire_compat.rs's fixture() does. function emitTrap(name, build) { const doc = new Y.Doc() doc.clientID = 1 build(doc) const update = Y.encodeStateAsUpdate(doc) writeFileSync(`vectors/fragment/${name}.update.bin`, Buffer.from(update)) writeFileSync( `vectors/fragment/${name}.expected.json`, JSON.stringify({ text: plainTextOf(doc) }, null, 2) + '\n', ) console.log(`${name}: ${update.length} bytes`) } emitTrap('hello', (doc) => { doc.getXmlFragment('prosemirror').insert(0, [el('paragraph', null, text('hello world'))]) }) emitTrap('unicode', (doc) => { // Surrogate pairs and combining marks: Yjs indexes UTF-16 code units and // Rust indexes UTF-8 bytes, same reasoning as fixtures/generate.mjs's // own 'unicode' fixture. doc.getXmlFragment('prosemirror').insert(0, [el('paragraph', null, text('héllo 🌍 café'))]) }) emitTrap('formatted', (doc) => { // Inline formatting: exercises format-op decoding under mutation // fuzzing, same reasoning as fixtures/generate.mjs's own 'formatted' // fixture. plainTextOf strips marks via toDelta, so the expected text // is just the concatenated runs regardless of the strong attribute. doc.getXmlFragment('prosemirror').insert(0, [ el('paragraph', null, text('plain '), text('bold', { strong: {} }), text(' plain')), ]) }) emitTrap('concurrent', (doc) => { // Two docs each insert their own top-level paragraph before syncing, // then merge — order-independent CRDT merge structure, same // reasoning as fixtures/generate.mjs's own 'concurrent' fixture, at // the block level rather than mid-run since Y.XmlFragment's children // are blocks, not characters. const a = new Y.Doc() a.clientID = 1 const b = new Y.Doc() b.clientID = 2 // doc.clientID (set to 3 here) overrides the emitTrap assignment // above, same as generate.mjs's own concurrent fixture. doc.clientID = 3 a.getXmlFragment('prosemirror').insert(0, [el('paragraph', null, text('AAA'))]) b.getXmlFragment('prosemirror').insert(0, [el('paragraph', null, text('BBB'))]) Y.applyUpdate(a, Y.encodeStateAsUpdate(b)) Y.applyUpdate(b, Y.encodeStateAsUpdate(a)) Y.applyUpdate(doc, Y.encodeStateAsUpdate(a)) }) console.log('wrote 6 fragment vectors + 4 trap-containment vectors')