@@ -14,20 +14,26 @@ import * as syncProtocol from 'y-protocols/sync'
1414import * as Y from 'yjs'
1515import type { IRoomManager } from '@/rooms'
1616
17- const { mockAuthorizeRoom, mockFetchFileDocSeed } = vi . hoisted ( ( ) => ( {
17+ const { mockAuthorizeRoom, mockFetchFileDocSeed, mockFetchFileDocMerge } = vi . hoisted ( ( ) => ( {
1818 mockAuthorizeRoom : vi . fn ( ) ,
1919 mockFetchFileDocSeed : vi . fn ( ) ,
20+ mockFetchFileDocMerge : vi . fn ( ) ,
2021} ) )
2122
2223vi . mock ( '@sim/platform-authz/rooms' , ( ) => ( {
2324 authorizeRoom : mockAuthorizeRoom ,
2425} ) )
2526
26- vi . mock ( '@/handlers/file-doc-seed ' , ( ) => ( {
27+ vi . mock ( '@/handlers/file-doc-app ' , ( ) => ( {
2728 fetchFileDocSeed : mockFetchFileDocSeed ,
29+ fetchFileDocMerge : mockFetchFileDocMerge ,
2830} ) )
2931
30- import { cleanupFileDocForSocket , setupWorkspaceFileDocHandlers } from '@/handlers/file-doc'
32+ import {
33+ applyMarkdownToLiveFileDoc ,
34+ cleanupFileDocForSocket ,
35+ setupWorkspaceFileDocHandlers ,
36+ } from '@/handlers/file-doc'
3137
3238type Handler = ( payload ?: unknown ) => Promise < void > | void
3339
@@ -173,6 +179,9 @@ describe('setupWorkspaceFileDocHandlers', () => {
173179 // Default: the server seed builder returns no content (empty file). Tests that
174180 // exercise seeding override this per-case with an encoded Yjs update.
175181 mockFetchFileDocSeed . mockResolvedValue ( null )
182+ // Default: the merge builder returns a valid no-op (empty-doc) update. Tests exercising copilot
183+ // merges override it.
184+ mockFetchFileDocMerge . mockResolvedValue ( Y . encodeStateAsUpdate ( new Y . Doc ( ) ) )
176185 } )
177186
178187 afterEach ( ( ) => {
@@ -399,6 +408,65 @@ describe('setupWorkspaceFileDocHandlers', () => {
399408 expect ( clientDoc . getText ( FILE_DOC_FIELD ) . toString ( ) ) . toContain ( '# Seeded' )
400409 } )
401410
411+ it ( 'merges a copilot edit into a seeded live room and relays it to editors' , async ( ) => {
412+ mockFetchFileDocSeed . mockResolvedValue ( encodedSeedUpdate ( '# Original' ) )
413+ const { io, sent } = createIo ( )
414+ const { handlers } = setup ( 'socket-1' , io )
415+ await handlers [ FILE_DOC_EVENTS . JOIN ] ( { fileId : 'file-1' , clientId : 1 } )
416+ await flushMicrotasks ( ) // seed lands → room is seeded/live
417+
418+ // The app returns a diff (here, an update introducing text) for the relay to apply.
419+ const diff = new Y . Doc ( )
420+ diff . getText ( FILE_DOC_FIELD ) . insert ( 0 , 'copilot content' )
421+ mockFetchFileDocMerge . mockResolvedValue ( Y . encodeStateAsUpdate ( diff ) )
422+ sent . length = 0
423+
424+ const result = await applyMarkdownToLiveFileDoc ( 'file-1' , '# Rewritten by copilot' )
425+
426+ expect ( result ) . toBe ( 'applied' )
427+ expect ( mockFetchFileDocMerge ) . toHaveBeenCalledWith (
428+ 'file-1' ,
429+ expect . any ( Uint8Array ) ,
430+ '# Rewritten by copilot'
431+ )
432+ // Applying the diff fires doc.on('update') → the merge is broadcast to the whole room.
433+ expect ( sent . some ( ( m ) => m . event === FILE_DOC_EVENTS . MESSAGE && m . target === ROOM_NAME ) ) . toBe (
434+ true
435+ )
436+ } )
437+
438+ it ( 'reports no-live-room (and does not call the app) when the file has no seeded room' , async ( ) => {
439+ const result = await applyMarkdownToLiveFileDoc ( 'file-1' , '# anything' )
440+ expect ( result ) . toBe ( 'no-live-room' )
441+ expect ( mockFetchFileDocMerge ) . not . toHaveBeenCalled ( )
442+ } )
443+
444+ it ( 'serializes concurrent merges for the same file (second waits for the first)' , async ( ) => {
445+ mockFetchFileDocSeed . mockResolvedValue ( encodedSeedUpdate ( '# Original' ) )
446+ const { io } = createIo ( )
447+ const { handlers } = setup ( 'socket-1' , io )
448+ await handlers [ FILE_DOC_EVENTS . JOIN ] ( { fileId : 'file-1' , clientId : 1 } )
449+ await flushMicrotasks ( )
450+
451+ // First merge is left in flight; the second must not start its own fetch until the first finishes.
452+ const noOpUpdate = Y . encodeStateAsUpdate ( new Y . Doc ( ) )
453+ let resolveFirst : ( v : Uint8Array ) => void = ( ) => { }
454+ mockFetchFileDocMerge
455+ . mockReturnValueOnce ( new Promise ( ( resolve ) => ( resolveFirst = resolve ) ) )
456+ . mockResolvedValueOnce ( noOpUpdate )
457+
458+ const first = applyMarkdownToLiveFileDoc ( 'file-1' , '# One' )
459+ const second = applyMarkdownToLiveFileDoc ( 'file-1' , '# Two' )
460+ await flushMicrotasks ( )
461+ expect ( mockFetchFileDocMerge ) . toHaveBeenCalledTimes ( 1 ) // second is queued behind the first
462+
463+ resolveFirst ( noOpUpdate )
464+ await first
465+ await second
466+ // Only after the first resolved did the second run — and it snapshotted the post-first state.
467+ expect ( mockFetchFileDocMerge ) . toHaveBeenCalledTimes ( 2 )
468+ } )
469+
402470 it ( 'relays a document update to the rest of the room, excluding the sender' , async ( ) => {
403471 const { io, sent } = createIo ( )
404472 const a = setup ( 'socket-a' , io )
0 commit comments