diff --git a/.gitignore b/.gitignore index 067b9d5b..f0623dc0 100644 --- a/.gitignore +++ b/.gitignore @@ -103,4 +103,6 @@ typings/ # TernJS port file .tern-port -src/schemas/build \ No newline at end of file +# Build directories +src/schemas/build +build/ \ No newline at end of file diff --git a/.npmignore b/.npmignore index 16e89c26..e4b99345 100644 --- a/.npmignore +++ b/.npmignore @@ -113,4 +113,11 @@ docs/ test/ .coderabbit.yaml .gitignore -CONTRIBUTIONS.md \ No newline at end of file +CONTRIBUTIONS.md + +# TypeScript source files (use compiled output in build/) +src/ +tsconfig.json + +# Schema build directory +src/schemas/build/ \ No newline at end of file diff --git a/README.md b/README.md index f2dac152..f8c938ce 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Doc Detective Common -Shared components for Doc Detective projects. +Shared components for Doc Detective projects. Written in TypeScript with Zod for type-safe schema validation. ## ๐Ÿ“ฆ Installation @@ -25,22 +25,41 @@ This package automatically publishes development versions on every commit to the This package exports the following components: - `schemas` - JSON schemas for validation -- `validate` - Validation functions +- `validate` - Validation functions using AJV - `resolvePaths` - Path resolution utilities - `readFile` - File reading utilities - `transformToSchemaKey` - Schema key transformation +### TypeScript & Zod Schemas + +The package includes Zod schemas for type inference: + +```typescript +import { + configV3Schema, + stepV3Schema, + type ConfigV3, + type StepV3 +} from 'doc-detective-common'; + +// Type-safe validation +const config: ConfigV3 = configV3Schema.parse(myConfigObject); +``` + ## ๐Ÿงช Development ```bash # Install dependencies npm install +# Build TypeScript and schemas +npm run build + # Run tests npm test -# Build schemas -npm run build +# Compile TypeScript only +npm run compile ``` ## ๐Ÿ“„ License diff --git a/package-lock.json b/package-lock.json index 633aa5ad..23229991 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,12 +15,18 @@ "ajv-formats": "^3.0.1", "ajv-keywords": "^5.1.0", "axios": "^1.13.2", - "yaml": "^2.8.2" + "uuid": "^13.0.0", + "yaml": "^2.8.2", + "zod": "^3.24.3" }, "devDependencies": { + "@types/node": "^24.10.1", + "@types/uuid": "^10.0.0", "chai": "^6.2.1", "mocha": "^11.7.5", - "sinon": "^21.0.0" + "sinon": "^21.0.0", + "ts-node": "^10.9.2", + "typescript": "^5.9.3" } }, "node_modules/@apidevtools/json-schema-ref-parser": { @@ -38,6 +44,19 @@ "@types/json-schema": "^7.0.15" } }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -141,6 +160,34 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -191,12 +238,83 @@ "node": ">=4" } }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "peer": true }, + "node_modules/@types/node": { + "version": "24.10.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", + "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/ajv": { "version": "8.17.1", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", @@ -272,6 +390,13 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -418,6 +543,13 @@ "node": ">= 0.8" } }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -900,6 +1032,13 @@ "dev": true, "license": "ISC" }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -1346,6 +1485,60 @@ "node": ">=8" } }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/ts-node/node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", @@ -1355,6 +1548,47 @@ "node": ">=4" } }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/uuid": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -1496,6 +1730,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -1507,6 +1751,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "3.24.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.3.tgz", + "integrity": "sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 1bfacfa2..6151fa72 100644 --- a/package.json +++ b/package.json @@ -2,10 +2,12 @@ "name": "doc-detective-common", "version": "3.6.0", "description": "Shared components for Doc Detective projects.", - "main": "src/index.js", + "main": "build/index.js", + "types": "build/index.d.ts", "scripts": { - "dereferenceSchemas": "node ./src/schemas/dereferenceSchemas.js", - "build": "npm run dereferenceSchemas", + "dereferenceSchemas": "npx ts-node ./src/schemas/dereferenceSchemas.ts", + "compile": "tsc", + "build": "npm run dereferenceSchemas && npm run compile", "postbuild": "npm run test", "test": "mocha" }, @@ -20,9 +22,13 @@ }, "homepage": "https://github.com/doc-detective/doc-detective-common#readme", "devDependencies": { + "@types/node": "^24.10.1", + "@types/uuid": "^10.0.0", "chai": "^6.2.1", "mocha": "^11.7.5", - "sinon": "^21.0.0" + "sinon": "^21.0.0", + "ts-node": "^10.9.2", + "typescript": "^5.9.3" }, "dependencies": { "@apidevtools/json-schema-ref-parser": "^15.1.3", @@ -31,6 +37,8 @@ "ajv-formats": "^3.0.1", "ajv-keywords": "^5.1.0", "axios": "^1.13.2", - "yaml": "^2.8.2" + "uuid": "^13.0.0", + "yaml": "^2.8.2", + "zod": "^3.24.3" } } diff --git a/src/files.js b/src/files.ts similarity index 59% rename from src/files.js rename to src/files.ts index 4bb92aae..da387695 100644 --- a/src/files.js +++ b/src/files.ts @@ -1,20 +1,24 @@ -const fs = require("fs"); -const YAML = require("yaml"); -const axios = require("axios"); -const { URL } = require("url"); +import * as fs from "fs"; +import YAML from "yaml"; +import axios from "axios"; +import { URL } from "url"; + +export interface ReadFileOptions { + fileURLOrPath: string; +} /** * Reads and parses content from a remote URL or local file path, supporting JSON and YAML formats. * * Attempts to parse the file content as JSON first, then YAML. If both parsing attempts fail, returns the raw content as a string. Returns `null` if the file cannot be read. * - * @param {Object} options - * @param {string} options.fileURLOrPath - The URL or local file path to read. - * @returns {Promise} Parsed object for JSON or YAML files, raw string for other formats, or `null` if reading fails. + * @param options - The options for reading the file. + * @param options.fileURLOrPath - The URL or local file path to read. + * @returns Parsed object for JSON or YAML files, raw string for other formats, or `null` if reading fails. * - * @throws {Error} If {@link fileURLOrPath} is missing, not a string, or is an empty string. + * @throws Error If fileURLOrPath is missing, not a string, or is an empty string. */ -async function readFile({ fileURLOrPath }) { +export async function readFile({ fileURLOrPath }: ReadFileOptions): Promise { if (!fileURLOrPath) { throw new Error("fileURLOrPath is required"); } @@ -25,14 +29,14 @@ async function readFile({ fileURLOrPath }) { throw new Error("fileURLOrPath cannot be an empty string"); } - let content; + let content: string; let isRemote = false; try { const parsedURL = new URL(fileURLOrPath); isRemote = parsedURL.protocol === "http:" || parsedURL.protocol === "https:"; - } catch (error) { + } catch { // Not a valid URL, assume local file path } @@ -42,7 +46,7 @@ async function readFile({ fileURLOrPath }) { content = response.data; } catch (error) { console.warn( - `Error reading remote file from ${fileURLOrPath}: ${error.message}` + `Error reading remote file from ${fileURLOrPath}: ${(error as Error).message}` ); return null; } @@ -50,35 +54,33 @@ async function readFile({ fileURLOrPath }) { try { content = await fs.promises.readFile(fileURLOrPath, "utf8"); } catch (error) { - if (error.code === "ENOENT") { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { console.warn(`File not found: ${fileURLOrPath}`); } else { - console.warn(`Error reading file: ${error.message}`); + console.warn(`Error reading file: ${(error as Error).message}`); } return null; } } // Parse based on file extension - const ext = fileURLOrPath.split('.').pop().toLowerCase(); + const ext = fileURLOrPath.split('.').pop()?.toLowerCase(); if (ext === "json") { try { return JSON.parse(content); } catch (error) { - console.warn(`Failed to parse JSON: ${error.message}`); + console.warn(`Failed to parse JSON: ${(error as Error).message}`); return content; } } else if (ext === "yaml" || ext === "yml") { try { return YAML.parse(content); } catch (error) { - console.warn(`Failed to parse YAML: ${error.message}`); + console.warn(`Failed to parse YAML: ${(error as Error).message}`); return content; } } else { return content; } } - -module.exports = { readFile }; diff --git a/src/index.js b/src/index.js deleted file mode 100644 index c1f0f0c2..00000000 --- a/src/index.js +++ /dev/null @@ -1,12 +0,0 @@ -const { schemas } = require("./schemas"); -const { validate, transformToSchemaKey } = require("./validate"); -const { resolvePaths } = require("./resolvePaths"); -const { readFile } = require("./files"); - -module.exports = { - schemas, - validate, - resolvePaths, - readFile, - transformToSchemaKey, -}; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 00000000..972d7f1e --- /dev/null +++ b/src/index.ts @@ -0,0 +1,16 @@ +import { schemas } from "./schemas"; +import { validate, transformToSchemaKey } from "./validate"; +import { resolvePaths } from "./resolvePaths"; +import { readFile } from "./files"; + +// Re-export Zod schemas and types +export * from "./zodSchemas"; + +// Export main functionality +export { + schemas, + validate, + resolvePaths, + readFile, + transformToSchemaKey, +}; diff --git a/src/resolvePaths.js b/src/resolvePaths.ts similarity index 64% rename from src/resolvePaths.js rename to src/resolvePaths.ts index d6304de1..34ce28ec 100644 --- a/src/resolvePaths.js +++ b/src/resolvePaths.ts @@ -1,31 +1,39 @@ -const fs = require("fs"); -const path = require("path"); -const { validate } = require("./validate"); +import * as fs from "fs"; +import * as path from "path"; +import { validate } from "./validate"; -exports.resolvePaths = resolvePaths; +export interface ResolvePathsOptions { + config: { + relativePathBase?: "file" | "cwd"; + [key: string]: unknown; + }; + object: Record; + filePath: string; + nested?: boolean; + objectType?: "config" | "spec"; +} /** * Recursively resolves all relative path properties in a configuration or specification object to absolute paths. * * Traverses the provided object, converting all recognized path-related properties to absolute paths using the given configuration and reference file path. Supports nested objects and distinguishes between config and spec objects based on schema validation. Throws an error if the object is not a valid config or spec, or if the object type is missing for nested objects. * - * @async - * @param {Object} options - Options for path resolution. - * @param {Object} options.config - Configuration object containing settings such as `relativePathBase`. - * @param {Object} options.object - The config or spec object whose path properties will be resolved. - * @param {string} options.filePath - Reference file path used for resolving relative paths. - * @param {boolean} [options.nested=false] - Indicates if this is a recursive call for a nested object. - * @param {string} [options.objectType] - Specifies the object type ('config' or 'spec'); required for nested objects. - * @returns {Promise} The object with all applicable path properties resolved to absolute paths. - * @throws {Error} If the object is neither a valid config nor spec, or if `objectType` is missing for nested objects. + * @param options - Options for path resolution. + * @param options.config - Configuration object containing settings such as `relativePathBase`. + * @param options.object - The config or spec object whose path properties will be resolved. + * @param options.filePath - Reference file path used for resolving relative paths. + * @param options.nested - Indicates if this is a recursive call for a nested object. + * @param options.objectType - Specifies the object type ('config' or 'spec'); required for nested objects. + * @returns The object with all applicable path properties resolved to absolute paths. + * @throws Error If the object is neither a valid config nor spec, or if `objectType` is missing for nested objects. */ -async function resolvePaths({ +export async function resolvePaths({ config, object, filePath, nested = false, objectType, -}) { +}: ResolvePathsOptions): Promise> { // Config properties that contain paths const configPaths = [ "input", @@ -70,15 +78,12 @@ async function resolvePaths({ /** * Resolves a relative path to an absolute path using a specified base type and reference file path. * - * @param {string} baseType - Indicates whether to resolve relative to the reference file's directory ("file") or the current working directory ("cwd"). - * @param {string} relativePath - The path to resolve, which may be relative or absolute. - * @param {string} filePath - The reference file or directory path used for resolution. - * @returns {string} The absolute path corresponding to {@link relativePath}. - * - * @remark If {@link relativePath} is already absolute, it is returned unchanged. If {@link filePath} does not exist, its extension is used to infer whether it is a file or directory. - * @remark HTTP and HTTPS URLs are returned unchanged without resolution. + * @param baseType - Indicates whether to resolve relative to the reference file's directory ("file") or the current working directory ("cwd"). + * @param relativePath - The path to resolve, which may be relative or absolute. + * @param referencePath - The reference file or directory path used for resolution. + * @returns The absolute path corresponding to relativePath. */ - function resolve(baseType, relativePath, filePath) { + function resolve(baseType: "file" | "cwd" | undefined, relativePath: string, referencePath: string): string { // If the path is an http:// or https:// URL, return it if (relativePath.startsWith("https://") || relativePath.startsWith("http://")) { return relativePath; @@ -90,13 +95,13 @@ async function resolvePaths({ } // Check if filePath exists and is a file - const fileExists = fs.existsSync(filePath); + const fileExists = fs.existsSync(referencePath); const isFile = fileExists - ? fs.lstatSync(filePath).isFile() - : path.parse(filePath).ext !== ""; + ? fs.lstatSync(referencePath).isFile() + : path.parse(referencePath).ext !== ""; // Use directory of filePath if it's a file (or looks like one) - const basePath = isFile ? path.dirname(filePath) : filePath; + const basePath = isFile ? path.dirname(referencePath) : referencePath; // Resolve the path based on the base type return baseType === "file" @@ -106,7 +111,7 @@ async function resolvePaths({ const relativePathBase = config.relativePathBase; - let pathProperties; + let pathProperties: string[]; if (!nested && !objectType) { // Check if object matches the config schema const validation = validate({ @@ -118,11 +123,11 @@ async function resolvePaths({ objectType = "config"; } else { // Check if object matches the spec schema - const validation = validate({ + const specValidation = validate({ schemaKey: "spec_v3", object: { ...object }, }); - if (validation.valid) { + if (specValidation.valid) { pathProperties = specPaths; objectType = "spec"; } else { @@ -138,6 +143,8 @@ async function resolvePaths({ } else if (objectType === "spec") { // If the object type is spec, use specPaths pathProperties = specPaths; + } else { + pathProperties = []; } // If the object is null or empty, return it as is @@ -146,16 +153,18 @@ async function resolvePaths({ } for (const property of Object.keys(object)) { + const value = object[property]; + // If the property is an array, recursively call resolvePaths for each item in the array - if (Array.isArray(object[property])) { - for (let i = 0; i < object[property].length; i++) { - const item = object[property][i]; + if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) { + const item = value[i]; // If the item is an object, recursively call resolvePaths to resolve paths within the object - if (typeof item === "object") { + if (typeof item === "object" && item !== null) { await resolvePaths({ config: config, - object: item, + object: item as Record, filePath: filePath, nested: true, objectType: objectType, @@ -165,53 +174,56 @@ async function resolvePaths({ pathProperties.includes(property) ) { // Resolve the string path and write it back into the array + const directory = object.directory as string | undefined; const resolved = property === "path" && - object.directory && - path.isAbsolute(object.directory) - ? resolve(relativePathBase, item, object.directory) + directory && + path.isAbsolute(directory) + ? resolve(relativePathBase, item, directory) : resolve(relativePathBase, item, filePath); - object[property][i] = resolved; + value[i] = resolved; } } } // If the property is an object, recursively call resolvePaths to resolve paths within the object else if ( - typeof object[property] === "object" && + typeof value === "object" && + value !== null && ((objectType === "spec" && !specNoResolve.includes(property)) || objectType === "config") ) { // If the property is an object, recursively call resolvePaths to resolve paths within the object object[property] = await resolvePaths({ config: config, - object: object[property], + object: value as Record, filePath: filePath, nested: true, objectType: objectType, }); - } else if (typeof object[property] === "string") { + } else if (typeof value === "string") { // If the property begins with "https://" or "http://", skip it if ( - object[property].startsWith("https://") || - object[property].startsWith("http://") + value.startsWith("https://") || + value.startsWith("http://") ) { continue; } // Check if it matches any of the path properties and resolve it if it does if (pathProperties.includes(property)) { - if (property === "path" && object.directory) { - const directory = path.isAbsolute(object.directory) - ? object.directory - : resolve(relativePathBase, object.directory, filePath); + const directory = object.directory as string | undefined; + if (property === "path" && directory) { + const resolvedDirectory = path.isAbsolute(directory) + ? directory + : resolve(relativePathBase, directory, filePath); object[property] = resolve( relativePathBase, - object[property], - directory + value, + resolvedDirectory ); } else { object[property] = resolve( relativePathBase, - object[property], + value, filePath ); } @@ -226,7 +238,7 @@ if (require.main === module) { (async () => { // Example usage const config = { - relativePathBase: "file", + relativePathBase: "file" as const, }; const object = { tests: [ diff --git a/src/schemas/dereferenceSchemas.js b/src/schemas/dereferenceSchemas.ts similarity index 76% rename from src/schemas/dereferenceSchemas.js rename to src/schemas/dereferenceSchemas.ts index cd1d7c5d..18192809 100644 --- a/src/schemas/dereferenceSchemas.js +++ b/src/schemas/dereferenceSchemas.ts @@ -1,6 +1,11 @@ -const parser = require("@apidevtools/json-schema-ref-parser"); -const path = require("path"); -const fs = require("fs"); +import parser from "@apidevtools/json-schema-ref-parser"; +import * as path from "path"; +import * as fs from "fs"; + +interface Schema { + $id?: string; + [key: string]: unknown; +} (async () => { await dereferenceSchemas(); @@ -10,10 +15,8 @@ const fs = require("fs"); * Processes JSON schema files by updating reference paths, dereferencing all `$ref` pointers, and generating fully resolved schema outputs. * * For each schema in the input directory, this function updates reference paths, writes intermediate schemas to a build directory, dereferences all references, removes `$id` properties, and writes the final schemas to an output directory. It also creates a consolidated `schemas.json` file containing all dereferenced schemas keyed by filename. - * - * @remark The function assumes all schema files listed exist in the input directory and does not handle missing files or invalid JSON beyond throwing synchronous errors. */ -async function dereferenceSchemas() { +async function dereferenceSchemas(): Promise { const inputDir = path.resolve(`${__dirname}/src_schemas`); const buildDir = path.resolve(`${__dirname}/build`); fs.mkdirSync(buildDir, { recursive: true }); @@ -23,7 +26,6 @@ async function dereferenceSchemas() { fs.mkdirSync(distDir, { recursive: true }); // List of schema files to process - // These files should be present in the input directory const files = [ // v3 schemas "checkLink_v3.schema.json", @@ -74,7 +76,7 @@ async function dereferenceSchemas() { // Update schema reference paths console.log("Updating schema reference paths..."); for (const file of files) { - console.log(`File: ${file}`) + console.log(`File: ${file}`); const filePath = path.resolve(`${inputDir}/${file}`); const buildFilePath = path.resolve(`${buildDir}/${file}`); try { @@ -83,8 +85,8 @@ async function dereferenceSchemas() { throw new Error(`File not found: ${filePath}`); } // Load schema - let schema = fs.readFileSync(filePath).toString(); - schema = JSON.parse(schema); + const schemaContent = fs.readFileSync(filePath).toString(); + let schema = JSON.parse(schemaContent) as Schema; // Update references to current relative path schema.$id = `${filePath}`; @@ -105,15 +107,16 @@ async function dereferenceSchemas() { const outputFilePath = path.resolve(`${outputDir}/${file}`); try { // Check if file exists - if (!fs.existsSync(filePath)) + if (!fs.existsSync(filePath)) { throw new Error(`File not found: ${filePath}`); + } // Load schema - let schema = fs.readFileSync(filePath).toString(); - schema = JSON.parse(schema); + const schemaContent = fs.readFileSync(filePath).toString(); + let schema = JSON.parse(schemaContent) as Schema; // Dereference schema - schema = await parser.dereference(schema); + schema = await parser.dereference(schema) as Schema; // Delete $id attributes schema = deleteDollarIds(schema); @@ -123,13 +126,15 @@ async function dereferenceSchemas() { console.error(`Error processing ${file}:`, err); } } + // Build final schemas.json file console.log("Building schemas.json file..."); - const schemas = {}; - files.forEach(async (file) => { + const schemas: Record = {}; + files.forEach((file) => { const key = file.replace(".schema.json", ""); // Load schema from file - let schema = require(`${outputDir}/${file}`); + const schemaContent = fs.readFileSync(`${outputDir}/${file}`).toString(); + const schema = JSON.parse(schemaContent) as Schema; // Load into `schema` object schemas[key] = schema; }); @@ -138,11 +143,6 @@ async function dereferenceSchemas() { JSON.stringify(schemas, null, 2) ); - // Clean up build dir - // fs.rm(buildDir, { recursive: true }, (err) => { - // if (err) throw err; - // }); - // Publish v3 schemas to distribution directory const publishedSchemas = files.filter(file => file.includes('_v3.schema.json')); @@ -166,20 +166,19 @@ async function dereferenceSchemas() { } // Prepend app-root path to referenced relative paths -function updateRefPaths(schema) { +function updateRefPaths(schema: Schema): Schema { if (schema === null || typeof schema !== "object") return schema; - for (let [key, value] of Object.entries(schema)) { - if (typeof value === "object") { - updateRefPaths(value); + for (const [key, value] of Object.entries(schema)) { + if (typeof value === "object" && value !== null) { + updateRefPaths(value as Schema); } - if (key === "$ref" && !value.startsWith("#")) { + if (key === "$ref" && typeof value === "string" && !value.startsWith("#")) { // File name of the referenced schema - valueFile = value.split("#")[0]; + const valueFile = value.split("#")[0]; // Attribute path in the referenced schema - valueAttribute = value.split("#")[1]; - valuePath = path.resolve(`${__dirname}/build/${valueFile}`); + const valueAttribute = value.split("#")[1]; + const valuePath = path.resolve(`${__dirname}/build/${valueFile}`); schema[key] = `${valuePath}#${valueAttribute}`; - // console.log({value, valueFile, valueAttribute, final: schema[key]}) } } return schema; @@ -188,14 +187,14 @@ function updateRefPaths(schema) { /** * Recursively removes all `$id` properties from a JSON schema object. * - * @param {object} schema - The JSON schema object to process. - * @returns {object} The schema object with all `$id` properties deleted. + * @param schema - The JSON schema object to process. + * @returns The schema object with all `$id` properties deleted. */ -function deleteDollarIds(schema) { +function deleteDollarIds(schema: Schema): Schema { if (schema === null || typeof schema !== "object") return schema; - for (let [key, value] of Object.entries(schema)) { - if (typeof value === "object") { - deleteDollarIds(value); + for (const [key, value] of Object.entries(schema)) { + if (typeof value === "object" && value !== null) { + deleteDollarIds(value as Schema); } if (key === "$id") { delete schema[key]; diff --git a/src/schemas/index.js b/src/schemas/index.js deleted file mode 100644 index 1a87702f..00000000 --- a/src/schemas/index.js +++ /dev/null @@ -1,6 +0,0 @@ -const schemas = require("./schemas.json"); - -// Exports -exports.schemas = schemas; - -// console.log(schemas); \ No newline at end of file diff --git a/src/schemas/index.ts b/src/schemas/index.ts new file mode 100644 index 00000000..c858320a --- /dev/null +++ b/src/schemas/index.ts @@ -0,0 +1,15 @@ +import schemasJson from "./schemas.json"; + +// Type definition for JSON schemas +export type JsonSchema = { + $schema?: string; + title?: string; + description?: string; + type?: string; + properties?: Record; + examples?: unknown[]; + [key: string]: unknown; +}; + +// Export the JSON schemas with explicit type +export const schemas: Record = schemasJson; diff --git a/src/validate.js b/src/validate.ts similarity index 51% rename from src/validate.js rename to src/validate.ts index 423430da..b2ea3087 100644 --- a/src/validate.js +++ b/src/validate.ts @@ -1,14 +1,14 @@ -const { schemas } = require("./schemas"); -const Ajv = require("ajv"); -// Ajv extra formats: https://ajv.js.org/packages/ajv-formats.html -const addFormats = require("ajv-formats"); -// Ajv extra keywords: https://ajv.js.org/packages/ajv-keywords.html -const addKeywords = require("ajv-keywords"); -// Ajv custom errors: https://ajv.js.org/packages/ajv-errors.html -const addErrors = require("ajv-errors"); -const { randomUUID } = require("crypto"); - -// Configure base Ajv +import { schemas as jsonSchemas } from "./schemas"; +import Ajv from "ajv"; +import addFormats from "ajv-formats"; +import addKeywords from "ajv-keywords"; +import addErrors from "ajv-errors"; +import { randomUUID } from "crypto"; + +// Re-export Zod schemas for type inference +export * from "./zodSchemas"; + +// Configure base Ajv for backward compatibility with v2 schemas const ajv = new Ajv({ strictSchema: false, useDefaults: true, @@ -26,16 +26,13 @@ addFormats(ajv); addKeywords(ajv); addErrors(ajv); -// Exports -exports.validate = validate; -exports.transformToSchemaKey = transformToSchemaKey; - -// Add all schemas from `schema` object. -for (const [key, value] of Object.entries(schemas)) { +// Add all JSON schemas from `schema` object for backward compatibility +for (const [key, value] of Object.entries(jsonSchemas)) { ajv.addSchema(value, key); } -const compatibleSchemas = { +// Map of compatible schemas for backward compatibility transformations +const compatibleSchemas: Record = { config_v3: ["config_v2"], context_v3: ["context_v2"], openApi_v3: ["openApi_v2"], @@ -57,14 +54,26 @@ const compatibleSchemas = { test_v3: ["test_v2"], }; +export interface ValidateOptions { + schemaKey: string; + object: unknown; + addDefaults?: boolean; +} + +export interface ValidationResult { + valid: boolean; + errors: string; + object: unknown; +} + /** * Escapes special characters in a string for safe use in a regular expression pattern. * - * @param {string} string - The input string to escape. - * @returns {string} The escaped string, safe for use in regular expressions. + * @param string - The input string to escape. + * @returns The escaped string, safe for use in regular expressions. */ -function escapeRegExp(string) { - return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string +function escapeRegExp(string: string): string { + return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } /** @@ -72,23 +81,29 @@ function escapeRegExp(string) { * * If validation against the target schema fails and compatible older schemas are defined, attempts validation against each compatible schema. On a match, transforms the object to the target schema and revalidates. Returns the validation result, any errors, and the (possibly transformed) object. * - * @param {Object} options - * @param {string} options.schemaKey - The key identifying the target JSON schema. - * @param {Object} options.object - The object to validate. - * @param {boolean} [options.addDefaults=true] - Whether to include default values in the returned object. - * @returns {{ valid: boolean, errors: string, object: Object }} Validation result, error messages, and the validated (and possibly transformed) object. + * @param options - Validation options. + * @param options.schemaKey - The key identifying the target JSON schema. + * @param options.object - The object to validate. + * @param options.addDefaults - Whether to include default values in the returned object. + * @returns Validation result, error messages, and the validated (and possibly transformed) object. * - * @throws {Error} If {@link schemaKey} or {@link object} is missing. + * @throws Error If schemaKey or object is missing. */ -function validate({ schemaKey, object, addDefaults = true }) { +export function validate({ schemaKey, object, addDefaults = true }: ValidateOptions): ValidationResult { if (!schemaKey) { throw new Error("Schema key is required."); } if (!object) { throw new Error("Object is required."); } - const result = {}; - let validationObject; + + const result: ValidationResult = { + valid: false, + errors: "", + object: object, + }; + + let validationObject: Record; let check = ajv.getSchema(schemaKey); if (!check) { result.valid = false; @@ -101,7 +116,7 @@ function validate({ schemaKey, object, addDefaults = true }) { validationObject = JSON.parse(JSON.stringify(object)); // Check if the object is compatible with the schema - result.valid = check(validationObject); + result.valid = check(validationObject) as boolean; result.errors = ""; if (check.errors) { @@ -122,8 +137,9 @@ function validate({ schemaKey, object, addDefaults = true }) { } const matchedSchemaKey = compatibleSchemasList.find((key) => { validationObject = JSON.parse(JSON.stringify(object)); - const check = ajv.getSchema(key); - if (check(validationObject)) return key; + const compatCheck = ajv.getSchema(key); + if (compatCheck && (compatCheck(validationObject) as boolean)) return true; + return false; }); if (!matchedSchemaKey) { result.errors = check.errors @@ -144,7 +160,7 @@ function validate({ schemaKey, object, addDefaults = true }) { object: validationObject, }); - result.valid = check(transformedObject); + result.valid = check(transformedObject) as boolean; if (result.valid) { validationObject = transformedObject; object = transformedObject; @@ -169,41 +185,46 @@ function validate({ schemaKey, object, addDefaults = true }) { return result; } +export interface TransformOptions { + currentSchema: string; + targetSchema: string; + object: Record; +} + /** * Transforms an object from one JSON schema version to another, supporting multiple schema types and nested conversions. * - * @param {Object} params - * @param {string} params.currentSchema - The schema key of the object's current version. - * @param {string} params.targetSchema - The schema key to which the object should be transformed. - * @param {Object} params.object - The object to transform. - * @returns {Object} The transformed object, validated against the target schema. - * - * @throws {Error} If transformation between the specified schemas is not supported, or if the transformed object fails validation. + * @param params - Transformation options. + * @param params.currentSchema - The schema key of the object's current version. + * @param params.targetSchema - The schema key to which the object should be transformed. + * @param params.object - The object to transform. + * @returns The transformed object, validated against the target schema. * - * @remark - * Supports deep and recursive transformations for complex schema types, including steps, configs, contexts, OpenAPI integrations, specs, and tests. Throws if the schemas are incompatible or if the resulting object does not conform to the target schema. + * @throws Error If transformation between the specified schemas is not supported, or if the transformed object fails validation. */ -function transformToSchemaKey({ +export function transformToSchemaKey({ currentSchema = "", targetSchema = "", object = {}, -}) { +}: TransformOptions): Record { // Check if the current schema is the same as the target schema if (currentSchema === targetSchema) { return object; } // Check if the current schema is compatible with the target schema - if (!compatibleSchemas[targetSchema].includes(currentSchema)) { + if (!compatibleSchemas[targetSchema]?.includes(currentSchema)) { throw new Error( `Can't transform from ${currentSchema} to ${targetSchema}.` ); } + // Transform the object if (targetSchema === "step_v3") { - const transformedObject = { - stepId: object.id, - description: object.description, + const transformedObject: Record = { + stepId: object.id as string | undefined, + description: object.description as string | undefined, }; + if (currentSchema === "goTo_v2") { transformedObject.goTo = { url: object.url, @@ -216,26 +237,30 @@ function transformToSchemaKey({ statusCodes: object.statusCodes, }; } else if (currentSchema === "find_v2") { + const typeKeys = object.typeKeys as { keys?: string; delay?: number } | undefined; transformedObject.find = { selector: object.selector, elementText: object.matchText, timeout: object.timeout, moveTo: object.moveTo, click: object.click, - type: object.typeKeys, + type: typeKeys, }; // Handle typeKeys.delay key change - if (typeof object.typeKeys === "object" && object.typeKeys.keys) { - transformedObject.find.type.inputDelay = object.typeKeys.delay; - delete transformedObject.find.type.delay; + if (typeof typeKeys === "object" && typeKeys.keys) { + (transformedObject.find as Record).type = { + keys: typeKeys.keys, + inputDelay: typeKeys.delay, + }; } transformedObject.variables = {}; - object.setVariables?.forEach((variable) => { - transformedObject.variables[ - variable.name - ] = `extract($$element.text, "${variable.regex}")`; + const setVariables = object.setVariables as Array<{ name: string; regex: string }> | undefined; + setVariables?.forEach((variable) => { + (transformedObject.variables as Record)[variable.name] = + `extract($$element.text, "${variable.regex}")`; }); } else if (currentSchema === "httpRequest_v2") { + const maxVariation = (object.maxVariation as number) ?? 0; transformedObject.httpRequest = { method: object.method, url: object.url, @@ -254,7 +279,7 @@ function transformToSchemaKey({ timeout: object.timeout, path: object.savePath, directory: object.saveDirectory, - maxVariation: object.maxVariation / 100, + maxVariation: maxVariation / 100, overwrite: object.overwrite === "byVariation" ? "aboveVariation" @@ -262,19 +287,20 @@ function transformToSchemaKey({ }; // Handle openApi.requestHeaders key change if (object.openApi) { - transformedObject.httpRequest.openApi = transformToSchemaKey({ + (transformedObject.httpRequest as Record).openApi = transformToSchemaKey({ currentSchema: "openApi_v2", targetSchema: "openApi_v3", - object: object.openApi, + object: object.openApi as Record, }); } transformedObject.variables = {}; - object.envsFromResponseData?.forEach((variable) => { - transformedObject.variables[ - variable.name - ] = `jq($$response.body, "${variable.jqFilter}")`; + const envsFromResponseData = object.envsFromResponseData as Array<{ name: string; jqFilter: string }> | undefined; + envsFromResponseData?.forEach((variable) => { + (transformedObject.variables as Record)[variable.name] = + `jq($$response.body, "${variable.jqFilter}")`; }); } else if (currentSchema === "runShell_v2") { + const maxVariation = (object.maxVariation as number) ?? 0; transformedObject.runShell = { command: object.command, args: object.args, @@ -283,7 +309,7 @@ function transformToSchemaKey({ stdio: object.output, path: object.savePath, directory: object.saveDirectory, - maxVariation: object.maxVariation / 100, + maxVariation: maxVariation / 100, overwrite: object.overwrite === "byVariation" ? "aboveVariation" @@ -291,12 +317,13 @@ function transformToSchemaKey({ timeout: object.timeout, }; transformedObject.variables = {}; - object.setVariables?.forEach((variable) => { - transformedObject.variables[ - variable.name - ] = `extract($$stdio.stdout, "${variable.regex}")`; + const setVariables = object.setVariables as Array<{ name: string; regex: string }> | undefined; + setVariables?.forEach((variable) => { + (transformedObject.variables as Record)[variable.name] = + `extract($$stdio.stdout, "${variable.regex}")`; }); } else if (currentSchema === "runCode_v2") { + const maxVariation = (object.maxVariation as number) ?? 0; transformedObject.runCode = { language: object.language, code: object.code, @@ -306,7 +333,7 @@ function transformToSchemaKey({ stdio: object.output, path: object.savePath, directory: object.saveDirectory, - maxVariation: object.maxVariation / 100, + maxVariation: maxVariation / 100, overwrite: object.overwrite === "byVariation" ? "aboveVariation" @@ -314,10 +341,10 @@ function transformToSchemaKey({ timeout: object.timeout, }; transformedObject.variables = {}; - object?.setVariables?.forEach((variable) => { - transformedObject.variables[ - variable.name - ] = `extract($$stdio.stdout, "${variable.regex}")`; + const setVariables = object.setVariables as Array<{ name: string; regex: string }> | undefined; + setVariables?.forEach((variable) => { + (transformedObject.variables as Record)[variable.name] = + `extract($$stdio.stdout, "${variable.regex}")`; }); } else if (currentSchema === "setVariables_v2") { transformedObject.loadVariables = object.path; @@ -327,10 +354,11 @@ function transformToSchemaKey({ inputDelay: object.delay, }; } else if (currentSchema === "saveScreenshot_v2") { + const maxVariation = (object.maxVariation as number) ?? 0; transformedObject.screenshot = { path: object.path, directory: object.directory, - maxVariation: object.maxVariation / 100, + maxVariation: maxVariation / 100, overwrite: object.overwrite === "byVariation" ? "aboveVariation" @@ -348,175 +376,204 @@ function transformToSchemaKey({ } else if (currentSchema === "wait_v2") { transformedObject.wait = object; } - const result = validate({ + + const validationResult = validate({ schemaKey: "step_v3", object: transformedObject, }); - if (!result.valid) { - throw new Error(`Invalid object: ${result.errors}`); + if (!validationResult.valid) { + throw new Error(`Invalid object: ${validationResult.errors}`); } - return result.object; + return validationResult.object as Record; } else if (targetSchema === "config_v3") { - // Handle config_v2 to config_v3 transformation - const transformedObject = { + const runTests = object.runTests as Record | undefined; + const transformedObject: Record = { loadVariables: object.envVariables, - input: object?.runTests?.input || object.input, - output: object?.runTests?.output || object.output, - recursive: object?.runTests?.recursive || object.recursive, + input: runTests?.input ?? object.input, + output: runTests?.output ?? object.output, + recursive: runTests?.recursive ?? object.recursive, relativePathBase: object.relativePathBase, - detectSteps: object?.runTests?.detectSteps, - beforeAny: object?.runTests?.setup, - afterAll: object?.runTests?.cleanup, + detectSteps: runTests?.detectSteps, + beforeAny: runTests?.setup, + afterAll: runTests?.cleanup, logLevel: object.logLevel, telemetry: object.telemetry, }; + // Handle context transformation - if (object?.runTests?.contexts) - transformedObject.runOn = object.runTests.contexts.map((context) => + if (runTests?.contexts) { + transformedObject.runOn = (runTests.contexts as Record[]).map((context) => transformToSchemaKey({ currentSchema: "context_v2", targetSchema: "context_v3", object: context, }) ); + } + // Handle openApi transformation - if (object?.integrations?.openApi) { - transformedObject.integrations = {}; - transformedObject.integrations.openApi = object.integrations.openApi.map( - (description) => + const integrations = object.integrations as Record | undefined; + if (integrations?.openApi) { + transformedObject.integrations = { + openApi: (integrations.openApi as Record[]).map((description) => transformToSchemaKey({ currentSchema: "openApi_v2", targetSchema: "openApi_v3", object: description, }) - ); + ), + }; } + // Handle fileTypes transformation - if (object?.fileTypes) - transformedObject.fileTypes = object.fileTypes.map((fileType) => { - const transformedFileType = { + const fileTypes = object.fileTypes as Array> | undefined; + if (fileTypes) { + transformedObject.fileTypes = fileTypes.map((fileType) => { + const extensions = fileType.extensions as string[] | undefined; + const transformedFileType: Record = { name: fileType.name, - extensions: fileType.extensions.map((extension) => - // Trim leading `.` from extension - extension.replace(/^\./, "") + extensions: extensions?.map((extension) => + (extension as string).replace(/^\./, "") ), inlineStatements: { - // Convert strings to regex, escaping special characters - testStart: `${escapeRegExp( - fileType.testStartStatementOpen - )}(.*?)${escapeRegExp(fileType.testStartStatementClose)}`, - testEnd: escapeRegExp(fileType.testEndStatement), - ignoreStart: escapeRegExp(fileType.testIgnoreStatement), - step: `${escapeRegExp( - fileType.stepStatementOpen - )}(.*?)${escapeRegExp(fileType.stepStatementClose)}`, + testStart: `${escapeRegExp(fileType.testStartStatementOpen as string)}(.*?)${escapeRegExp(fileType.testStartStatementClose as string)}`, + testEnd: escapeRegExp(fileType.testEndStatement as string), + ignoreStart: escapeRegExp(fileType.testIgnoreStatement as string), + step: `${escapeRegExp(fileType.stepStatementOpen as string)}(.*?)${escapeRegExp(fileType.stepStatementClose as string)}`, }, }; - if (fileType.markup) - transformedFileType.markup = fileType.markup.map((markup) => { - const transformedMarkup = { - name: markup.name, - regex: markup.regex, + + const markup = fileType.markup as Array> | undefined; + if (markup) { + transformedFileType.markup = markup.map((markupItem) => { + const transformedMarkup: Record = { + name: markupItem.name, + regex: markupItem.regex, }; - if (markup.actions) - transformedMarkup.actions = markup.actions.map((action) => { + + const actions = markupItem.actions as Array | undefined; + if (actions) { + transformedMarkup.actions = actions.map((action) => { if (typeof action === "string") return action; - if (typeof action === "object") { - if (action.params) { - action = { - action: action.name, - ...action.params, + if (typeof action === "object" && action !== null) { + const actionObj = action as Record; + if (actionObj.params) { + const newAction = { + action: actionObj.name, + ...(actionObj.params as Record), }; + return transformToSchemaKey({ + currentSchema: `${newAction.action}_v2`, + targetSchema: "step_v3", + object: newAction, + }); } - const transformedAction = transformToSchemaKey({ - currentSchema: `${action.action}_v2`, + return transformToSchemaKey({ + currentSchema: `${actionObj.action}_v2`, targetSchema: "step_v3", - object: action, + object: actionObj, }); - return transformedAction; } + return action; }); - + } return transformedMarkup; }); + } return transformedFileType; }); - const result = validate({ + } + + const validationResult = validate({ schemaKey: "config_v3", object: transformedObject, }); - if (!result.valid) { - throw new Error(`Invalid object: ${result.errors}`); + if (!validationResult.valid) { + throw new Error(`Invalid object: ${validationResult.errors}`); } - return result.object; + return validationResult.object as Record; } else if (targetSchema === "context_v3") { - const transformedObject = {}; + const transformedObject: Record = {}; // Handle context_v2 to context_v3 transformation transformedObject.platforms = object.platforms; - if (object.app?.name) { - const name = object.app.name === "edge" ? "chrome" : object.app?.name; - transformedObject.browsers = []; - transformedObject.browsers.push({ - name, - headless: object.app?.options?.headless, - window: { - width: object.app?.options?.width, - height: object.app?.options?.height, - }, - viewport: { - width: object.app?.options?.viewport_width, - height: object.app?.options?.viewport_height, + + const app = object.app as Record | undefined; + if (app?.name) { + const name = app.name === "edge" ? "chrome" : app.name; + const options = app.options as Record | undefined; + transformedObject.browsers = [ + { + name, + headless: options?.headless, + window: { + width: options?.width, + height: options?.height, + }, + viewport: { + width: options?.viewport_width, + height: options?.viewport_height, + }, }, - }); + ]; } - const result = validate({ + + const validationResult = validate({ schemaKey: "context_v3", object: transformedObject, }); - if (!result.valid) { - throw new Error(`Invalid object: ${result.errors}`); + if (!validationResult.valid) { + throw new Error(`Invalid object: ${validationResult.errors}`); } - return result.object; + return validationResult.object as Record; } else if (targetSchema === "openApi_v3") { - let transformedObject; // Handle openApi_v2 to openApi_v3 transformation const { name, requestHeaders, ...intermediaryObject } = object; - intermediaryObject.name = object.name; - intermediaryObject.headers = object.requestHeaders; - transformedObject = { ...intermediaryObject }; + const transformedObject = { + ...intermediaryObject, + name: name, + headers: requestHeaders, + }; - const result = validate({ + const validationResult = validate({ schemaKey: "openApi_v3", object: transformedObject, }); - if (!result.valid) { - throw new Error(`Invalid object: ${result.errors}`); + if (!validationResult.valid) { + throw new Error(`Invalid object: ${validationResult.errors}`); } - return transformedObject; + return transformedObject as Record; } else if (targetSchema === "spec_v3") { // Handle spec_v2 to spec_v3 transformation - const transformedObject = { + const transformedObject: Record = { specId: object.id, description: object.description, contentPath: object.file, }; - if (object.contexts) - transformedObject.runOn = object.contexts.map((context) => + + const contexts = object.contexts as Record[] | undefined; + if (contexts) { + transformedObject.runOn = contexts.map((context) => transformToSchemaKey({ currentSchema: "context_v2", targetSchema: "context_v3", object: context, }) ); - if (object.openApi) - transformedObject.openApi = object.openApi.map((description) => + } + + const openApi = object.openApi as Record[] | undefined; + if (openApi) { + transformedObject.openApi = openApi.map((description) => transformToSchemaKey({ currentSchema: "openApi_v2", targetSchema: "openApi_v3", object: description, }) ); - transformedObject.tests = object.tests.map((test) => + } + + const tests = object.tests as Record[]; + transformedObject.tests = tests.map((test) => transformToSchemaKey({ currentSchema: "test_v2", targetSchema: "test_v3", @@ -524,17 +581,17 @@ function transformToSchemaKey({ }) ); - const result = validate({ + const validationResult = validate({ schemaKey: "spec_v3", object: transformedObject, }); - if (!result.valid) { - throw new Error(`Invalid object: ${result.errors}`); + if (!validationResult.valid) { + throw new Error(`Invalid object: ${validationResult.errors}`); } - return result.object; + return validationResult.object as Record; } else if (targetSchema === "test_v3") { // Handle test_v2 to test_v3 transformation - const transformedObject = { + const transformedObject: Record = { testId: object.id, description: object.description, contentPath: object.file, @@ -542,23 +599,31 @@ function transformToSchemaKey({ before: object.setup, after: object.cleanup, }; - if (object.contexts) - transformedObject.runOn = object.contexts.map((context) => + + const contexts = object.contexts as Record[] | undefined; + if (contexts) { + transformedObject.runOn = contexts.map((context) => transformToSchemaKey({ currentSchema: "context_v2", targetSchema: "context_v3", object: context, }) ); - if (object.openApi) - transformedObject.openApi = object.openApi.map((description) => + } + + const openApi = object.openApi as Record[] | undefined; + if (openApi) { + transformedObject.openApi = openApi.map((description) => transformToSchemaKey({ currentSchema: "openApi_v2", targetSchema: "openApi_v3", object: description, }) ); - transformedObject.steps = object.steps.map((step) => + } + + const steps = object.steps as Record[]; + transformedObject.steps = steps.map((step) => transformToSchemaKey({ currentSchema: `${step.action}_v2`, targetSchema: "step_v3", @@ -566,21 +631,22 @@ function transformToSchemaKey({ }) ); - const result = validate({ + const validationResult = validate({ schemaKey: "test_v3", object: transformedObject, }); - if (!result.valid) { - throw new Error(`Invalid object: ${result.errors}`); + if (!validationResult.valid) { + throw new Error(`Invalid object: ${validationResult.errors}`); } - return result.object; + return validationResult.object as Record; } - return null; + + throw new Error(`Transformation to ${targetSchema} is not implemented.`); } // If called directly, validate an example object if (require.main === module) { - const example = {path: "/User/manny/projects/doc-detective/static/images/image.png"}; + const example = { path: "/User/manny/projects/doc-detective/static/images/image.png" }; const result = validate({ schemaKey: "screenshot_v3", object: example }); console.log(JSON.stringify(result, null, 2)); diff --git a/src/zodSchemas/index.ts b/src/zodSchemas/index.ts new file mode 100644 index 00000000..3421f810 --- /dev/null +++ b/src/zodSchemas/index.ts @@ -0,0 +1,629 @@ +import { z } from "zod"; +import { v4 as uuidv4 } from "uuid"; + +// ========================================== +// Helper schemas +// ========================================== + +export const stringOrArraySchema = z.union([ + z.string(), + z.array(z.string()).min(1), +]); + +// ========================================== +// Context schemas (v3) +// ========================================== + +export const platformSchema = z.enum(["linux", "mac", "windows"]); +export type Platform = z.infer; + +export const browserNameSchema = z.enum(["chrome", "firefox", "safari", "webkit"]); +export type BrowserName = z.infer; + +export const browserWindowSchema = z.object({ + width: z.number().int().optional(), + height: z.number().int().optional(), +}).strict(); + +export const browserViewportSchema = z.object({ + width: z.number().int().optional(), + height: z.number().int().optional(), +}).strict(); + +export const browserSchema = z.object({ + name: browserNameSchema, + headless: z.boolean().default(true).optional(), + window: browserWindowSchema.optional(), + viewport: browserViewportSchema.optional(), +}).strict(); +export type Browser = z.infer; + +export const contextV3Schema = z.object({ + $schema: z.literal("https://raw.githubusercontent.com/doc-detective/common/refs/heads/main/dist/schemas/context_v3.schema.json").optional(), + contextId: z.string().default(() => uuidv4()).optional(), + platforms: z.union([ + platformSchema, + z.array(platformSchema), + ]).optional(), + browsers: z.union([ + browserNameSchema, + browserSchema, + z.array(z.union([browserNameSchema, browserSchema])), + ]).optional(), +}).strict(); +export type ContextV3 = z.infer; + +// ========================================== +// checkLink schema (v3) +// ========================================== + +const urlPatternRegex = /^(http:\/\/|https:\/\/|\/).*|\$[A-Za-z0-9_]+$/; + +export const checkLinkStringSchema = z.string().regex(urlPatternRegex).transform(s => s.trim()); + +export const checkLinkObjectSchema = z.object({ + url: z.string().regex(urlPatternRegex).transform(s => s.trim()), + origin: z.string().transform(s => s.trim()).optional(), + statusCodes: z.union([ + z.number().int(), + z.array(z.number().int()), + ]).default([200, 301, 302, 307, 308]).optional(), +}).strict(); + +export const checkLinkV3Schema = z.union([ + checkLinkStringSchema, + checkLinkObjectSchema, +]); +export type CheckLinkV3 = z.infer; + +// ========================================== +// goTo schema (v3) +// ========================================== + +const goToUrlPatternRegex = /^(http:\/\/|https:\/\/|\/).*|\$[A-Za-z0-9_]+/; + +export const goToFindSchema = z.object({ + selector: z.string().optional(), + elementText: z.string().optional(), + elementId: z.string().optional(), + elementTestId: z.string().optional(), + elementClass: z.union([z.string(), z.array(z.string())]).optional(), + elementAttribute: z.record(z.union([z.string(), z.number(), z.boolean()])).optional(), + elementAria: z.string().optional(), +}).strict().refine( + (data) => data.selector || data.elementText || data.elementId || data.elementTestId || data.elementClass || data.elementAttribute || data.elementAria, + { message: "At least one of selector, elementText, elementId, elementTestId, elementClass, elementAttribute, or elementAria must be specified" } +); + +export const goToWaitUntilSchema = z.object({ + networkIdleTime: z.union([z.number().int().min(0), z.null()]).default(500).optional(), + domIdleTime: z.union([z.number().int().min(0), z.null()]).default(1000).optional(), + find: goToFindSchema.optional(), +}).strict(); + +export const goToStringSchema = z.string().regex(goToUrlPatternRegex).transform(s => s.trim()); + +export const goToObjectSchema = z.object({ + url: z.string().regex(goToUrlPatternRegex).transform(s => s.trim()), + origin: z.string().transform(s => s.trim()).optional(), + timeout: z.number().int().min(0).default(30000).optional(), + waitUntil: goToWaitUntilSchema.optional(), +}).strict(); + +export const goToV3Schema = z.union([goToStringSchema, goToObjectSchema]); +export type GoToV3 = z.infer; + +// ========================================== +// click schema (v3) +// ========================================== + +export const clickButtonSchema = z.enum(["left", "middle", "right"]).default("left"); +export const clickCountSchema = z.number().int().min(1).default(1); +export const clickDelaySchema = z.number().int().min(0).default(0); +export const clickPositionSchema = z.object({ + x: z.number(), + y: z.number(), +}).strict(); +export const clickModifiersSchema = z.array(z.enum(["Alt", "Control", "Meta", "Shift"])); + +export const clickObjectSchema = z.object({ + button: clickButtonSchema.optional(), + count: clickCountSchema.optional(), + delay: clickDelaySchema.optional(), + position: clickPositionSchema.optional(), + modifiers: clickModifiersSchema.optional(), +}).strict(); + +export const clickV3Schema = z.union([ + z.literal(true), + clickObjectSchema, +]); +export type ClickV3 = z.infer; + +// ========================================== +// type schema (v3) +// ========================================== + +export const typeStringSchema = z.string(); + +export const typeObjectSchema = z.object({ + keys: z.union([z.string(), z.array(z.string())]), + inputDelay: z.number().int().min(0).default(0).optional(), + selector: z.string().optional(), + elementText: z.string().optional(), + elementId: z.string().optional(), + elementTestId: z.string().optional(), + elementClass: z.union([z.string(), z.array(z.string())]).optional(), + elementAttribute: z.record(z.union([z.string(), z.number(), z.boolean()])).optional(), + elementAria: z.string().optional(), +}).strict(); + +export const typeV3Schema = z.union([typeStringSchema, typeObjectSchema]); +export type TypeV3 = z.infer; + +// ========================================== +// find schema (v3) +// ========================================== + +export const findStringSchema = z.string(); + +export const findObjectSchema = z.object({ + elementText: z.string().optional(), + selector: z.string().optional(), + elementId: z.string().optional(), + elementTestId: z.string().optional(), + elementClass: z.union([z.string(), z.array(z.string())]).optional(), + elementAttribute: z.record(z.union([z.string(), z.number(), z.boolean()])).optional(), + elementAria: z.string().optional(), + timeout: z.number().int().default(5000).optional(), + moveTo: z.boolean().default(true).optional(), + click: z.union([clickV3Schema, z.object({ button: clickButtonSchema.optional() }).strict()]).optional(), + type: typeV3Schema.optional(), +}).strict().refine( + (data) => data.selector || data.elementText || data.elementId || data.elementTestId || data.elementClass || data.elementAttribute || data.elementAria, + { message: "At least one of selector, elementText, elementId, elementTestId, elementClass, elementAttribute, or elementAria must be specified" } +); + +export const findV3Schema = z.union([findStringSchema, findObjectSchema]); +export type FindV3 = z.infer; + +// ========================================== +// wait schema (v3) +// ========================================== + +export const waitV3Schema = z.union([ + z.number().int().min(0), + z.object({ + duration: z.number().int().min(0), + }).strict(), +]); +export type WaitV3 = z.infer; + +// ========================================== +// screenshot schema (v3) +// ========================================== + +export const screenshotCropStringSchema = z.string(); + +export const screenshotCropObjectSchema = z.object({ + selector: z.string().optional(), + elementText: z.string().optional(), + padding: z.object({ + top: z.number().default(0).optional(), + right: z.number().default(0).optional(), + bottom: z.number().default(0).optional(), + left: z.number().default(0).optional(), + }).strict().optional(), +}).strict(); + +export const screenshotObjectSchema = z.object({ + path: z.string().optional(), + directory: z.string().optional(), + maxVariation: z.number().min(0).max(1).default(0.05).optional(), + overwrite: z.union([z.boolean(), z.enum(["aboveVariation"])]).default("aboveVariation").optional(), + crop: z.union([screenshotCropStringSchema, screenshotCropObjectSchema]).optional(), +}).strict(); + +export const screenshotV3Schema = z.union([ + z.literal(true), + z.string(), + screenshotObjectSchema, +]); +export type ScreenshotV3 = z.infer; + +// ========================================== +// record schema (v3) +// ========================================== + +export const recordObjectSchema = z.object({ + path: z.string().optional(), + directory: z.string().optional(), + overwrite: z.boolean().default(true).optional(), +}).strict(); + +export const recordV3Schema = z.union([ + z.literal(true), + z.string(), + recordObjectSchema, +]); +export type RecordV3 = z.infer; + +// ========================================== +// stopRecord schema (v3) +// ========================================== + +export const stopRecordV3Schema = z.literal(true); +export type StopRecordV3 = z.infer; + +// ========================================== +// loadVariables schema (v3) +// ========================================== + +export const loadVariablesV3Schema = z.string(); +export type LoadVariablesV3 = z.infer; + +// ========================================== +// runShell schema (v3) +// ========================================== + +export const runShellV3Schema = z.object({ + command: z.string(), + args: z.array(z.string()).optional(), + workingDirectory: z.string().optional(), + exitCodes: z.union([z.number().int(), z.array(z.number().int())]).default([0]).optional(), + stdio: z.union([z.string(), z.object({ + stdout: z.string().optional(), + stderr: z.string().optional(), + }).strict()]).optional(), + path: z.string().optional(), + directory: z.string().optional(), + maxVariation: z.number().min(0).max(1).default(0.05).optional(), + overwrite: z.union([z.boolean(), z.enum(["aboveVariation"])]).default("aboveVariation").optional(), + timeout: z.number().int().min(0).optional(), +}).strict(); +export type RunShellV3 = z.infer; + +// ========================================== +// runCode schema (v3) +// ========================================== + +export const runCodeV3Schema = z.object({ + language: z.enum(["python", "javascript", "bash", "go"]), + code: z.string(), + args: z.array(z.string()).optional(), + workingDirectory: z.string().optional(), + exitCodes: z.union([z.number().int(), z.array(z.number().int())]).default([0]).optional(), + stdio: z.union([z.string(), z.object({ + stdout: z.string().optional(), + stderr: z.string().optional(), + }).strict()]).optional(), + path: z.string().optional(), + directory: z.string().optional(), + maxVariation: z.number().min(0).max(1).default(0.05).optional(), + overwrite: z.union([z.boolean(), z.enum(["aboveVariation"])]).default("aboveVariation").optional(), + timeout: z.number().int().min(0).optional(), +}).strict(); +export type RunCodeV3 = z.infer; + +// ========================================== +// httpRequest schema (v3) +// ========================================== + +export const httpMethodSchema = z.enum(["get", "GET", "post", "POST", "put", "PUT", "patch", "PATCH", "delete", "DELETE", "head", "HEAD", "options", "OPTIONS"]); + +export const httpRequestV3Schema = z.object({ + method: httpMethodSchema.default("GET").optional(), + url: z.string(), + openApi: z.any().optional(), // Reference to OpenAPI schema + request: z.object({ + body: z.any().optional(), + headers: z.record(z.string()).optional(), + parameters: z.record(z.any()).optional(), + }).strict().optional(), + response: z.object({ + body: z.any().optional(), + headers: z.record(z.string()).optional(), + }).strict().optional(), + statusCodes: z.union([z.number().int(), z.array(z.number().int())]).default([200, 201]).optional(), + allowAdditionalFields: z.boolean().optional(), + timeout: z.number().int().min(0).optional(), + path: z.string().optional(), + directory: z.string().optional(), + maxVariation: z.number().min(0).max(1).default(0.05).optional(), + overwrite: z.union([z.boolean(), z.enum(["aboveVariation"])]).default("aboveVariation").optional(), +}).strict(); +export type HttpRequestV3 = z.infer; + +// ========================================== +// saveCookie schema (v3) +// ========================================== + +export const saveCookieStringSchema = z.string(); + +export const saveCookieObjectSchema = z.object({ + name: z.string(), + path: z.string().optional(), + directory: z.string().optional(), + overwrite: z.boolean().default(true).optional(), +}).strict(); + +export const saveCookieV3Schema = z.union([saveCookieStringSchema, saveCookieObjectSchema]); +export type SaveCookieV3 = z.infer; + +// ========================================== +// loadCookie schema (v3) +// ========================================== + +export const loadCookieStringSchema = z.string(); + +export const loadCookieObjectSchema = z.object({ + name: z.string(), + path: z.string().optional(), + directory: z.string().optional(), +}).strict(); + +export const loadCookieV3Schema = z.union([loadCookieStringSchema, loadCookieObjectSchema]); +export type LoadCookieV3 = z.infer; + +// ========================================== +// dragAndDrop schema (v3) +// ========================================== + +export const dragAndDropTargetSchema = z.object({ + selector: z.string().optional(), + elementText: z.string().optional(), + elementId: z.string().optional(), + elementTestId: z.string().optional(), + elementClass: z.union([z.string(), z.array(z.string())]).optional(), + elementAttribute: z.record(z.union([z.string(), z.number(), z.boolean()])).optional(), + elementAria: z.string().optional(), +}).strict(); + +export const dragAndDropV3Schema = z.object({ + source: dragAndDropTargetSchema, + target: dragAndDropTargetSchema, +}).strict(); +export type DragAndDropV3 = z.infer; + +// ========================================== +// openApi schema (v3) +// ========================================== + +export const openApiV3Schema = z.object({ + name: z.string().optional(), + descriptionPath: z.string().optional(), + definition: z.any().optional(), + headers: z.record(z.string()).optional(), +}).strict(); +export type OpenApiV3 = z.infer; + +// ========================================== +// step schema (v3) +// ========================================== + +export const stepCommonSchema = z.object({ + $schema: z.literal("https://raw.githubusercontent.com/doc-detective/common/refs/heads/main/dist/schemas/step_v3.schema.json").optional(), + stepId: z.string().default(() => uuidv4()).optional(), + description: z.string().optional(), + unsafe: z.boolean().default(false).optional(), + outputs: z.record(z.string()).default({}).optional(), + variables: z.record(z.string()).default({}).optional(), + breakpoint: z.boolean().default(false).optional(), +}); + +export const stepV3Schema = z.union([ + stepCommonSchema.extend({ checkLink: checkLinkV3Schema }).strict(), + stepCommonSchema.extend({ click: clickV3Schema }).strict(), + stepCommonSchema.extend({ find: findV3Schema }).strict(), + stepCommonSchema.extend({ goTo: goToV3Schema }).strict(), + stepCommonSchema.extend({ httpRequest: httpRequestV3Schema }).strict(), + stepCommonSchema.extend({ runShell: runShellV3Schema }).strict(), + stepCommonSchema.extend({ runCode: runCodeV3Schema }).strict(), + stepCommonSchema.extend({ type: typeV3Schema }).strict(), + stepCommonSchema.extend({ screenshot: screenshotV3Schema }).strict(), + stepCommonSchema.extend({ saveCookie: saveCookieV3Schema }).strict(), + stepCommonSchema.extend({ record: recordV3Schema }).strict(), + stepCommonSchema.extend({ stopRecord: stopRecordV3Schema }).strict(), + stepCommonSchema.extend({ loadVariables: loadVariablesV3Schema }).strict(), + stepCommonSchema.extend({ dragAndDrop: dragAndDropV3Schema }).strict(), + stepCommonSchema.extend({ loadCookie: loadCookieV3Schema }).strict(), + stepCommonSchema.extend({ wait: waitV3Schema }).strict(), +]); +export type StepV3 = z.infer; + +// ========================================== +// test schema (v3) +// ========================================== + +export const testV3Schema = z.object({ + $schema: z.literal("https://raw.githubusercontent.com/doc-detective/common/refs/heads/main/dist/schemas/test_v3.schema.json").optional(), + testId: z.string().default(() => uuidv4()).optional(), + description: z.string().optional(), + contentPath: z.string().optional(), + detectSteps: z.boolean().optional(), + before: z.union([z.string(), z.array(z.string())]).optional(), + after: z.union([z.string(), z.array(z.string())]).optional(), + runOn: z.union([contextV3Schema, z.array(contextV3Schema)]).optional(), + openApi: z.array(openApiV3Schema).optional(), + steps: z.array(stepV3Schema).min(1), +}).strict(); +export type TestV3 = z.infer; + +// ========================================== +// spec schema (v3) +// ========================================== + +export const specV3Schema = z.object({ + $schema: z.literal("https://raw.githubusercontent.com/doc-detective/common/refs/heads/main/dist/schemas/spec_v3.schema.json").optional(), + specId: z.string().default(() => uuidv4()).optional(), + description: z.string().optional(), + contentPath: z.string().optional(), + runOn: z.union([contextV3Schema, z.array(contextV3Schema)]).optional(), + openApi: z.array(openApiV3Schema).optional(), + tests: z.array(testV3Schema).min(1), +}).strict(); +export type SpecV3 = z.infer; + +// ========================================== +// config schema (v3) +// ========================================== + +export const environmentSchema = z.object({ + workingDirectory: z.string().optional(), + platform: platformSchema, + arch: z.enum(["arm32", "arm64", "x32", "x64"]).optional(), +}).strict(); + +export const markupActionStringSchema = z.enum([ + "checkLink", "click", "find", "goTo", "httpRequest", "loadCookie", + "loadVariables", "record", "runCode", "runShell", "saveCookie", + "screenshot", "stopRecord", "type", "wait" +]); + +export const inlineStatementsSchema = z.object({ + testStart: stringOrArraySchema.optional(), + testEnd: stringOrArraySchema.optional(), + ignoreStart: stringOrArraySchema.optional(), + ignoreEnd: stringOrArraySchema.optional(), + step: stringOrArraySchema.optional(), +}).strict(); + +export const markupDefinitionSchema = z.object({ + name: z.string().optional(), + regex: stringOrArraySchema.optional(), + batchMatches: z.boolean().default(false).optional(), + actions: z.union([ + markupActionStringSchema, + z.array(z.union([markupActionStringSchema, stepV3Schema])), + ]).optional(), +}).strict(); + +export const fileTypePredefinedSchema = z.enum(["markdown", "asciidoc", "html", "dita"]); + +export const fileTypeCustomSchema = z.object({ + name: z.string().optional(), + extends: z.enum(["markdown", "asciidoc", "html"]).optional(), + extensions: stringOrArraySchema.optional(), + inlineStatements: inlineStatementsSchema.optional(), + markup: z.array(markupDefinitionSchema).min(1).optional(), +}).strict().refine( + (data) => data.extensions || data.extends, + { message: "Either 'extensions' or 'extends' must be specified" } +); + +export const fileTypeExecutableSchema = z.object({ + name: z.string().optional(), + extensions: stringOrArraySchema, + runShell: runShellV3Schema.optional(), +}).strict(); + +export const fileTypeSchema = z.union([ + fileTypePredefinedSchema, + fileTypeCustomSchema, + fileTypeExecutableSchema, +]); + +export const telemetrySchema = z.object({ + send: z.boolean().default(true), + userId: z.string().optional(), +}).strict(); + +export const integrationsSchema = z.object({ + openApi: z.array(openApiV3Schema).optional(), + docDetectiveApi: z.object({ + apiKey: z.string().optional(), + }).strict().optional(), +}).strict(); + +export const configV3Schema = z.object({ + $schema: z.literal("https://raw.githubusercontent.com/doc-detective/common/refs/heads/main/dist/schemas/config_v3.schema.json").optional(), + configId: z.string().default(() => uuidv4()).optional(), + configPath: z.string().optional(), + input: stringOrArraySchema.default(".").optional(), + output: z.string().default(".").optional(), + recursive: z.boolean().default(true).optional(), + relativePathBase: z.enum(["cwd", "file"]).default("file").optional(), + loadVariables: loadVariablesV3Schema.optional(), + origin: z.string().optional(), + beforeAny: z.union([z.string(), z.array(z.string())]).optional(), + afterAll: z.union([z.string(), z.array(z.string())]).optional(), + detectSteps: z.boolean().default(true).optional(), + allowUnsafeSteps: z.boolean().optional(), + crawl: z.boolean().default(false).optional(), + processDitaMaps: z.boolean().default(true).optional(), + logLevel: z.enum(["silent", "error", "warning", "info", "debug"]).default("info").optional(), + runOn: z.union([contextV3Schema, z.array(contextV3Schema)]).optional(), + fileTypes: z.array(fileTypeSchema).min(1).default(["markdown", "asciidoc", "html", "dita"]).optional(), + integrations: integrationsSchema.optional(), + telemetry: telemetrySchema.default({ send: true }).optional(), + concurrentRunners: z.union([z.number().int().min(1), z.literal(true)]).default(1).optional(), + environment: environmentSchema.optional(), + debug: z.union([z.boolean(), z.literal("stepThrough")]).default(false).optional(), +}).strict(); +export type ConfigV3 = z.infer; + +// ========================================== +// report schema (v3) +// ========================================== + +export const reportV3Schema = z.object({ + $schema: z.literal("https://raw.githubusercontent.com/doc-detective/common/refs/heads/main/dist/schemas/report_v3.schema.json").optional(), + reportId: z.string().default(() => uuidv4()).optional(), + timestamp: z.string().optional(), + duration: z.number().optional(), + status: z.enum(["passed", "failed", "skipped", "unknown"]).optional(), + summary: z.object({ + total: z.number().int().optional(), + passed: z.number().int().optional(), + failed: z.number().int().optional(), + skipped: z.number().int().optional(), + }).strict().optional(), + config: configV3Schema.optional(), + results: z.array(z.any()).optional(), +}).strict(); +export type ReportV3 = z.infer; + +// ========================================== +// resolvedTests schema (v3) +// ========================================== + +export const resolvedTestsV3Schema = z.object({ + beforeAny: z.array(specV3Schema).optional(), + specs: z.array(specV3Schema), + afterAll: z.array(specV3Schema).optional(), +}).strict(); +export type ResolvedTestsV3 = z.infer; + +// ========================================== +// Exports - All schemas +// ========================================== + +export const zodSchemaMap: Record = { + // v3 schemas + checkLink_v3: checkLinkV3Schema, + click_v3: clickV3Schema, + config_v3: configV3Schema, + context_v3: contextV3Schema, + dragAndDrop_v3: dragAndDropV3Schema, + find_v3: findV3Schema, + goTo_v3: goToV3Schema, + httpRequest_v3: httpRequestV3Schema, + loadCookie_v3: loadCookieV3Schema, + loadVariables_v3: loadVariablesV3Schema, + openApi_v3: openApiV3Schema, + record_v3: recordV3Schema, + report_v3: reportV3Schema, + resolvedTests_v3: resolvedTestsV3Schema, + runCode_v3: runCodeV3Schema, + runShell_v3: runShellV3Schema, + saveCookie_v3: saveCookieV3Schema, + screenshot_v3: screenshotV3Schema, + spec_v3: specV3Schema, + step_v3: stepV3Schema, + stopRecord_v3: stopRecordV3Schema, + test_v3: testV3Schema, + type_v3: typeV3Schema, + wait_v3: waitV3Schema, +}; + +export type SchemaKey = keyof typeof zodSchemaMap; diff --git a/test/files.test.js b/test/files.test.js index 00fa8de1..df32bf28 100644 --- a/test/files.test.js +++ b/test/files.test.js @@ -1,7 +1,7 @@ const sinon = require("sinon"); const axios = require("axios"); const fs = require("fs"); -const { readFile } = require("../src/files"); +const { readFile } = require("../build/files"); (async () => { const { expect } = await import("chai"); diff --git a/test/schema.test.js b/test/schema.test.js index f3acc711..9198549f 100644 --- a/test/schema.test.js +++ b/test/schema.test.js @@ -1,4 +1,4 @@ -const { validate, schemas } = require("../src/index"); +const { validate, schemas } = require("../build/index"); const assert = require("assert"); // Loop through JSON schemas diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..9310dae4 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "lib": ["ES2022"], + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./build", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "moduleResolution": "node" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "build", "test"] +}