|
| 1 | +/** |
| 2 | + * @vitest-environment jsdom |
| 3 | + * |
| 4 | + * Two-writer evaluation: does a PEER editing the shared doc WHILE the agent streams cause corruption, |
| 5 | + * clobbering, duplication, or stray empty paragraphs? The agent applies via the real |
| 6 | + * `beginAgentStream`/`applyAgentStreamFrame` path (a shadow doc diffed with `updateYFragment`, seeded |
| 7 | + * once and never shown the peer's edits). A second editor is wired as a genuine Yjs peer (bidirectional |
| 8 | + * update forwarding), so this reproduces the production two-client scenario, not a mock. |
| 9 | + * |
| 10 | + * Convergence is a hard invariant everywhere (CRDT MUST converge). Peer-edit survival is hard-asserted |
| 11 | + * only for the NON-overlapping case (an agent that appends must not clobber an unrelated peer edit); for |
| 12 | + * the overlapping case it is diagnostic (CRDT last-writer semantics are acceptable there), so those are |
| 13 | + * logged for judgement. Run: bunx vitest run <thisfile> --disable-console-intercept |
| 14 | + */ |
| 15 | +import { Editor } from '@tiptap/core' |
| 16 | +import { afterEach, beforeAll, describe, expect, it } from 'vitest' |
| 17 | +import { Awareness } from 'y-protocols/awareness' |
| 18 | +import * as Y from 'yjs' |
| 19 | +import { createMarkdownEditorExtensions } from '../editor-extensions' |
| 20 | +import { parseMarkdownToDoc } from '../markdown-parse' |
| 21 | +import { applyAgentStreamFrame, beginAgentStream, endAgentStream } from './apply-streamed-markdown' |
| 22 | + |
| 23 | +beforeAll(() => { |
| 24 | + if (!document.elementFromPoint) document.elementFromPoint = () => null |
| 25 | +}) |
| 26 | + |
| 27 | +function makeCollabEditor() { |
| 28 | + const doc = new Y.Doc() |
| 29 | + const awareness = new Awareness(doc) |
| 30 | + const editor = new Editor({ |
| 31 | + extensions: createMarkdownEditorExtensions({ |
| 32 | + placeholder: '', |
| 33 | + collaboration: { doc, awareness, user: { name: 'U', color: '#fff', clientId: doc.clientID } }, |
| 34 | + }), |
| 35 | + content: '', |
| 36 | + }) |
| 37 | + return { editor, doc, awareness } |
| 38 | +} |
| 39 | + |
| 40 | +const teardown: Array<() => void> = [] |
| 41 | +afterEach(() => { |
| 42 | + for (const fn of teardown.splice(0)) fn() |
| 43 | +}) |
| 44 | +function track(t: { editor: Editor; doc: Y.Doc; awareness: Awareness }) { |
| 45 | + teardown.push(() => { |
| 46 | + t.editor.destroy() |
| 47 | + t.awareness.destroy() |
| 48 | + t.doc.destroy() |
| 49 | + }) |
| 50 | + return t |
| 51 | +} |
| 52 | + |
| 53 | +/** Wire two Y.Docs as real peers: forward each update to the other, origin-guarded to avoid echo. */ |
| 54 | +function wirePeers(a: Y.Doc, b: Y.Doc) { |
| 55 | + const A2B = Symbol('a->b') |
| 56 | + const B2A = Symbol('b->a') |
| 57 | + a.on('update', (u: Uint8Array, origin: unknown) => { |
| 58 | + if (origin !== B2A) Y.applyUpdate(b, u, A2B) |
| 59 | + }) |
| 60 | + b.on('update', (u: Uint8Array, origin: unknown) => { |
| 61 | + if (origin !== A2B) Y.applyUpdate(a, u, B2A) |
| 62 | + }) |
| 63 | +} |
| 64 | + |
| 65 | +/** Seed editor A with markdown (through the real parse), then bring up B as a synced peer. */ |
| 66 | +function seededPair(markdown: string) { |
| 67 | + const A = track(makeCollabEditor()) |
| 68 | + A.editor.commands.setContent(parseMarkdownToDoc(markdown), { contentType: 'json' }) |
| 69 | + const B = track(makeCollabEditor()) |
| 70 | + Y.applyUpdate(B.doc, Y.encodeStateAsUpdate(A.doc)) |
| 71 | + wirePeers(A.doc, B.doc) |
| 72 | + return { A, B } |
| 73 | +} |
| 74 | + |
| 75 | +/** A peer edit: insert `text` at the start of the first text node containing `needle`. */ |
| 76 | +function peerInsertNear(editor: Editor, needle: string, text: string): boolean { |
| 77 | + let pos: number | null = null |
| 78 | + editor.state.doc.descendants((node, p) => { |
| 79 | + if (pos !== null) return false |
| 80 | + if (node.isText && node.text?.includes(needle)) pos = p + node.text.indexOf(needle) |
| 81 | + }) |
| 82 | + if (pos === null) return false |
| 83 | + return editor.commands.insertContentAt(pos, text) |
| 84 | +} |
| 85 | + |
| 86 | +function fragStr(doc: Y.Doc): string { |
| 87 | + return doc.getXmlFragment('default').toString() |
| 88 | +} |
| 89 | +function count(hay: string, needle: string): number { |
| 90 | + return hay.split(needle).length - 1 |
| 91 | +} |
| 92 | +function emptyParas(editor: Editor): number { |
| 93 | + let n = 0 |
| 94 | + editor.state.doc.descendants((node) => { |
| 95 | + if (node.type.name === 'paragraph' && node.childCount === 0) n++ |
| 96 | + }) |
| 97 | + return n |
| 98 | +} |
| 99 | + |
| 100 | +describe('two-writer: peer edits while the agent streams', () => { |
| 101 | + it('SANITY: peers converge on seed and a plain peer edit with no agent activity', () => { |
| 102 | + const { A, B } = seededPair('# Title\n\nAlpha\n\nBeta') |
| 103 | + expect(peerInsertNear(B.editor, 'Alpha', 'PEER ')).toBe(true) |
| 104 | + expect(fragStr(A.doc)).toBe(fragStr(B.doc)) |
| 105 | + expect(A.editor.state.doc.textContent).toContain('PEER Alpha') |
| 106 | + }) |
| 107 | + |
| 108 | + it('NON-OVERLAPPING: agent appends at the bottom while the peer edits the top — peer edit MUST survive', () => { |
| 109 | + const { A, B } = seededPair('# Title\n\nAlpha\n\nBeta') |
| 110 | + const session = beginAgentStream(A.editor)! |
| 111 | + |
| 112 | + // Frame 1: agent appends Gamma (region far from the peer's target). |
| 113 | + applyAgentStreamFrame(A.editor, session, '# Title\n\nAlpha\n\nBeta\n\nGamma') |
| 114 | + // Peer edits the TOP paragraph mid-stream (the agent never touches or knows about this). |
| 115 | + expect(peerInsertNear(B.editor, 'Alpha', 'PEER ')).toBe(true) |
| 116 | + // Frames 2-3: agent keeps appending. Its bodies say "Alpha" (no PEER) — the test is whether the |
| 117 | + // (aggressive) updateYFragment re-emits/clobbers the unchanged Alpha paragraph. |
| 118 | + applyAgentStreamFrame(A.editor, session, '# Title\n\nAlpha\n\nBeta\n\nGamma\n\nDelta') |
| 119 | + applyAgentStreamFrame( |
| 120 | + A.editor, |
| 121 | + session, |
| 122 | + '# Title\n\nAlpha\n\nBeta\n\nGamma\n\nDelta\n\nEpsilon' |
| 123 | + ) |
| 124 | + endAgentStream(session) |
| 125 | + |
| 126 | + const textA = A.editor.state.doc.textContent |
| 127 | + console.log(`\n[NON-OVERLAP] A: ${JSON.stringify(textA)}`) |
| 128 | + console.log( |
| 129 | + `[NON-OVERLAP] converged=${fragStr(A.doc) === fragStr(B.doc)} peerCount=${count(textA, 'PEER ')} emptyParas=${emptyParas(A.editor)}` |
| 130 | + ) |
| 131 | + |
| 132 | + expect(fragStr(A.doc)).toBe(fragStr(B.doc)) // CRDT convergence |
| 133 | + expect(count(textA, 'PEER ')).toBe(1) // peer edit survives, exactly once (no clobber, no dup) |
| 134 | + expect(textA).toContain('Epsilon') // agent's stream landed |
| 135 | + expect(textA).toContain('Beta') // untouched content intact |
| 136 | + expect(emptyParas(A.editor)).toBe(0) // no stray empties from the merge |
| 137 | + }) |
| 138 | + |
| 139 | + it('POSITION DRIFT: agent inserts a paragraph ABOVE while the peer edits the paragraph BELOW', () => { |
| 140 | + // The exact scenario relative-position anchoring is meant to protect: the agent shifts positions by |
| 141 | + // inserting content above the region the peer is editing. Without anchoring, an offset-based writer |
| 142 | + // would misplace the edit; a whole-doc CRDT diff should not. |
| 143 | + const { A, B } = seededPair('# Title\n\nAlpha\n\nBeta') |
| 144 | + const session = beginAgentStream(A.editor)! |
| 145 | + |
| 146 | + applyAgentStreamFrame(A.editor, session, '# Title\n\nAlpha\n\nMIDDLE\n\nBeta') |
| 147 | + // Peer edits Beta, which just shifted down by the agent's inserted MIDDLE paragraph. |
| 148 | + expect(peerInsertNear(B.editor, 'Beta', 'PEER ')).toBe(true) |
| 149 | + applyAgentStreamFrame(A.editor, session, '# Title\n\nAlpha\n\nMIDDLE\n\nMIDDLE2\n\nBeta') |
| 150 | + endAgentStream(session) |
| 151 | + |
| 152 | + const textA = A.editor.state.doc.textContent |
| 153 | + console.log(`\n[POS-DRIFT] A: ${JSON.stringify(textA)}`) |
| 154 | + console.log( |
| 155 | + `[POS-DRIFT] converged=${fragStr(A.doc) === fragStr(B.doc)} peerCount=${count(textA, 'PEER ')} peerOnBeta=${textA.includes('PEER Beta')} emptyParas=${emptyParas(A.editor)}` |
| 156 | + ) |
| 157 | + |
| 158 | + expect(fragStr(A.doc)).toBe(fragStr(B.doc)) // convergence |
| 159 | + expect(count(textA, 'PEER ')).toBe(1) // no duplication |
| 160 | + expect(textA).toContain('PEER Beta') // peer edit stayed attached to Beta despite the insert above |
| 161 | + expect(textA).toContain('MIDDLE2') // agent's inserts landed |
| 162 | + expect(emptyParas(A.editor)).toBe(0) |
| 163 | + }) |
| 164 | + |
| 165 | + it('OVERLAPPING: agent rewrites the exact paragraph the peer is editing (diagnostic + must converge)', () => { |
| 166 | + const { A, B } = seededPair('# Title\n\noriginal body text') |
| 167 | + const session = beginAgentStream(A.editor)! |
| 168 | + |
| 169 | + applyAgentStreamFrame(A.editor, session, '# Title\n\noriginal body text extended') |
| 170 | + // Peer edits the SAME paragraph the agent is rewriting. |
| 171 | + expect(peerInsertNear(B.editor, 'original', 'PEER ')).toBe(true) |
| 172 | + applyAgentStreamFrame(A.editor, session, '# Title\n\nagent fully rewrote this paragraph') |
| 173 | + endAgentStream(session) |
| 174 | + |
| 175 | + const textA = A.editor.state.doc.textContent |
| 176 | + console.log(`\n[OVERLAP] A: ${JSON.stringify(textA)}`) |
| 177 | + console.log( |
| 178 | + `[OVERLAP] converged=${fragStr(A.doc) === fragStr(B.doc)} peerSurvived=${textA.includes('PEER')} emptyParas=${emptyParas(A.editor)}` |
| 179 | + ) |
| 180 | + |
| 181 | + expect(fragStr(A.doc)).toBe(fragStr(B.doc)) // convergence is non-negotiable even in conflict |
| 182 | + expect(emptyParas(A.editor)).toBe(0) // conflict must not leave stray empty paragraphs |
| 183 | + // peer survival here is CRDT-dependent — reported above, not hard-asserted. |
| 184 | + }) |
| 185 | + |
| 186 | + it('FULL REWRITE: peer edits original content that the agent then deletes in a full rewrite', () => { |
| 187 | + const { A, B } = seededPair('# Title\n\nAlpha\n\nBeta\n\nGamma') |
| 188 | + const session = beginAgentStream(A.editor)! |
| 189 | + |
| 190 | + // Peer edits Beta WHILE it still exists — genuinely concurrent with the impending rewrite. |
| 191 | + // (Asserting the insert landed guards against a false-green where the target was already gone.) |
| 192 | + expect(peerInsertNear(B.editor, 'Beta', 'PEER ')).toBe(true) |
| 193 | + // Agent replaces the WHOLE doc across two frames, deleting Alpha/Beta/Gamma. |
| 194 | + applyAgentStreamFrame(A.editor, session, '# Report\n\nOne\n\nTwo') |
| 195 | + applyAgentStreamFrame(A.editor, session, '# Report\n\nOne\n\nTwo\n\nThree') |
| 196 | + endAgentStream(session) |
| 197 | + |
| 198 | + const textA = A.editor.state.doc.textContent |
| 199 | + console.log(`\n[FULL-REWRITE] A: ${JSON.stringify(textA)}`) |
| 200 | + console.log( |
| 201 | + `[FULL-REWRITE] converged=${fragStr(A.doc) === fragStr(B.doc)} peerCount=${count(textA, 'PEER ')} oneCount=${count(textA, 'One')} threeCount=${count(textA, 'Three')} emptyParas=${emptyParas(A.editor)}` |
| 202 | + ) |
| 203 | + |
| 204 | + expect(fragStr(A.doc)).toBe(fragStr(B.doc)) // convergence |
| 205 | + expect(count(textA, 'One')).toBe(1) // agent content not duplicated by the concurrent merge |
| 206 | + expect(count(textA, 'Three')).toBe(1) |
| 207 | + expect(emptyParas(A.editor)).toBe(0) // no stray empties from a delete/insert conflict |
| 208 | + // The peer's insert is NOT lost when the rewrite deletes its surrounding paragraph: Yjs preserves |
| 209 | + // the inserted text and reattaches it to the nearest surviving anchor (it relocates into the |
| 210 | + // rewritten content rather than vanishing). What matters is that it survives exactly once — never |
| 211 | + // duplicated, never silently dropped. |
| 212 | + expect(count(textA, 'PEER ')).toBe(1) |
| 213 | + }) |
| 214 | +}) |
0 commit comments