diff --git a/package-lock.json b/package-lock.json index 66dce58..4da7503 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,28 +9,28 @@ "version": "3.6.1-dev.2", "license": "AGPL-3.0-only", "dependencies": { - "@apidevtools/json-schema-ref-parser": "^15.1.3", + "@apidevtools/json-schema-ref-parser": "^15.2.2", "ajv": "^8.17.1", "ajv-errors": "^3.0.0", "ajv-formats": "^3.0.1", "ajv-keywords": "^5.1.0", - "axios": "^1.13.2", + "axios": "^1.13.4", "yaml": "^2.8.2" }, "devDependencies": { - "@types/node": "^22.10.5", + "@types/node": "^25.0.10", "c8": "^10.1.3", "chai": "^6.2.2", "json-schema-to-typescript": "^15.0.4", "mocha": "^11.7.5", "sinon": "^21.0.1", - "typescript": "^5.7.3" + "typescript": "^5.9.3" } }, "node_modules/@apidevtools/json-schema-ref-parser": { - "version": "15.1.3", - "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-15.1.3.tgz", - "integrity": "sha512-XvEitlOaU8S+hOrMPuGyCjp6vC51K+syUN4HHrSUdSDLLWRWQJYjInU6xlSoRGCVBCfcoHxbRm+yiaYq2yFR5w==", + "version": "15.2.2", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-15.2.2.tgz", + "integrity": "sha512-54fvjSwWiBTdVviiUItOCeyxtPSBmCrSEjlOl8XFEDuYD3lXY1lOBWKim/WJ3i1EYzdGx6rSOjK5KRDMppLI4Q==", "license": "MIT", "dependencies": { "js-yaml": "^4.1.1" @@ -262,8 +262,7 @@ "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 + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==" }, "node_modules/@types/lodash": { "version": "4.17.23", @@ -273,13 +272,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.19.7", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz", - "integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==", + "version": "25.0.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.10.tgz", + "integrity": "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" + "undici-types": "~7.16.0" } }, "node_modules/ajv": { @@ -287,7 +286,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -371,9 +369,9 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", - "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.4.tgz", + "integrity": "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", @@ -1161,9 +1159,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "dev": true, "license": "MIT" }, @@ -1430,7 +1428,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -1597,9 +1594,9 @@ } }, "node_modules/sinon/node_modules/diff": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.2.tgz", - "integrity": "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==", + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz", + "integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -1743,9 +1740,9 @@ } }, "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "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" }, diff --git a/package.json b/package.json index a09ad78..000577c 100644 --- a/package.json +++ b/package.json @@ -34,21 +34,21 @@ }, "homepage": "https://github.com/doc-detective/doc-detective-common#readme", "devDependencies": { - "@types/node": "^22.10.5", + "@types/node": "^25.0.10", "c8": "^10.1.3", "chai": "^6.2.2", "json-schema-to-typescript": "^15.0.4", "mocha": "^11.7.5", "sinon": "^21.0.1", - "typescript": "^5.7.3" + "typescript": "^5.9.3" }, "dependencies": { - "@apidevtools/json-schema-ref-parser": "^15.1.3", + "@apidevtools/json-schema-ref-parser": "^15.2.2", "ajv": "^8.17.1", "ajv-errors": "^3.0.0", "ajv-formats": "^3.0.1", "ajv-keywords": "^5.1.0", - "axios": "^1.13.2", + "axios": "^1.13.4", "yaml": "^2.8.2" } } diff --git a/plans/plan-typescriptMigration.prompt.md b/plans/plan-typescriptMigration.prompt.md deleted file mode 100644 index 6ae55be..0000000 --- a/plans/plan-typescriptMigration.prompt.md +++ /dev/null @@ -1,25 +0,0 @@ -## Plan: Incremental TypeScript Migration for doc-detective-common - -A phased approach to convert this CommonJS JavaScript library to TypeScript, leveraging JSON schemas to auto-generate types while maintaining backward compatibility for downstream consumers. - -### Steps - -1. **Set up TypeScript infrastructure** in [package.json](package.json) — add `typescript`, `@types/node`, configure `tsconfig.json` with `declaration: true`, `resolveJsonModule: true`, and dual CJS/ESM output to `dist/` - -2. **Generate TypeScript interfaces from JSON schemas** — use `json-schema-to-typescript` on [output_schemas/](src/schemas/output_schemas) to auto-create types like `StepV3`, `ConfigV3`, `SpecV3` in a new `src/types/generated/` directory - -3. **Convert low-complexity files first** — migrate [src/index.ts](src/index.js) → barrel exports, [src/schemas/index.ts](src/schemas/index.js) → typed schema map, [src/files.ts](src/files.js) → straightforward async function - -4. **Convert medium-complexity** [src/resolvePaths.ts](src/resolvePaths.js) — type the recursive path resolution with discriminated unions for `config` vs `spec` object types and `RelativePathBase` literal types - -5. **Convert high-complexity** [src/validate.ts](src/validate.js) — split 350+ line `transformToSchemaKey()` into modular transformation functions, type the `compatibleSchemas` map, and use generic `validate()` signature - -6. **Update build pipeline** — ensure schema dereference runs before generate:types and compile (pipeline order: `dereferenceSchemas → generate:types → compile`), and update CI workflows to run the TypeScript build - -### Further Considerations - -1. **Module system strategy?** Keep CommonJS output for backward compat + add ESM build (recommended) / Switch to pure ESM (breaking change) / Dual-publish with conditional exports - -2. **Schema type generation timing?** Generate types at build time via script (dynamic, adds build step) / Generate once and commit to repo (simpler, requires manual sync) / Use `zod` or `typebox` to replace JSON schemas entirely (major refactor) - -3. **Test migration approach?** Convert tests to TypeScript with `ts-mocha` (full type coverage) / Keep tests as JavaScript importing from `dist/` (faster migration, tests compiled output) / Gradual conversion file-by-file alongside source diff --git a/src/files.js b/src/files.js deleted file mode 100644 index 4bb92aa..0000000 --- a/src/files.js +++ /dev/null @@ -1,84 +0,0 @@ -const fs = require("fs"); -const YAML = require("yaml"); -const axios = require("axios"); -const { URL } = require("url"); - -/** - * 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. - * - * @throws {Error} If {@link fileURLOrPath} is missing, not a string, or is an empty string. - */ -async function readFile({ fileURLOrPath }) { - if (!fileURLOrPath) { - throw new Error("fileURLOrPath is required"); - } - if (typeof fileURLOrPath !== "string") { - throw new Error("fileURLOrPath must be a string"); - } - if (fileURLOrPath.trim() === "") { - throw new Error("fileURLOrPath cannot be an empty string"); - } - - let content; - let isRemote = false; - - try { - const parsedURL = new URL(fileURLOrPath); - isRemote = - parsedURL.protocol === "http:" || parsedURL.protocol === "https:"; - } catch (error) { - // Not a valid URL, assume local file path - } - - if (isRemote) { - try { - const response = await axios.get(fileURLOrPath); - content = response.data; - } catch (error) { - console.warn( - `Error reading remote file from ${fileURLOrPath}: ${error.message}` - ); - return null; - } - } else { - try { - content = await fs.promises.readFile(fileURLOrPath, "utf8"); - } catch (error) { - if (error.code === "ENOENT") { - console.warn(`File not found: ${fileURLOrPath}`); - } else { - console.warn(`Error reading file: ${error.message}`); - } - return null; - } - } - - // Parse based on file extension - const ext = fileURLOrPath.split('.').pop().toLowerCase(); - - if (ext === "json") { - try { - return JSON.parse(content); - } catch (error) { - console.warn(`Failed to parse JSON: ${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}`); - return content; - } - } else { - return content; - } -} - -module.exports = { readFile }; diff --git a/src/index.js b/src/index.js deleted file mode 100644 index c1f0f0c..0000000 --- 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/resolvePaths.js b/src/resolvePaths.js deleted file mode 100644 index 794bc4e..0000000 --- a/src/resolvePaths.js +++ /dev/null @@ -1,253 +0,0 @@ -const fs = require("fs"); -const path = require("path"); -const { validate } = require("./validate"); - -exports.resolvePaths = resolvePaths; - -/** - * Convert recognized relative path properties in a config or spec object to absolute paths. - * - * Traverses the provided object (recursing into nested objects and arrays), resolving fields that represent filesystem paths according to the provided config.relativePathBase and reference filePath. On top-level calls the function infers whether the object is a config or spec via schema validation; for nested calls objectType must be provided. - * - * @param {Object} options - Options for path resolution. - * @param {Object} options.config - Configuration 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 or directory used to resolve relative paths. - * @param {boolean} [options.nested=false] - True when invoked recursively for nested objects. - * @param {string} [options.objectType] - 'config' or 'spec'; required for nested invocations to select which properties to resolve. - * @returns {Object} The same object with applicable path properties converted to absolute paths. - * @throws {Error} If the top-level object matches neither config nor spec schema, or if `objectType` is missing for nested calls. - */ -async function resolvePaths({ - config, - object, - filePath, - nested = false, - objectType, -}) { - // Config properties that contain paths - const configPaths = [ - "input", - "output", - "loadVariables", - "setup", - "cleanup", - "configPath", - "beforeAny", - "afterAll", - "mediaDirectory", - "downloadDirectory", - "descriptionPath", - "path", - ]; - // Spec properties that contain paths - const specPaths = [ - "file", - "path", - "directory", - "before", - "after", - "loadVariables", - "setup", - "cleanup", - "savePath", - "saveDirectory", - "specPath", - "descriptionPath", - "workingDirectory", - ]; - // Spec objects that are configurable by the user and shouldn't be resolved - const specNoResolve = [ - "requestData", - "responseData", - "requestHeaders", - "responseHeaders", - "requestParams", - "responseParams", - ]; - - /** - * 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. - */ - function resolve(baseType, relativePath, filePath) { - // If the path is an http:// or https:// URL, or a heretto: URI, return it - if (relativePath.startsWith("https://") || relativePath.startsWith("http://") || relativePath.startsWith("heretto:")) { - return relativePath; - } - - // If path is already absolute, return it - if (path.isAbsolute(relativePath)) { - return relativePath; - } - - // Check if filePath exists and is a file - const fileExists = fs.existsSync(filePath); - const isFile = fileExists - ? fs.lstatSync(filePath).isFile() - : path.parse(filePath).ext !== ""; - - // Use directory of filePath if it's a file (or looks like one) - const basePath = isFile ? path.dirname(filePath) : filePath; - - // Resolve the path based on the base type - return baseType === "file" - ? path.resolve(basePath, relativePath) - : path.resolve(relativePath); - } - - const relativePathBase = config.relativePathBase; - - let pathProperties; - if (!nested && !objectType) { - // Check if object matches the config schema - const validation = validate({ - schemaKey: "config_v3", - object: { ...object }, - }); - if (validation.valid) { - pathProperties = configPaths; - objectType = "config"; - } else { - // Check if object matches the spec schema - const validation = validate({ - schemaKey: "spec_v3", - object: { ...object }, - }); - if (validation.valid) { - pathProperties = specPaths; - objectType = "spec"; - } else { - throw new Error("Object isn't a valid config or spec."); - } - } - } else if (nested && !objectType) { - // If the object is nested, the object type is required - throw new Error("Object type is required for nested objects."); - } else if (objectType === "config") { - // If the object type is config, use configPaths - pathProperties = configPaths; - } else if (objectType === "spec") { - // If the object type is spec, use specPaths - pathProperties = specPaths; - } - - // If the object is null or empty, return it as is - if (object === null || Object.keys(object).length === 0) { - return object; - } - - for (const property of Object.keys(object)) { - // 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 the item is an object, recursively call resolvePaths to resolve paths within the object - if (typeof item === "object") { - await resolvePaths({ - config: config, - object: item, - filePath: filePath, - nested: true, - objectType: objectType, - }); - } else if ( - typeof item === "string" && - pathProperties.includes(property) - ) { - // Resolve the string path and write it back into the array - const resolved = - property === "path" && - object.directory && - path.isAbsolute(object.directory) - ? resolve(relativePathBase, item, object.directory) - : resolve(relativePathBase, item, filePath); - object[property][i] = resolved; - } - } - } - // If the property is an object, recursively call resolvePaths to resolve paths within the object - else if ( - typeof object[property] === "object" && - ((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], - filePath: filePath, - nested: true, - objectType: objectType, - }); - } else if (typeof object[property] === "string") { - // If the property begins with "https://", "http://", or "heretto:", skip it - if ( - object[property].startsWith("https://") || - object[property].startsWith("http://") || - object[property].startsWith("heretto:") - ) { - 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); - object[property] = resolve( - relativePathBase, - object[property], - directory - ); - } else { - object[property] = resolve( - relativePathBase, - object[property], - filePath - ); - } - } - } - } - return object; -} - -// If called directly, resolve paths in the provided object -/* c8 ignore start */ -if (require.main === module) { - (async () => { - // Example usage - const config = { - relativePathBase: "file", - }; - const object = { - tests: [ - { - steps: [ - { - screenshot: { - path: "file.png", - directory: - "/home/hawkeyexl/Workspaces/doc-detective-common/screenshots", - }, - }, - ], - }, - ], - }; - const filePath = process.cwd(); - - await resolvePaths({ config, object, filePath }); - console.log(JSON.stringify(object, null, 2)); - })(); -} -/* c8 ignore stop */ \ No newline at end of file diff --git a/src/schemas/index.js b/src/schemas/index.js deleted file mode 100644 index 1a87702..0000000 --- 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/validate.js b/src/validate.js deleted file mode 100644 index e260265..0000000 --- a/src/validate.js +++ /dev/null @@ -1,596 +0,0 @@ -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 -const ajv = new Ajv({ - strictSchema: false, - useDefaults: true, - allErrors: true, - allowUnionTypes: true, - coerceTypes: true, -}); - -// Enable `uuid` dynamic default -const def = require("ajv-keywords/dist/definitions/dynamicDefaults"); -def.DEFAULTS.uuid = () => randomUUID; - -// Enhance Ajv -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)) { - ajv.addSchema(value, key); -} - -const compatibleSchemas = { - config_v3: ["config_v2"], - context_v3: ["context_v2"], - openApi_v3: ["openApi_v2"], - spec_v3: ["spec_v2"], - step_v3: [ - "checkLink_v2", - "find_v2", - "goTo_v2", - "httpRequest_v2", - "runShell_v2", - "runCode_v2", - "saveScreenshot_v2", - "setVariables_v2", - "startRecording_v2", - "stopRecording_v2", - "typeKeys_v2", - "wait_v2", - ], - test_v3: ["test_v2"], -}; - -/** - * 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. - */ -function escapeRegExp(string) { - return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string -} - -/** - * Validates an object against a specified JSON schema, supporting backward compatibility and automatic transformation from older schema versions if needed. - * - * 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. - * - * @throws {Error} If {@link schemaKey} or {@link object} is missing. - */ -function validate({ schemaKey, object, addDefaults = true }) { - if (!schemaKey) { - throw new Error("Schema key is required."); - } - if (!object) { - throw new Error("Object is required."); - } - const result = {}; - let validationObject; - let check = ajv.getSchema(schemaKey); - if (!check) { - result.valid = false; - result.errors = `Schema not found: ${schemaKey}`; - result.object = object; - return result; - } - - // Clone the object to avoid modifying the original object - validationObject = JSON.parse(JSON.stringify(object)); - - // Check if the object is compatible with the schema - result.valid = check(validationObject); - result.errors = ""; - - if (check.errors) { - // Check if the object is compatible with another schema - const compatibleSchemasList = compatibleSchemas[schemaKey]; - if (!compatibleSchemasList) { - result.errors = check.errors - .map( - (error) => - `${error.instancePath} ${error.message} (${JSON.stringify( - error.params - )})` - ) - .join(", "); - result.object = object; - result.valid = false; - return result; - } - const matchedSchemaKey = compatibleSchemasList.find((key) => { - validationObject = JSON.parse(JSON.stringify(object)); - const check = ajv.getSchema(key); - if (check(validationObject)) return key; - }); - if (!matchedSchemaKey) { - result.errors = check.errors - .map( - (error) => - `${error.instancePath} ${error.message} (${JSON.stringify( - error.params - )})` - ) - .join(", "); - result.object = object; - result.valid = false; - return result; - } else { - const transformedObject = transformToSchemaKey({ - currentSchema: matchedSchemaKey, - targetSchema: schemaKey, - object: validationObject, - }); - - result.valid = check(transformedObject); - if (result.valid) { - validationObject = transformedObject; - object = transformedObject; - /* c8 ignore start - Defensive: transformToSchemaKey validates internally, so this is unreachable */ - } else if (check.errors) { - const errors = check.errors.map( - (error) => - `${error.instancePath} ${error.message} (${JSON.stringify( - error.params - )})` - ); - result.errors = errors.join(", "); - return result; - } - /* c8 ignore stop */ - } - } - if (addDefaults) { - result.object = validationObject; - } else { - result.object = object; - } - - return result; -} - -/** - * Transform an object from one schema key to another and return a validated instance of the target schema. - * - * @param {Object} params - Function parameters. - * @param {string} params.currentSchema - Schema key representing the object's current version. - * @param {string} params.targetSchema - Schema key to transform the object into. - * @param {Object} params.object - The source object to transform. - * @returns {Object} The transformed object conforming to the target schema. - * @throws {Error} If transformation between the specified schemas is not supported or if the transformed object fails validation. - */ -function transformToSchemaKey({ - currentSchema = "", - targetSchema = "", - object = {}, -}) { - // 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)) { - 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, - }; - if (currentSchema === "goTo_v2") { - transformedObject.goTo = { - url: object.url, - origin: object.origin, - }; - } else if (currentSchema === "checkLink_v2") { - transformedObject.checkLink = { - url: object.url, - origin: object.origin, - statusCodes: object.statusCodes, - }; - } else if (currentSchema === "find_v2") { - transformedObject.find = { - selector: object.selector, - elementText: object.matchText, - timeout: object.timeout, - moveTo: object.moveTo, - click: object.click, - type: object.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; - } - transformedObject.variables = {}; - object.setVariables?.forEach((variable) => { - transformedObject.variables[ - variable.name - ] = `extract($$element.text, "${variable.regex}")`; - }); - } else if (currentSchema === "httpRequest_v2") { - transformedObject.httpRequest = { - method: object.method, - url: object.url, - openApi: object.openApi, - request: { - body: object.requestData, - headers: object.requestHeaders, - parameters: object.requestParams, - }, - response: { - body: object.responseData, - headers: object.responseHeaders, - }, - statusCodes: object.statusCodes, - allowAdditionalFields: object.allowAdditionalFields, - timeout: object.timeout, - path: object.savePath, - directory: object.saveDirectory, - maxVariation: object.maxVariation / 100, - overwrite: - object.overwrite === "byVariation" - ? "aboveVariation" - : object.overwrite, - }; - // Handle openApi.requestHeaders key change - if (object.openApi) { - transformedObject.httpRequest.openApi = transformToSchemaKey({ - currentSchema: "openApi_v2", - targetSchema: "openApi_v3", - object: object.openApi, - }); - } - transformedObject.variables = {}; - object.envsFromResponseData?.forEach((variable) => { - transformedObject.variables[ - variable.name - ] = `jq($$response.body, "${variable.jqFilter}")`; - }); - } else if (currentSchema === "runShell_v2") { - transformedObject.runShell = { - command: object.command, - args: object.args, - workingDirectory: object.workingDirectory, - exitCodes: object.exitCodes, - stdio: object.output, - path: object.savePath, - directory: object.saveDirectory, - maxVariation: object.maxVariation / 100, - overwrite: - object.overwrite === "byVariation" - ? "aboveVariation" - : object.overwrite, - timeout: object.timeout, - }; - transformedObject.variables = {}; - object.setVariables?.forEach((variable) => { - transformedObject.variables[ - variable.name - ] = `extract($$stdio.stdout, "${variable.regex}")`; - }); - } else if (currentSchema === "runCode_v2") { - transformedObject.runCode = { - language: object.language, - code: object.code, - args: object.args, - workingDirectory: object.workingDirectory, - exitCodes: object.exitCodes, - stdio: object.output, - path: object.savePath, - directory: object.saveDirectory, - maxVariation: object.maxVariation / 100, - overwrite: - object.overwrite === "byVariation" - ? "aboveVariation" - : object.overwrite, - timeout: object.timeout, - }; - transformedObject.variables = {}; - object?.setVariables?.forEach((variable) => { - transformedObject.variables[ - variable.name - ] = `extract($$stdio.stdout, "${variable.regex}")`; - }); - } else if (currentSchema === "setVariables_v2") { - transformedObject.loadVariables = object.path; - } else if (currentSchema === "typeKeys_v2") { - transformedObject.type = { - keys: object.keys, - inputDelay: object.delay, - }; - } else if (currentSchema === "saveScreenshot_v2") { - transformedObject.screenshot = { - path: object.path, - directory: object.directory, - maxVariation: object.maxVariation / 100, - overwrite: - object.overwrite === "byVariation" - ? "aboveVariation" - : object.overwrite, - crop: object.crop, - }; - } else if (currentSchema === "startRecording_v2") { - transformedObject.record = { - path: object.path, - directory: object.directory, - overwrite: object.overwrite, - }; - } else if (currentSchema === "stopRecording_v2") { - transformedObject.stopRecord = true; - } else if (currentSchema === "wait_v2") { - transformedObject.wait = object; - } - const result = validate({ - schemaKey: "step_v3", - object: transformedObject, - }); - if (!result.valid) { - throw new Error(`Invalid object: ${result.errors}`); - } - return result.object; - } else if (targetSchema === "config_v3") { - // Handle config_v2 to config_v3 transformation - const transformedObject = { - loadVariables: object.envVariables, - input: object?.runTests?.input || object.input, - output: object?.runTests?.output || object.output, - recursive: object?.runTests?.recursive || object.recursive, - relativePathBase: object.relativePathBase, - detectSteps: object?.runTests?.detectSteps, - beforeAny: object?.runTests?.setup, - afterAll: object?.runTests?.cleanup, - logLevel: object.logLevel, - telemetry: object.telemetry, - }; - // Handle context transformation - if (object?.runTests?.contexts) - transformedObject.runOn = object.runTests.contexts.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) => - transformToSchemaKey({ - currentSchema: "openApi_v2", - targetSchema: "openApi_v3", - object: description, - }) - ); - } - // Handle fileTypes transformation - if (object?.fileTypes) - transformedObject.fileTypes = object.fileTypes.map((fileType) => { - const transformedFileType = { - name: fileType.name, - extensions: fileType.extensions.map((extension) => - // Trim leading `.` from extension - extension.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)}`, - }, - }; - if (fileType.markup) - transformedFileType.markup = fileType.markup.map((markup) => { - const transformedMarkup = { - name: markup.name, - regex: markup.regex, - }; - if (markup.actions) - transformedMarkup.actions = markup.actions.map((action) => { - if (typeof action === "string") return action; - if (typeof action === "object") { - if (action.params) { - action = { - action: action.name, - ...action.params, - }; - } - const transformedAction = transformToSchemaKey({ - currentSchema: `${action.action}_v2`, - targetSchema: "step_v3", - object: action, - }); - return transformedAction; - } - }); - - return transformedMarkup; - }); - return transformedFileType; - }); - const result = validate({ - schemaKey: "config_v3", - object: transformedObject, - }); - // Defensive: transformation always produces valid config_v3, unreachable - /* c8 ignore next 3 */ - if (!result.valid) { - throw new Error(`Invalid object: ${result.errors}`); - } - return result.object; - } else if (targetSchema === "context_v3") { - const transformedObject = {}; - // 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 result = validate({ - schemaKey: "context_v3", - object: transformedObject, - }); - if (!result.valid) { - throw new Error(`Invalid object: ${result.errors}`); - } - return result.object; - } 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 result = validate({ - schemaKey: "openApi_v3", - object: transformedObject, - }); - if (!result.valid) { - throw new Error(`Invalid object: ${result.errors}`); - } - return transformedObject; - } else if (targetSchema === "spec_v3") { - // Handle spec_v2 to spec_v3 transformation - const transformedObject = { - specId: object.id, - description: object.description, - contentPath: object.file, - }; - if (object.contexts) - transformedObject.runOn = object.contexts.map((context) => - transformToSchemaKey({ - currentSchema: "context_v2", - targetSchema: "context_v3", - object: context, - }) - ); - if (object.openApi) - transformedObject.openApi = object.openApi.map((description) => - transformToSchemaKey({ - currentSchema: "openApi_v2", - targetSchema: "openApi_v3", - object: description, - }) - ); - transformedObject.tests = object.tests.map((test) => - transformToSchemaKey({ - currentSchema: "test_v2", - targetSchema: "test_v3", - object: test, - }) - ); - - const result = validate({ - schemaKey: "spec_v3", - object: transformedObject, - }); - // Defensive: nested transforms validate; this is unreachable - /* c8 ignore next 3 */ - if (!result.valid) { - throw new Error(`Invalid object: ${result.errors}`); - } - return result.object; - } else if (targetSchema === "test_v3") { - // Handle test_v2 to test_v3 transformation - const transformedObject = { - testId: object.id, - description: object.description, - contentPath: object.file, - detectSteps: object.detectSteps, - before: object.setup, - after: object.cleanup, - }; - if (object.contexts) - transformedObject.runOn = object.contexts.map((context) => - transformToSchemaKey({ - currentSchema: "context_v2", - targetSchema: "context_v3", - object: context, - }) - ); - if (object.openApi) - transformedObject.openApi = object.openApi.map((description) => - transformToSchemaKey({ - currentSchema: "openApi_v2", - targetSchema: "openApi_v3", - object: description, - }) - ); - transformedObject.steps = object.steps.map((step) => - transformToSchemaKey({ - currentSchema: `${step.action}_v2`, - targetSchema: "step_v3", - object: step, - }) - ); - - const result = validate({ - schemaKey: "test_v3", - object: transformedObject, - }); - // Defensive: nested transforms validate; this is unreachable - /* c8 ignore next 3 */ - if (!result.valid) { - throw new Error(`Invalid object: ${result.errors}`); - } - return result.object; - } - /* c8 ignore next - Dead code: incompatible schemas throw at line 197-200 */ - return null; -} - -// If called directly, validate an example object -/* c8 ignore start */ -if (require.main === module) { - 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)); -} -/* c8 ignore stop */ \ No newline at end of file