Skip to content

Commit 2ccda18

Browse files
authored
test(files): collaborative agent-streaming coverage (two-writer, multi-editor, undo, persist round-trip) (#6199)
* test(files): two-writer concurrent-editing regression coverage for agent streaming * test(files): multi-editor + undo + persist round-trip + late-joiner streaming integration coverage * test(files): make full-rewrite two-writer test actually exercise the concurrent peer edit * test(files): assert every peer edit lands (no false-green from a no-op peerInsertNear)
1 parent 5460133 commit 2ccda18

2 files changed

Lines changed: 414 additions & 0 deletions

File tree

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
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

Comments
 (0)