Tldraw/packages/tlsync/src/test/syncFuzz.test.ts

295 wiersze
7.6 KiB
TypeScript

import isEqual from 'lodash.isequal'
import { nanoid } from 'nanoid'
import {
Editor,
TLArrowShape,
TLRecord,
TLStore,
computed,
createPresenceStateDerivation,
createTLStore,
} from 'tldraw'
import { TLSyncClient } from '../lib/TLSyncClient'
import { schema } from '../lib/schema'
import { FuzzEditor, Op } from './FuzzEditor'
import { RandomSource } from './RandomSource'
import { TestServer } from './TestServer'
import { TestSocketPair } from './TestSocketPair'
jest.mock('@tldraw/editor/src/lib/editor/managers/TickManager.ts', () => {
return {
TickManager: class {
start() {
// noop
}
},
}
})
// @ts-expect-error
global.requestAnimationFrame = (cb: () => any) => {
cb()
}
jest.mock('nanoid', () => {
const { RandomSource } = jest.requireActual('./RandomSource')
let source = new RandomSource(0)
// eslint-disable-next-line @typescript-eslint/no-var-requires
const readable = require('uuid-readable')
// eslint-disable-next-line @typescript-eslint/no-var-requires
const uuid = require('uuid-by-string')
const nanoid = () => {
return readable.short(uuid(source.randomInt().toString(16))).replaceAll(' ', '_')
}
return {
nanoid,
default: nanoid,
__reseed(seed: number) {
source = new RandomSource(seed)
},
}
})
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.store = createTLStore({ schema })
this.id = nanoid()
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)
},
onLoadError: (e) => {
throw new Error('onLoadError', e)
},
presence: createPresenceStateDerivation(
computed('', () => ({
id: this.id,
name: 'test',
color: 'red',
locale: 'en',
}))
)(this.store),
})
disposables.push(() => {
this.client.close()
})
}
}
let totalNumShapes = 0
let totalNumPages = 0
function arrowsAreSound(editor: Editor) {
const arrows = editor.getCurrentPageShapes().filter((s) => s.type === 'arrow') as TLArrowShape[]
for (const arrow of arrows) {
for (const terminal of [arrow.props.start, arrow.props.end]) {
if (terminal.type === 'binding' && !editor.store.has(terminal.boundShapeId)) {
return false
}
}
}
return true
}
function runTest(seed: number) {
// eslint-disable-next-line @typescript-eslint/no-var-requires
require('nanoid').__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)
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')
}
}
}
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')
}
}
}
const equalityResults = []
for (let i = 0; i < peers.length; i++) {
const row = []
for (let j = 0; j < peers.length; j++) {
row.push(
isEqual(
peers[i].editor?.store.serialize('document'),
peers[j].editor?.store.serialize('document')
)
)
}
equalityResults.push(row)
}
const [first, ...rest] = peers.map((peer) => peer.editor?.store.serialize('document'))
// writeFileSync(`./test-results.${seed}.json`, JSON.stringify(ops, null, '\t'))
expect(first).toEqual(rest[0])
// all snapshots should be the same
expect(rest.every((other) => isEqual(other, first))).toBe(true)
totalNumPages += Object.values(first!).filter((v) => v.typeName === 'page').length
totalNumShapes += Object.values(first!).filter((v) => v.typeName === 'shape').length
} 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.only('seed 8343632005032947', () => {
// runTest(8343632005032947)
// })
test('fuzzzzz', () => {
for (let i = 0; i < NUM_TESTS; i++) {
const seed = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER)
try {
runTest(seed)
} catch (e) {
console.error('seed', seed)
throw e
}
}
})
test('totalNumPages', () => {
expect(totalNumPages).not.toBe(0)
})
test('totalNumShapes', () => {
expect(totalNumShapes).not.toBe(0)
})