openpencil/src/app/browser-bridge.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

73 lines
2.1 KiB
TypeScript

import type { ChatTransport, UIMessage } from 'ai'
import type { CollabReturn } from '@/app/collab/context'
import type { EditorStore } from '@/app/editor/session/create'
import { IS_BROWSER } from '@/constants'
export interface OpenPencilTestHooks {
writeCount?: () => number
mockHandle?: FileSystemFileHandle
savedOpen?: Window['open']
collab?: Pick<
CollabReturn,
'connect' | 'disconnect' | 'updateCursor' | 'updateSelection' | 'setLocalName'
> & {
peerCount: () => number
peerSelections: () => Array<string[] | undefined>
}
}
export interface OpenPencilWindowAPI {
getStore?: () => EditorStore
setChatTransport?: (factory: () => ChatTransport<UIMessage>) => void
openFile?: (path: string) => Promise<void>
test?: OpenPencilTestHooks
}
declare global {
interface Window {
openPencil?: OpenPencilWindowAPI
}
}
let activeStore: EditorStore | null = null
function windowAPI(): OpenPencilWindowAPI {
window.openPencil ??= {}
window.openPencil.getStore ??= () => {
if (!activeStore) throw new Error('OpenPencil store not initialized')
return activeStore
}
return window.openPencil
}
export function setOpenPencilStore(store: EditorStore) {
activeStore = store
windowAPI()
}
export function exposeCollaborationActions(collab: CollabReturn) {
if (!IS_BROWSER || !import.meta.env.DEV) return
if (!new URLSearchParams(window.location.search).has('test')) return
const testHooks = (windowAPI().test ??= {})
testHooks.collab = {
connect: collab.connect,
disconnect: collab.disconnect,
updateCursor: collab.updateCursor,
updateSelection: collab.updateSelection,
setLocalName: collab.setLocalName,
peerCount: () => collab.remotePeers.value.length,
peerSelections: () => collab.remotePeers.value.map((peer) => peer.selection)
}
}
export function exposeChatTransportOverride(
setChatTransport: (factory: () => ChatTransport<UIMessage>) => void
) {
windowAPI().setChatTransport = setChatTransport
}
export function setOpenPencilOpenFileHandler(openFile: (path: string) => Promise<void>) {
windowAPI().openFile = openFile
}