mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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)
This commit is contained in:
+214
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*
|
||||
* Two-writer evaluation: does a PEER editing the shared doc WHILE the agent streams cause corruption,
|
||||
* clobbering, duplication, or stray empty paragraphs? The agent applies via the real
|
||||
* `beginAgentStream`/`applyAgentStreamFrame` path (a shadow doc diffed with `updateYFragment`, seeded
|
||||
* once and never shown the peer's edits). A second editor is wired as a genuine Yjs peer (bidirectional
|
||||
* update forwarding), so this reproduces the production two-client scenario, not a mock.
|
||||
*
|
||||
* Convergence is a hard invariant everywhere (CRDT MUST converge). Peer-edit survival is hard-asserted
|
||||
* only for the NON-overlapping case (an agent that appends must not clobber an unrelated peer edit); for
|
||||
* the overlapping case it is diagnostic (CRDT last-writer semantics are acceptable there), so those are
|
||||
* logged for judgement. Run: bunx vitest run <thisfile> --disable-console-intercept
|
||||
*/
|
||||
import { Editor } from '@tiptap/core'
|
||||
import { afterEach, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { Awareness } from 'y-protocols/awareness'
|
||||
import * as Y from 'yjs'
|
||||
import { createMarkdownEditorExtensions } from '../editor-extensions'
|
||||
import { parseMarkdownToDoc } from '../markdown-parse'
|
||||
import { applyAgentStreamFrame, beginAgentStream, endAgentStream } from './apply-streamed-markdown'
|
||||
|
||||
beforeAll(() => {
|
||||
if (!document.elementFromPoint) document.elementFromPoint = () => null
|
||||
})
|
||||
|
||||
function makeCollabEditor() {
|
||||
const doc = new Y.Doc()
|
||||
const awareness = new Awareness(doc)
|
||||
const editor = new Editor({
|
||||
extensions: createMarkdownEditorExtensions({
|
||||
placeholder: '',
|
||||
collaboration: { doc, awareness, user: { name: 'U', color: '#fff', clientId: doc.clientID } },
|
||||
}),
|
||||
content: '',
|
||||
})
|
||||
return { editor, doc, awareness }
|
||||
}
|
||||
|
||||
const teardown: Array<() => void> = []
|
||||
afterEach(() => {
|
||||
for (const fn of teardown.splice(0)) fn()
|
||||
})
|
||||
function track(t: { editor: Editor; doc: Y.Doc; awareness: Awareness }) {
|
||||
teardown.push(() => {
|
||||
t.editor.destroy()
|
||||
t.awareness.destroy()
|
||||
t.doc.destroy()
|
||||
})
|
||||
return t
|
||||
}
|
||||
|
||||
/** Wire two Y.Docs as real peers: forward each update to the other, origin-guarded to avoid echo. */
|
||||
function wirePeers(a: Y.Doc, b: Y.Doc) {
|
||||
const A2B = Symbol('a->b')
|
||||
const B2A = Symbol('b->a')
|
||||
a.on('update', (u: Uint8Array, origin: unknown) => {
|
||||
if (origin !== B2A) Y.applyUpdate(b, u, A2B)
|
||||
})
|
||||
b.on('update', (u: Uint8Array, origin: unknown) => {
|
||||
if (origin !== A2B) Y.applyUpdate(a, u, B2A)
|
||||
})
|
||||
}
|
||||
|
||||
/** Seed editor A with markdown (through the real parse), then bring up B as a synced peer. */
|
||||
function seededPair(markdown: string) {
|
||||
const A = track(makeCollabEditor())
|
||||
A.editor.commands.setContent(parseMarkdownToDoc(markdown), { contentType: 'json' })
|
||||
const B = track(makeCollabEditor())
|
||||
Y.applyUpdate(B.doc, Y.encodeStateAsUpdate(A.doc))
|
||||
wirePeers(A.doc, B.doc)
|
||||
return { A, B }
|
||||
}
|
||||
|
||||
/** A peer edit: insert `text` at the start of the first text node containing `needle`. */
|
||||
function peerInsertNear(editor: Editor, needle: string, text: string): boolean {
|
||||
let pos: number | null = null
|
||||
editor.state.doc.descendants((node, p) => {
|
||||
if (pos !== null) return false
|
||||
if (node.isText && node.text?.includes(needle)) pos = p + node.text.indexOf(needle)
|
||||
})
|
||||
if (pos === null) return false
|
||||
return editor.commands.insertContentAt(pos, text)
|
||||
}
|
||||
|
||||
function fragStr(doc: Y.Doc): string {
|
||||
return doc.getXmlFragment('default').toString()
|
||||
}
|
||||
function count(hay: string, needle: string): number {
|
||||
return hay.split(needle).length - 1
|
||||
}
|
||||
function emptyParas(editor: Editor): number {
|
||||
let n = 0
|
||||
editor.state.doc.descendants((node) => {
|
||||
if (node.type.name === 'paragraph' && node.childCount === 0) n++
|
||||
})
|
||||
return n
|
||||
}
|
||||
|
||||
describe('two-writer: peer edits while the agent streams', () => {
|
||||
it('SANITY: peers converge on seed and a plain peer edit with no agent activity', () => {
|
||||
const { A, B } = seededPair('# Title\n\nAlpha\n\nBeta')
|
||||
expect(peerInsertNear(B.editor, 'Alpha', 'PEER ')).toBe(true)
|
||||
expect(fragStr(A.doc)).toBe(fragStr(B.doc))
|
||||
expect(A.editor.state.doc.textContent).toContain('PEER Alpha')
|
||||
})
|
||||
|
||||
it('NON-OVERLAPPING: agent appends at the bottom while the peer edits the top — peer edit MUST survive', () => {
|
||||
const { A, B } = seededPair('# Title\n\nAlpha\n\nBeta')
|
||||
const session = beginAgentStream(A.editor)!
|
||||
|
||||
// Frame 1: agent appends Gamma (region far from the peer's target).
|
||||
applyAgentStreamFrame(A.editor, session, '# Title\n\nAlpha\n\nBeta\n\nGamma')
|
||||
// Peer edits the TOP paragraph mid-stream (the agent never touches or knows about this).
|
||||
expect(peerInsertNear(B.editor, 'Alpha', 'PEER ')).toBe(true)
|
||||
// Frames 2-3: agent keeps appending. Its bodies say "Alpha" (no PEER) — the test is whether the
|
||||
// (aggressive) updateYFragment re-emits/clobbers the unchanged Alpha paragraph.
|
||||
applyAgentStreamFrame(A.editor, session, '# Title\n\nAlpha\n\nBeta\n\nGamma\n\nDelta')
|
||||
applyAgentStreamFrame(
|
||||
A.editor,
|
||||
session,
|
||||
'# Title\n\nAlpha\n\nBeta\n\nGamma\n\nDelta\n\nEpsilon'
|
||||
)
|
||||
endAgentStream(session)
|
||||
|
||||
const textA = A.editor.state.doc.textContent
|
||||
console.log(`\n[NON-OVERLAP] A: ${JSON.stringify(textA)}`)
|
||||
console.log(
|
||||
`[NON-OVERLAP] converged=${fragStr(A.doc) === fragStr(B.doc)} peerCount=${count(textA, 'PEER ')} emptyParas=${emptyParas(A.editor)}`
|
||||
)
|
||||
|
||||
expect(fragStr(A.doc)).toBe(fragStr(B.doc)) // CRDT convergence
|
||||
expect(count(textA, 'PEER ')).toBe(1) // peer edit survives, exactly once (no clobber, no dup)
|
||||
expect(textA).toContain('Epsilon') // agent's stream landed
|
||||
expect(textA).toContain('Beta') // untouched content intact
|
||||
expect(emptyParas(A.editor)).toBe(0) // no stray empties from the merge
|
||||
})
|
||||
|
||||
it('POSITION DRIFT: agent inserts a paragraph ABOVE while the peer edits the paragraph BELOW', () => {
|
||||
// The exact scenario relative-position anchoring is meant to protect: the agent shifts positions by
|
||||
// inserting content above the region the peer is editing. Without anchoring, an offset-based writer
|
||||
// would misplace the edit; a whole-doc CRDT diff should not.
|
||||
const { A, B } = seededPair('# Title\n\nAlpha\n\nBeta')
|
||||
const session = beginAgentStream(A.editor)!
|
||||
|
||||
applyAgentStreamFrame(A.editor, session, '# Title\n\nAlpha\n\nMIDDLE\n\nBeta')
|
||||
// Peer edits Beta, which just shifted down by the agent's inserted MIDDLE paragraph.
|
||||
expect(peerInsertNear(B.editor, 'Beta', 'PEER ')).toBe(true)
|
||||
applyAgentStreamFrame(A.editor, session, '# Title\n\nAlpha\n\nMIDDLE\n\nMIDDLE2\n\nBeta')
|
||||
endAgentStream(session)
|
||||
|
||||
const textA = A.editor.state.doc.textContent
|
||||
console.log(`\n[POS-DRIFT] A: ${JSON.stringify(textA)}`)
|
||||
console.log(
|
||||
`[POS-DRIFT] converged=${fragStr(A.doc) === fragStr(B.doc)} peerCount=${count(textA, 'PEER ')} peerOnBeta=${textA.includes('PEER Beta')} emptyParas=${emptyParas(A.editor)}`
|
||||
)
|
||||
|
||||
expect(fragStr(A.doc)).toBe(fragStr(B.doc)) // convergence
|
||||
expect(count(textA, 'PEER ')).toBe(1) // no duplication
|
||||
expect(textA).toContain('PEER Beta') // peer edit stayed attached to Beta despite the insert above
|
||||
expect(textA).toContain('MIDDLE2') // agent's inserts landed
|
||||
expect(emptyParas(A.editor)).toBe(0)
|
||||
})
|
||||
|
||||
it('OVERLAPPING: agent rewrites the exact paragraph the peer is editing (diagnostic + must converge)', () => {
|
||||
const { A, B } = seededPair('# Title\n\noriginal body text')
|
||||
const session = beginAgentStream(A.editor)!
|
||||
|
||||
applyAgentStreamFrame(A.editor, session, '# Title\n\noriginal body text extended')
|
||||
// Peer edits the SAME paragraph the agent is rewriting.
|
||||
expect(peerInsertNear(B.editor, 'original', 'PEER ')).toBe(true)
|
||||
applyAgentStreamFrame(A.editor, session, '# Title\n\nagent fully rewrote this paragraph')
|
||||
endAgentStream(session)
|
||||
|
||||
const textA = A.editor.state.doc.textContent
|
||||
console.log(`\n[OVERLAP] A: ${JSON.stringify(textA)}`)
|
||||
console.log(
|
||||
`[OVERLAP] converged=${fragStr(A.doc) === fragStr(B.doc)} peerSurvived=${textA.includes('PEER')} emptyParas=${emptyParas(A.editor)}`
|
||||
)
|
||||
|
||||
expect(fragStr(A.doc)).toBe(fragStr(B.doc)) // convergence is non-negotiable even in conflict
|
||||
expect(emptyParas(A.editor)).toBe(0) // conflict must not leave stray empty paragraphs
|
||||
// peer survival here is CRDT-dependent — reported above, not hard-asserted.
|
||||
})
|
||||
|
||||
it('FULL REWRITE: peer edits original content that the agent then deletes in a full rewrite', () => {
|
||||
const { A, B } = seededPair('# Title\n\nAlpha\n\nBeta\n\nGamma')
|
||||
const session = beginAgentStream(A.editor)!
|
||||
|
||||
// Peer edits Beta WHILE it still exists — genuinely concurrent with the impending rewrite.
|
||||
// (Asserting the insert landed guards against a false-green where the target was already gone.)
|
||||
expect(peerInsertNear(B.editor, 'Beta', 'PEER ')).toBe(true)
|
||||
// Agent replaces the WHOLE doc across two frames, deleting Alpha/Beta/Gamma.
|
||||
applyAgentStreamFrame(A.editor, session, '# Report\n\nOne\n\nTwo')
|
||||
applyAgentStreamFrame(A.editor, session, '# Report\n\nOne\n\nTwo\n\nThree')
|
||||
endAgentStream(session)
|
||||
|
||||
const textA = A.editor.state.doc.textContent
|
||||
console.log(`\n[FULL-REWRITE] A: ${JSON.stringify(textA)}`)
|
||||
console.log(
|
||||
`[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)}`
|
||||
)
|
||||
|
||||
expect(fragStr(A.doc)).toBe(fragStr(B.doc)) // convergence
|
||||
expect(count(textA, 'One')).toBe(1) // agent content not duplicated by the concurrent merge
|
||||
expect(count(textA, 'Three')).toBe(1)
|
||||
expect(emptyParas(A.editor)).toBe(0) // no stray empties from a delete/insert conflict
|
||||
// The peer's insert is NOT lost when the rewrite deletes its surrounding paragraph: Yjs preserves
|
||||
// the inserted text and reattaches it to the nearest surviving anchor (it relocates into the
|
||||
// rewritten content rather than vanishing). What matters is that it survives exactly once — never
|
||||
// duplicated, never silently dropped.
|
||||
expect(count(textA, 'PEER ')).toBe(1)
|
||||
})
|
||||
})
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*
|
||||
* Integration coverage for the collaborative agent-streaming surface with all the moving pieces:
|
||||
* multiple peers, undo isolation, the durable persist→reopen round-trip, empty-collapse on the live
|
||||
* streaming path, and a late joiner. Editors are wired as genuine Yjs peers (mesh update forwarding).
|
||||
* This exercises the CRDT/merge/convert LOGIC deterministically; it does NOT cover the realtime socket
|
||||
* transport, RAF-paced stream loop, or real browser timing (those need a live 2-browser E2E harness).
|
||||
* Run: bunx vitest run <thisfile> --disable-console-intercept
|
||||
*/
|
||||
import { Editor } from '@tiptap/core'
|
||||
import { afterEach, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { Awareness } from 'y-protocols/awareness'
|
||||
import * as Y from 'yjs'
|
||||
import { markdownToYDoc, yDocToMarkdown } from '@/lib/collab-doc/converter'
|
||||
import { createMarkdownEditorExtensions } from '../editor-extensions'
|
||||
import { parseMarkdownToDoc } from '../markdown-parse'
|
||||
import { applyAgentStreamFrame, beginAgentStream, endAgentStream } from './apply-streamed-markdown'
|
||||
|
||||
beforeAll(() => {
|
||||
if (!document.elementFromPoint) document.elementFromPoint = () => null
|
||||
})
|
||||
|
||||
const teardown: Array<() => void> = []
|
||||
afterEach(() => {
|
||||
for (const fn of teardown.splice(0)) fn()
|
||||
})
|
||||
|
||||
function makeCollabEditor() {
|
||||
const doc = new Y.Doc()
|
||||
const awareness = new Awareness(doc)
|
||||
const editor = new Editor({
|
||||
extensions: createMarkdownEditorExtensions({
|
||||
placeholder: '',
|
||||
collaboration: { doc, awareness, user: { name: 'U', color: '#fff', clientId: doc.clientID } },
|
||||
}),
|
||||
content: '',
|
||||
})
|
||||
const t = { editor, doc, awareness }
|
||||
teardown.push(() => {
|
||||
editor.destroy()
|
||||
awareness.destroy()
|
||||
doc.destroy()
|
||||
})
|
||||
return t
|
||||
}
|
||||
|
||||
/** Forward every local/agent update from each doc to all others (origin-guarded), a full mesh. */
|
||||
function wireMesh(docs: Y.Doc[]) {
|
||||
const MESH = Symbol('mesh')
|
||||
for (const d of docs) {
|
||||
d.on('update', (u: Uint8Array, origin: unknown) => {
|
||||
if (origin === MESH) return
|
||||
for (const other of docs) if (other !== d) Y.applyUpdate(other, u, MESH)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function peerInsertNear(editor: Editor, needle: string, text: string): boolean {
|
||||
let pos: number | null = null
|
||||
editor.state.doc.descendants((node, p) => {
|
||||
if (pos !== null) return false
|
||||
if (node.isText && node.text?.includes(needle)) pos = p + node.text.indexOf(needle)
|
||||
})
|
||||
if (pos === null) return false
|
||||
return editor.commands.insertContentAt(pos, text)
|
||||
}
|
||||
|
||||
const fragStr = (doc: Y.Doc) => doc.getXmlFragment('default').toString()
|
||||
const countText = (hay: string, needle: string) => hay.split(needle).length - 1
|
||||
function emptyParas(editor: Editor): number {
|
||||
let n = 0
|
||||
editor.state.doc.descendants((node) => {
|
||||
if (node.type.name === 'paragraph' && node.childCount === 0) n++
|
||||
})
|
||||
return n
|
||||
}
|
||||
|
||||
describe('collab streaming integration — moving pieces', () => {
|
||||
it('THREE-WAY: agent + two peers editing different regions all converge, both peer edits survive', () => {
|
||||
const A = makeCollabEditor()
|
||||
A.editor.commands.setContent(parseMarkdownToDoc('# Title\n\nAlpha\n\nBeta\n\nGamma'), {
|
||||
contentType: 'json',
|
||||
})
|
||||
const B = makeCollabEditor()
|
||||
const C = makeCollabEditor()
|
||||
Y.applyUpdate(B.doc, Y.encodeStateAsUpdate(A.doc))
|
||||
Y.applyUpdate(C.doc, Y.encodeStateAsUpdate(A.doc))
|
||||
wireMesh([A.doc, B.doc, C.doc])
|
||||
|
||||
const session = beginAgentStream(A.editor)!
|
||||
applyAgentStreamFrame(A.editor, session, '# Title\n\nAlpha\n\nBeta\n\nGamma\n\nDelta')
|
||||
expect(peerInsertNear(B.editor, 'Alpha', 'B_EDIT ')).toBe(true) // peer B edits the top
|
||||
expect(peerInsertNear(C.editor, 'Gamma', 'C_EDIT ')).toBe(true) // peer C edits the bottom
|
||||
applyAgentStreamFrame(
|
||||
A.editor,
|
||||
session,
|
||||
'# Title\n\nAlpha\n\nBeta\n\nGamma\n\nDelta\n\nEpsilon'
|
||||
)
|
||||
endAgentStream(session)
|
||||
|
||||
const textA = A.editor.state.doc.textContent
|
||||
console.log(`\n[3-WAY] A: ${JSON.stringify(textA)}`)
|
||||
console.log(
|
||||
`[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)}`
|
||||
)
|
||||
|
||||
expect(fragStr(A.doc)).toBe(fragStr(B.doc))
|
||||
expect(fragStr(B.doc)).toBe(fragStr(C.doc))
|
||||
expect(countText(textA, 'B_EDIT ')).toBe(1)
|
||||
expect(countText(textA, 'C_EDIT ')).toBe(1)
|
||||
expect(textA).toContain('Epsilon')
|
||||
expect(emptyParas(A.editor)).toBe(0)
|
||||
})
|
||||
|
||||
it('UNDO ISOLATION: a peer undo reverts only the peer’s own edit, never the agent’s stream', () => {
|
||||
const A = makeCollabEditor()
|
||||
A.editor.commands.setContent(parseMarkdownToDoc('# Title\n\nbase'), { contentType: 'json' })
|
||||
const B = makeCollabEditor()
|
||||
Y.applyUpdate(B.doc, Y.encodeStateAsUpdate(A.doc))
|
||||
wireMesh([A.doc, B.doc])
|
||||
|
||||
expect(peerInsertNear(B.editor, 'base', 'PEER_UNDOABLE ')).toBe(true) // peer's own edit (undo stack)
|
||||
const session = beginAgentStream(A.editor)!
|
||||
applyAgentStreamFrame(
|
||||
A.editor,
|
||||
session,
|
||||
'# Title\n\nPEER_UNDOABLE base\n\nagent added this line'
|
||||
)
|
||||
endAgentStream(session)
|
||||
|
||||
const undid = B.editor.commands.undo()
|
||||
const textB = B.editor.state.doc.textContent
|
||||
console.log(`\n[UNDO] undoRan=${undid} afterUndo=${JSON.stringify(textB)}`)
|
||||
|
||||
expect(fragStr(A.doc)).toBe(fragStr(B.doc)) // still converged after undo
|
||||
expect(textB).toContain('agent added this line') // agent content NOT undone by the peer
|
||||
expect(textB).not.toContain('PEER_UNDOABLE') // peer's own edit was undone
|
||||
})
|
||||
|
||||
it('PERSIST ROUND-TRIP: stream → serialize to durable markdown → reopen yields the same content, no empties', () => {
|
||||
const A = makeCollabEditor()
|
||||
const session = beginAgentStream(A.editor)!
|
||||
applyAgentStreamFrame(A.editor, session, '# Report\n\n## Section 1\n\nbody one')
|
||||
applyAgentStreamFrame(
|
||||
A.editor,
|
||||
session,
|
||||
'# Report\n\n## Section 1\n\nbody one\n\n## Section 2\n\nbody two'
|
||||
)
|
||||
endAgentStream(session)
|
||||
|
||||
const durable = yDocToMarkdown(A.doc) // server-side projection to durable markdown
|
||||
const reopened = markdownToYDoc(durable) // cold reopen from durable
|
||||
const reopenedMd = yDocToMarkdown(reopened)
|
||||
const blankRuns = (durable.match(/\n{3,}/g) ?? []).length
|
||||
console.log(`\n[ROUND-TRIP] durable=${JSON.stringify(durable)}`)
|
||||
console.log(`[ROUND-TRIP] reopenStable=${reopenedMd === durable} blankRuns=${blankRuns}`)
|
||||
|
||||
expect(durable).toContain('Section 1')
|
||||
expect(durable).toContain('Section 2')
|
||||
expect(durable).toContain('body two')
|
||||
expect(blankRuns).toBe(0) // no pathological blank runs in the persisted markdown
|
||||
expect(reopenedMd).toBe(durable) // reopen is a fixed point (stable)
|
||||
reopened.destroy()
|
||||
})
|
||||
|
||||
it('EMPTY-COLLAPSE ON THE STREAM PATH: an agent body with a huge blank run does not strand empties', () => {
|
||||
const A = makeCollabEditor()
|
||||
A.editor.commands.setContent(parseMarkdownToDoc('# Title\n\nintro'), { contentType: 'json' })
|
||||
const session = beginAgentStream(A.editor)!
|
||||
// The agent emits a pathological blank run between two blocks (the original incident's shape).
|
||||
applyAgentStreamFrame(A.editor, session, `# Title\n\nintro${'\n'.repeat(400)}tail paragraph`)
|
||||
endAgentStream(session)
|
||||
|
||||
console.log(
|
||||
`\n[STREAM-COLLAPSE] text=${JSON.stringify(A.editor.state.doc.textContent)} empty=${emptyParas(A.editor)}`
|
||||
)
|
||||
expect(emptyParas(A.editor)).toBe(0) // collapse protects the live streaming path, not just static open
|
||||
expect(A.editor.state.doc.textContent).toContain('tail paragraph')
|
||||
})
|
||||
|
||||
it('LATE JOINER: a peer that syncs AFTER the stream sees the full, clean document', () => {
|
||||
const A = makeCollabEditor()
|
||||
A.editor.commands.setContent(parseMarkdownToDoc('# Doc\n\nstart'), { contentType: 'json' })
|
||||
const session = beginAgentStream(A.editor)!
|
||||
applyAgentStreamFrame(A.editor, session, '# Doc\n\nstart\n\nstreamed body')
|
||||
endAgentStream(session)
|
||||
|
||||
// A brand-new client joins now and syncs from the current state.
|
||||
const D = makeCollabEditor()
|
||||
Y.applyUpdate(D.doc, Y.encodeStateAsUpdate(A.doc))
|
||||
|
||||
console.log(
|
||||
`\n[LATE-JOIN] D: ${JSON.stringify(D.editor.state.doc.textContent)} converged=${fragStr(A.doc) === fragStr(D.doc)}`
|
||||
)
|
||||
expect(fragStr(A.doc)).toBe(fragStr(D.doc))
|
||||
expect(D.editor.state.doc.textContent).toContain('streamed body')
|
||||
expect(emptyParas(D.editor)).toBe(0)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user