Skip to content

Commit 625efbd

Browse files
committed
test(files): multi-editor + undo + persist round-trip + late-joiner streaming integration coverage
1 parent 5c2d9b7 commit 625efbd

1 file changed

Lines changed: 200 additions & 0 deletions

File tree

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*
4+
* Integration coverage for the collaborative agent-streaming surface with all the moving pieces:
5+
* multiple peers, undo isolation, the durable persist→reopen round-trip, empty-collapse on the live
6+
* streaming path, and a late joiner. Editors are wired as genuine Yjs peers (mesh update forwarding).
7+
* This exercises the CRDT/merge/convert LOGIC deterministically; it does NOT cover the realtime socket
8+
* transport, RAF-paced stream loop, or real browser timing (those need a live 2-browser E2E harness).
9+
* Run: bunx vitest run <thisfile> --disable-console-intercept
10+
*/
11+
import { Editor } from '@tiptap/core'
12+
import { afterEach, beforeAll, describe, expect, it } from 'vitest'
13+
import { Awareness } from 'y-protocols/awareness'
14+
import * as Y from 'yjs'
15+
import { markdownToYDoc, yDocToMarkdown } from '@/lib/collab-doc/converter'
16+
import { createMarkdownEditorExtensions } from '../editor-extensions'
17+
import { parseMarkdownToDoc } from '../markdown-parse'
18+
import { applyAgentStreamFrame, beginAgentStream, endAgentStream } from './apply-streamed-markdown'
19+
20+
beforeAll(() => {
21+
if (!document.elementFromPoint) document.elementFromPoint = () => null
22+
})
23+
24+
const teardown: Array<() => void> = []
25+
afterEach(() => {
26+
for (const fn of teardown.splice(0)) fn()
27+
})
28+
29+
function makeCollabEditor() {
30+
const doc = new Y.Doc()
31+
const awareness = new Awareness(doc)
32+
const editor = new Editor({
33+
extensions: createMarkdownEditorExtensions({
34+
placeholder: '',
35+
collaboration: { doc, awareness, user: { name: 'U', color: '#fff', clientId: doc.clientID } },
36+
}),
37+
content: '',
38+
})
39+
const t = { editor, doc, awareness }
40+
teardown.push(() => {
41+
editor.destroy()
42+
awareness.destroy()
43+
doc.destroy()
44+
})
45+
return t
46+
}
47+
48+
/** Forward every local/agent update from each doc to all others (origin-guarded), a full mesh. */
49+
function wireMesh(docs: Y.Doc[]) {
50+
const MESH = Symbol('mesh')
51+
for (const d of docs) {
52+
d.on('update', (u: Uint8Array, origin: unknown) => {
53+
if (origin === MESH) return
54+
for (const other of docs) if (other !== d) Y.applyUpdate(other, u, MESH)
55+
})
56+
}
57+
}
58+
59+
function peerInsertNear(editor: Editor, needle: string, text: string): boolean {
60+
let pos: number | null = null
61+
editor.state.doc.descendants((node, p) => {
62+
if (pos !== null) return false
63+
if (node.isText && node.text?.includes(needle)) pos = p + node.text.indexOf(needle)
64+
})
65+
if (pos === null) return false
66+
return editor.commands.insertContentAt(pos, text)
67+
}
68+
69+
const fragStr = (doc: Y.Doc) => doc.getXmlFragment('default').toString()
70+
const countText = (hay: string, needle: string) => hay.split(needle).length - 1
71+
function emptyParas(editor: Editor): number {
72+
let n = 0
73+
editor.state.doc.descendants((node) => {
74+
if (node.type.name === 'paragraph' && node.childCount === 0) n++
75+
})
76+
return n
77+
}
78+
79+
describe('collab streaming integration — moving pieces', () => {
80+
it('THREE-WAY: agent + two peers editing different regions all converge, both peer edits survive', () => {
81+
const A = makeCollabEditor()
82+
A.editor.commands.setContent(parseMarkdownToDoc('# Title\n\nAlpha\n\nBeta\n\nGamma'), {
83+
contentType: 'json',
84+
})
85+
const B = makeCollabEditor()
86+
const C = makeCollabEditor()
87+
Y.applyUpdate(B.doc, Y.encodeStateAsUpdate(A.doc))
88+
Y.applyUpdate(C.doc, Y.encodeStateAsUpdate(A.doc))
89+
wireMesh([A.doc, B.doc, C.doc])
90+
91+
const session = beginAgentStream(A.editor)!
92+
applyAgentStreamFrame(A.editor, session, '# Title\n\nAlpha\n\nBeta\n\nGamma\n\nDelta')
93+
peerInsertNear(B.editor, 'Alpha', 'B_EDIT ') // peer B edits the top
94+
peerInsertNear(C.editor, 'Gamma', 'C_EDIT ') // peer C edits the bottom
95+
applyAgentStreamFrame(
96+
A.editor,
97+
session,
98+
'# Title\n\nAlpha\n\nBeta\n\nGamma\n\nDelta\n\nEpsilon'
99+
)
100+
endAgentStream(session)
101+
102+
const textA = A.editor.state.doc.textContent
103+
console.log(`\n[3-WAY] A: ${JSON.stringify(textA)}`)
104+
console.log(
105+
`[3-WAY] converged=${fragStr(A.doc) === fragStr(B.doc) && fragStr(B.doc) === fragStr(C.doc)} B_EDIT=${countText(textA, 'B_EDIT ')} C_EDIT=${countText(textA, 'C_EDIT ')} empty=${emptyParas(A.editor)}`
106+
)
107+
108+
expect(fragStr(A.doc)).toBe(fragStr(B.doc))
109+
expect(fragStr(B.doc)).toBe(fragStr(C.doc))
110+
expect(countText(textA, 'B_EDIT ')).toBe(1)
111+
expect(countText(textA, 'C_EDIT ')).toBe(1)
112+
expect(textA).toContain('Epsilon')
113+
expect(emptyParas(A.editor)).toBe(0)
114+
})
115+
116+
it('UNDO ISOLATION: a peer undo reverts only the peer’s own edit, never the agent’s stream', () => {
117+
const A = makeCollabEditor()
118+
A.editor.commands.setContent(parseMarkdownToDoc('# Title\n\nbase'), { contentType: 'json' })
119+
const B = makeCollabEditor()
120+
Y.applyUpdate(B.doc, Y.encodeStateAsUpdate(A.doc))
121+
wireMesh([A.doc, B.doc])
122+
123+
peerInsertNear(B.editor, 'base', 'PEER_UNDOABLE ') // peer's own edit (goes on peer's undo stack)
124+
const session = beginAgentStream(A.editor)!
125+
applyAgentStreamFrame(
126+
A.editor,
127+
session,
128+
'# Title\n\nPEER_UNDOABLE base\n\nagent added this line'
129+
)
130+
endAgentStream(session)
131+
132+
const undid = B.editor.commands.undo()
133+
const textB = B.editor.state.doc.textContent
134+
console.log(`\n[UNDO] undoRan=${undid} afterUndo=${JSON.stringify(textB)}`)
135+
136+
expect(fragStr(A.doc)).toBe(fragStr(B.doc)) // still converged after undo
137+
expect(textB).toContain('agent added this line') // agent content NOT undone by the peer
138+
expect(textB).not.toContain('PEER_UNDOABLE') // peer's own edit was undone
139+
})
140+
141+
it('PERSIST ROUND-TRIP: stream → serialize to durable markdown → reopen yields the same content, no empties', () => {
142+
const A = makeCollabEditor()
143+
const session = beginAgentStream(A.editor)!
144+
applyAgentStreamFrame(A.editor, session, '# Report\n\n## Section 1\n\nbody one')
145+
applyAgentStreamFrame(
146+
A.editor,
147+
session,
148+
'# Report\n\n## Section 1\n\nbody one\n\n## Section 2\n\nbody two'
149+
)
150+
endAgentStream(session)
151+
152+
const durable = yDocToMarkdown(A.doc) // server-side projection to durable markdown
153+
const reopened = markdownToYDoc(durable) // cold reopen from durable
154+
const reopenedMd = yDocToMarkdown(reopened)
155+
const blankRuns = (durable.match(/\n{3,}/g) ?? []).length
156+
console.log(`\n[ROUND-TRIP] durable=${JSON.stringify(durable)}`)
157+
console.log(`[ROUND-TRIP] reopenStable=${reopenedMd === durable} blankRuns=${blankRuns}`)
158+
159+
expect(durable).toContain('Section 1')
160+
expect(durable).toContain('Section 2')
161+
expect(durable).toContain('body two')
162+
expect(blankRuns).toBe(0) // no pathological blank runs in the persisted markdown
163+
expect(reopenedMd).toBe(durable) // reopen is a fixed point (stable)
164+
reopened.destroy()
165+
})
166+
167+
it('EMPTY-COLLAPSE ON THE STREAM PATH: an agent body with a huge blank run does not strand empties', () => {
168+
const A = makeCollabEditor()
169+
A.editor.commands.setContent(parseMarkdownToDoc('# Title\n\nintro'), { contentType: 'json' })
170+
const session = beginAgentStream(A.editor)!
171+
// The agent emits a pathological blank run between two blocks (the original incident's shape).
172+
applyAgentStreamFrame(A.editor, session, `# Title\n\nintro${'\n'.repeat(400)}tail paragraph`)
173+
endAgentStream(session)
174+
175+
console.log(
176+
`\n[STREAM-COLLAPSE] text=${JSON.stringify(A.editor.state.doc.textContent)} empty=${emptyParas(A.editor)}`
177+
)
178+
expect(emptyParas(A.editor)).toBe(0) // collapse protects the live streaming path, not just static open
179+
expect(A.editor.state.doc.textContent).toContain('tail paragraph')
180+
})
181+
182+
it('LATE JOINER: a peer that syncs AFTER the stream sees the full, clean document', () => {
183+
const A = makeCollabEditor()
184+
A.editor.commands.setContent(parseMarkdownToDoc('# Doc\n\nstart'), { contentType: 'json' })
185+
const session = beginAgentStream(A.editor)!
186+
applyAgentStreamFrame(A.editor, session, '# Doc\n\nstart\n\nstreamed body')
187+
endAgentStream(session)
188+
189+
// A brand-new client joins now and syncs from the current state.
190+
const D = makeCollabEditor()
191+
Y.applyUpdate(D.doc, Y.encodeStateAsUpdate(A.doc))
192+
193+
console.log(
194+
`\n[LATE-JOIN] D: ${JSON.stringify(D.editor.state.doc.textContent)} converged=${fragStr(A.doc) === fragStr(D.doc)}`
195+
)
196+
expect(fragStr(A.doc)).toBe(fragStr(D.doc))
197+
expect(D.editor.state.doc.textContent).toContain('streamed body')
198+
expect(emptyParas(D.editor)).toBe(0)
199+
})
200+
})

0 commit comments

Comments
 (0)