diff --git a/package-lock.json b/package-lock.json index ab59e82..f3aaf82 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,10 +12,13 @@ "@json-schema-tools/meta-schema": "^1.7.5", "@types/jest": "^29.1.1", "@types/node": "^20.12.6", + "@types/seedrandom": "^3.0.8", "@typescript-eslint/eslint-plugin": "^5.39.0", "@typescript-eslint/parser": "^5.39.0", + "ajv": "^8.17.1", "eslint": "^8.24.0", "jest": "^29.1.2", + "seedrandom": "^3.0.5", "ts-jest": "^29.0.3", "typedoc": "^0.25.13", "typescript": "^5.4.4" @@ -689,6 +692,28 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.7", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.7.tgz", @@ -1335,6 +1360,12 @@ "undici-types": "~5.26.4" } }, + "node_modules/@types/seedrandom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/seedrandom/-/seedrandom-3.0.8.tgz", + "integrity": "sha512-TY1eezMU2zH2ozQoAFAQFOPpvP15g+ZgSfTZt31AUUH/Rxtnz3H+A/Sv1Snw2/amp//omibc+AEkTaA8KUeOLQ==", + "dev": true + }, "node_modules/@types/semver": { "version": "7.5.8", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz", @@ -1572,15 +1603,15 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, "funding": { "type": "github", @@ -2267,6 +2298,22 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/eslint/node_modules/eslint-scope": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.1.1.tgz", @@ -2289,6 +2336,12 @@ "node": ">=4.0" } }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, "node_modules/espree": { "version": "9.4.0", "resolved": "https://registry.npmjs.org/espree/-/espree-9.4.0.tgz", @@ -2473,6 +2526,12 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true }, + "node_modules/fast-uri": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.3.tgz", + "integrity": "sha512-aLrHthzCjH5He4Z2H9YZ+v6Ujb9ocRuW6ZzkJQOrTxleEijANq4v1TsaPaVG1PZcuurEzrLcWRyYBYXD5cEiaw==", + "dev": true + }, "node_modules/fastq": { "version": "1.13.0", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.13.0.tgz", @@ -3574,9 +3633,9 @@ "dev": true }, "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true }, "node_modules/json-stable-stringify-without-jsonify": { @@ -4119,9 +4178,9 @@ } }, "node_modules/punycode": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", - "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, "engines": { "node": ">=6" @@ -4190,6 +4249,15 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", @@ -4294,6 +4362,12 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/seedrandom": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz", + "integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==", + "dev": true + }, "node_modules/semver": { "version": "7.6.0", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", @@ -5408,6 +5482,26 @@ "js-yaml": "^4.1.0", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" + }, + "dependencies": { + "ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + } } }, "@humanwhocodes/config-array": { @@ -5944,6 +6038,12 @@ "undici-types": "~5.26.4" } }, + "@types/seedrandom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/seedrandom/-/seedrandom-3.0.8.tgz", + "integrity": "sha512-TY1eezMU2zH2ozQoAFAQFOPpvP15g+ZgSfTZt31AUUH/Rxtnz3H+A/Sv1Snw2/amp//omibc+AEkTaA8KUeOLQ==", + "dev": true + }, "@types/semver": { "version": "7.5.8", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz", @@ -6084,15 +6184,15 @@ "requires": {} }, "ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "requires": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" } }, "ansi-escapes": { @@ -6552,6 +6652,18 @@ "text-table": "^0.2.0" }, "dependencies": { + "ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, "eslint-scope": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.1.1.tgz", @@ -6567,6 +6679,12 @@ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true + }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true } } }, @@ -6744,6 +6862,12 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true }, + "fast-uri": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.3.tgz", + "integrity": "sha512-aLrHthzCjH5He4Z2H9YZ+v6Ujb9ocRuW6ZzkJQOrTxleEijANq4v1TsaPaVG1PZcuurEzrLcWRyYBYXD5cEiaw==", + "dev": true + }, "fastq": { "version": "1.13.0", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.13.0.tgz", @@ -7573,9 +7697,9 @@ "dev": true }, "json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true }, "json-stable-stringify-without-jsonify": { @@ -7981,9 +8105,9 @@ } }, "punycode": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", - "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true }, "pure-rand": { @@ -8016,6 +8140,12 @@ "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", "dev": true }, + "require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true + }, "resolve": { "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", @@ -8080,6 +8210,12 @@ "queue-microtask": "^1.2.2" } }, + "seedrandom": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz", + "integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==", + "dev": true + }, "semver": { "version": "7.6.0", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", diff --git a/package.json b/package.json index 09d9092..ba6b936 100644 --- a/package.json +++ b/package.json @@ -30,10 +30,13 @@ "@json-schema-tools/meta-schema": "^1.7.5", "@types/jest": "^29.1.1", "@types/node": "^20.12.6", + "@types/seedrandom": "^3.0.8", "@typescript-eslint/eslint-plugin": "^5.39.0", "@typescript-eslint/parser": "^5.39.0", + "ajv": "^8.17.1", "eslint": "^8.24.0", "jest": "^29.1.2", + "seedrandom": "^3.0.5", "ts-jest": "^29.0.3", "typedoc": "^0.25.13", "typescript": "^5.4.4" diff --git a/src/fuzz.test.ts b/src/fuzz.test.ts new file mode 100644 index 0000000..4a23656 --- /dev/null +++ b/src/fuzz.test.ts @@ -0,0 +1,123 @@ +import { runTest } from "./traverse.tiger.test"; +import { JSONSchema } from "@json-schema-tools/meta-schema"; +import { TraverseOptions } from "./index"; +import Ajv from 'ajv'; +import metaSchema from '@json-schema-tools/meta-schema'; + +const ajv = new Ajv(); +ajv.addMetaSchema(metaSchema); + +const isValidJSONSchema = (schema: any): boolean => { + const valid = ajv.validate(metaSchema, schema) as boolean; + if (!valid) { + console.error('JSON Schema validation error:', ajv.errorsText()); + } + return valid; +}; + +const isValidTraverseOptions = (options: TraverseOptions): boolean => { + return ( + typeof options.skipFirstMutation === 'boolean' && + typeof options.mergeNotMutate === 'boolean' && + typeof options.mutable === 'boolean' && + typeof options.bfs === 'boolean' + ); +}; + +const checkMutation = (original: any, mutated: any, path: string, options: TraverseOptions, mutationType: 'inPlace' | 'copy'): boolean => { + if (options.skipFirstMutation && path === '$') { + return true; + } + + const originalValue = getValueAtPath(original, path); + const mutatedValue = getValueAtPath(mutated, path); + + if (options.mutable) { + if (mutationType === 'inPlace') { + return originalValue === mutatedValue && !isEqual(originalValue, mutatedValue); + } else { + return originalValue !== mutatedValue; + } + } else { + if (options.mergeNotMutate) { + if (typeof originalValue === 'object' && originalValue !== null) { + return Object.keys(mutatedValue).some(key => + !Object.prototype.hasOwnProperty.call(originalValue, key) || + !isEqual(mutatedValue[key], originalValue[key]) + ); + } else { + return !isEqual(originalValue, mutatedValue); + } + } else { + return originalValue !== mutatedValue; + } + } +}; + +const fuzz = (iterations: number) => { + for (let i = 0; i < iterations; i++) { + const seed = `fuzz-${Date.now()}-${i}`; + const { schema, result, options, mutatedPaths, mutationTypes } = runTest(seed); + + try { + expect(isValidJSONSchema(schema)).toBe(true); + expect(isValidJSONSchema(result)).toBe(true); + expect(isValidTraverseOptions(options)).toBe(true); + + if (!options.mutable && !options.skipFirstMutation) { + expect(result).not.toBe(schema); + } + + mutatedPaths.forEach(path => { + expect(checkMutation(schema, result, path, options, mutationTypes[path])).toBe(true); + }); + } catch (error) { + const errorInfo = { + seed, + schema: JSON.stringify(schema, null, 2), + result: JSON.stringify(result, null, 2), + options, + mutatedPaths, + mutationTypes + }; + console.error('Error in fuzzing run:', JSON.stringify(errorInfo, null, 2)); + console.error(error); + throw error; + } + } + + console.log(`Successfully completed ${iterations} fuzzing iterations.`); +}; + +const getValueAtPath = (obj: any, path: string): any => { + const parts = path.split('.'); + let current = obj; + for (const part of parts) { + if (current === undefined) return undefined; + if (part.includes('[') && part.includes(']')) { + const [arrayName, indexStr] = part.split('['); + const index = parseInt(indexStr.replace(']', '')); + current = current[arrayName][index]; + } else { + current = current[part]; + } + } + return current; +}; + +const isEqual = (a: any, b: any): boolean => { + if (a === b) return true; + if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false; + const keysA = Object.keys(a), keysB = Object.keys(b); + if (keysA.length !== keysB.length) return false; + for (const key of keysA) { + if (!keysB.includes(key) || !isEqual(a[key], b[key])) return false; + } + return true; +}; + +describe("Fuzz tests", () => { + it("should run 10,000 fuzz tests with a high success rate", () => { + fuzz(10000); + }); +}); diff --git a/src/generators.ts b/src/generators.ts new file mode 100644 index 0000000..db4d1d9 --- /dev/null +++ b/src/generators.ts @@ -0,0 +1,57 @@ +import { JSONSchema } from "@json-schema-tools/meta-schema"; +import seedrandom from "seedrandom"; +import { TraverseOptions } from "./index"; + +export class SchemaGenerator { + private rng: seedrandom.PRNG; + + constructor(seed: string) { + this.rng = seedrandom(seed); + } + + generateSchema(depth: number = 0): JSONSchema { + const types = ["object", "array", "string", "number", "boolean", "null"] as const; + const type = types[Math.floor(this.rng() * types.length)]; + + switch (type) { + case "object": + return this.generateObjectSchema(depth); + case "array": + return this.generateArraySchema(depth); + default: + return { type: type }; + } + } + + generateTraverseOptions(): TraverseOptions { + return { + skipFirstMutation: this.rng() > 0.5, + mergeNotMutate: this.rng() > 0.5, + mutable: this.rng() > 0.5, + bfs: this.rng() > 0.5, + }; + } + + private generateObjectSchema(depth: number): JSONSchema { + const properties: Record = {}; + const propertyCount = Math.floor(this.rng() * 5) + 1; + + for (let i = 0; i < propertyCount; i++) { + const propertyName = `prop${i}`; + properties[propertyName] = depth < 3 ? this.generateSchema(depth + 1) : { type: "string" }; + } + + return { + type: "object", + properties, + required: Object.keys(properties).filter(() => this.rng() > 0.5) + }; + } + + private generateArraySchema(depth: number): JSONSchema { + return { + type: "array", + items: depth < 3 ? this.generateSchema(depth + 1) : { type: "string" } + }; + } +} diff --git a/src/traverse.tiger.test.ts b/src/traverse.tiger.test.ts new file mode 100644 index 0000000..b9bdbc0 --- /dev/null +++ b/src/traverse.tiger.test.ts @@ -0,0 +1,153 @@ +import traverse, { MutationFunction, TraverseOptions } from "../src/index"; +import { SchemaGenerator } from "./generators"; +import { JSONSchema } from "@json-schema-tools/meta-schema"; +import seedrandom from "seedrandom"; + +type ASTNode = + | { type: 'addProperty', key: string, value: any } + | { type: 'removeProperty', key: string } + | { type: 'modifyType', newType: string } + | { type: 'addEnum', values: any[] } + | { type: 'addFormat', format: string } + | { type: 'addPattern', pattern: string } + | { type: 'addRequired', key: string } + | { type: 'addItems', items: any } + | { type: 'addAdditionalProperties', value: boolean | object } + | { type: 'addMinMax', min?: number, max?: number } + | { type: 'addMultipleOf', value: number }; + +function generateRandomASTNode(rng: seedrandom.PRNG): ASTNode { + const nodeTypes = [ + 'addProperty', 'removeProperty', 'modifyType', 'addEnum', 'addFormat', + 'addPattern', 'addRequired', 'addItems', 'addAdditionalProperties', + 'addMinMax', 'addMultipleOf' + ]; + const selectedType = nodeTypes[Math.floor(rng() * nodeTypes.length)]; + + switch (selectedType) { + case 'addProperty': + return { type: 'addProperty', key: `prop${Math.floor(rng() * 1000)}`, value: generateRandomValue(rng) }; + case 'removeProperty': + return { type: 'removeProperty', key: `prop${Math.floor(rng() * 1000)}` }; + case 'modifyType': + return { type: 'modifyType', newType: ['string', 'number', 'boolean', 'object', 'array'][Math.floor(rng() * 5)] }; + case 'addEnum': + return { type: 'addEnum', values: Array.from({ length: Math.floor(rng() * 5) + 1 }, () => generateRandomValue(rng)) }; + case 'addFormat': + return { type: 'addFormat', format: ['date-time', 'email', 'hostname', 'ipv4', 'ipv6', 'uri'][Math.floor(rng() * 6)] }; + case 'addPattern': + return { type: 'addPattern', pattern: `^[a-z]{${Math.floor(rng() * 5) + 1}}$` }; + case 'addRequired': + return { type: 'addRequired', key: `prop${Math.floor(rng() * 1000)}` }; + case 'addItems': + return { type: 'addItems', items: generateRandomValue(rng) }; + case 'addAdditionalProperties': + return { type: 'addAdditionalProperties', value: rng() > 0.5 ? true : generateRandomValue(rng) }; + case 'addMinMax': + return { type: 'addMinMax', min: Math.floor(rng() * 10), max: Math.floor(rng() * 90) + 10 }; + case 'addMultipleOf': + return { type: 'addMultipleOf', value: Math.floor(rng() * 10) + 1 }; + default: + throw new Error('Unexpected node type'); + } +} + +function generateRandomValue(rng: seedrandom.PRNG): any { + const types = ['string', 'number', 'boolean', 'object', 'array']; + const type = types[Math.floor(rng() * types.length)]; + + switch (type) { + case 'string': + return `value${Math.floor(rng() * 1000)}`; + case 'number': + return Math.floor(rng() * 1000); + case 'boolean': + return rng() > 0.5; + case 'object': + return { [`key${Math.floor(rng() * 100)}`]: generateRandomValue(rng) }; + case 'array': + return Array.from({ length: Math.floor(rng() * 3) + 1 }, () => generateRandomValue(rng)); + } +} + +function applyASTNode(schema: any, node: ASTNode): any { + switch (node.type) { + case 'addProperty': + return { ...schema, properties: { ...schema.properties, [node.key]: node.value } }; + case 'removeProperty': + const { [node.key]: _, ...rest } = schema.properties || {}; + return { ...schema, properties: rest }; + case 'modifyType': + return { ...schema, type: node.newType }; + case 'addEnum': + return { ...schema, enum: node.values }; + case 'addFormat': + return { ...schema, format: node.format }; + case 'addPattern': + return { ...schema, pattern: node.pattern }; + case 'addRequired': + return { ...schema, required: [...(schema.required || []), node.key] }; + case 'addItems': + return { ...schema, items: node.items }; + case 'addAdditionalProperties': + return { ...schema, additionalProperties: node.value }; + case 'addMinMax': + return { ...schema, minimum: node.min, maximum: node.max }; + case 'addMultipleOf': + return { ...schema, multipleOf: node.value }; + default: + return schema; + } +} + +export const runTest = (seed: string) => { + const generator = new SchemaGenerator(seed); + const schema = generator.generateSchema(); + const options = generator.generateTraverseOptions(); + const mutatedPaths: string[] = []; + const mutationTypes: { [path: string]: 'inPlace' | 'copy' } = {}; + const rng = seedrandom(seed); + + const mutation: MutationFunction = (s, isCycle, path) => { + if (typeof s === "object" && s !== null) { + mutatedPaths.push(path); + const numMutations = Math.floor(rng() * 3) + 1; // Apply 1-3 mutations + const shouldModifyInPlace = rng() < 0.5; // 50% chance to modify in place + mutationTypes[path] = shouldModifyInPlace ? 'inPlace' : 'copy'; + let mutatedSchema = shouldModifyInPlace ? s : { ...s }; + + for (let i = 0; i < numMutations; i++) { + const astNode = generateRandomASTNode(rng); + mutatedSchema = applyASTNode(mutatedSchema, astNode); + } + + return mutatedSchema; + } + return s; + }; + + const result = traverse(schema, mutation, options); + + return { seed, schema, result, options, mutatedPaths, mutationTypes }; +}; + +describe("TigerStyle traverse tests", () => { + + const testCases = [ + { seed: "test1", description: "Simple schema" }, + { seed: "test2", description: "Complex nested schema" }, + { seed: "test3", description: "Schema with arrays" }, + // Add more test cases as needed + ]; + + testCases.forEach(({ seed, description }) => { + it(`should produce deterministic results for ${description} (seed: ${seed})`, () => { + const run1 = runTest(seed); + const run2 = runTest(seed); + + expect(run1.schema).toEqual(run2.schema); + expect(run1.result).toEqual(run2.result); + expect(run1.options).toEqual(run2.options); + }); + }); +});