openpencil/src/app/collab/room.ts
Danila Poyarkov 97e42e8102
test(collab): add deterministic browser sessions (#541)
* test(collab): add deterministic browser sessions

- Extract a typed room transport boundary around Trystero
- Add a development-only WebSocket relay transport for browser tests
- Cover two-peer edits, awareness, departure cleanup, and reconnect convergence without public network services

* fix(collab): harden browser test transport

- Scope collaboration controls to explicit development test hooks
- Validate bounded relay messages and reject duplicate action receivers
- Remove only awareness clients owned by a departing peer
- Guarantee relay and browser cleanup after setup failures

* fix(collab): strengthen browser session coverage

- Prevent remote awareness rebroadcast and track ownership by transport peer
- Guard and validate the development relay lifecycle
- Simulate a network partition with retained peers and concurrent offline edits
- Make browser and relay setup and teardown failure-safe

* test(collab): prove partitioned peer convergence

- Queue and assert traffic during a deterministic network partition
- Converge non-conflicting edits from retained host and guest sessions
- Reject relay startup failures and guard test-only browser APIs
2026-08-17 12:57:03 +03:00

106 lines
3.1 KiB
TypeScript

import * as decoding from 'lib0/decoding'
import * as awarenessProtocol from 'y-protocols/awareness'
import * as Y from 'yjs'
import { joinCollabRoom, type JoinCollabRoom } from '@/app/collab/transport'
export type CollabRoomOptions = {
roomId: string
ydoc: Y.Doc
awareness: awarenessProtocol.Awareness
setConnected: () => void
updatePeersList: () => void
joinRoom?: JoinCollabRoom
}
export type CollabRoomConnection = {
room: ReturnType<JoinCollabRoom>
sendYjsUpdate: (data: Uint8Array, peerId?: string) => void
sendAwareness: (data: Uint8Array, peerId?: string) => void
sendSyncStep1: (data: Uint8Array, peerId?: string) => void
}
function awarenessClientIds(data: Uint8Array): number[] {
try {
const decoder = decoding.createDecoder(data)
const count = decoding.readVarUint(decoder)
const clients: number[] = []
for (let index = 0; index < count; index++) {
clients.push(decoding.readVarUint(decoder))
decoding.readVarUint(decoder)
decoding.readVarString(decoder)
}
return clients
} catch {
return []
}
}
export function connectCollabRoom({
roomId,
ydoc,
awareness,
setConnected,
updatePeersList,
joinRoom = joinCollabRoom
}: CollabRoomOptions): CollabRoomConnection {
const room = joinRoom(roomId)
const [sendYjsUpdate, getUpdate] = room.makeAction('yjs-update')
const [sendAwareness, getAwareness] = room.makeAction('awareness')
const [sendSyncStep1, getSyncStep1] = room.makeAction('sync-step1')
const [sendSyncReply, getSyncReply] = room.makeAction('sync-reply')
const awarenessClientsByPeer = new Map<string, Set<number>>()
getUpdate((data) => {
Y.applyUpdate(ydoc, data, 'remote')
})
getAwareness((data, peerId) => {
awarenessClientsByPeer.set(peerId, new Set(awarenessClientIds(data)))
awarenessProtocol.applyAwarenessUpdate(awareness, data, 'remote')
})
getSyncStep1((stateVector, peerId) => {
const update = Y.encodeStateAsUpdate(ydoc, stateVector)
sendSyncReply(update, peerId)
})
getSyncReply((data) => {
Y.applyUpdate(ydoc, data, 'remote')
})
ydoc.on('update', (update: Uint8Array, origin: unknown) => {
if (origin === 'remote') return
sendYjsUpdate(update)
})
awareness.on(
'update',
(
{ added, updated, removed }: { added: number[]; updated: number[]; removed: number[] },
origin: unknown
) => {
if (origin === 'remote' || origin === 'peer-left') return
const changedClients = [...added, ...updated, ...removed]
const encodedUpdate = awarenessProtocol.encodeAwarenessUpdate(awareness, changedClients)
sendAwareness(encodedUpdate)
}
)
room.onPeerJoin((peerId) => {
setConnected()
sendSyncStep1(Y.encodeStateVector(ydoc), peerId)
sendAwareness(awarenessProtocol.encodeAwarenessUpdate(awareness, [awareness.clientID]), peerId)
})
room.onPeerLeave((peerId) => {
const remoteClients = [...(awarenessClientsByPeer.get(peerId) ?? [])]
awarenessClientsByPeer.delete(peerId)
awarenessProtocol.removeAwarenessStates(awareness, remoteClients, 'peer-left')
updatePeersList()
})
return { room, sendYjsUpdate, sendAwareness, sendSyncStep1 }
}