UNPKG

@tldraw/sync-core

Version:

tldraw infinite canvas SDK (multiplayer sync).

306 lines (269 loc) • 8.2 kB
import { Editor, TLArrowBinding, TLArrowShape, TLRecord, TLStore, computed, createPresenceStateDerivation, createTLSchema, createTLStore, isRecordsDiffEmpty, mockUniqueId, uniqueId, } from 'tldraw' import uuid from 'uuid-by-string' import readable from 'uuid-readable' import { vi } from 'vitest' import { prettyPrintDiff } from '../../../tldraw/src/test/testutils/pretty' import { TLSyncClient } from '../lib/TLSyncClient' import { FuzzEditor, Op } from './FuzzEditor' import { RandomSource } from './RandomSource' import { TestServer } from './TestServer' import { TestSocketPair } from './TestSocketPair' const schema = createTLSchema() vi.mock('@tldraw/editor/src/lib/editor/managers/TickManager/TickManager.ts', () => { return { TickManager: class { start() { // noop } }, } }) // @ts-expect-error global.requestAnimationFrame = (cb: () => any) => { cb() } let source = new RandomSource(0) const nanoid = () => { return readable.short(uuid(source.randomInt().toString(16))).replaceAll(' ', '_') } const reseed = (seed: number) => { source = new RandomSource(seed) } mockUniqueId(nanoid) const disposables: Array<() => void> = [] afterEach(() => { for (const dispose of disposables) { dispose() } disposables.length = 0 }) class FuzzTestInstance extends RandomSource { store: TLStore editor: FuzzEditor | null = null client: TLSyncClient<TLRecord> socketPair: TestSocketPair<TLRecord> id: string hasLoaded = false constructor( public readonly seed: number, server: TestServer<TLRecord> ) { super(seed) this.id = uniqueId() this.store = createTLStore({ schema, id: this.id }) this.socketPair = new TestSocketPair(this.id, server) this.client = new TLSyncClient<TLRecord>({ store: this.store, socket: this.socketPair.clientSocket, onSyncError: (reason) => { throw new Error('onSyncError:' + reason) }, onLoad: () => { this.editor = new FuzzEditor(this.id, this.seed, this.store) }, presence: createPresenceStateDerivation( computed('', () => ({ id: this.id, name: 'test', color: 'red', locale: 'en', })) )(this.store), }) disposables.push(() => { this.client.close() }) } } function assertPeerStoreIsUsable(peer: FuzzTestInstance) { const diffToEnsureUsable = peer.store.extractingChanges(() => peer.store.ensureStoreIsUsable()) if (!isRecordsDiffEmpty(diffToEnsureUsable)) { throw new Error(`store of ${peer.id} was not usable\n${prettyPrintDiff(diffToEnsureUsable)}`) } } let totalNumShapes = 0 let totalNumPages = 0 function arrowsAreSound(editor: Editor) { const arrows = editor.getCurrentPageShapes().filter((s): s is TLArrowShape => s.type === 'arrow') for (const arrow of arrows) { const bindings = editor.getBindingsFromShape<TLArrowBinding>(arrow, 'arrow') const terminalsSeen = new Set() for (const binding of bindings) { if (terminalsSeen.has(binding.props.terminal)) { return false } terminalsSeen.add(binding.props.terminal) if (!editor.store.has(binding.toId)) { return false } } } return true } function runTest(seed: number) { reseed(seed) const server = new TestServer(schema) const instance = new FuzzTestInstance(seed, server) const peers = [instance, new FuzzTestInstance(instance.randomInt(), server)] const numExtraPeers = instance.randomInt(MAX_PEERS - 2) for (let i = 0; i < numExtraPeers; i++) { peers.push(new FuzzTestInstance(instance.randomInt(), server)) } const allOk = (when: string) => { if (peers.some((p) => p.editor?.editor && !p.editor?.editor.getCurrentPage())) { throw new Error(`not all peer editors have current page (${when})`) } if (peers.some((p) => p.editor?.editor && !p.editor?.editor.getCurrentPageState())) { throw new Error(`not all peer editors have page states (${when})`) } if ( peers.some( (p) => p.client.isConnectedToRoom && p.socketPair.clientSocket.connectionStatus !== 'online' ) ) { throw new Error(`peer client connection status mismatch (${when})`) } if (peers.some((p) => p.editor?.editor && !arrowsAreSound(p.editor.editor))) { throw new Error(`peer editor arrows are not sound (${when})`) } const numOtherPeersConnected = peers.filter((p) => p.hasLoaded).length - 1 if ( peers.some( (p) => p.hasLoaded && p.editor?.editor.store.query.ids('instance_presence').get().size !== numOtherPeersConnected ) ) { throw new Error(`not all peer editors have instance presence (${when})`) } } const ops: Array<{ peerId: string; op: Op; id: number }> = [] try { for (let i = 0; i < NUM_OPS_PER_TEST; i++) { const peer = peers[instance.randomInt(peers.length)] if (peer.editor) { const op = peer.editor.getRandomOp() ops.push({ peerId: peer.id, op, id: ops.length }) allOk('before applyOp') peer.editor.applyOp(op) assertPeerStoreIsUsable(peer) allOk('after applyOp') server.flushDebouncingMessages() if (peer.socketPair.isConnected && peer.randomInt(6) === 0) { // randomly disconnect a peer peer.socketPair.disconnect() allOk('disconnect') } else if (!peer.socketPair.isConnected && peer.randomInt(2) === 0) { // randomly reconnect a peer peer.socketPair.connect() allOk('connect') } } else if (!peer.socketPair.isConnected && peer.randomInt(2) === 0) { peer.socketPair.connect() allOk('connect 2') } const peersThatNeedFlushing = peers.filter((p) => p.socketPair.getNeedsFlushing()) for (const peer of peersThatNeedFlushing) { if (peer.randomInt(10) < 4) { allOk('before flush server ' + i) peer.socketPair.flushServerSentEvents() allOk('flush server ' + i) } else if (peer.randomInt(10) < 2) { peer.socketPair.flushClientSentEvents() allOk('flush client') } } } // bring all clients online and flush all messages to make sure everyone has seen all messages while (peers.some((p) => !p.socketPair.isConnected)) { for (const peer of peers) { if (!peer.socketPair.isConnected && peer.randomInt(2) === 0) { peer.socketPair.connect() allOk('final connect') assertPeerStoreIsUsable(peer) } } } while (peers.some((p) => p.socketPair.getNeedsFlushing())) { server.flushDebouncingMessages() for (const peer of peers) { if (peer.socketPair.getNeedsFlushing()) { peer.socketPair.flushServerSentEvents() allOk('final flushServer') peer.socketPair.flushClientSentEvents() allOk('final flushClient') assertPeerStoreIsUsable(peer) } } } // peers should all be usable without changes: for (const peer of peers) { assertPeerStoreIsUsable(peer) } // all stores should be the same for (let i = 1; i < peers.length; i++) { const expected = peers[i - 1] const actual = peers[i] try { expect(actual.store.serialize('document')).toEqual(expected.store.serialize('document')) } catch (e: any) { throw new Error(`received = ${actual.id}, expected = ${expected.id}\n${e.message}`) } } totalNumPages += peers[0].store.query.ids('page').get().size totalNumShapes += peers[0].store.query.ids('shape').get().size } catch (e) { console.error('seed', seed) console.error( 'peers', JSON.stringify( peers.map((p) => p.id), null, 2 ) ) console.error('ops', JSON.stringify(ops, null, '\t')) throw e } } const NUM_TESTS = 50 const NUM_OPS_PER_TEST = 100 const MAX_PEERS = 4 test('seed 8360926944486245 - undo/redo page integrity regression', () => { runTest(8360926944486245) }) test('seed 3467175630814895 - undo/redo page integrity regression', () => { runTest(3467175630814895) }) test('seed 6820615056006575 - undo/redo page integrity regression', () => { runTest(6820615056006575) }) test('seed 5279266392988747 - undo/redo page integrity regression', () => { runTest(5279266392988747) }) for (let i = 0; i < NUM_TESTS; i++) { const seed = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER) test(`seed ${seed}`, () => { runTest(seed) }) } test('totalNumPages', () => { expect(totalNumPages).not.toBe(0) }) test('totalNumShapes', () => { expect(totalNumShapes).not.toBe(0) })