| 1 | // fixtures/poison.mjs — documents no op can produce: the malformed |
| 2 | // shapes a peer could put into the CRDT by hand (box.x = "a", |
| 3 | // background.kind = 5, fonts.body = [], an asset that is a string), |
| 4 | // built directly with yjs. view.test.mjs runs the view over them and |
| 5 | // core/src/view.rs's tests build the same shapes with yrs; run as a |
| 6 | // script this writes each one as a Yjs v1 update into |
| 7 | // internal/slidestest/testdata/poison/, which |
| 8 | // TestPoisonUpdateDoesNotBrickTheDeck posts at the real server. |
| 9 | // |
| 10 | // Every document has a fixed guid and client id, so a re-run writes the |
| 11 | // same bytes and the committed files only move when this file does. |
| 12 | // Each update adds a slide of its own (or touches meta/assets), so it |
| 13 | // applies cleanly to any deck. |
| 14 | import * as Y from 'yjs' |
| 15 | import { mkdirSync, writeFileSync } from 'node:fs' |
| 16 | import { join, dirname } from 'node:path' |
| 17 | import { fileURLToPath } from 'node:url' |
| 18 | |
| 19 | const here = dirname(fileURLToPath(import.meta.url)) |
| 20 | export const poisonDir = join(here, '..', 'internal', 'slidestest', 'testdata', 'poison') |
| 21 | |
| 22 | function slideWith (doc, id, fill) { |
| 23 | const slide = new Y.Map() |
| 24 | doc.getArray('slides').push([slide]) |
| 25 | slide.set('id', id) |
| 26 | slide.set('layout', 'canvas') |
| 27 | slide.set('notes', new Y.XmlFragment()) |
| 28 | const blocks = new Y.Array() |
| 29 | slide.set('blocks', blocks) |
| 30 | fill(slide, blocks) |
| 31 | } |
| 32 | |
| 33 | function blockWith (blocks, id, fill) { |
| 34 | const block = new Y.Map() |
| 35 | blocks.push([block]) |
| 36 | block.set('id', id) |
| 37 | block.set('type', 'text') |
| 38 | block.set('props', new Y.Map()) |
| 39 | block.set('phone', new Y.Map()) |
| 40 | block.set('z', 0) |
| 41 | block.set('text', new Y.XmlFragment()) |
| 42 | fill(block) |
| 43 | } |
| 44 | |
| 45 | /** The poison shapes, name → a fill function over a fresh Y.Doc. */ |
| 46 | export const shapes = { |
| 47 | // A canvas block whose box is not four numbers. |
| 48 | box: doc => slideWith(doc, 'poisonboxsld', (slide, blocks) => { |
| 49 | blockWith(blocks, 'poisonboxblk', block => block.set('box', { x: 'a', y: 0, w: 1, h: 1 })) |
| 50 | }), |
| 51 | // A slide whose background kind is a number. |
| 52 | background: doc => slideWith(doc, 'poisonbgsld1', slide => { |
| 53 | const bg = new Y.Map() |
| 54 | slide.set('background', bg) |
| 55 | bg.set('kind', 5) |
| 56 | bg.set('value', '') |
| 57 | }), |
| 58 | // meta.fonts with a body that is an array. |
| 59 | fonts: doc => { doc.getMap('meta').set('fonts', { heading: 'Serif', body: [] }) }, |
| 60 | // An assets entry that is a string, one with no ref, one with a |
| 61 | // mistyped name. |
| 62 | assets: doc => { |
| 63 | const assets = doc.getMap('assets') |
| 64 | assets.set('poisonstr', 'not a map') |
| 65 | assets.set('poisonnoref', { name: 'x.png' }) |
| 66 | assets.set('poisonbadnm', { ref: 'poisonref', name: 3, width: 'w', height: 9.9, alt: null }) |
| 67 | }, |
| 68 | // A phone order the view passes (it is a number) and the server's |
| 69 | // typed reader cannot hold: what the parse gate in appendUpdate |
| 70 | // exists for, over and above the view's own tolerance. |
| 71 | magnitude: doc => slideWith(doc, 'poisonmagsld', (slide, blocks) => { |
| 72 | blockWith(blocks, 'poisonmagblk', block => { |
| 73 | block.set('region', 'body') |
| 74 | block.get('phone').set('order', 1e300) |
| 75 | }) |
| 76 | }), |
| 77 | // A run under a formatting attribute the schema does not define |
| 78 | // (codex 3). The view reads the words fine, so the typed parse |
| 79 | // passes; y-prosemirror 1.3.7 deletes the XmlText when it binds. The |
| 80 | // gate refuses it by checking the shared text, not the projection |
| 81 | // (core/src/check.rs). Last in this list, so the client ids of the |
| 82 | // shapes before it — and their committed bytes — do not move. |
| 83 | unknownmark: doc => slideWith(doc, 'poisonmrksld', (slide, blocks) => { |
| 84 | blockWith(blocks, 'poisonmrkblk', block => { |
| 85 | block.set('region', 'body') |
| 86 | const p = new Y.XmlElement('paragraph') |
| 87 | const t = new Y.XmlText() |
| 88 | p.insert(0, [t]) |
| 89 | block.get('text').insert(0, [p]) |
| 90 | t.applyDelta([{ insert: 'keep ' }, { insert: 'these words', attributes: { unknown_mark: true } }]) |
| 91 | }) |
| 92 | }) |
| 93 | } |
| 94 | |
| 95 | /** A fresh document holding one poison shape. */ |
| 96 | export function poisonDoc (name) { |
| 97 | const fill = shapes[name] |
| 98 | if (!fill) throw new Error(`no poison shape ${name}`) |
| 99 | const doc = new Y.Doc({ guid: `poison-${name}` }) |
| 100 | doc.clientID = 900 + Object.keys(shapes).indexOf(name) |
| 101 | doc.transact(() => fill(doc)) |
| 102 | return doc |
| 103 | } |
| 104 | |
| 105 | /** Every shape as a Yjs v1 update, name → bytes. */ |
| 106 | export function poisonUpdates () { |
| 107 | const out = {} |
| 108 | for (const name of Object.keys(shapes)) out[name] = Y.encodeStateAsUpdate(poisonDoc(name)) |
| 109 | return out |
| 110 | } |
| 111 | |
| 112 | export function writePoison () { |
| 113 | mkdirSync(poisonDir, { recursive: true }) |
| 114 | for (const [name, bytes] of Object.entries(poisonUpdates())) { |
| 115 | writeFileSync(join(poisonDir, `${name}.yjs`), Buffer.from(bytes)) |
| 116 | console.log(`${name}: ${bytes.length} bytes`) |
| 117 | } |
| 118 | } |
| 119 | |
| 120 | if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) writePoison() |
| 121 | |