snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
This commit is contained in:
378
packages/validate/src/test/validation.fuzz.test.ts
Normal file
378
packages/validate/src/test/validation.fuzz.test.ts
Normal file
@@ -0,0 +1,378 @@
|
||||
import { isEqual, mapObjectMapValues } from '@tldraw/utils'
|
||||
import { T, Validator } from '..'
|
||||
|
||||
class RandomSource {
|
||||
private seed: number
|
||||
|
||||
constructor(seed: number) {
|
||||
this.seed = seed
|
||||
}
|
||||
|
||||
nextFloat(): number {
|
||||
this.seed = (this.seed * 9301 + 49297) % 233280
|
||||
return this.seed / 233280
|
||||
}
|
||||
|
||||
nextInt(max: number): number {
|
||||
return Math.floor(this.nextFloat() * max)
|
||||
}
|
||||
|
||||
nextIntInRange(min: number, max: number): number {
|
||||
return this.nextInt(max - min) + min
|
||||
}
|
||||
|
||||
nextId(): string {
|
||||
return this.nextInt(Number.MAX_SAFE_INTEGER).toString(36)
|
||||
}
|
||||
|
||||
selectOne<T>(arr: readonly T[]): T {
|
||||
return arr[this.nextInt(arr.length)]
|
||||
}
|
||||
|
||||
choice(probability: number): boolean {
|
||||
return this.nextFloat() < probability
|
||||
}
|
||||
|
||||
executeOne<Result>(
|
||||
_choices: Record<string, (() => Result) | { weight?: number; do(): Result }>
|
||||
): Result {
|
||||
const choices = Object.values(_choices).map((choice) => {
|
||||
if (typeof choice === 'function') {
|
||||
return { weight: 1, do: choice }
|
||||
}
|
||||
return choice
|
||||
})
|
||||
const totalWeight = Object.values(choices).reduce(
|
||||
(total, choice) => total + (choice.weight ?? 1),
|
||||
0
|
||||
)
|
||||
const randomWeight = this.nextInt(totalWeight)
|
||||
let weight = 0
|
||||
for (const choice of Object.values(choices)) {
|
||||
weight += choice.weight ?? 1
|
||||
if (randomWeight < weight) {
|
||||
return choice.do()
|
||||
}
|
||||
}
|
||||
throw new Error('unreachable')
|
||||
}
|
||||
|
||||
nextPropertyName(): string {
|
||||
return this.selectOne(['foo', 'bar', 'baz', 'qux', 'mux', 'bah'])
|
||||
}
|
||||
|
||||
nextJsonValue(): any {
|
||||
return this.executeOne<any>({
|
||||
string: { weight: 1, do: () => this.nextId() },
|
||||
number: { weight: 1, do: () => this.nextFloat() },
|
||||
integer: { weight: 1, do: () => this.nextInt(100) },
|
||||
boolean: { weight: 1, do: () => this.choice(0.5) },
|
||||
null: { weight: 1, do: () => null },
|
||||
array: {
|
||||
weight: 1,
|
||||
do: () => {
|
||||
const numItems = this.nextInt(4)
|
||||
const result = []
|
||||
for (let i = 0; i < numItems; i++) {
|
||||
result.push(this.nextJsonValue())
|
||||
}
|
||||
return result
|
||||
},
|
||||
},
|
||||
object: {
|
||||
weight: 1,
|
||||
do: () => {
|
||||
const numItems = this.nextInt(4)
|
||||
const result = {} as any
|
||||
for (let i = 0; i < numItems; i++) {
|
||||
result[this.nextPropertyName()] = this.nextJsonValue()
|
||||
}
|
||||
return result
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
nextTestType(depth: number): TestType {
|
||||
if (depth >= 3) {
|
||||
return this.selectOne(Object.values(builtinTypes))
|
||||
}
|
||||
return this.executeOne<TestType>({
|
||||
primitive: () => this.selectOne(Object.values(builtinTypes)),
|
||||
array: () => generateArrayType(this, depth),
|
||||
object: () => generateObjectType(this, {}, depth),
|
||||
union: () => generateUnionType(this, depth),
|
||||
dict: () => generateDictType(this, depth),
|
||||
model: () => {
|
||||
const objType = generateObjectType(this, {}, depth)
|
||||
const name = this.nextPropertyName()
|
||||
return {
|
||||
...objType,
|
||||
validator: T.model(name, objType.validator),
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
interface TestType {
|
||||
validator: T.Validator<any>
|
||||
generateValid(source: RandomSource): any
|
||||
generateInvalid(source: RandomSource): any
|
||||
}
|
||||
const builtinTypes = {
|
||||
string: {
|
||||
validator: T.string,
|
||||
generateValid: (source) => source.selectOne(['a', 'b', 'c', 'd']),
|
||||
generateInvalid: (source) => source.selectOne([5, /regexp/, {}]),
|
||||
},
|
||||
number: {
|
||||
validator: T.number,
|
||||
generateValid: (source) => source.nextInt(5),
|
||||
generateInvalid: (source) => source.selectOne(['a', /num/]),
|
||||
},
|
||||
integer: {
|
||||
validator: T.integer,
|
||||
generateValid: (source) => source.nextInt(5),
|
||||
generateInvalid: (source) => source.selectOne([0.2, '3', 5n, /int/]),
|
||||
},
|
||||
json: {
|
||||
validator: T.jsonValue,
|
||||
generateValid: (source) => source.nextJsonValue(),
|
||||
generateInvalid: (source) => source.selectOne([/regexp/, 343n, { key: /regexp/ }]),
|
||||
},
|
||||
} as const satisfies Record<string, TestType>
|
||||
|
||||
function generateObjectType(
|
||||
source: RandomSource,
|
||||
injectProperties: Record<string, TestType>,
|
||||
depth: number
|
||||
): TestType {
|
||||
const numProperties = source.nextIntInRange(1, 5)
|
||||
const propertyTypes: Record<string, TestType> = {
|
||||
...injectProperties,
|
||||
}
|
||||
const optionalTypes = new Set<string>()
|
||||
const nullableTypes = new Set<string>()
|
||||
for (let i = 0; i < numProperties; i++) {
|
||||
const type = source.nextTestType(depth + 1)
|
||||
const name = source.nextPropertyName()
|
||||
if (source.choice(0.2)) {
|
||||
optionalTypes.add(name)
|
||||
}
|
||||
if (source.choice(0.2)) {
|
||||
nullableTypes.add(name)
|
||||
}
|
||||
let validator = type.validator
|
||||
if (nullableTypes.has(name)) {
|
||||
validator = validator.nullable()
|
||||
}
|
||||
if (optionalTypes.has(name)) {
|
||||
validator = validator.optional()
|
||||
}
|
||||
propertyTypes[name] = { ...type, validator }
|
||||
}
|
||||
|
||||
const generateValid = (source: RandomSource) => {
|
||||
const result = {} as any
|
||||
for (const [name, type] of Object.entries(propertyTypes)) {
|
||||
if (optionalTypes.has(name) && source.choice(0.2)) {
|
||||
continue
|
||||
} else if (nullableTypes.has(name) && source.choice(0.2)) {
|
||||
result[name] = null
|
||||
continue
|
||||
}
|
||||
result[name] = type.generateValid(source)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
return {
|
||||
validator: T.object(mapObjectMapValues(propertyTypes, (_, { validator }) => validator)),
|
||||
generateValid,
|
||||
generateInvalid: (source) => {
|
||||
return source.executeOne<any>({
|
||||
otherType: () =>
|
||||
source.executeOne<any>({
|
||||
string: () => source.selectOne(['a', 'b', 'c', 'd']),
|
||||
number: () => source.nextInt(5),
|
||||
array: () => [source.nextId(), source.nextFloat()],
|
||||
bool: () => true,
|
||||
}),
|
||||
missingProperty: () => {
|
||||
const val = generateValid(source)
|
||||
const keyToDelete = source.selectOne(
|
||||
Object.keys(val).filter((key) => !optionalTypes.has(key))
|
||||
)
|
||||
if (!keyToDelete) {
|
||||
// no non-optional properties, do a invalid property test instead
|
||||
val[keyToDelete] =
|
||||
propertyTypes[source.selectOne(Object.keys(propertyTypes))].generateInvalid(source)
|
||||
return val
|
||||
}
|
||||
delete val[keyToDelete]
|
||||
return val
|
||||
},
|
||||
extraProperty: () => {
|
||||
const val = generateValid(source)
|
||||
val[source.nextPropertyName() + '_'] = source.nextJsonValue()
|
||||
return val
|
||||
},
|
||||
invalidProperty: () => {
|
||||
const val = generateValid(source)
|
||||
const keyToChange = source.selectOne(Object.keys(propertyTypes))
|
||||
val[keyToChange] = propertyTypes[keyToChange].generateInvalid(source)
|
||||
return val
|
||||
},
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function generateDictType(source: RandomSource, depth: number): TestType {
|
||||
const keyType = builtinTypes.string
|
||||
const keySet = ['a', 'b', 'c', 'd', 'e', 'f'] as const
|
||||
const valueType = source.nextTestType(depth + 1)
|
||||
|
||||
const validator = T.dict(keyType.validator, valueType.validator)
|
||||
|
||||
const generateValid = (source: RandomSource) => {
|
||||
const result = {} as any
|
||||
const numItems = source.nextInt(4)
|
||||
for (let i = 0; i < numItems; i++) {
|
||||
result[source.selectOne(keySet)] = valueType.generateValid(source)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
return {
|
||||
validator,
|
||||
generateValid,
|
||||
generateInvalid: (source) => {
|
||||
const result = generateValid(source)
|
||||
const key = source.selectOne(Object.keys(result)) ?? source.nextPropertyName()
|
||||
result[key] = valueType.generateInvalid(source)
|
||||
return result
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createLiteralType(value: string): TestType {
|
||||
return {
|
||||
validator: T.literal(value),
|
||||
generateValid: () => value,
|
||||
generateInvalid: (source) => source.selectOne(['_invalid_' + value, 2324, {}]),
|
||||
}
|
||||
}
|
||||
|
||||
function generateUnionType(source: RandomSource, depth: number): TestType {
|
||||
const key = source.selectOne(['type', 'name', 'kind'])
|
||||
const numMembers = source.nextIntInRange(1, 4)
|
||||
const members: TestType[] = []
|
||||
const unionMap: Record<string, Validator<any>> = {}
|
||||
for (let i = 0; i < numMembers; i++) {
|
||||
const id = source.nextId()
|
||||
const keyType = createLiteralType(id)
|
||||
const type = generateObjectType(source, { [key]: keyType }, depth + 1)
|
||||
members.push(type)
|
||||
unionMap[id] = type.validator
|
||||
}
|
||||
const validator = T.union(key, unionMap)
|
||||
|
||||
return {
|
||||
validator,
|
||||
generateValid: (source) => {
|
||||
const member = source.selectOne(members)
|
||||
return member.generateValid(source)
|
||||
},
|
||||
generateInvalid(source) {
|
||||
return source.executeOne<any>({
|
||||
otherType: () => source.selectOne(['_invalid_', 2324, {}]),
|
||||
badMember: {
|
||||
weight: 4,
|
||||
do() {
|
||||
const member = source.selectOne(members)
|
||||
return member.generateInvalid(source)
|
||||
},
|
||||
},
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function generateArrayType(source: RandomSource, depth: number): TestType {
|
||||
const valueType = source.nextTestType(depth + 1)
|
||||
const validator = T.arrayOf(valueType.validator)
|
||||
const generateValid = (source: RandomSource) => {
|
||||
const result = [] as any[]
|
||||
const numItems = source.nextInt(4)
|
||||
for (let i = 0; i < numItems; i++) {
|
||||
result.push(valueType.generateValid(source))
|
||||
}
|
||||
return result
|
||||
}
|
||||
return {
|
||||
validator,
|
||||
generateValid,
|
||||
generateInvalid: (source) => {
|
||||
return source.executeOne<any>({
|
||||
otherType: () =>
|
||||
source.executeOne<any>({
|
||||
string: () => source.nextId(),
|
||||
number: () => source.nextInt(100),
|
||||
object: () => ({ key: source.nextId() }),
|
||||
}),
|
||||
invalidItem: () => {
|
||||
const val = generateValid(source)
|
||||
if (val.length === 0) {
|
||||
return [valueType.generateInvalid(source)]
|
||||
}
|
||||
const indexToChange = source.nextInt(val.length)
|
||||
val[indexToChange] = valueType.generateInvalid(source)
|
||||
return val
|
||||
},
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function runTest(seed: number) {
|
||||
test(`fuzz test with seed ${seed}`, () => {
|
||||
const source = new RandomSource(seed)
|
||||
const type = source.nextTestType(0)
|
||||
const oldValid = type.generateValid(source)
|
||||
const newValid = source.choice(0.5) ? type.generateValid(source) : oldValid
|
||||
const didChange = !isEqual(oldValid, newValid)
|
||||
const invalid = type.generateInvalid(source)
|
||||
|
||||
expect(type.validator.validate(oldValid)).toBe(oldValid)
|
||||
expect(type.validator.validate(newValid)).toBe(newValid)
|
||||
expect(() => {
|
||||
type.validator.validate(invalid)
|
||||
}).toThrow()
|
||||
|
||||
expect(() => type.validator.validateUsingKnownGoodVersion(oldValid, newValid)).not.toThrow()
|
||||
expect(() => type.validator.validateUsingKnownGoodVersion(oldValid, invalid)).toThrow()
|
||||
|
||||
if (didChange) {
|
||||
expect(type.validator.validateUsingKnownGoodVersion(oldValid, newValid)).toBe(newValid)
|
||||
} else {
|
||||
expect(type.validator.validateUsingKnownGoodVersion(oldValid, newValid)).toBe(oldValid)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const NUM_TESTS = 1000
|
||||
const source = new RandomSource(Math.random())
|
||||
|
||||
// 54480484
|
||||
const onlySeed: null | number = null
|
||||
|
||||
if (onlySeed) {
|
||||
runTest(onlySeed)
|
||||
} else {
|
||||
for (let i = 0; i < NUM_TESTS; i++) {
|
||||
const seed = source.nextInt(100000000)
|
||||
runTest(seed)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user