kopia lustrzana https://github.com/Tldraw/Tldraw
295 wiersze
7.6 KiB
TypeScript
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)
|
|
})
|