diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 1293867..96a3ac8 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -6,13 +6,13 @@ on: - main - heretto paths: - - 'src/heretto*.js' + - 'src/heretto*' - '.github/workflows/integration-tests.yml' pull_request: branches: - main paths: - - 'src/heretto*.js' + - 'src/heretto*' - '.github/workflows/integration-tests.yml' workflow_dispatch: # Allow manual triggering for testing @@ -41,6 +41,9 @@ jobs: - name: Install dependencies run: npm ci + - name: Build + run: npm run build + - name: Run integration tests env: CI: 'true' diff --git a/.github/workflows/npm-test.yaml b/.github/workflows/npm-test.yaml index ffc5577..8fdacee 100644 --- a/.github/workflows/npm-test.yaml +++ b/.github/workflows/npm-test.yaml @@ -38,6 +38,7 @@ jobs: registry-url: https://registry.npmjs.org/ - run: npm ci + - run: npm run build - run: npm test publish-npm: @@ -53,6 +54,7 @@ jobs: registry-url: https://registry.npmjs.org/ - run: npm ci + - run: npm run build - run: npm publish env: NODE_AUTH_TOKEN: ${{secrets.npm_token}} diff --git a/package-lock.json b/package-lock.json index f508bf6..ffc7427 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,8 @@ "posthog-node": "^5.18.1" }, "devDependencies": { + "@types/adm-zip": "^0.5.7", + "@types/node": "^22.10.5", "body-parser": "^2.2.1", "chai": "^6.2.2", "express": "^5.2.1", @@ -27,6 +29,7 @@ "proxyquire": "^2.1.3", "semver": "^7.7.3", "sinon": "^21.0.1", + "typescript": "^5.7.3", "yaml": "^2.8.2" } }, @@ -175,12 +178,32 @@ "node": ">=4" } }, + "node_modules/@types/adm-zip": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@types/adm-zip/-/adm-zip-0.5.7.tgz", + "integrity": "sha512-DNEs/QvmyRLurdQPChqq0Md4zGvPwHerAJYWk9l2jCbD1VPpnzRJorOdiq4zsw09NFbYnhfsoEhWtxIzXpn2yw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "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": "22.19.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz", + "integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -232,6 +255,7 @@ "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", @@ -1400,6 +1424,7 @@ "resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz", "integrity": "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==", "license": "MIT", + "peer": true, "engines": { "node": ">= 10.16.0" } @@ -2455,6 +2480,27 @@ "node": ">= 0.6" } }, + "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": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", diff --git a/package.json b/package.json index 25bc9fb..a5f4c30 100644 --- a/package.json +++ b/package.json @@ -2,8 +2,20 @@ "name": "doc-detective-resolver", "version": "3.6.2", "description": "Detect and resolve docs into Doc Detective tests.", - "main": "src/index.js", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "require": "./dist/index.js", + "import": "./dist/index.mjs", + "types": "./dist/index.d.ts" + } + }, "scripts": { + "compile": "tsc && node scripts/createEsmWrapper.js", + "build": "npm run compile", + "prebuild": "rm -rf dist", + "postbuild": "npm run test", "test": "mocha src/*.test.js --ignore src/*.integration.test.js", "test:integration": "mocha src/*.integration.test.js --timeout 600000", "test:all": "mocha src/*.test.js --timeout 600000", @@ -37,6 +49,8 @@ "posthog-node": "^5.18.1" }, "devDependencies": { + "@types/adm-zip": "^0.5.7", + "@types/node": "^22.10.5", "body-parser": "^2.2.1", "chai": "^6.2.2", "express": "^5.2.1", @@ -44,6 +58,7 @@ "proxyquire": "^2.1.3", "semver": "^7.7.3", "sinon": "^21.0.1", + "typescript": "^5.7.3", "yaml": "^2.8.2" } } diff --git a/scripts/createEsmWrapper.js b/scripts/createEsmWrapper.js new file mode 100644 index 0000000..634151d --- /dev/null +++ b/scripts/createEsmWrapper.js @@ -0,0 +1,21 @@ +const fs = require("fs").promises; +const path = require("path"); + +async function createEsmWrapper() { + const distDir = path.join(__dirname, "..", "dist"); + await fs.mkdir(distDir, { recursive: true }); + + const esmContent = `// ESM wrapper for CommonJS output +import cjsModule from './index.js'; +export const { detectTests, resolveTests, detectAndResolveTests } = cjsModule; +export default cjsModule; +`; + + await fs.writeFile(path.join(distDir, "index.mjs"), esmContent); + console.log("Created ESM wrapper at dist/index.mjs"); +} + +createEsmWrapper().catch((error) => { + console.error("Failed to create ESM wrapper:", error); + process.exit(1); +}); diff --git a/src/arazzo.js b/src/arazzo.ts similarity index 58% rename from src/arazzo.js rename to src/arazzo.ts index cc70cbe..122a80f 100644 --- a/src/arazzo.js +++ b/src/arazzo.ts @@ -1,16 +1,32 @@ -const crypto = require("crypto"); +import crypto from "crypto"; +import type { ArazzoDescription, ArazzoWorkflowStep, DetectedTest, Step, OpenApiDefinition } from "./types"; + +/** + * Doc Detective test specification created from Arazzo workflow + */ +interface ArazzoTestSpec extends DetectedTest { + id: string; + description?: string; + steps: Step[]; + openApi: OpenApiDefinition[]; +} /** * Translates an Arazzo description into a Doc Detective test specification - * @param {Object} arazzoDescription - The Arazzo description object - * @returns {Object} - The Doc Detective test specification object + * @param arazzoDescription - The Arazzo description object + * @param workflowId - The ID of the workflow to translate + * @param _inputs - Optional inputs for the workflow (currently unused) + * @returns The Doc Detective test specification object, or undefined if workflow not found */ -function workflowToTest(arazzoDescription, workflowId, inputs) { +export function workflowToTest( + arazzoDescription: ArazzoDescription, + workflowId: string, + _inputs?: unknown +): ArazzoTestSpec | undefined { // Initialize the Doc Detective test specification - const test = { + const test: ArazzoTestSpec = { id: arazzoDescription.info.title || `${crypto.randomUUID()}`, - description: - arazzoDescription.info.description || arazzoDescription.info.summary, + description: arazzoDescription.info.description || arazzoDescription.info.summary, steps: [], openApi: [], }; @@ -18,7 +34,7 @@ function workflowToTest(arazzoDescription, workflowId, inputs) { arazzoDescription.sourceDescriptions.forEach((source) => { // Translate OpenAPI definitions to Doc Detective format if (source.type === "openapi") { - const openApiDefinition = { + const openApiDefinition: OpenApiDefinition = { name: source.name, descriptionPath: source.url, }; @@ -28,17 +44,17 @@ function workflowToTest(arazzoDescription, workflowId, inputs) { // Find workflow by ID const workflow = arazzoDescription.workflows.find( - (workflow) => workflow.workflowId === workflowId + (w) => w.workflowId === workflowId ); if (!workflow) { console.warn(`Workflow with ID ${workflowId} not found.`); - return; + return undefined; } // Translate each step in the workflow to a Doc Detective step - workflow.steps.forEach((workflowStep) => { - const docDetectiveStep = { + workflow.steps.forEach((workflowStep: ArazzoWorkflowStep) => { + const docDetectiveStep: Step = { action: "httpRequest", }; @@ -48,13 +64,13 @@ function workflowToTest(arazzoDescription, workflowId, inputs) { } else if (workflowStep.operationPath) { // Handle operation path references (not yet supported in Doc Detective) console.warn( - `Operation path references arne't yet supported in Doc Detective: ${workflowStep.operationPath}` + `Operation path references aren't yet supported in Doc Detective: ${workflowStep.operationPath}` ); return; } else if (workflowStep.workflowId) { // Handle workflow references (not yet supported in Doc Detective) console.warn( - `Workflow references arne't yet supported in Doc Detective: ${workflowStep.workflowId}` + `Workflow references aren't yet supported in Doc Detective: ${workflowStep.workflowId}` ); return; } else { @@ -65,14 +81,15 @@ function workflowToTest(arazzoDescription, workflowId, inputs) { // Add parameters if (workflowStep.parameters) { - docDetectiveStep.requestParams = {}; + docDetectiveStep.requestParams = {} as Record; workflowStep.parameters.forEach((param) => { if (param.in === "query") { - docDetectiveStep.requestParams[param.name] = param.value; + (docDetectiveStep.requestParams as Record)[param.name] = param.value; } else if (param.in === "header") { - if (!docDetectiveStep.requestHeaders) - docDetectiveStep.requestHeaders = {}; - docDetectiveStep.requestHeaders[param.name] = param.value; + if (!docDetectiveStep.requestHeaders) { + docDetectiveStep.requestHeaders = {} as Record; + } + (docDetectiveStep.requestHeaders as Record)[param.name] = param.value; } // Note: path parameters would require modifying the URL, which is not handled in this simple translation }); @@ -85,7 +102,7 @@ function workflowToTest(arazzoDescription, workflowId, inputs) { // Translate success criteria to response validation if (workflowStep.successCriteria) { - docDetectiveStep.responseData = {}; + docDetectiveStep.responseData = {} as Record; workflowStep.successCriteria.forEach((criterion) => { if (criterion.condition.startsWith("$statusCode")) { docDetectiveStep.statusCodes = [ @@ -93,7 +110,7 @@ function workflowToTest(arazzoDescription, workflowId, inputs) { ]; } else if (criterion.context === "$response.body") { // This is a simplification; actual JSONPath translation would be more complex - docDetectiveStep.responseData[criterion.condition] = true; + (docDetectiveStep.responseData as Record)[criterion.condition] = true; } }); } diff --git a/src/config.test.js b/src/config.test.js index decedb1..31e1438 100644 --- a/src/config.test.js +++ b/src/config.test.js @@ -1,7 +1,7 @@ const assert = require("assert"); const sinon = require("sinon"); const proxyquire = require("proxyquire"); -const { setConfig } = require("./config"); +const { setConfig } = require("../dist/config"); before(async function () { const { expect } = await import("chai"); @@ -24,10 +24,10 @@ describe("envMerge", function () { replaceEnvsStub = sinon.stub().returnsArg(0); // Setup proxyquire - setConfig = proxyquire("./config", { + setConfig = proxyquire("../dist/config", { "doc-detective-common": { validate: validStub }, - "./utils": { log: logStub, loadEnvs: loadEnvsStub, replaceEnvs: replaceEnvsStub }, - "./openapi": { loadDescription: sinon.stub().resolves({}) } + "../dist/utils": { log: logStub, loadEnvs: loadEnvsStub, replaceEnvs: replaceEnvsStub }, + "../dist/openapi": { loadDescription: sinon.stub().resolves({}) } }).setConfig; }); @@ -404,7 +404,7 @@ function deepObjectExpect(actual, expected) { } describe("resolveConcurrentRunners", function () { - const { resolveConcurrentRunners } = require("./config"); + const { resolveConcurrentRunners } = require("../dist/config"); const os = require("os"); let originalCpus; diff --git a/src/config.js b/src/config.ts similarity index 81% rename from src/config.js rename to src/config.ts index 0dc59f3..92d9464 100644 --- a/src/config.js +++ b/src/config.ts @@ -1,58 +1,87 @@ -const os = require("os"); -const { validate } = require("doc-detective-common"); -const { log, loadEnvs, replaceEnvs } = require("./utils"); -const { loadDescription } = require("./openapi"); +import os from "os"; +import { validate } from "doc-detective-common"; +import { log, loadEnvs, replaceEnvs } from "./utils"; +import { loadDescription } from "./openapi"; +import type { + Config, + FileType, + OpenApiDefinition, + Platform, + MarkupPattern, +} from "./types"; -exports.setConfig = setConfig; -exports.resolveConcurrentRunners = resolveConcurrentRunners; +/** + * Extended FileType for internal use during config processing + */ +interface ExtendedFileType extends FileType { + extends?: string; +} + +/** + * OpenAPI config with definition loaded + */ +interface OpenApiConfigWithDefinition extends OpenApiDefinition { + definition?: Record; +} + +// Map of Node-detected platforms to common-term equivalents +const platformMap: Record = { + darwin: "mac", + linux: "linux", + win32: "windows", +}; /** * Deep merge two objects, with override properties taking precedence - * @param {Object} target - The target object to merge into - * @param {Object} override - The override object containing properties to merge - * @returns {Object} A new object with merged properties + * @param target - The target object to merge into + * @param override - The override object containing properties to merge + * @returns A new object with merged properties */ -function deepMerge(target, override) { - const result = { ...target }; +function deepMerge>( + target: T, + override: Partial +): T { + const result = { ...target } as Record; for (const key in override) { - if (override.hasOwnProperty(key)) { + if (Object.prototype.hasOwnProperty.call(override, key)) { + const overrideValue = override[key]; if ( - override[key] != null && - typeof override[key] === "object" && - !Array.isArray(override[key]) + overrideValue != null && + typeof overrideValue === "object" && + !Array.isArray(overrideValue) ) { // If both target and override have objects at this key, deep merge them + const targetValue = result[key]; if ( - result[key] != null && - typeof result[key] === "object" && - !Array.isArray(result[key]) + targetValue != null && + typeof targetValue === "object" && + !Array.isArray(targetValue) ) { - result[key] = deepMerge(result[key], override[key]); + result[key] = deepMerge( + targetValue as Record, + overrideValue as Record + ); } else { // If target doesn't have an object at this key, just assign the override - result[key] = deepMerge({}, override[key]); + result[key] = deepMerge( + {} as Record, + overrideValue as Record + ); } } else { // For primitive values, arrays, or null, just override - result[key] = override[key]; + result[key] = overrideValue; } } } - return result; + return result as T; } -// Map of Node-detected platforms to common-term equivalents -const platformMap = { - darwin: "mac", - linux: "linux", - win32: "windows", -}; - // List of default file type definitions // TODO: Add defaults for all supported files -let defaultFileTypes = { +let defaultFileTypes: Record = { asciidoc_1_0: { name: "asciidoc", extensions: ["adoc", "asciidoc", "asc"], @@ -291,7 +320,7 @@ let defaultFileTypes = { }, ], }, - ], + ] as MarkupPattern[], }, html_1_0: { name: "html", @@ -439,9 +468,10 @@ let defaultFileTypes = { }, ], }, - ], + ] as MarkupPattern[], }, }; + // Set keyword versions defaultFileTypes = { ...defaultFileTypes, @@ -455,48 +485,66 @@ defaultFileTypes = { * Resolves the concurrentRunners configuration value from various input formats * to a concrete integer for the core execution engine. * - * @param {Object} config - The configuration object - * @returns {number} The resolved concurrent runners value + * @param config - The configuration object + * @returns The resolved concurrent runners value */ -function resolveConcurrentRunners(config) { +export function resolveConcurrentRunners( + config: Config +): number { if (config.concurrentRunners === true) { // Cap at 4 only for the boolean convenience option return Math.min(os.cpus().length, 4); } // Respect explicit numeric values and default - return config.concurrentRunners || 1; + return (config.concurrentRunners as number) || 1; } /** * Sets up and validates the configuration object for Doc Detective * @async - * @param {Object} config - The configuration object to process - * @returns {Promise} The processed and validated configuration object - * @throws Will exit process with code 1 if configuration is invalid + * @param config - The configuration object to process + * @returns The processed and validated configuration object + * @throws Will throw error if configuration is invalid */ -async function setConfig({ config }) { +export async function setConfig({ + config, +}: { + config: Config; +}): Promise { // Set environment variables from file - if (config.loadVariables) await loadEnvs(config.loadVariables); + if (config.loadVariables) { + const loadVariablesArray = Array.isArray(config.loadVariables) + ? config.loadVariables + : [config.loadVariables]; + for (const envFile of loadVariablesArray) { + await loadEnvs(envFile); + } + } // Load environment variables for `config` - config = replaceEnvs(config); + config = replaceEnvs(config) as Config; // Apply config overrides from DOC_DETECTIVE environment variable if (process.env.DOC_DETECTIVE) { try { - const docDetectiveEnv = JSON.parse(process.env.DOC_DETECTIVE); + const docDetectiveEnv = JSON.parse(process.env.DOC_DETECTIVE) as { + config?: Partial; + }; if ( docDetectiveEnv.config && typeof docDetectiveEnv.config === "object" ) { // Apply config overrides using deep merge to preserve nested properties - config = deepMerge(config, docDetectiveEnv.config); + config = deepMerge( + config as Record, + docDetectiveEnv.config as Record + ) as Config; } } catch (error) { log( config, "warning", - `Invalid JSON in DOC_DETECTIVE environment variable: ${error.message}. Ignoring config overrides.` + `Invalid JSON in DOC_DETECTIVE environment variable: ${(error as Error).message}. Ignoring config overrides.` ); } } @@ -512,10 +560,11 @@ async function setConfig({ config }) { ); throw new Error(`Invalid config object: ${validityCheck.errors}. Exiting.`); } - config = validityCheck.object; + config = validityCheck.object as Config; // Replace fileType strings with objects - config.fileTypes = config.fileTypes.map((fileType) => { + const fileTypesArray = config.fileTypes as unknown as (string | FileType)[]; + config.fileTypes = fileTypesArray.map((fileType) => { if (typeof fileType === "object") return fileType; const fileTypeObject = defaultFileTypes[fileType]; if (typeof fileTypeObject !== "undefined") return fileTypeObject; @@ -527,30 +576,34 @@ async function setConfig({ config }) { throw new Error( `Invalid config. "${fileType}" isn't a valid fileType value.` ); - }); + }) as FileType[]; // TODO: Combine extended fileTypes with overrides // Standardize value formats if (typeof config.input === "string") config.input = [config.input]; if (typeof config.beforeAny === "string") { - if (config.beforeAny === "") { + const beforeAny = config.beforeAny; + if (beforeAny === "") { config.beforeAny = []; } else { - config.beforeAny = [config.beforeAny]; + config.beforeAny = [beforeAny]; } } if (typeof config.afterAll === "string") { - if (config.afterAll === "") { + const afterAll = config.afterAll; + if (afterAll === "") { config.afterAll = []; } else { - config.afterAll = [config.afterAll]; + config.afterAll = [afterAll]; } } if (typeof config.fileTypes === "string") { config.fileTypes = [config.fileTypes]; } - config.fileTypes = config.fileTypes.map((fileType) => { + + const fileTypes = config.fileTypes as unknown as ExtendedFileType[]; + config.fileTypes = fileTypes.map((fileType) => { if (fileType.inlineStatements) { if (typeof fileType.inlineStatements.testStart === "string") fileType.inlineStatements.testStart = [ @@ -571,7 +624,8 @@ async function setConfig({ config }) { } if (fileType.markup) { fileType.markup = fileType.markup.map((markup) => { - if (typeof markup?.regex === "string") markup.regex = [markup.regex]; + if (typeof markup?.regex === "string") + markup.regex = [markup.regex]; return markup; }); } @@ -592,7 +646,9 @@ async function setConfig({ config }) { '".' ); } - const extendedFileType = JSON.parse(JSON.stringify(extendedFileTypeRaw)); + const extendedFileType = JSON.parse( + JSON.stringify(extendedFileTypeRaw) + ) as FileType; if (extendedFileType) { if (!fileType.name) { fileType.name = extendedFileType.name; @@ -611,7 +667,13 @@ async function setConfig({ config }) { // Merge property values for inlineStatements children if (extendedFileType?.inlineStatements) { if (fileType.inlineStatements === undefined) { - fileType.inlineStatements = {}; + fileType.inlineStatements = { + testStart: [], + testEnd: [], + ignoreStart: [], + ignoreEnd: [], + step: [], + }; } // Merge each inlineStatements property using Set to ensure uniqueness const keys = [ @@ -620,7 +682,7 @@ async function setConfig({ config }) { "ignoreStart", "ignoreEnd", "step", - ]; + ] as const; for (const key of keys) { if ( extendedFileType?.inlineStatements?.[key] || @@ -640,12 +702,12 @@ async function setConfig({ config }) { if (extendedFileType?.markup) { fileType.markup = fileType.markup || []; extendedFileType.markup.forEach((extendedMarkup) => { - const existingMarkupIndex = fileType.markup.findIndex( + const existingMarkupIndex = fileType.markup!.findIndex( (markup) => markup.name === extendedMarkup.name ); if (existingMarkupIndex === -1) { // Add to markup array - fileType.markup.push(extendedMarkup); + fileType.markup!.push(extendedMarkup); } }); } @@ -671,29 +733,30 @@ async function setConfig({ config }) { * Loads OpenAPI descriptions for all configured OpenAPI integrations. * * @async - * @param {Object} config - The configuration object. - * @returns {Promise} - A promise that resolves when all descriptions are loaded. + * @param config - The configuration object. + * @returns A promise that resolves when all descriptions are loaded. * * @remarks * This function modifies the input config object by: * 1. Adding a 'definition' property to each OpenAPI configuration with the loaded description. * 2. Removing any OpenAPI configurations where the description failed to load. */ -async function loadDescriptions(config) { +async function loadDescriptions(config: Config): Promise { if (config?.integrations?.openApi) { - for (const openApiConfig of config.integrations.openApi) { + for (const openApiConfig of config.integrations + .openApi as OpenApiConfigWithDefinition[]) { try { openApiConfig.definition = await loadDescription( - openApiConfig.descriptionPath + openApiConfig.descriptionPath! ); } catch (error) { log( config, "error", - `Failed to load OpenAPI description from ${openApiConfig.descriptionPath}: ${error.message}` + `Failed to load OpenAPI description from ${openApiConfig.descriptionPath}: ${(error as Error).message}` ); // Remove the failed OpenAPI configuration - config.integrations.openApi = config.integrations.openApi.filter( + config.integrations.openApi = config.integrations.openApi!.filter( (item) => item !== openApiConfig ); } @@ -702,13 +765,10 @@ async function loadDescriptions(config) { } // Detect aspects of the environment running Doc Detective. -function getEnvironment() { - const environment = {}; - // Detect system architecture - environment.arch = os.arch(); - // Detect system platform - environment.platform = platformMap[process.platform]; - // Detect working directory - environment.workingDirectory = process.cwd(); - return environment; +function getEnvironment(): Config["environment"] { + return { + arch: os.arch(), + platform: platformMap[process.platform], + workingDirectory: process.cwd(), + }; } diff --git a/src/doc-detective-common.d.ts b/src/doc-detective-common.d.ts new file mode 100644 index 0000000..e3c2260 --- /dev/null +++ b/src/doc-detective-common.d.ts @@ -0,0 +1,46 @@ +/** + * Type declarations for doc-detective-common + */ +declare module "doc-detective-common" { + export interface ValidationResult { + valid: boolean; + errors?: string; + object: unknown; + } + + /** + * Validates an object against a specified JSON schema + */ + export function validate(params: { + schemaKey: string; + object: unknown; + addDefaults?: boolean; + }): ValidationResult; + + /** + * Recursively resolves all relative path properties in a configuration or specification object to absolute paths + */ + export function resolvePaths(params: { + config: unknown; + object: unknown; + filePath: string; + nested?: boolean; + objectType?: "config" | "spec"; + }): Promise; + + /** + * Transforms an object from one JSON schema version to another + */ + export function transformToSchemaKey(params: { + currentSchema: string; + targetSchema: string; + object: unknown; + }): unknown; + + /** + * Reads and parses content from a remote URL or local file path, supporting JSON and YAML formats + */ + export function readFile(params: { + fileURLOrPath: string; + }): Promise; +} diff --git a/src/heretto.integration.test.js b/src/heretto.integration.test.js index f3a7919..6a192ed 100644 --- a/src/heretto.integration.test.js +++ b/src/heretto.integration.test.js @@ -14,7 +14,7 @@ * - Required environment variables are not set */ -const heretto = require("./heretto"); +const heretto = require("../dist/heretto"); const fs = require("fs"); const path = require("path"); const os = require("os"); diff --git a/src/heretto.test.js b/src/heretto.test.js index c47a5a8..456992d 100644 --- a/src/heretto.test.js +++ b/src/heretto.test.js @@ -24,7 +24,7 @@ describe("Heretto Integration", function () { axiosCreateStub = sinon.stub().returns(mockClient); // Use proxyquire to inject stubbed axios - heretto = proxyquire("../src/heretto", { + heretto = proxyquire("../dist/heretto", { axios: { create: axiosCreateStub, }, @@ -623,7 +623,7 @@ describe("Heretto Integration", function () { }; // Create heretto with mocked dependencies - herettoWithMocks = proxyquire("../src/heretto", { + herettoWithMocks = proxyquire("../dist/heretto", { axios: { create: axiosCreateStub }, fs: fsMock, "adm-zip": admZipMock, diff --git a/src/heretto.js b/src/heretto.ts similarity index 54% rename from src/heretto.js rename to src/heretto.ts index be6f7d8..be97321 100644 --- a/src/heretto.js +++ b/src/heretto.ts @@ -1,46 +1,100 @@ -const axios = require("axios"); -const fs = require("fs"); -const path = require("path"); -const os = require("os"); -const crypto = require("crypto"); -const AdmZip = require("adm-zip"); -const { XMLParser } = require("fast-xml-parser"); +import axios, { AxiosInstance, AxiosResponse } from "axios"; +import fs from "fs"; +import path from "path"; +import os from "os"; +import crypto from "crypto"; +import AdmZip from "adm-zip"; +import { XMLParser } from "fast-xml-parser"; +import type { Config, HerettoConfig, HerettoResourceInfo, HerettoFileMapping, LogLevel } from "./types"; + +// Type for log function +type LogFunction = (config: Config, level: LogLevel, message: unknown) => void; // Internal constants - not exposed to users -const POLLING_INTERVAL_MS = 5000; -const POLLING_TIMEOUT_MS = 300000; // 5 minutes +export const POLLING_INTERVAL_MS = 5000; +export const POLLING_TIMEOUT_MS = 300000; // 5 minutes const API_REQUEST_TIMEOUT_MS = 30000; // 30 seconds for individual API requests const DOWNLOAD_TIMEOUT_MS = 300000; // 5 minutes for downloads -const DEFAULT_SCENARIO_NAME = "Doc Detective"; +export const DEFAULT_SCENARIO_NAME = "Doc Detective"; // Base URL for REST API (different from publishing API) const REST_API_PATH = "/rest/all-files"; +// Publishing scenario types +interface PublishingScenario { + id: string; + name: string; +} + +interface ScenarioParameter { + name: string; + type: string; + value?: string; + options?: Array<{ value: string }>; +} + +interface ScenarioParameters { + content: ScenarioParameter[]; +} + +interface PublishingJob { + jobId: string; + status?: { + status: string; + result?: string; + }; +} + +interface JobAsset { + filePath?: string; +} + +interface JobAssetsResponse { + content: JobAsset[]; + totalPages?: number; +} + +interface ScenarioResult { + scenarioId: string; + fileId: string; +} + +interface UploadResult { + status: "PASS" | "FAIL"; + description: string; +} + +interface FileSearchResult { + fileId: string; + filePath: string; + name: string; +} + /** * Creates a Base64-encoded Basic Auth header from username and API token. - * @param {string} username - Heretto CCMS username (email) - * @param {string} apiToken - API token generated in Heretto CCMS - * @returns {string} Base64-encoded authorization header value + * @param username - Heretto CCMS username (email) + * @param apiToken - API token generated in Heretto CCMS + * @returns Base64-encoded authorization header value */ -function createAuthHeader(username, apiToken) { +export function createAuthHeader(username: string, apiToken: string): string { const credentials = `${username}:${apiToken}`; return Buffer.from(credentials).toString("base64"); } /** * Builds the base URL for Heretto CCMS API. - * @param {string} organizationId - The organization subdomain - * @returns {string} Base API URL + * @param organizationId - The organization subdomain + * @returns Base API URL */ -function getBaseUrl(organizationId) { +function getBaseUrl(organizationId: string): string { return `https://${organizationId}.heretto.com/ezdnxtgen/api/v2`; } /** * Creates an axios instance configured for Heretto API requests. - * @param {Object} herettoConfig - Heretto integration configuration - * @returns {Object} Configured axios instance + * @param herettoConfig - Heretto integration configuration + * @returns Configured axios instance */ -function createApiClient(herettoConfig) { +export function createApiClient(herettoConfig: HerettoConfig): AxiosInstance { const authHeader = createAuthHeader( herettoConfig.username, herettoConfig.apiToken @@ -57,10 +111,10 @@ function createApiClient(herettoConfig) { /** * Creates an axios instance configured for Heretto REST API requests (different base URL). - * @param {Object} herettoConfig - Heretto integration configuration - * @returns {Object} Configured axios instance for REST API + * @param herettoConfig - Heretto integration configuration + * @returns Configured axios instance for REST API */ -function createRestApiClient(herettoConfig) { +export function createRestApiClient(herettoConfig: HerettoConfig): AxiosInstance { const authHeader = createAuthHeader( herettoConfig.username, herettoConfig.apiToken @@ -77,22 +131,25 @@ function createRestApiClient(herettoConfig) { /** * Fetches all available publishing scenarios from Heretto. - * @param {Object} client - Configured axios instance - * @returns {Promise} Array of publishing scenarios + * @param client - Configured axios instance + * @returns Array of publishing scenarios */ -async function getPublishingScenarios(client) { - const response = await client.get("/publishes/scenarios"); +async function getPublishingScenarios(client: AxiosInstance): Promise { + const response: AxiosResponse<{ content?: PublishingScenario[] }> = await client.get("/publishes/scenarios"); return response.data.content || []; } /** * Fetches parameters for a specific publishing scenario. - * @param {Object} client - Configured axios instance - * @param {string} scenarioId - ID of the publishing scenario - * @returns {Promise} Scenario parameters object + * @param client - Configured axios instance + * @param scenarioId - ID of the publishing scenario + * @returns Scenario parameters object */ -async function getPublishingScenarioParameters(client, scenarioId) { - const response = await client.get( +async function getPublishingScenarioParameters( + client: AxiosInstance, + scenarioId: string +): Promise { + const response: AxiosResponse = await client.get( `/publishes/scenarios/${scenarioId}/parameters` ); return response.data; @@ -100,13 +157,18 @@ async function getPublishingScenarioParameters(client, scenarioId) { /** * Finds an existing publishing scenario by name and validates its configuration. - * @param {Object} client - Configured axios instance - * @param {Function} log - Logging function - * @param {Object} config - Doc Detective config for logging - * @param {string} scenarioName - Name of the scenario to find - * @returns {Promise} Object with scenarioId and fileId, or null if not found or invalid + * @param client - Configured axios instance + * @param log - Logging function + * @param config - Doc Detective config for logging + * @param scenarioName - Name of the scenario to find + * @returns Object with scenarioId and fileId, or null if not found or invalid */ -async function findScenario(client, log, config, scenarioName) { +export async function findScenario( + client: AxiosInstance, + log: LogFunction, + config: Config, + scenarioName: string +): Promise { try { const scenarios = await getPublishingScenarios(client); const foundScenario = scenarios.find((s) => s.name === scenarioName); @@ -142,7 +204,7 @@ async function findScenario(client, log, config, scenarioName) { ); return null; } - + // Make sure that scenarioParameters.content has an object with name="tool-kit-name" and value="default/dita-ot-3.6.1" const toolKitParam = scenarioParameters.content.find( (param) => param.name === "tool-kit-name" @@ -155,7 +217,7 @@ async function findScenario(client, log, config, scenarioName) { ); return null; } - + // Make sure that scenarioParameters.content has an object with type="file_uuid_picker" and a value const fileUuidPickerParam = scenarioParameters.content.find( (param) => param.type === "file_uuid_picker" @@ -182,7 +244,7 @@ async function findScenario(client, log, config, scenarioName) { log( config, "error", - `Failed to find publishing scenario: ${error.message}` + `Failed to find publishing scenario: ${(error as Error).message}` ); return null; } @@ -190,28 +252,36 @@ async function findScenario(client, log, config, scenarioName) { /** * Triggers a publishing job for a DITA map. - * @param {Object} client - Configured axios instance - * @param {string} fileId - UUID of the DITA map - * @param {string} scenarioId - ID of the publishing scenario to use - * @returns {Promise} Publishing job object + * @param client - Configured axios instance + * @param fileId - UUID of the DITA map + * @param scenarioId - ID of the publishing scenario to use + * @returns Publishing job object */ -async function triggerPublishingJob(client, fileId, scenarioId) { - const response = await client.post(`/files/${fileId}/publishes`, { +export async function triggerPublishingJob( + client: AxiosInstance, + fileId: string, + scenarioId: string +): Promise { + const response: AxiosResponse = await client.post(`/files/${fileId}/publishes`, { scenario: scenarioId, - parameters: [] + parameters: [], }); return response.data; } /** * Gets the status of a publishing job. - * @param {Object} client - Configured axios instance - * @param {string} fileId - UUID of the DITA map - * @param {string} jobId - ID of the publishing job - * @returns {Promise} Job status object + * @param client - Configured axios instance + * @param fileId - UUID of the DITA map + * @param jobId - ID of the publishing job + * @returns Job status object */ -async function getJobStatus(client, fileId, jobId) { - const response = await client.get( +export async function getJobStatus( + client: AxiosInstance, + fileId: string, + jobId: string +): Promise { + const response: AxiosResponse = await client.get( `/files/${fileId}/publishes/${jobId}` ); return response.data; @@ -220,19 +290,23 @@ async function getJobStatus(client, fileId, jobId) { /** * Gets all asset file paths from a completed publishing job. * Handles pagination to retrieve all assets. - * @param {Object} client - Configured axios instance - * @param {string} fileId - UUID of the DITA map - * @param {string} jobId - ID of the publishing job - * @returns {Promise>} Array of asset file paths + * @param client - Configured axios instance + * @param fileId - UUID of the DITA map + * @param jobId - ID of the publishing job + * @returns Array of asset file paths */ -async function getJobAssetDetails(client, fileId, jobId) { - const allAssets = []; +export async function getJobAssetDetails( + client: AxiosInstance, + fileId: string, + jobId: string +): Promise { + const allAssets: string[] = []; let page = 0; const pageSize = 100; let hasMorePages = true; while (hasMorePages) { - const response = await client.get( + const response: AxiosResponse = await client.get( `/files/${fileId}/publishes/${jobId}/assets`, { params: { @@ -263,26 +337,33 @@ async function getJobAssetDetails(client, fileId, jobId) { /** * Validates that a .ditamap file exists in the job assets. * Checks for any .ditamap file in the ot-output/dita/ directory. - * @param {Array} assets - Array of asset file paths - * @returns {boolean} True if a .ditamap is found in ot-output/dita/ + * @param assets - Array of asset file paths + * @returns True if a .ditamap is found in ot-output/dita/ */ -function validateDitamapInAssets(assets) { - return assets.some((assetPath) => - assetPath.startsWith("ot-output/dita/") && assetPath.endsWith(".ditamap") +export function validateDitamapInAssets(assets: string[]): boolean { + return assets.some( + (assetPath) => + assetPath.startsWith("ot-output/dita/") && assetPath.endsWith(".ditamap") ); } /** * Polls a publishing job until completion or timeout. * After job completes, validates that a .ditamap file exists in the output. - * @param {Object} client - Configured axios instance - * @param {string} fileId - UUID of the DITA map - * @param {string} jobId - ID of the publishing job - * @param {Function} log - Logging function - * @param {Object} config - Doc Detective config for logging - * @returns {Promise} Completed job object or null on timeout/failure + * @param client - Configured axios instance + * @param fileId - UUID of the DITA map + * @param jobId - ID of the publishing job + * @param log - Logging function + * @param config - Doc Detective config for logging + * @returns Completed job object or null on timeout/failure */ -async function pollJobStatus(client, fileId, jobId, log, config) { +export async function pollJobStatus( + client: AxiosInstance, + fileId: string, + jobId: string, + log: LogFunction, + config: Config +): Promise { const startTime = Date.now(); while (Date.now() - startTime < POLLING_TIMEOUT_MS) { @@ -301,18 +382,10 @@ async function pollJobStatus(client, fileId, jobId, log, config) { // Validate that a .ditamap file exists in the output try { const assets = await getJobAssetDetails(client, fileId, jobId); - log( - config, - "debug", - `Job ${jobId} has ${assets.length} assets` - ); + log(config, "debug", `Job ${jobId} has ${assets.length} assets`); if (validateDitamapInAssets(assets)) { - log( - config, - "debug", - `Found .ditamap file in ot-output/dita/` - ); + log(config, "debug", `Found .ditamap file in ot-output/dita/`); return job; } @@ -326,7 +399,7 @@ async function pollJobStatus(client, fileId, jobId, log, config) { log( config, "warning", - `Failed to validate job assets: ${assetError.message}` + `Failed to validate job assets: ${(assetError as Error).message}` ); return null; } @@ -335,7 +408,7 @@ async function pollJobStatus(client, fileId, jobId, log, config) { // Wait before next poll await new Promise((resolve) => setTimeout(resolve, POLLING_INTERVAL_MS)); } catch (error) { - log(config, "warning", `Error polling job status: ${error.message}`); + log(config, "warning", `Error polling job status: ${(error as Error).message}`); return null; } } @@ -343,31 +416,29 @@ async function pollJobStatus(client, fileId, jobId, log, config) { log( config, "warning", - `Publishing job ${jobId} timed out after ${ - POLLING_TIMEOUT_MS / 1000 - } seconds` + `Publishing job ${jobId} timed out after ${POLLING_TIMEOUT_MS / 1000} seconds` ); return null; } /** * Downloads the publishing job output and extracts it to temp directory. - * @param {Object} client - Configured axios instance - * @param {string} fileId - UUID of the DITA map - * @param {string} jobId - ID of the publishing job - * @param {string} herettoName - Name of the Heretto integration for directory naming - * @param {Function} log - Logging function - * @param {Object} config - Doc Detective config for logging - * @returns {Promise} Path to extracted content or null on failure + * @param client - Configured axios instance + * @param fileId - UUID of the DITA map + * @param jobId - ID of the publishing job + * @param herettoName - Name of the Heretto integration for directory naming + * @param log - Logging function + * @param config - Doc Detective config for logging + * @returns Path to extracted content or null on failure */ -async function downloadAndExtractOutput( - client, - fileId, - jobId, - herettoName, - log, - config -) { +export async function downloadAndExtractOutput( + client: AxiosInstance, + fileId: string, + jobId: string, + herettoName: string, + log: LogFunction, + config: Config +): Promise { try { // Create temp directory if it doesn't exist const tempDir = `${os.tmpdir()}/doc-detective`; @@ -381,12 +452,8 @@ async function downloadAndExtractOutput( const outputDir = path.join(tempDir, `heretto_${hash}`); // Download the output file - log( - config, - "debug", - `Downloading publishing job output for ${herettoName}...` - ); - const response = await client.get( + log(config, "debug", `Downloading publishing job output for ${herettoName}...`); + const response: AxiosResponse = await client.get( `/files/${fileId}/publishes/${jobId}/assets-all`, { responseType: "arraybuffer", @@ -399,24 +466,27 @@ async function downloadAndExtractOutput( // Save ZIP to temp file const zipPath = path.join(tempDir, `heretto_${hash}.zip`); - fs.writeFileSync(zipPath, response.data); + fs.writeFileSync(zipPath, Buffer.from(response.data)); // Extract ZIP contents with path traversal protection log(config, "debug", `Extracting output to ${outputDir}...`); const zip = new AdmZip(zipPath); const resolvedOutputDir = path.resolve(outputDir); - + // Validate and extract entries safely to prevent zip slip attacks for (const entry of zip.getEntries()) { const entryPath = path.join(outputDir, entry.entryName); const resolvedPath = path.resolve(entryPath); - + // Ensure the resolved path is within outputDir - if (!resolvedPath.startsWith(resolvedOutputDir + path.sep) && resolvedPath !== resolvedOutputDir) { + if ( + !resolvedPath.startsWith(resolvedOutputDir + path.sep) && + resolvedPath !== resolvedOutputDir + ) { log(config, "warning", `Skipping potentially malicious ZIP entry: ${entry.entryName}`); continue; } - + if (entry.isDirectory) { fs.mkdirSync(resolvedPath, { recursive: true }); } else { @@ -428,18 +498,10 @@ async function downloadAndExtractOutput( // Clean up ZIP file fs.unlinkSync(zipPath); - log( - config, - "info", - `Heretto content "${herettoName}" extracted to ${outputDir}` - ); + log(config, "info", `Heretto content "${herettoName}" extracted to ${outputDir}`); return outputDir; } catch (error) { - log( - config, - "warning", - `Failed to download or extract output: ${error.message}` - ); + log(config, "warning", `Failed to download or extract output: ${(error as Error).message}`); return null; } } @@ -447,150 +509,185 @@ async function downloadAndExtractOutput( /** * Retrieves resource dependencies (all files) for a ditamap from Heretto REST API. * This provides the complete file structure with UUIDs and paths. - * @param {Object} restClient - Configured axios instance for REST API - * @param {string} ditamapId - UUID of the ditamap file - * @param {Function} log - Logging function - * @param {Object} config - Doc Detective config for logging - * @returns {Promise} Object mapping relative paths to UUIDs and parent folder info + * @param restClient - Configured axios instance for REST API + * @param ditamapId - UUID of the ditamap file + * @param log - Logging function + * @param config - Doc Detective config for logging + * @returns Object mapping relative paths to UUIDs and parent folder info */ -async function getResourceDependencies(restClient, ditamapId, log, config) { - const pathToUuidMap = {}; - +export async function getResourceDependencies( + restClient: AxiosInstance, + ditamapId: string, + log: LogFunction, + config: Config +): Promise> { + const pathToUuidMap: Record = {}; + const xmlParser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: "@_", }); - + // First, try to get the ditamap's own info (this is more reliable than the dependencies endpoint) try { log(config, "debug", `Fetching ditamap info for: ${ditamapId}`); const ditamapInfo = await restClient.get(`${REST_API_PATH}/${ditamapId}`); const ditamapParsed = xmlParser.parse(ditamapInfo.data); - - const ditamapUri = ditamapParsed.resource?.["xmldb-uri"] || ditamapParsed["@_uri"]; - const ditamapName = ditamapParsed.resource?.name || ditamapParsed["@_name"]; - const ditamapParentFolder = ditamapParsed.resource?.["folder-uuid"] || - ditamapParsed.resource?.["@_folder-uuid"] || - ditamapParsed["@_folder-uuid"]; - - log(config, "debug", `Ditamap info: uri=${ditamapUri}, name=${ditamapName}, parentFolder=${ditamapParentFolder}`); - + + const resource = ditamapParsed.resource as Record | undefined; + const ditamapUri = resource?.["xmldb-uri"] || ditamapParsed["@_uri"]; + const ditamapName = resource?.name || ditamapParsed["@_name"]; + const ditamapParentFolder = + resource?.["folder-uuid"] || + resource?.["@_folder-uuid"] || + ditamapParsed["@_folder-uuid"]; + + log( + config, + "debug", + `Ditamap info: uri=${ditamapUri}, name=${ditamapName}, parentFolder=${ditamapParentFolder}` + ); + if (ditamapUri) { - let relativePath = ditamapUri; + let relativePath = ditamapUri as string; const orgPathMatch = relativePath?.match(/\/db\/organizations\/[^/]+\/(.+)/); if (orgPathMatch) { relativePath = orgPathMatch[1]; } - + pathToUuidMap[relativePath] = { uuid: ditamapId, - fullPath: ditamapUri, - name: ditamapName, - parentFolderId: ditamapParentFolder, + fullPath: ditamapUri as string, + name: ditamapName as string, + parentFolderId: ditamapParentFolder as string, isDitamap: true, }; - + // Store the ditamap info as reference points for creating new files pathToUuidMap._ditamapPath = relativePath; pathToUuidMap._ditamapId = ditamapId; - pathToUuidMap._ditamapParentFolderId = ditamapParentFolder; - + pathToUuidMap._ditamapParentFolderId = ditamapParentFolder as string; + log(config, "debug", `Ditamap path: ${relativePath}, parent folder: ${ditamapParentFolder}`); } } catch (ditamapError) { - log(config, "warning", `Could not get ditamap info: ${ditamapError.message}`); + log(config, "warning", `Could not get ditamap info: ${(ditamapError as Error).message}`); } - + // Then try to get the full dependencies list (this endpoint may not be available) try { log(config, "debug", `Fetching resource dependencies for ditamap: ${ditamapId}`); - + const response = await restClient.get(`${REST_API_PATH}/${ditamapId}/dependencies`); const xmlData = response.data; - + const parsed = xmlParser.parse(xmlData); - + // Extract dependencies from the response - // Response format: ...... - const extractDependencies = (obj, parentPath = "") => { + interface DependencyNode { + "@_id"?: string; + "@_uuid"?: string; + "@_uri"?: string; + "@_path"?: string; + "@_name"?: string; + "@_folder-uuid"?: string; + "@_parent"?: string; + id?: string; + uuid?: string; + uri?: string; + path?: string; + name?: string; + "xmldb-uri"?: string; + "folder-uuid"?: string; + dependencies?: { dependency?: DependencyNode | DependencyNode[] }; + dependency?: DependencyNode | DependencyNode[]; + } + + const extractDependencies = (obj: DependencyNode | Record): void => { if (!obj) return; - + + const node = obj as DependencyNode; // Handle single dependency or array of dependencies - let dependencies = obj.dependencies?.dependency || obj.dependency; + let dependencies: DependencyNode | DependencyNode[] | undefined = + node.dependencies?.dependency || node.dependency; if (!dependencies) { // Try to extract from root-level response - if (obj["@_id"] && obj["@_uri"]) { - dependencies = [obj]; + if (node["@_id"] && node["@_uri"]) { + dependencies = [node]; } else if (Array.isArray(obj)) { - dependencies = obj; + dependencies = obj as DependencyNode[]; } } - + if (!dependencies) return; if (!Array.isArray(dependencies)) { dependencies = [dependencies]; } - + for (const dep of dependencies) { const uuid = dep["@_id"] || dep["@_uuid"] || dep.id || dep.uuid; const uri = dep["@_uri"] || dep["@_path"] || dep.uri || dep.path || dep["xmldb-uri"]; const name = dep["@_name"] || dep.name; const parentFolderId = dep["@_folder-uuid"] || dep["@_parent"] || dep["folder-uuid"]; - + if (uuid && (uri || name)) { // Extract the relative path from the full URI // URI format: /db/organizations/{org}/{path} - let relativePath = uri || name; + let relativePath = (uri || name) as string; const orgPathMatch = relativePath?.match(/\/db\/organizations\/[^/]+\/(.+)/); if (orgPathMatch) { relativePath = orgPathMatch[1]; } - + pathToUuidMap[relativePath] = { - uuid, - fullPath: uri, - name: name || path.basename(relativePath || ""), - parentFolderId, + uuid: uuid as string, + fullPath: uri as string, + name: (name || path.basename(relativePath || "")) as string, + parentFolderId: parentFolderId as string, }; - + log(config, "debug", `Mapped: ${relativePath} -> ${uuid}`); } - + // Recursively process nested dependencies if (dep.dependencies || dep.dependency) { extractDependencies(dep); } } }; - - extractDependencies(parsed); - + + extractDependencies(parsed as Record); + log(config, "info", `Retrieved ${Object.keys(pathToUuidMap).length} resource dependencies from Heretto`); - } catch (error) { // Log more details about the error for debugging - const statusCode = error.response?.status; - log(config, "debug", `Dependencies endpoint not available (${statusCode}), will use ditamap info as fallback`); + const axiosError = error as { response?: { status?: number } }; + const statusCode = axiosError.response?.status; + log( + config, + "debug", + `Dependencies endpoint not available (${statusCode}), will use ditamap info as fallback` + ); // Continue with ditamap info only - the fallback will create files in the ditamap's parent folder } - + return pathToUuidMap; } /** * Main function to load content from a Heretto CMS instance. * Triggers a publishing job, waits for completion, and downloads the output. - * @param {Object} herettoConfig - Heretto integration configuration - * @param {Function} log - Logging function - * @param {Object} config - Doc Detective config for logging - * @returns {Promise} Path to extracted content or null on failure + * @param herettoConfig - Heretto integration configuration + * @param log - Logging function + * @param config - Doc Detective config for logging + * @returns Path to extracted content or null on failure */ -async function loadHerettoContent(herettoConfig, log, config) { - log( - config, - "info", - `Loading content from Heretto "${herettoConfig.name}"...` - ); +export async function loadHerettoContent( + herettoConfig: HerettoConfig, + log: LogFunction, + config: Config +): Promise { + log(config, "info", `Loading content from Heretto "${herettoConfig.name}"...`); try { const client = createApiClient(herettoConfig); @@ -598,12 +695,7 @@ async function loadHerettoContent(herettoConfig, log, config) { // Find the Doc Detective publishing scenario const scenarioName = herettoConfig.scenarioName || DEFAULT_SCENARIO_NAME; - const scenario = await findScenario( - client, - log, - config, - scenarioName - ); + const scenario = await findScenario(client, log, config, scenarioName); if (!scenario) { log( config, @@ -623,31 +715,17 @@ async function loadHerettoContent(herettoConfig, log, config) { log, config ); - herettoConfig.resourceDependencies = resourceDependencies; + herettoConfig.resourceDependencies = resourceDependencies as Record; } // Trigger publishing job - log( - config, - "debug", - `Triggering publishing job for file ${scenario.fileId}...` - ); - const job = await triggerPublishingJob( - client, - scenario.fileId, - scenario.scenarioId - ); + log(config, "debug", `Triggering publishing job for file ${scenario.fileId}...`); + const job = await triggerPublishingJob(client, scenario.fileId, scenario.scenarioId); log(config, "debug", `Publishing job started: ${job.jobId}`); // Poll for completion log(config, "info", `Waiting for publishing job to complete...`); - const completedJob = await pollJobStatus( - client, - scenario.fileId, - job.jobId, - log, - config - ); + const completedJob = await pollJobStatus(client, scenario.fileId, job.jobId, log, config); if (!completedJob) { log( config, @@ -669,22 +747,13 @@ async function loadHerettoContent(herettoConfig, log, config) { // Build file mapping from extracted content (legacy approach, still useful as fallback) if (outputPath && herettoConfig.uploadOnChange) { - const fileMapping = await buildFileMapping( - outputPath, - herettoConfig, - log, - config - ); + const fileMapping = await buildFileMapping(outputPath, herettoConfig, log, config); herettoConfig.fileMapping = fileMapping; } return outputPath; } catch (error) { - log( - config, - "warning", - `Failed to load Heretto "${herettoConfig.name}": ${error.message}` - ); + log(config, "warning", `Failed to load Heretto "${herettoConfig.name}": ${(error as Error).message}`); return null; } } @@ -692,14 +761,19 @@ async function loadHerettoContent(herettoConfig, log, config) { /** * Builds a mapping of local file paths to Heretto file metadata. * Parses DITA files to extract file references and attempts to resolve UUIDs. - * @param {string} outputPath - Path to extracted Heretto content - * @param {Object} herettoConfig - Heretto integration configuration - * @param {Function} log - Logging function - * @param {Object} config - Doc Detective config for logging - * @returns {Promise} Mapping of local paths to {fileId, filePath} + * @param outputPath - Path to extracted Heretto content + * @param herettoConfig - Heretto integration configuration + * @param log - Logging function + * @param config - Doc Detective config for logging + * @returns Mapping of local paths to {fileId, filePath} */ -async function buildFileMapping(outputPath, herettoConfig, log, config) { - const fileMapping = {}; +export async function buildFileMapping( + outputPath: string, + _herettoConfig: HerettoConfig, + log: LogFunction, + config: Config +): Promise> { + const fileMapping: Record = {}; const xmlParser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: "@_", @@ -707,11 +781,7 @@ async function buildFileMapping(outputPath, herettoConfig, log, config) { try { // Recursively find all DITA/XML files - const ditaFiles = findFilesWithExtensions(outputPath, [ - ".dita", - ".ditamap", - ".xml", - ]); + const ditaFiles = findFilesWithExtensions(outputPath, [".dita", ".ditamap", ".xml"]); for (const ditaFile of ditaFiles) { try { @@ -723,10 +793,7 @@ async function buildFileMapping(outputPath, herettoConfig, log, config) { for (const imageRef of imageRefs) { // Resolve relative path to absolute local path - const absoluteLocalPath = path.resolve( - path.dirname(ditaFile), - imageRef - ); + const absoluteLocalPath = path.resolve(path.dirname(ditaFile), imageRef); if (!fileMapping[absoluteLocalPath]) { fileMapping[absoluteLocalPath] = { @@ -739,18 +806,14 @@ async function buildFileMapping(outputPath, herettoConfig, log, config) { log( config, "debug", - `Failed to parse ${ditaFile} for file mapping: ${parseError.message}` + `Failed to parse ${ditaFile} for file mapping: ${(parseError as Error).message}` ); } } - log( - config, - "debug", - `Built file mapping with ${Object.keys(fileMapping).length} entries` - ); + log(config, "debug", `Built file mapping with ${Object.keys(fileMapping).length} entries`); } catch (error) { - log(config, "warning", `Failed to build file mapping: ${error.message}`); + log(config, "warning", `Failed to build file mapping: ${(error as Error).message}`); } return fileMapping; @@ -758,12 +821,12 @@ async function buildFileMapping(outputPath, herettoConfig, log, config) { /** * Recursively finds files with specified extensions. - * @param {string} dir - Directory to search - * @param {Array} extensions - File extensions to match (e.g., ['.dita', '.xml']) - * @returns {Array} Array of matching file paths + * @param dir - Directory to search + * @param extensions - File extensions to match (e.g., ['.dita', '.xml']) + * @returns Array of matching file paths */ -function findFilesWithExtensions(dir, extensions) { - const results = []; +function findFilesWithExtensions(dir: string, extensions: string[]): string[] { + const results: string[] = []; try { const items = fs.readdirSync(dir); @@ -774,13 +837,11 @@ function findFilesWithExtensions(dir, extensions) { if (stat.isDirectory()) { results.push(...findFilesWithExtensions(fullPath, extensions)); - } else if ( - extensions.some((ext) => fullPath.toLowerCase().endsWith(ext)) - ) { + } else if (extensions.some((ext) => fullPath.toLowerCase().endsWith(ext))) { results.push(fullPath); } } - } catch (error) { + } catch (_error) { // Ignore read errors for inaccessible directories } @@ -790,29 +851,32 @@ function findFilesWithExtensions(dir, extensions) { /** * Extracts image references from parsed DITA XML content. * Looks for elements with href attributes. - * @param {Object} parsedXml - Parsed XML object - * @returns {Array} Array of image href values + * @param parsedXml - Parsed XML object + * @returns Array of image href values */ -function extractImageReferences(parsedXml) { - const refs = []; +function extractImageReferences(parsedXml: unknown): string[] { + const refs: string[] = []; - function traverse(obj) { + function traverse(obj: unknown): void { if (!obj || typeof obj !== "object") return; + const record = obj as Record; + // Check for image elements - if (obj.image) { - const images = Array.isArray(obj.image) ? obj.image : [obj.image]; + if (record.image) { + const images = Array.isArray(record.image) ? record.image : [record.image]; for (const img of images) { - if (img["@_href"]) { - refs.push(img["@_href"]); + const imgRecord = img as Record; + if (imgRecord["@_href"]) { + refs.push(imgRecord["@_href"] as string); } } } // Recursively traverse all properties - for (const key of Object.keys(obj)) { - if (typeof obj[key] === "object") { - traverse(obj[key]); + for (const key of Object.keys(record)) { + if (typeof record[key] === "object") { + traverse(record[key]); } } } @@ -823,26 +887,26 @@ function extractImageReferences(parsedXml) { /** * Searches for a file in Heretto by filename. - * @param {Object} herettoConfig - Heretto integration configuration - * @param {string} filename - Name of the file to search for - * @param {string} folderPath - Optional folder path to search within - * @param {Function} log - Logging function - * @param {Object} config - Doc Detective config for logging - * @returns {Promise} File info with ID and URI, or null if not found + * @param herettoConfig - Heretto integration configuration + * @param filename - Name of the file to search for + * @param folderPath - Optional folder path to search within + * @param log - Logging function + * @param config - Doc Detective config for logging + * @returns File info with ID and URI, or null if not found */ -async function searchFileByName( - herettoConfig, - filename, - folderPath, - log, - config -) { +export async function searchFileByName( + herettoConfig: HerettoConfig, + filename: string, + folderPath: string | null, + log: LogFunction, + config: Config +): Promise { const client = createApiClient(herettoConfig); try { - const searchBody = { + const searchBody: Record = { queryString: filename, - foldersToSearch: {}, + foldersToSearch: {} as Record, startOffset: 0, endOffset: 10, searchResultType: "FILES_ONLY", @@ -851,30 +915,34 @@ async function searchFileByName( // If folderPath provided, search within that folder; otherwise search root if (folderPath) { - searchBody.foldersToSearch[folderPath] = true; + (searchBody.foldersToSearch as Record)[folderPath] = true; } else { // Search in organization root - searchBody.foldersToSearch[ + (searchBody.foldersToSearch as Record)[ `/db/organizations/${herettoConfig.organizationId}/` ] = true; } - const response = await client.post( - "/ezdnxtgen/api/search", - searchBody, - { - baseURL: `https://${herettoConfig.organizationId}.heretto.com`, - headers: { "Content-Type": "application/json" }, - } - ); + const response = await client.post("/ezdnxtgen/api/search", searchBody, { + baseURL: `https://${herettoConfig.organizationId}.heretto.com`, + headers: { "Content-Type": "application/json" }, + }); + + interface SearchHit { + fileEntity?: { + ID: string; + URI: string; + name: string; + }; + } - if (response.data?.hits?.length > 0) { + const data = response.data as { hits?: SearchHit[] }; + + if (data.hits && data.hits.length > 0) { // Find exact filename match - const exactMatch = response.data.hits.find( - (hit) => hit.fileEntity?.name === filename - ); + const exactMatch = data.hits.find((hit) => hit.fileEntity?.name === filename); - if (exactMatch) { + if (exactMatch?.fileEntity) { return { fileId: exactMatch.fileEntity.ID, filePath: exactMatch.fileEntity.URI, @@ -885,35 +953,33 @@ async function searchFileByName( return null; } catch (error) { - log( - config, - "debug", - `Failed to search for file "${filename}": ${error.message}` - ); + log(config, "debug", `Failed to search for file "${filename}": ${(error as Error).message}`); return null; } } /** * Uploads a file to Heretto CMS. - * @param {Object} herettoConfig - Heretto integration configuration - * @param {string} fileId - UUID of the file to update - * @param {string} localFilePath - Local path to the file to upload - * @param {Function} log - Logging function - * @param {Object} config - Doc Detective config for logging - * @returns {Promise} Result object with status and description + * @param herettoConfig - Heretto integration configuration + * @param fileId - UUID of the file to update + * @param localFilePath - Local path to the file to upload + * @param log - Logging function + * @param config - Doc Detective config for logging + * @returns Result object with status and description */ -async function uploadFile(herettoConfig, fileId, localFilePath, log, config) { +export async function uploadFile( + herettoConfig: HerettoConfig, + fileId: string, + localFilePath: string, + log: LogFunction, + config: Config +): Promise { const client = createRestApiClient(herettoConfig); try { // Ensure the local file exists before attempting to read it if (!fs.existsSync(localFilePath)) { - log( - config, - "warning", - `Local file does not exist, cannot upload to Heretto: ${localFilePath}` - ); + log(config, "warning", `Local file does not exist, cannot upload to Heretto: ${localFilePath}`); return { status: "FAIL", description: `Local file not found: ${localFilePath}`, @@ -934,25 +1000,17 @@ async function uploadFile(herettoConfig, fileId, localFilePath, log, config) { log(config, "debug", `Uploading ${localFilePath} to Heretto file ${fileId}`); - const response = await client.put( - `${REST_API_PATH}/${fileId}/content`, - fileBuffer, - { - headers: { - "Content-Type": contentType, - "Content-Length": fileBuffer.length, - }, - maxBodyLength: Infinity, - maxContentLength: Infinity, - } - ); + const response = await client.put(`${REST_API_PATH}/${fileId}/content`, fileBuffer, { + headers: { + "Content-Type": contentType, + "Content-Length": fileBuffer.length, + }, + maxBodyLength: Infinity, + maxContentLength: Infinity, + }); if (response.status === 200 || response.status === 201) { - log( - config, - "info", - `Successfully uploaded ${path.basename(localFilePath)} to Heretto` - ); + log(config, "info", `Successfully uploaded ${path.basename(localFilePath)} to Heretto`); return { status: "PASS", description: `File uploaded successfully to Heretto`, @@ -964,12 +1022,9 @@ async function uploadFile(herettoConfig, fileId, localFilePath, log, config) { description: `Unexpected response status: ${response.status}`, }; } catch (error) { - const errorMessage = error.response?.data || error.message; - log( - config, - "warning", - `Failed to upload file to Heretto: ${errorMessage}` - ); + const axiosError = error as { response?: { data?: string }; message: string }; + const errorMessage = axiosError.response?.data || axiosError.message; + log(config, "warning", `Failed to upload file to Heretto: ${errorMessage}`); return { status: "FAIL", description: `Failed to upload: ${errorMessage}`, @@ -977,23 +1032,27 @@ async function uploadFile(herettoConfig, fileId, localFilePath, log, config) { } } +interface SourceIntegration { + fileId?: string; +} + /** * Resolves a local file path to a Heretto file ID. * First checks file mapping, then searches by filename if needed. - * @param {Object} herettoConfig - Heretto integration configuration - * @param {string} localFilePath - Local path to the file - * @param {Object} sourceIntegration - Source integration metadata from step - * @param {Function} log - Logging function - * @param {Object} config - Doc Detective config for logging - * @returns {Promise} Heretto file ID or null if not found + * @param herettoConfig - Heretto integration configuration + * @param localFilePath - Local path to the file + * @param sourceIntegration - Source integration metadata from step + * @param log - Logging function + * @param config - Doc Detective config for logging + * @returns Heretto file ID or null if not found */ -async function resolveFileId( - herettoConfig, - localFilePath, - sourceIntegration, - log, - config -) { +export async function resolveFileId( + herettoConfig: HerettoConfig, + localFilePath: string, + sourceIntegration: SourceIntegration | undefined, + log: LogFunction, + config: Config +): Promise { // If fileId is already known, use it if (sourceIntegration?.fileId) { return sourceIntegration.fileId; @@ -1009,13 +1068,7 @@ async function resolveFileId( // Search by filename const filename = path.basename(localFilePath); - const searchResult = await searchFileByName( - herettoConfig, - filename, - null, - log, - config - ); + const searchResult = await searchFileByName(herettoConfig, filename, null, log, config); if (searchResult?.fileId) { // Cache the result in file mapping @@ -1029,33 +1082,6 @@ async function resolveFileId( return searchResult.fileId; } - log( - config, - "warning", - `Could not resolve Heretto file ID for ${localFilePath}` - ); + log(config, "warning", `Could not resolve Heretto file ID for ${localFilePath}`); return null; } - -module.exports = { - createAuthHeader, - createApiClient, - createRestApiClient, - findScenario, - triggerPublishingJob, - getJobStatus, - getJobAssetDetails, - validateDitamapInAssets, - pollJobStatus, - downloadAndExtractOutput, - loadHerettoContent, - buildFileMapping, - searchFileByName, - uploadFile, - resolveFileId, - getResourceDependencies, - // Export constants for testing - POLLING_INTERVAL_MS, - POLLING_TIMEOUT_MS, - DEFAULT_SCENARIO_NAME, -}; diff --git a/src/index.test.js b/src/index.test.js index 0e8ed35..7c5a9dd 100644 --- a/src/index.test.js +++ b/src/index.test.js @@ -2,7 +2,7 @@ const assert = require("assert"); const sinon = require("sinon"); const proxyquire = require("proxyquire"); const fs = require("fs"); -const { detectTests, resolveTests, detectAndResolveTests } = require("./index"); +const { detectTests, resolveTests, detectAndResolveTests } = require("../dist/index"); before(async function () { const { expect } = await import("chai"); @@ -25,9 +25,9 @@ describe("detectTests", function () { parseTestsStub = sinon.stub().resolves(specs); logStub = sinon.stub(); - detectTests = proxyquire("./index", { - "./config": { setConfig: setConfigStub }, - "./utils": { + detectTests = proxyquire("../dist/index", { + "../dist/config": { setConfig: setConfigStub }, + "../dist/utils": { qualifyFiles: qualifyFilesStub, parseTests: parseTestsStub, log: logStub, diff --git a/src/index.js b/src/index.ts similarity index 59% rename from src/index.js rename to src/index.ts index 34ece77..239634c 100644 --- a/src/index.js +++ b/src/index.ts @@ -1,11 +1,29 @@ -const { setConfig } = require("./config"); -const { qualifyFiles, parseTests, log } = require("./utils"); -const { resolveDetectedTests } = require("./resolve"); -// const { telemetryNotice, sendTelemetry } = require("./telem"); +import { setConfig } from "./config"; +import { qualifyFiles, parseTests, log } from "./utils"; +import { resolveDetectedTests } from "./resolve"; +import type { Config, DetectedSpec, ResolvedTests } from "./types"; -exports.detectTests = detectTests; -exports.resolveTests = resolveTests; -exports.detectAndResolveTests = detectAndResolveTests; +// Re-export types for consumers +export type { + Config, + DetectedSpec, + DetectedTest, + ResolvedSpec, + ResolvedTest, + ResolvedTests, + FileType, + Step, + OpenApiDefinition, +} from "./types"; + +// Re-export functions from other modules +export { setConfig, resolveConcurrentRunners } from "./config"; +export { resolveDetectedTests } from "./resolve"; +export { qualifyFiles, parseTests, log, loadEnvs, replaceEnvs } from "./utils"; +export { loadDescription, getOperation } from "./openapi"; +export { workflowToTest } from "./arazzo"; +export { telemetryNotice, sendTelemetry } from "./telem"; +export { sanitizeUri, sanitizePath } from "./sanitize"; // const supportMessage = ` // ########################################################################## @@ -24,11 +42,15 @@ exports.detectAndResolveTests = detectAndResolveTests; * 3. Resolves the detected tests * * @async - * @param {Object} options - The options object - * @param {Object} options.config - The configuration object for test detection and resolution - * @returns {Promise} A promise that resolves to an object of resolved tests + * @param options - The options object + * @param options.config - The configuration object for test detection and resolution + * @returns A promise that resolves to an object of resolved tests, or null if no tests detected */ -async function detectAndResolveTests({ config }) { +export async function detectAndResolveTests({ + config, +}: { + config: Config; +}): Promise { // Set config config = await setConfig({ config }); // Detect tests @@ -47,12 +69,18 @@ async function detectAndResolveTests({ config }) { * then processing the detected tests to resolve them according to the configuration. * * @async - * @param {Object} params - The parameters object. - * @param {Object} params.config - The configuration object, which may need to be resolved if environment isn't set. - * @param {Object} params.detectedTests - The tests that have been detected and need to be resolved. - * @returns {Promise} A promise that resolves to an object of resolved test configurations. + * @param params - The parameters object. + * @param params.config - The configuration object, which may need to be resolved if environment isn't set. + * @param params.detectedTests - The tests that have been detected and need to be resolved. + * @returns A promise that resolves to an object of resolved test configurations. */ -async function resolveTests({ config, detectedTests }) { +export async function resolveTests({ + config, + detectedTests, +}: { + config: Config; + detectedTests: DetectedSpec[]; +}): Promise { if (!config.environment) { // If environment isn't set, config hasn't been resolved config = await setConfig({ config }); @@ -73,11 +101,15 @@ async function resolveTests({ config, detectedTests }) { * 3. Parses test specifications from the qualified files * * @async - * @param {Object} options - The options object - * @param {Object} options.config - Configuration object, may be unresolved - * @returns {Promise} - Promise resolving to an array of test specifications + * @param options - The options object + * @param options.config - Configuration object, may be unresolved + * @returns Promise resolving to an array of test specifications */ -async function detectTests({ config }) { +export async function detectTests({ + config, +}: { + config: Config; +}): Promise { if (!config.environment) { // If environment isn't set, config hasn't been resolved config = await setConfig({ config }); diff --git a/src/openapi.js b/src/openapi.js deleted file mode 100644 index 5d2fa9b..0000000 --- a/src/openapi.js +++ /dev/null @@ -1,408 +0,0 @@ -const { replaceEnvs } = require("./utils"); -const { JSONSchemaFaker } = require("json-schema-faker"); -const { readFile } = require("doc-detective-common"); -const parser = require("@apidevtools/json-schema-ref-parser"); - -JSONSchemaFaker.option({ requiredOnly: true }); - -/** - * Dereferences an OpenAPI or Arazzo description - * - * @param {String} descriptionPath - The OpenAPI or Arazzo description to be dereferenced. - * @returns {Promise} - The dereferenced OpenAPI or Arazzo description. - */ -async function loadDescription(descriptionPath = "") { - // Error handling - if (!descriptionPath) { - throw new Error("Description is required."); - } - - // Load the definition from the URL or local file path - const definition = await readFile({ fileURLOrPath: descriptionPath }); - - // Dereference the definition - const dereferencedDefinition = await parser.dereference(definition); - - return dereferencedDefinition; -} - -/** - * Retrieves the operation details from an OpenAPI definition based on the provided operationId. - * - * @param {Object} [definition={}] - The OpenAPI definition object. - * @param {string} [operationId=""] - The unique identifier for the operation. - * @param {string} [responseCode=""] - The HTTP response code to filter the operation. - * @param {string} [exampleKey=""] - The key for the example to be compiled. - * @param {string} [server=""] - The server URL to use for examples. - * @throws {Error} Will throw an error if the definition or operationId is not provided. - * @returns {Object|null} Returns an object containing the operation details, schemas, and example if found; otherwise, returns null. - */ -function getOperation( - definition = {}, - operationId = "", - responseCode = "", - exampleKey = "", - server = "" -) { - // Error handling - if (!definition) { - throw new Error("OpenAPI definition is required."); - } - if (!operationId) { - throw new Error("OperationId is required."); - } - // Search for the operationId in the OpenAPI definition - for (const path in definition.paths) { - for (const method in definition.paths[path]) { - if (definition.paths[path][method].operationId === operationId) { - const operation = definition.paths[path][method]; - if (!server) { - if (definition.servers && definition.servers.length > 0) { - server = definition.servers[0].url; - } else { - throw new Error( - "No server URL provided and no servers defined in the OpenAPI definition." - ); - } - } - const example = compileExample( - operation, - server + path, - responseCode, - exampleKey - ); - const schemas = getSchemas(operation, responseCode); - return { path, method, definition: operation, schemas, example }; - } - } - } - return null; -} - -function getSchemas(definition = {}, responseCode = "") { - const schemas = {}; - - // Get request schema for operation - if (definition.requestBody) { - schemas.request = - definition.requestBody.content[ - Object.keys(definition.requestBody.content)[0] - ].schema; - } - if (!responseCode) { - if (definition.responses && Object.keys(definition.responses).length > 0) { - responseCode = Object.keys(definition.responses)[0]; - } else { - throw new Error("No responses defined for the operation."); - } - } - schemas.response = - definition.responses[responseCode].content[ - Object.keys(definition.responses[responseCode].content)[0] - ].schema; - - return schemas; -} - -/** - * Compiles an example object based on the provided operation, path, and example key. - * - * @param {Object} operation - The operation object. - * @param {string} path - The path string. - * @param {string} exampleKey - The example key string. - * @returns {Object} - The compiled example object. - * @throws {Error} - If operation or path is not provided. - */ -function compileExample( - operation = {}, - path = "", - responseCode = "", - exampleKey = "" -) { - // Error handling - if (!operation) { - throw new Error("Operation is required."); - } - if (!path) { - throw new Error("Path is required."); - } - - // Setup - let example = { - url: path, - request: { parameters: {}, headers: {}, body: {} }, - response: { headers: {}, body: {} }, - }; - - // Path parameters - const pathParameters = getExampleParameters(operation, "path", exampleKey); - pathParameters.forEach((param) => { - example.url = example.url.replace(`{${param.key}}`, param.value); - }); - - // Query parameters - const queryParameters = getExampleParameters(operation, "query", exampleKey); - queryParameters.forEach((param) => { - example.request.parameters[param.key] = param.value; - }); - - // Headers - const headerParameters = getExampleParameters( - operation, - "header", - exampleKey - ); - headerParameters.forEach((param) => { - example.request.headers[param.key] = param.value; - }); - - // Request body - if (operation.requestBody) { - const requestBody = getExample(operation.requestBody, exampleKey); - if (typeof requestBody != "undefined") { - example.request.body = requestBody; - } - } - - // Response - if (!responseCode) { - responseCode = Object.keys(operation.responses)[0]; - } - const response = operation.responses[responseCode]; - - // Response headers - if (response.headers) { - for (const header in response.headers) { - const headerExample = getExample(response.headers[header], exampleKey); - if (typeof headerExample != "undefined") - example.response.headers[header] = headerExample; - } - } - - // Response body - if (response.content) { - for (const key in response.content) { - const responseBody = getExample(response.content[key], exampleKey); - if (typeof responseBody != "undefined") { - example.response.body = responseBody; - } - } - } - - // Load environment variables - example = replaceEnvs(example); - // console.log(JSON.stringify(example, null, 2)); - return example; -} - -// Return array of query parameters for the example -/** - * Retrieves example parameters based on the given operation, type, and example key. - * - * @param {object} operation - The operation object. - * @param {string} [type=""] - The type of parameter to retrieve. - * @param {string} [exampleKey=""] - The example key to use. - * @returns {Array} - An array of example parameters. - * @throws {Error} - If the operation is not provided. - */ -function getExampleParameters(operation = {}, type = "", exampleKey = "") { - const params = []; - - // Error handling - if (!operation) { - throw new Error("Operation is required."); - } - if (!operation.parameters) return params; - - // Find all query parameters - for (const parameter of operation.parameters) { - if (parameter.in === type) { - const value = getExample(parameter, exampleKey); - if (value) { - params.push({ key: parameter.name, value }); - } - } - } - - return params; -} - -/** - * Retrieves an example value based on the given definition and example key. - * - * @param {object} definition - The definition object. - * @param {string} exampleKey - The key of the example to retrieve. - * @returns {object|null} - The example value. - * @throws {Error} - If the definition is not provided. - */ -function getExample( - definition = {}, - exampleKey = "", - generateFromSchema = null -) { - // Debug - // console.log({definition, exampleKey}); - - // Setup - let example; - - // Error handling - if (!definition) { - throw new Error("Definition is required."); - } - - // If there are no examples in the definition, generate example based on definition schema - if (generateFromSchema == null) { - const hasExamples = checkForExamples(definition, exampleKey); - generateFromSchema = - !hasExamples && - (definition.required || definition?.schema?.required || !exampleKey); - } - - if (generateFromSchema && definition.type) { - try { - example = JSONSchemaFaker.generate(definition); - if (example) return example; - } catch (error) { - console.warn(`Error generating example: ${error}`); - } - } - - if ( - definition.examples && - typeof exampleKey !== "undefined" && - exampleKey !== "" && - typeof definition.examples[exampleKey] !== "undefined" && - typeof definition.examples[exampleKey].value !== "undefined" - ) { - // If the definition has an `examples` property, exampleKey is specified, and the exampleKey exists in the examples object, use that example. - example = definition.examples[exampleKey].value; - } else if (typeof definition.example !== "undefined") { - // If the definition has an `example` property, use that example. - example = definition.example; - } else { - // If the definition has no examples, generate an example based on the definition/properties. - // Find the next `schema` child property in the definition, regardless of depth - let schema; - if (definition.schema) { - // Parameter pattern - schema = definition.schema; - } else if (definition.properties) { - // Object pattern - schema = definition; - } else if (definition.items) { - // Array pattern - schema = definition; - } else if (definition.content) { - // Request/response body pattern - for (const key in definition.content) { - if (definition.content[key]) { - schema = definition.content[key]; - break; - } - } - } else { - return null; - } - - if (schema.type === "object") { - example = generateObjectExample(schema, exampleKey, generateFromSchema); - } else if (schema.type === "array") { - example = generateArrayExample( - schema.items, - exampleKey, - generateFromSchema - ); - } else { - example = getExample(schema, exampleKey, generateFromSchema); - } - } - - // console.log(example); - return example; -} - -/** - * Generates an object example based on the provided schema and example key. - * - * @param {object} schema - The schema object. - * @param {string} exampleKey - The example key. - * @returns {object} - The generated object example. - */ -function generateObjectExample( - schema = {}, - exampleKey = "", - generateFromSchema = null -) { - const example = {}; - for (const property in schema.properties) { - const objectExample = getExample( - schema.properties[property], - exampleKey, - generateFromSchema - ); - if (objectExample) example[property] = objectExample; - } - return example; -} - -/** - * Generates an array example based on the provided items and example key. - * - * @param {Object} items - The items object. - * @param {string} exampleKey - The example key. - * @returns {Array} - The generated array example. - */ -function generateArrayExample( - items = {}, - exampleKey = "", - generateFromSchema = null -) { - // Debug - // console.log({ items, exampleKey }); - - const example = []; - const itemExample = getExample(items, exampleKey, generateFromSchema); - if (itemExample) example.push(itemExample); - - // Debug - // console.log(example); - return example; -} - -/** - * Checks if the provided definition object contains any examples. - * - * @param {Object} [definition={}] - The object to traverse for examples. - * @param {string} [exampleKey=""] - The specific key to look for in the examples. - * @returns {boolean} - Returns true if examples are found, otherwise false. - */ -function checkForExamples(definition = {}, exampleKey = "") { - const examples = []; - - function traverse(obj) { - if (typeof obj !== "object" || obj === null) return; - - if (obj.hasOwnProperty("example")) { - examples.push(obj.example); - } - if ( - exampleKey && - Object.hasOwn(obj, "examples") && - Object.hasOwn(obj.examples, exampleKey) && - Object.hasOwn(obj.examples[exampleKey], "value") - ) { - examples.push(obj.examples[exampleKey].value); - } - - for (const key in obj) { - traverse(obj[key]); - } - } - - traverse(definition); - if (examples.length) return true; - return false; -} - -module.exports = { getOperation, loadDescription }; diff --git a/src/openapi.ts b/src/openapi.ts new file mode 100644 index 0000000..70e5a9a --- /dev/null +++ b/src/openapi.ts @@ -0,0 +1,471 @@ +import { readFile } from "doc-detective-common"; +import type { + OpenApiDescription, + OpenApiOperation, + OpenApiParameter, + OpenApiResponse, + CompiledExample, + OperationResult +} from "./types"; + +// JSONSchemaFaker types +interface JSONSchemaFakerStatic { + option(options: Record): void; + generate(schema: Record): unknown; +} + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { JSONSchemaFaker } = require("json-schema-faker") as { JSONSchemaFaker: JSONSchemaFakerStatic }; +// eslint-disable-next-line @typescript-eslint/no-require-imports +const parser = require("@apidevtools/json-schema-ref-parser") as { dereference(schema: unknown): Promise }; + +JSONSchemaFaker.option({ requiredOnly: true }); + +// Forward declaration for replaceEnvs - will be imported at runtime to avoid circular deps +let replaceEnvs: (obj: unknown) => unknown; + +/** + * Sets the replaceEnvs function from utils module + * This is called during module initialization to avoid circular dependencies + */ +export function setReplaceEnvs(fn: (obj: unknown) => unknown): void { + replaceEnvs = fn; +} + +/** + * Dereferences an OpenAPI or Arazzo description + * + * @param descriptionPath - The OpenAPI or Arazzo description to be dereferenced. + * @returns The dereferenced OpenAPI or Arazzo description. + */ +export async function loadDescription(descriptionPath: string = ""): Promise { + // Error handling + if (!descriptionPath) { + throw new Error("Description is required."); + } + + // Load the definition from the URL or local file path + const definition = await readFile({ fileURLOrPath: descriptionPath }) as OpenApiDescription; + + // Dereference the definition + const dereferencedDefinition = await parser.dereference(definition) as OpenApiDescription; + + return dereferencedDefinition; +} + +/** + * Retrieves the operation details from an OpenAPI definition based on the provided operationId. + * + * @param definition - The OpenAPI definition object. + * @param operationId - The unique identifier for the operation. + * @param responseCode - The HTTP response code to filter the operation. + * @param exampleKey - The key for the example to be compiled. + * @param server - The server URL to use for examples. + * @throws Will throw an error if the definition or operationId is not provided. + * @returns Returns an object containing the operation details, schemas, and example if found; otherwise, returns null. + */ +export function getOperation( + definition: OpenApiDescription = {}, + operationId: string = "", + responseCode: string = "", + exampleKey: string = "", + server: string = "" +): OperationResult | null { + // Error handling + if (!definition) { + throw new Error("OpenAPI definition is required."); + } + if (!operationId) { + throw new Error("OperationId is required."); + } + + // Search for the operationId in the OpenAPI definition + if (!definition.paths) return null; + + for (const path in definition.paths) { + for (const method in definition.paths[path]) { + const operation = definition.paths[path][method] as OpenApiOperation; + if (operation.operationId === operationId) { + if (!server) { + if (definition.servers && definition.servers.length > 0) { + server = definition.servers[0].url; + } else { + throw new Error( + "No server URL provided and no servers defined in the OpenAPI definition." + ); + } + } + const example = compileExample( + operation, + server + path, + responseCode, + exampleKey + ); + const schemas = getSchemas(operation, responseCode); + return { path, method, definition: operation, schemas, example }; + } + } + } + return null; +} + +interface Schemas { + request?: Record; + response?: Record; +} + +function getSchemas(definition: OpenApiOperation = {}, responseCode: string = ""): Schemas { + const schemas: Schemas = {}; + + // Get request schema for operation + if (definition.requestBody?.content) { + const contentKeys = Object.keys(definition.requestBody.content); + if (contentKeys.length > 0) { + const firstContent = definition.requestBody.content[contentKeys[0]]; + schemas.request = firstContent.schema as Record; + } + } + + if (!responseCode) { + if (definition.responses && Object.keys(definition.responses).length > 0) { + responseCode = Object.keys(definition.responses)[0]; + } else { + throw new Error("No responses defined for the operation."); + } + } + + const response = definition.responses?.[responseCode] as OpenApiResponse | undefined; + if (response?.content) { + const contentKeys = Object.keys(response.content); + if (contentKeys.length > 0) { + const firstContent = response.content[contentKeys[0]]; + schemas.response = firstContent.schema as Record; + } + } + + return schemas; +} + +/** + * Compiles an example object based on the provided operation, path, and example key. + * + * @param operation - The operation object. + * @param path - The path string. + * @param responseCode - The HTTP response code. + * @param exampleKey - The example key string. + * @returns The compiled example object. + * @throws If operation or path is not provided. + */ +function compileExample( + operation: OpenApiOperation = {}, + path: string = "", + responseCode: string = "", + exampleKey: string = "" +): CompiledExample { + // Error handling + if (!operation) { + throw new Error("Operation is required."); + } + if (!path) { + throw new Error("Path is required."); + } + + // Setup + let example: CompiledExample = { + url: path, + request: { parameters: {}, headers: {}, body: {} }, + response: { headers: {}, body: {} }, + }; + + // Path parameters + const pathParameters = getExampleParameters(operation, "path", exampleKey); + pathParameters.forEach((param) => { + example.url = example.url.replace(`{${param.key}}`, String(param.value)); + }); + + // Query parameters + const queryParameters = getExampleParameters(operation, "query", exampleKey); + queryParameters.forEach((param) => { + example.request.parameters[param.key] = param.value; + }); + + // Headers + const headerParameters = getExampleParameters(operation, "header", exampleKey); + headerParameters.forEach((param) => { + example.request.headers[param.key] = param.value; + }); + + // Request body + if (operation.requestBody) { + const requestBody = getExample(operation.requestBody, exampleKey); + if (typeof requestBody !== "undefined") { + example.request.body = requestBody; + } + } + + // Response + if (!responseCode && operation.responses) { + responseCode = Object.keys(operation.responses)[0]; + } + const response = operation.responses?.[responseCode] as OpenApiResponse | undefined; + + // Response headers + if (response?.headers) { + for (const header in response.headers) { + const headerExample = getExample(response.headers[header], exampleKey); + if (typeof headerExample !== "undefined") { + example.response.headers[header] = headerExample; + } + } + } + + // Response body + if (response?.content) { + for (const key in response.content) { + const responseBody = getExample(response.content[key] as DefinitionWithExamples, exampleKey); + if (typeof responseBody !== "undefined") { + example.response.body = responseBody; + } + } + } + + // Load environment variables + if (replaceEnvs) { + example = replaceEnvs(example) as CompiledExample; + } + + return example; +} + +interface ExampleParameter { + key: string; + value: unknown; +} + +/** + * Retrieves example parameters based on the given operation, type, and example key. + * + * @param operation - The operation object. + * @param type - The type of parameter to retrieve. + * @param exampleKey - The example key to use. + * @returns An array of example parameters. + * @throws If the operation is not provided. + */ +function getExampleParameters( + operation: OpenApiOperation = {}, + type: string = "", + exampleKey: string = "" +): ExampleParameter[] { + const params: ExampleParameter[] = []; + + // Error handling + if (!operation) { + throw new Error("Operation is required."); + } + if (!operation.parameters) return params; + + // Find all parameters of the given type + for (const parameter of operation.parameters as OpenApiParameter[]) { + if (parameter.in === type) { + const value = getExample(parameter, exampleKey); + if (value) { + params.push({ key: parameter.name, value }); + } + } + } + + return params; +} + +interface DefinitionWithExamples { + example?: unknown; + examples?: Record; + schema?: Record; + properties?: Record; + items?: Record; + content?: Record; + type?: string; + required?: boolean; +} + +/** + * Retrieves an example value based on the given definition and example key. + * + * @param definition - The definition object. + * @param exampleKey - The key of the example to retrieve. + * @param generateFromSchema - Whether to generate from schema if no example found. + * @returns The example value. + * @throws If the definition is not provided. + */ +function getExample( + definition: DefinitionWithExamples = {}, + exampleKey: string = "", + generateFromSchema: boolean | null = null +): unknown { + // Setup + let example: unknown; + + // Error handling + if (!definition) { + throw new Error("Definition is required."); + } + + // If there are no examples in the definition, generate example based on definition schema + if (generateFromSchema === null) { + const hasExamples = checkForExamples(definition as Record, exampleKey); + const schemaWithRequired = definition.schema as { required?: boolean } | undefined; + generateFromSchema = + !hasExamples && + (definition.required || schemaWithRequired?.required || !exampleKey); + } + + if (generateFromSchema && definition.type) { + try { + example = JSONSchemaFaker.generate(definition as Record); + if (example) return example; + } catch (error) { + console.warn(`Error generating example: ${error}`); + } + } + + if ( + definition.examples && + typeof exampleKey !== "undefined" && + exampleKey !== "" && + typeof definition.examples[exampleKey] !== "undefined" && + typeof definition.examples[exampleKey].value !== "undefined" + ) { + // If the definition has an `examples` property, exampleKey is specified, and the exampleKey exists in the examples object, use that example. + example = definition.examples[exampleKey].value; + } else if (typeof definition.example !== "undefined") { + // If the definition has an `example` property, use that example. + example = definition.example; + } else { + // If the definition has no examples, generate an example based on the definition/properties. + // Find the next `schema` child property in the definition, regardless of depth + let schema: Record | undefined; + if (definition.schema) { + // Parameter pattern + schema = definition.schema; + } else if (definition.properties) { + // Object pattern + schema = definition as unknown as Record; + } else if (definition.items) { + // Array pattern + schema = definition as unknown as Record; + } else if (definition.content) { + // Request/response body pattern + for (const key in definition.content) { + if (definition.content[key]) { + schema = definition.content[key] as Record; + break; + } + } + } else { + return null; + } + + if (!schema) return null; + + const schemaType = (schema as { type?: string }).type; + if (schemaType === "object") { + example = generateObjectExample(schema, exampleKey, generateFromSchema); + } else if (schemaType === "array") { + const items = (schema as { items?: Record }).items; + example = generateArrayExample(items || {}, exampleKey, generateFromSchema); + } else { + example = getExample(schema as DefinitionWithExamples, exampleKey, generateFromSchema); + } + } + + return example; +} + +/** + * Generates an object example based on the provided schema and example key. + * + * @param schema - The schema object. + * @param exampleKey - The example key. + * @param generateFromSchema - Whether to generate from schema. + * @returns The generated object example. + */ +function generateObjectExample( + schema: Record = {}, + exampleKey: string = "", + generateFromSchema: boolean | null = null +): Record { + const example: Record = {}; + const properties = schema.properties as Record | undefined; + + if (!properties) return example; + + for (const property in properties) { + const objectExample = getExample( + properties[property] as DefinitionWithExamples, + exampleKey, + generateFromSchema + ); + if (objectExample) example[property] = objectExample; + } + return example; +} + +/** + * Generates an array example based on the provided items and example key. + * + * @param items - The items object. + * @param exampleKey - The example key. + * @param generateFromSchema - Whether to generate from schema. + * @returns The generated array example. + */ +function generateArrayExample( + items: Record = {}, + exampleKey: string = "", + generateFromSchema: boolean | null = null +): unknown[] { + const example: unknown[] = []; + const itemExample = getExample(items as DefinitionWithExamples, exampleKey, generateFromSchema); + if (itemExample) example.push(itemExample); + + return example; +} + +/** + * Checks if the provided definition object contains any examples. + * + * @param definition - The object to traverse for examples. + * @param exampleKey - The specific key to look for in the examples. + * @returns Returns true if examples are found, otherwise false. + */ +function checkForExamples(definition: Record = {}, exampleKey: string = ""): boolean { + const examples: unknown[] = []; + + function traverse(obj: unknown): void { + if (typeof obj !== "object" || obj === null) return; + + const record = obj as Record; + + if (Object.prototype.hasOwnProperty.call(record, "example")) { + examples.push(record.example); + } + if ( + exampleKey && + Object.hasOwn(record, "examples") && + typeof record.examples === "object" && + record.examples !== null && + Object.hasOwn(record.examples as Record, exampleKey) + ) { + const examplesObj = record.examples as Record; + if (Object.hasOwn(examplesObj[exampleKey], "value")) { + examples.push(examplesObj[exampleKey].value); + } + } + + for (const key in record) { + traverse(record[key]); + } + } + + traverse(definition); + return examples.length > 0; +} diff --git a/src/resolve.js b/src/resolve.js deleted file mode 100644 index 2775c29..0000000 --- a/src/resolve.js +++ /dev/null @@ -1,235 +0,0 @@ -const crypto = require("crypto"); -const { log } = require("./utils"); -const { loadDescription } = require("./openapi"); - -exports.resolveDetectedTests = resolveDetectedTests; - -// Doc Detective actions that require a driver. -const driverActions = [ - "click", - "dragAndDrop", - "find", - "goTo", - "loadCookie", - "record", - "saveCookie", - "screenshot", - "stopRecord", - "type", -]; - -function isDriverRequired({ test }) { - let driverRequired = false; - test.steps.forEach((step) => { - // Check if test includes actions that require a driver. - driverActions.forEach((action) => { - if (typeof step[action] !== "undefined") driverRequired = true; - }); - }); - return driverRequired; -} - -function resolveContexts({ contexts, test, config }) { - log(config, "debug", `Determining required contexts for test: ${test.testId}`); - const resolvedContexts = []; - - // Check if current test requires a browser - let browserRequired = false; - test.steps.forEach((step) => { - // Check if test includes actions that require a driver. - driverActions.forEach((action) => { - if (typeof step[action] !== "undefined") browserRequired = true; - }); - }); - - // Standardize context format - contexts.forEach((context) => { - if (context.browsers) { - if ( - typeof context.browsers === "string" || - (typeof context.browsers === "object" && - !Array.isArray(context.browsers)) - ) { - // If browsers is a string or an object, convert to array - context.browsers = [context.browsers]; - } - context.browsers = context.browsers.map((browser) => { - if (typeof browser === "string") { - browser = { name: browser }; - } - if (browser.name === "safari") browser.name = "webkit"; - return browser; - }); - } - if (context.platforms) { - if (typeof context.platforms === "string") { - context.platforms = [context.platforms]; - } - } - }); - - // Resolve to final contexts. Each context should include a single platform and at most a single browser. - // If no browsers are required, filter down to platform-based contexts - // If browsers are required, create contexts for each specified combination of platform and browser - contexts.forEach((context) => { - const staticContexts = []; - context.platforms.forEach((platform) => { - if (!browserRequired) { - const staticContext = { platform }; - staticContexts.push(staticContext); - } else { - context.browsers.forEach((browser) => { - const staticContext = { platform, browser }; - staticContexts.push(staticContext); - }); - } - }); - // For each static context, check if a matching object already exists in resolvedContexts. If not, push to resolvedContexts. - staticContexts.forEach((staticContext) => { - const existingContext = resolvedContexts.find((resolvedContext) => { - return ( - resolvedContext.platform === staticContext.platform && - JSON.stringify(resolvedContext.browser) === - JSON.stringify(staticContext.browser) - ); - }); - if (!existingContext) { - resolvedContexts.push(staticContext); - } - }); - }); - - // If no contexts are defined, use default contexts - if (resolvedContexts.length === 0) { - resolvedContexts.push({}); - } - - log(config, "debug", `Resolved contexts for test ${test.testId}:\n${JSON.stringify(resolvedContexts, null, 2)}`); - return resolvedContexts; -} - -async function fetchOpenApiDocuments({ config, documentArray }) { - log(config, "debug", `Fetching OpenAPI documents:\n${JSON.stringify(documentArray, null, 2)}`); - const openApiDocuments = []; - if (config?.integrations?.openApi?.length > 0) - openApiDocuments.push(...config.integrations.openApi); - if (documentArray?.length > 0) { - for (const definition of documentArray) { - try { - const openApiDefinition = await loadDescription( - definition.descriptionPath - ); - definition.definition = openApiDefinition; - } catch (error) { - log( - config, - "error", - `Failed to load OpenAPI definition from ${definition.descriptionPath}: ${error.message}` - ); - continue; // Skip this definition - } - const existingDefinitionIndex = openApiDocuments.findIndex( - (def) => def.name === definition.name - ); - if (existingDefinitionIndex > -1) { - openApiDocuments.splice(existingDefinitionIndex, 1); - } - openApiDocuments.push(definition); - } - } - log(config, "debug", `Fetched OpenAPI documents:\n${JSON.stringify(openApiDocuments, null, 2)}`); - return openApiDocuments; -} - -// Iterate through and resolve test specifications and contained tests. -async function resolveDetectedTests({ config, detectedTests }) { - log(config, "debug", `RESOLVING DETECTED TEST SPECS:\n${JSON.stringify(detectedTests, null, 2)}`); - // Set initial shorthand values - const resolvedTests = { - resolvedTestsId: crypto.randomUUID(), - config: config, - specs: [], - }; - - // Iterate specs - log(config, "info", "Resolving test specs."); - for (const spec of detectedTests) { - const resolvedSpec = await resolveSpec({ config, spec }); - resolvedTests.specs.push(resolvedSpec); - } - - log(config, "debug", `RESOLVED TEST SPECS:\n${JSON.stringify(resolvedTests, null, 2)}`); - return resolvedTests; -} - -async function resolveSpec({ config, spec }) { - const specId = spec.specId || crypto.randomUUID(); - log(config, "debug", `RESOLVING SPEC ID ${specId}:\n${JSON.stringify(spec, null, 2)}`); - const resolvedSpec = { - ...spec, - specId: specId, - runOn: spec.runOn || config.runOn || [], - openApi: await fetchOpenApiDocuments({ - config, - documentArray: spec.openApi, - }), - tests: [], - }; - for (const test of spec.tests) { - const resolvedTest = await resolveTest({ - config, - spec: resolvedSpec, - test, - }); - resolvedSpec.tests.push(resolvedTest); - } - log(config, "debug", `RESOLVED SPEC ${specId}:\n${JSON.stringify(resolvedSpec, null, 2)}`); - return resolvedSpec; -} - -async function resolveTest({ config, spec, test }) { - const testId = test.testId || crypto.randomUUID(); - log(config, "debug", `RESOLVING TEST ID ${testId}:\n${JSON.stringify(test, null, 2)}`); - const resolvedTest = { - ...test, - testId: testId, - runOn: test.runOn || spec.runOn, - openApi: await fetchOpenApiDocuments({ - config, - documentArray: [...spec.openApi, ...(test.openApi || [])], - }), - contexts: [], - }; - delete resolvedTest.steps; - - const testContexts = resolveContexts({ - test: test, - contexts: resolvedTest.runOn, - config: config, - }); - - for (const context of testContexts) { - const resolvedContext = await resolveContext({ - config, - test: test, - context, - }); - resolvedTest.contexts.push(resolvedContext); - } - log(config, "debug", `RESOLVED TEST ${testId}:\n${JSON.stringify(resolvedTest, null, 2)}`); - return resolvedTest; -} - -async function resolveContext({ config, test, context }) { - const contextId = context.contextId || crypto.randomUUID(); - log(config, "debug", `RESOLVING CONTEXT ID ${contextId}:\n${JSON.stringify(context, null, 2)}`); - const resolvedContext = { - ...context, - unsafe: test.unsafe || false, - openApi: test.openApi || [], - steps: [...test.steps], - contextId: contextId, - }; - log(config, "debug", `RESOLVED CONTEXT ${contextId}:\n${JSON.stringify(resolvedContext, null, 2)}`); - return resolvedContext; -} diff --git a/src/resolve.ts b/src/resolve.ts new file mode 100644 index 0000000..b64ba67 --- /dev/null +++ b/src/resolve.ts @@ -0,0 +1,397 @@ +import crypto from "crypto"; +import type { + Config, + DetectedSpec, + DetectedTest, + ResolvedTests, + ResolvedSpec, + ResolvedTest, + TestContext, + RunOnContext, + OpenApiDefinition, + Step, + BrowserConfig, + LogLevel, +} from "./types"; +import { loadDescription } from "./openapi"; + +// Type for log function +type LogFunction = (config: Config, level: LogLevel, message: unknown) => void; + +// Forward declaration for log function - will be set during initialization +let log: LogFunction; + +/** + * Sets the log function from utils module + * This is called during module initialization to avoid circular dependencies + */ +export function setLogFunction(fn: LogFunction): void { + log = fn; +} + +// Doc Detective actions that require a driver. +const driverActions: string[] = [ + "click", + "dragAndDrop", + "find", + "goTo", + "loadCookie", + "record", + "saveCookie", + "screenshot", + "stopRecord", + "type", +]; + +/** + * Checks if a test requires a browser driver. + * @param test - The test to check + * @returns True if the test requires a driver + */ +function isDriverRequired({ test }: { test: DetectedTest }): boolean { + let driverRequired = false; + test.steps.forEach((step) => { + // Check if test includes actions that require a driver. + driverActions.forEach((action) => { + if (typeof step[action] !== "undefined") driverRequired = true; + }); + }); + return driverRequired; +} + +interface StaticContext { + platform?: string; + browser?: BrowserConfig; +} + +/** + * Resolves test contexts based on platforms and browser requirements. + * @param contexts - Array of context configurations + * @param test - The test to resolve contexts for + * @param config - Doc Detective configuration + * @returns Array of resolved contexts + */ +function resolveContexts({ + contexts, + test, + config, +}: { + contexts: RunOnContext[]; + test: DetectedTest; + config: Config; +}): TestContext[] { + if (log) { + log(config, "debug", `Determining required contexts for test: ${test.testId}`); + } + const resolvedContexts: TestContext[] = []; + + // Check if current test requires a browser + let browserRequired = false; + test.steps.forEach((step) => { + // Check if test includes actions that require a driver. + driverActions.forEach((action) => { + if (typeof step[action] !== "undefined") browserRequired = true; + }); + }); + + // Standardize context format + contexts.forEach((context) => { + if (context.browsers) { + if ( + typeof context.browsers === "string" || + (typeof context.browsers === "object" && !Array.isArray(context.browsers)) + ) { + // If browsers is a string or an object, convert to array + context.browsers = [context.browsers as BrowserConfig | string] as BrowserConfig[]; + } + context.browsers = (context.browsers as (BrowserConfig | string)[]).map((browser) => { + if (typeof browser === "string") { + const browserName = browser === "safari" ? "webkit" : browser; + return { name: browserName } as BrowserConfig; + } + if (browser.name === "safari") browser.name = "webkit" as BrowserConfig["name"]; + return browser as BrowserConfig; + }); + } + if (context.platforms) { + if (typeof context.platforms === "string") { + context.platforms = [context.platforms]; + } + } + }); + + // Resolve to final contexts. Each context should include a single platform and at most a single browser. + // If no browsers are required, filter down to platform-based contexts + // If browsers are required, create contexts for each specified combination of platform and browser + contexts.forEach((context) => { + const staticContexts: StaticContext[] = []; + const platforms = context.platforms || []; + + platforms.forEach((platform) => { + if (!browserRequired) { + const staticContext: StaticContext = { platform: platform as string }; + staticContexts.push(staticContext); + } else { + const browsers = context.browsers as BrowserConfig[] | undefined; + if (browsers) { + browsers.forEach((browser) => { + const staticContext: StaticContext = { platform: platform as string, browser }; + staticContexts.push(staticContext); + }); + } + } + }); + + // For each static context, check if a matching object already exists in resolvedContexts. If not, push to resolvedContexts. + staticContexts.forEach((staticContext) => { + const existingContext = resolvedContexts.find((resolvedContext) => { + return ( + resolvedContext.platform === staticContext.platform && + JSON.stringify(resolvedContext.browser) === JSON.stringify(staticContext.browser) + ); + }); + if (!existingContext) { + resolvedContexts.push(staticContext as TestContext); + } + }); + }); + + // If no contexts are defined, use default contexts + if (resolvedContexts.length === 0) { + resolvedContexts.push({}); + } + + if (log) { + log(config, "debug", `Resolved contexts for test ${test.testId}:\n${JSON.stringify(resolvedContexts, null, 2)}`); + } + return resolvedContexts; +} + +/** + * Fetches OpenAPI documents and merges with config-level definitions. + * @param config - Doc Detective configuration + * @param documentArray - Array of OpenAPI definitions to fetch + * @returns Array of fetched OpenAPI definitions + */ +async function fetchOpenApiDocuments({ + config, + documentArray, +}: { + config: Config; + documentArray?: OpenApiDefinition[]; +}): Promise { + if (log) { + log(config, "debug", `Fetching OpenAPI documents:\n${JSON.stringify(documentArray, null, 2)}`); + } + const openApiDocuments: OpenApiDefinition[] = []; + + if (config?.integrations?.openApi && config.integrations.openApi.length > 0) { + openApiDocuments.push(...config.integrations.openApi); + } + + if (documentArray && documentArray.length > 0) { + for (const definition of documentArray) { + try { + if (definition.descriptionPath) { + const openApiDefinition = await loadDescription(definition.descriptionPath); + definition.definition = openApiDefinition; + } + } catch (error) { + if (log) { + log( + config, + "error", + `Failed to load OpenAPI definition from ${definition.descriptionPath}: ${(error as Error).message}` + ); + } + continue; // Skip this definition + } + const existingDefinitionIndex = openApiDocuments.findIndex( + (def) => def.name === definition.name + ); + if (existingDefinitionIndex > -1) { + openApiDocuments.splice(existingDefinitionIndex, 1); + } + openApiDocuments.push(definition); + } + } + + if (log) { + log(config, "debug", `Fetched OpenAPI documents:\n${JSON.stringify(openApiDocuments, null, 2)}`); + } + return openApiDocuments; +} + +/** + * Resolves detected test specifications into execution-ready format. + * @param config - Doc Detective configuration + * @param detectedTests - Array of detected test specifications + * @returns Resolved tests object + */ +export async function resolveDetectedTests({ + config, + detectedTests, +}: { + config: Config; + detectedTests: DetectedSpec[]; +}): Promise { + if (log) { + log(config, "debug", `RESOLVING DETECTED TEST SPECS:\n${JSON.stringify(detectedTests, null, 2)}`); + } + + // Set initial shorthand values + const resolvedTests: ResolvedTests = { + resolvedTestsId: crypto.randomUUID(), + config: config, + specs: [], + }; + + // Iterate specs + if (log) { + log(config, "info", "Resolving test specs."); + } + for (const spec of detectedTests) { + const resolvedSpec = await resolveSpec({ config, spec }); + resolvedTests.specs.push(resolvedSpec); + } + + if (log) { + log(config, "debug", `RESOLVED TEST SPECS:\n${JSON.stringify(resolvedTests, null, 2)}`); + } + return resolvedTests; +} + +/** + * Resolves a single test specification. + * @param config - Doc Detective configuration + * @param spec - The spec to resolve + * @returns Resolved specification + */ +async function resolveSpec({ + config, + spec, +}: { + config: Config; + spec: DetectedSpec; +}): Promise { + const specId = spec.specId || crypto.randomUUID(); + if (log) { + log(config, "debug", `RESOLVING SPEC ID ${specId}:\n${JSON.stringify(spec, null, 2)}`); + } + + const resolvedSpec: ResolvedSpec = { + ...spec, + specId: specId, + runOn: spec.runOn || config.runOn || [], + openApi: await fetchOpenApiDocuments({ + config, + documentArray: spec.openApi, + }), + tests: [], + }; + + for (const test of spec.tests) { + const resolvedTest = await resolveTest({ + config, + spec: resolvedSpec, + test, + }); + resolvedSpec.tests.push(resolvedTest); + } + + if (log) { + log(config, "debug", `RESOLVED SPEC ${specId}:\n${JSON.stringify(resolvedSpec, null, 2)}`); + } + return resolvedSpec; +} + +/** + * Resolves a single test within a specification. + * @param config - Doc Detective configuration + * @param spec - The parent spec + * @param test - The test to resolve + * @returns Resolved test + */ +async function resolveTest({ + config, + spec, + test, +}: { + config: Config; + spec: ResolvedSpec; + test: DetectedTest; +}): Promise { + const testId = test.testId || crypto.randomUUID(); + if (log) { + log(config, "debug", `RESOLVING TEST ID ${testId}:\n${JSON.stringify(test, null, 2)}`); + } + + const resolvedTest: ResolvedTest = { + ...test, + testId: testId, + runOn: test.runOn || spec.runOn, + openApi: await fetchOpenApiDocuments({ + config, + documentArray: [...spec.openApi, ...(test.openApi || [])], + }), + contexts: [], + }; + delete (resolvedTest as { steps?: Step[] }).steps; + + const testContexts = resolveContexts({ + test: test, + contexts: resolvedTest.runOn, + config: config, + }); + + for (const context of testContexts) { + const resolvedContext = await resolveContext({ + config, + test: test, + context, + }); + resolvedTest.contexts.push(resolvedContext); + } + + if (log) { + log(config, "debug", `RESOLVED TEST ${testId}:\n${JSON.stringify(resolvedTest, null, 2)}`); + } + return resolvedTest; +} + +/** + * Resolves a single context within a test. + * @param config - Doc Detective configuration + * @param test - The parent test + * @param context - The context to resolve + * @returns Resolved context + */ +async function resolveContext({ + config, + test, + context, +}: { + config: Config; + test: DetectedTest; + context: TestContext; +}): Promise { + const contextId = context.contextId || crypto.randomUUID(); + if (log) { + log(config, "debug", `RESOLVING CONTEXT ID ${contextId}:\n${JSON.stringify(context, null, 2)}`); + } + + const resolvedContext: TestContext = { + ...context, + unsafe: test.unsafe || false, + openApi: test.openApi || [], + steps: [...test.steps], + contextId: contextId, + }; + + if (log) { + log(config, "debug", `RESOLVED CONTEXT ${contextId}:\n${JSON.stringify(resolvedContext, null, 2)}`); + } + return resolvedContext; +} + +export { resolveDetectedTests as default }; diff --git a/src/sanitize.js b/src/sanitize.js deleted file mode 100644 index f4d30bd..0000000 --- a/src/sanitize.js +++ /dev/null @@ -1,23 +0,0 @@ -const fs = require("fs"); -const path = require("path"); - -exports.sanitizePath = sanitizePath; -exports.sanitizeUri = sanitizeUri; - -function sanitizeUri(uri) { - uri = uri.trim(); - // If no protocol, add "https://" - if (!uri.includes("://")) uri = "https://" + uri; - return uri; -} - -// Resolve path and make sure it exists -function sanitizePath(filepath) { - filepath = path.resolve(filepath); - exists = fs.existsSync(filepath); - if (exists) { - return filepath; - } else { - return null; - } -} diff --git a/src/sanitize.ts b/src/sanitize.ts new file mode 100644 index 0000000..f750025 --- /dev/null +++ b/src/sanitize.ts @@ -0,0 +1,30 @@ +import fs from "fs"; +import path from "path"; + +/** + * Sanitizes a URI by ensuring it has a protocol. + * If no protocol is present, "https://" is prepended. + * @param uri - The URI to sanitize + * @returns The sanitized URI with protocol + */ +export function sanitizeUri(uri: string): string { + uri = uri.trim(); + // If no protocol, add "https://" + if (!uri.includes("://")) uri = "https://" + uri; + return uri; +} + +/** + * Resolves a file path and verifies it exists. + * @param filepath - The file path to sanitize + * @returns The resolved absolute path if it exists, null otherwise + */ +export function sanitizePath(filepath: string): string | null { + filepath = path.resolve(filepath); + const exists = fs.existsSync(filepath); + if (exists) { + return filepath; + } else { + return null; + } +} diff --git a/src/telem.js b/src/telem.ts similarity index 50% rename from src/telem.js rename to src/telem.ts index a156260..a170e0a 100644 --- a/src/telem.js +++ b/src/telem.ts @@ -1,15 +1,32 @@ -const os = require("os"); -const { log } = require("./utils"); -const { PostHog } = require("posthog-node"); +import os from "os"; +import type { Config, TelemetryData, TestResults, LogLevel } from "./types"; -const platformMap = { +// PostHog client type +interface PostHogClient { + capture(event: { distinctId: string; event: string; properties: TelemetryData }): void; + shutdown(): void; +} + +// Import PostHog dynamically to avoid issues with ESM/CJS +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { PostHog } = require("posthog-node") as { PostHog: new (apiKey: string, options: { host: string }) => PostHogClient }; + +// Import log from utils - we'll use a simple console.log for now to avoid circular deps +// The actual log function will be imported when the module is used +type LogFunction = (config: Config, level: LogLevel, message: unknown) => void; + +const platformMap: Record = { win32: "windows", darwin: "mac", linux: "linux", }; -// TODO: Add link to docs -function telemetryNotice(config) { +/** + * Displays a telemetry notice to the user based on their configuration. + * @param config - Doc Detective configuration object + * @param log - Logging function + */ +export function telemetryNotice(config: Config, log: LogFunction): void { if (config?.telemetry?.send === false) { log( config, @@ -25,35 +42,28 @@ function telemetryNotice(config) { } } -// meta = { -// distribution: "doc-detective", // doc-detective, core -// dist_platform: "windows", // windows, mac, linux -// dist_platform_version: "10", // 10, 11, 12, 20.04, 21.04 -// dist_platform_arch: "x64", // x64, arm64, armv7l -// dist_version: version, -// dist_deployment: "node", // node, electron, docker, github-action, lambda, vscode-extension, browser-extension -// dist_deployment_version: "18.19.0", -// dist_interface: "cli", // cli, rest, gui, vscode -// core_version: version, -// core_platform: "windows", // windows, mac, linux -// core_platform_version: "10", // 10, 11, 12, 20.04, 21.04 -// core_deployment: "node", // node, electron, docker, github-action, lambda, vscode-extension, browser-extension -// }; - -// Send telemetry data to PostHog -function sendTelemetry(config, command, results) { +/** + * Sends telemetry data to PostHog. + * @param config - Doc Detective configuration object + * @param command - The command being executed (e.g., "runTests", "runCoverage", "detect") + * @param results - Test results object (optional) + */ +export function sendTelemetry(config: Config, command: string, results?: TestResults): void { // Exit early if telemetry is disabled if (config?.telemetry?.send === false) return; // Assemble telemetry data - const telemetryData = + const telemetryData: TelemetryData = process.env["DOC_DETECTIVE_META"] !== undefined ? JSON.parse(process.env["DOC_DETECTIVE_META"]) : {}; - const package = require("../package.json"); + + // eslint-disable-next-line @typescript-eslint/no-require-imports + const packageJson = require("../package.json") as { version: string }; + telemetryData.distribution = telemetryData.distribution || "doc-detective-core"; telemetryData.dist_interface = telemetryData.dist_interface || "package"; - telemetryData.core_version = package.version; + telemetryData.core_version = packageJson.version; telemetryData.dist_version = telemetryData.dist_version || telemetryData.core_version; telemetryData.core_platform = platformMap[os.platform()] || os.platform(); telemetryData.dist_platform = telemetryData.dist_platform || telemetryData.core_platform; @@ -63,27 +73,26 @@ function sendTelemetry(config, command, results) { telemetryData.dist_platform_arch = telemetryData.dist_platform_arch || telemetryData.core_platform_arch; telemetryData.core_deployment = telemetryData.core_deployment || "node"; telemetryData.dist_deployment = telemetryData.dist_deployment || telemetryData.core_deployment; - telemetryData.core_deployment_version = - telemetryData.core_deployment_version || process.version; + telemetryData.core_deployment_version = telemetryData.core_deployment_version || process.version; telemetryData.dist_deployment_version = telemetryData.dist_deployment_version || telemetryData.core_deployment_version; + const distinctId = config?.telemetry?.userId || "anonymous"; - // parse results to assemble flat list of properties for runTests and runCoverage actions - if (command === "runTests" || command === "runCoverage") { - // Get summary data + // Parse results to assemble flat list of properties for runTests and runCoverage actions + if ((command === "runTests" || command === "runCoverage") && results?.summary) { Object.entries(results.summary).forEach(([parentKey, value]) => { - if (typeof value === "object") { - Object.entries(value).forEach(([key, value]) => { - if (typeof value === "object") { - Object.entries(value).forEach(([key2, value2]) => { - telemetryData[`${parentKey.replace(" ","_")}_${key.replace(" ","_")}_${key2.replace(" ","_")}`] = value2; + if (typeof value === "object" && value !== null) { + Object.entries(value as Record).forEach(([key, val]) => { + if (typeof val === "object" && val !== null) { + Object.entries(val as Record).forEach(([key2, value2]) => { + telemetryData[`${parentKey.replace(" ", "_")}_${key.replace(" ", "_")}_${key2.replace(" ", "_")}`] = value2; }); } else { - telemetryData[`${parentKey.replace(" ","_")}_${key.replace(" ","_")}`] = value; + telemetryData[`${parentKey.replace(" ", "_")}_${key.replace(" ", "_")}`] = val; } }); } else { - telemetryData[parentKey.replace(" ","_")] = value; + telemetryData[parentKey.replace(" ", "_")] = value; } }); } @@ -98,6 +107,3 @@ function sendTelemetry(config, command, results) { client.capture(event); client.shutdown(); } - -exports.telemetryNotice = telemetryNotice; -exports.sendTelemetry = sendTelemetry; diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..2ec167c --- /dev/null +++ b/src/types.ts @@ -0,0 +1,435 @@ +/** + * Shared TypeScript interfaces for Doc Detective Resolver + */ + +// Re-export types from doc-detective-common if needed +// import type { Config as CommonConfig } from "doc-detective-common"; + +/** + * Log levels supported by the logging system + */ +export type LogLevel = "debug" | "info" | "warning" | "error"; + +/** + * Platform identifiers + */ +export type Platform = "mac" | "linux" | "windows"; + +/** + * Browser names supported by Doc Detective + */ +export type BrowserName = "chromium" | "firefox" | "webkit" | "safari"; + +/** + * Browser configuration + */ +export interface BrowserConfig { + name: BrowserName; + headless?: boolean; + [key: string]: unknown; +} + +/** + * Context for test execution + */ +export interface TestContext { + contextId?: string; + platform?: Platform; + browser?: BrowserConfig; + unsafe?: boolean; + openApi?: OpenApiDefinition[]; + steps?: Step[]; +} + +/** + * Run-on context configuration + */ +export interface RunOnContext { + platforms?: Platform[] | string[]; + browsers?: BrowserConfig[] | BrowserName[] | string[]; +} + +/** + * File type definition for parsing + */ +export interface FileType { + name: string; + extensions: string[]; + inlineStatements: { + testStart: string[]; + testEnd: string[]; + ignoreStart: string[]; + ignoreEnd: string[]; + step: string[]; + }; + markup?: MarkupPattern[]; +} + +/** + * Markup pattern for extracting test steps from content + */ +export interface MarkupPattern { + name: string; + regex: string[]; + actions?: Record[]; +} + +/** + * OpenAPI definition reference + */ +export interface OpenApiDefinition { + name: string; + descriptionPath?: string; + definition?: OpenApiDescription; + server?: string; +} + +/** + * OpenAPI description object (dereferenced) + */ +export interface OpenApiDescription { + openapi?: string; + info?: { + title?: string; + version?: string; + description?: string; + }; + servers?: Array<{ url: string; description?: string }>; + paths?: Record>; + [key: string]: unknown; +} + +/** + * OpenAPI operation + */ +export interface OpenApiOperation { + operationId?: string; + summary?: string; + description?: string; + parameters?: OpenApiParameter[]; + requestBody?: { + content: Record; examples?: Record }>; + required?: boolean; + }; + responses?: Record; + [key: string]: unknown; +} + +/** + * OpenAPI parameter + */ +export interface OpenApiParameter { + name: string; + in: "query" | "header" | "path" | "cookie"; + required?: boolean; + schema?: Record; + example?: unknown; + examples?: Record; + [key: string]: unknown; +} + +/** + * OpenAPI response + */ +export interface OpenApiResponse { + description?: string; + headers?: Record; example?: unknown }>; + content?: Record; examples?: Record }>; +} + +/** + * Test step action + */ +export interface Step { + action?: string; + [key: string]: unknown; +} + +/** + * Detected test specification + */ +export interface DetectedTest { + testId?: string; + description?: string; + steps: Step[]; + runOn?: RunOnContext[]; + openApi?: OpenApiDefinition[]; + unsafe?: boolean; + [key: string]: unknown; +} + +/** + * Detected test specification (file-level) + */ +export interface DetectedSpec { + specId?: string; + file?: string; + tests: DetectedTest[]; + runOn?: RunOnContext[]; + openApi?: OpenApiDefinition[]; + [key: string]: unknown; +} + +/** + * Resolved test with contexts + */ +export interface ResolvedTest { + testId: string; + description?: string; + runOn: RunOnContext[]; + openApi: OpenApiDefinition[]; + contexts: TestContext[]; + [key: string]: unknown; +} + +/** + * Resolved test specification + */ +export interface ResolvedSpec { + specId: string; + file?: string; + runOn: RunOnContext[]; + openApi: OpenApiDefinition[]; + tests: ResolvedTest[]; + [key: string]: unknown; +} + +/** + * Collection of resolved tests + */ +export interface ResolvedTests { + resolvedTestsId: string; + config: Config; + specs: ResolvedSpec[]; +} + +/** + * Telemetry configuration + */ +export interface TelemetryConfig { + send?: boolean; + userId?: string; +} + +/** + * Heretto integration configuration + */ +export interface HerettoConfig { + name: string; + organizationId: string; + username: string; + apiToken: string; + scenarioName?: string; + uploadOnChange?: boolean; + resourceDependencies?: Record; + fileMapping?: Record; +} + +/** + * Heretto resource information + */ +export interface HerettoResourceInfo { + uuid: string; + fullPath?: string; + name?: string; + parentFolderId?: string; + isDitamap?: boolean; +} + +/** + * Heretto file mapping entry + */ +export interface HerettoFileMapping { + fileId?: string; + filePath?: string; + sourceFile?: string; + name?: string; +} + +/** + * Integration configurations + */ +export interface IntegrationsConfig { + openApi?: OpenApiDefinition[]; + heretto?: HerettoConfig[]; +} + +/** + * Environment variables record + */ +export interface Environment { + [key: string]: string | undefined; +} + +/** + * Main configuration object + */ +export interface Config { + input?: string | string[]; + output?: string; + recursive?: boolean; + logLevel?: LogLevel; + runOn?: RunOnContext[]; + fileTypes?: FileType[] | (string | FileType)[]; + integrations?: IntegrationsConfig; + telemetry?: TelemetryConfig; + environment?: RuntimeEnvironment; + envVariables?: Environment; + concurrentRunners?: number | boolean; + detectSteps?: boolean; + beforeAny?: string | string[]; + afterAll?: string | string[]; + loadVariables?: string | string[]; + _herettoPathMapping?: Record; + [key: string]: unknown; +} + +/** + * Runtime environment info + */ +export interface RuntimeEnvironment { + arch: string; + platform: Platform | undefined; + workingDirectory: string; +} + +/** + * Qualified file information + */ +export interface QualifiedFile { + path: string; + content: string; + fileType: FileType; +} + +/** + * Arazzo source description + */ +export interface ArazzoSourceDescription { + name: string; + type: "openapi" | "arazzo"; + url: string; +} + +/** + * Arazzo workflow step + */ +export interface ArazzoWorkflowStep { + stepId?: string; + operationId?: string; + operationPath?: string; + workflowId?: string; + parameters?: Array<{ + name: string; + in: string; + value: unknown; + }>; + requestBody?: { + payload: unknown; + }; + successCriteria?: Array<{ + condition: string; + context?: string; + }>; +} + +/** + * Arazzo workflow + */ +export interface ArazzoWorkflow { + workflowId: string; + summary?: string; + description?: string; + steps: ArazzoWorkflowStep[]; +} + +/** + * Arazzo description object + */ +export interface ArazzoDescription { + arazzo?: string; + info: { + title?: string; + summary?: string; + description?: string; + version?: string; + }; + sourceDescriptions: ArazzoSourceDescription[]; + workflows: ArazzoWorkflow[]; +} + +/** + * Telemetry data object + */ +export interface TelemetryData { + distribution?: string; + dist_interface?: string; + dist_version?: string; + dist_platform?: string; + dist_platform_version?: string; + dist_platform_arch?: string; + dist_deployment?: string; + dist_deployment_version?: string; + core_version?: string; + core_platform?: string; + core_platform_version?: string; + core_platform_arch?: string; + core_deployment?: string; + core_deployment_version?: string; + [key: string]: unknown; +} + +/** + * Compiled OpenAPI example + */ +export interface CompiledExample { + url: string; + request: { + parameters: Record; + headers: Record; + body: unknown; + }; + response: { + headers: Record; + body: unknown; + }; +} + +/** + * OpenAPI operation result from getOperation + */ +export interface OperationResult { + path: string; + method: string; + definition: OpenApiOperation; + schemas: { + request?: Record; + response?: Record; + }; + example: CompiledExample; +} + +/** + * Test results for telemetry + */ +export interface TestResults { + summary: Record; + [key: string]: unknown; +} + +/** + * File fetch result + */ +export interface FetchResult { + content: string; + path: string; +} + +/** + * Spawn command result + */ +export interface SpawnResult { + stdout: string; + stderr: string; + code: number | null; +} diff --git a/src/utils.js b/src/utils.js deleted file mode 100644 index 8517ad3..0000000 --- a/src/utils.js +++ /dev/null @@ -1,1320 +0,0 @@ -const fs = require("fs"); -const os = require("os"); -const crypto = require("crypto"); -const YAML = require("yaml"); -const axios = require("axios"); -const path = require("path"); -const { spawn } = require("child_process"); -const { - validate, - resolvePaths, - transformToSchemaKey, - readFile, -} = require("doc-detective-common"); -const { loadHerettoContent } = require("./heretto"); - -exports.qualifyFiles = qualifyFiles; -exports.parseTests = parseTests; -exports.outputResults = outputResults; -exports.loadEnvs = loadEnvs; -exports.log = log; -exports.timestamp = timestamp; -exports.replaceEnvs = replaceEnvs; -exports.spawnCommand = spawnCommand; -exports.inContainer = inContainer; -exports.cleanTemp = cleanTemp; -exports.calculatePercentageDifference = calculatePercentageDifference; -exports.fetchFile = fetchFile; -exports.isRelativeUrl = isRelativeUrl; -exports.findHerettoIntegration = findHerettoIntegration; - -/** - * Finds which Heretto integration a file belongs to based on its path. - * @param {Object} config - Doc Detective config with _herettoPathMapping - * @param {string} filePath - Path to check - * @returns {string|null} Heretto integration name or null if not from Heretto - */ -function findHerettoIntegration(config, filePath) { - if (!config._herettoPathMapping) return null; - - const normalizedFilePath = path.resolve(filePath); - - for (const [outputPath, integrationName] of Object.entries(config._herettoPathMapping)) { - const normalizedOutputPath = path.resolve(outputPath); - if (normalizedFilePath.startsWith(normalizedOutputPath)) { - return integrationName; - } - } - - return null; -} - -function isRelativeUrl(url) { - try { - new URL(url); - // If no error is thrown, it's a complete URL - return false; - } catch (error) { - // If URL constructor throws an error, it's a relative URL - return true; - } -} - -/** - * Generates a unique specId from a file path that is safe for storage/URLs. - * Uses relative path from cwd when possible to provide uniqueness while - * avoiding collisions from files with the same basename in different directories. - * @param {string} filePath - Absolute or relative file path - * @returns {string} A safe specId derived from the file path - */ -function generateSpecId(filePath) { - const absolutePath = path.resolve(filePath); - const cwd = process.cwd(); - - let relativePath; - if (absolutePath.startsWith(cwd)) { - relativePath = path.relative(cwd, absolutePath); - } else { - relativePath = absolutePath; - } - - const normalizedPath = relativePath - .split(path.sep) - .join("/") - .replace(/^\.\//, "") - .replace(/[^a-zA-Z0-9._\-\/]/g, "_"); - - return normalizedPath; -} - -// Parse XML-style attributes to an object -// Example: 'wait=500' becomes { wait: 500 } -// Example: 'testId="myTestId" detectSteps=false' becomes { testId: "myTestId", detectSteps: false } -// Example: 'httpRequest.url="https://example.com" httpRequest.method="GET"' becomes { httpRequest: { url: "https://example.com", method: "GET" } } -function parseXmlAttributes({ stringifiedObject }) { - if (typeof stringifiedObject !== "string") { - return null; - } - - // Trim the string - const str = stringifiedObject.trim(); - - // Check if it looks like JSON or YAML - if so, return null to let JSON/YAML parsers handle it - // JSON starts with { or [ - if (str.startsWith("{") || str.startsWith("[")) { - return null; - } - - // Check if it looks like YAML (key: value pattern outside of quotes) - // This regex checks for word followed by colon and space/newline, not inside quotes - const yamlPattern = /^\w+:\s/; - if (yamlPattern.test(str)) { - return null; - } - // Check if it looks like a YAML array (starts with '-') - if (str.startsWith("-")) { - return null; - } - - // Parse XML-style attributes - const result = {}; - // Regex to match key=value or key="value" or key='value' - // Updated to handle dot notation in keys (e.g., httpRequest.url) - const attrRegex = /([\w.]+)=(?:"([^"]*)"|'([^']*)'|(\S+))/g; - let match; - let hasMatches = false; - - while ((match = attrRegex.exec(str)) !== null) { - hasMatches = true; - const keyPath = match[1]; - // Value can be in group 2 (double quotes), 3 (single quotes), or 4 (unquoted) - let value = - match[2] !== undefined - ? match[2] - : match[3] !== undefined - ? match[3] - : match[4]; - - // Try to parse as boolean - if (value === "true") { - value = true; - } else if (value === "false") { - value = false; - } else if (!isNaN(value) && value !== "") { - // Try to parse as number - value = Number(value); - } - // else keep as string - - // Handle dot notation for nested objects - if (keyPath.includes(".")) { - const keys = keyPath.split("."); - let current = result; - - // Navigate/create the nested structure - for (let i = 0; i < keys.length - 1; i++) { - const key = keys[i]; - if (!current[key] || typeof current[key] !== "object") { - current[key] = {}; - } - current = current[key]; - } - - // Set the final value - current[keys[keys.length - 1]] = value; - } else { - // Simple key without dot notation - result[keyPath] = value; - } - } - - return hasMatches ? result : null; -} - -// Parse a JSON or YAML object -function parseObject({ stringifiedObject }) { - if (typeof stringifiedObject === "string") { - // First, try to parse as XML attributes - const xmlAttrs = parseXmlAttributes({ stringifiedObject }); - if (xmlAttrs !== null) { - return xmlAttrs; - } - - // Try to parse as JSON first (handles valid JSON including those with escaped quotes in string values) - try { - const json = JSON.parse(stringifiedObject); - return json; - } catch (jsonError) { - // JSON parsing failed - check if this looks like escaped/double-encoded JSON - const trimmedString = stringifiedObject.trim(); - const looksLikeEscapedJson = - (trimmedString.startsWith("{") || trimmedString.startsWith("[")) && - trimmedString.includes('\\"'); - - if (looksLikeEscapedJson) { - let stringToParse; - try { - // Attempt to parse as double-encoded JSON - stringToParse = JSON.parse('"' + stringifiedObject + '"'); - } catch { - // Fallback to simple quote replacement for basic cases - stringToParse = stringifiedObject.replace(/\\"/g, '"'); - } - try { - const json = JSON.parse(stringToParse); - return json; - } catch { - // Fall through to YAML parsing - } - } - - // Try YAML as final fallback - try { - const yaml = YAML.parse(stringifiedObject); - return yaml; - } catch (yamlError) { - throw new Error("Invalid JSON or YAML format"); - } - } - } - return stringifiedObject; -} - -// Delete all contents of doc-detective temp directory -function cleanTemp() { - const tempDir = `${os.tmpdir}/doc-detective`; - if (fs.existsSync(tempDir)) { - fs.readdirSync(tempDir).forEach((file) => { - const curPath = `${tempDir}/${file}`; - fs.unlinkSync(curPath); - }); - } -} - -// Fetch a file from a URL and save to a temp directory -// If the file is not JSON, return the contents as a string -// If the file is not found, return an error -async function fetchFile(fileURL) { - try { - const response = await axios.get(fileURL); - if (typeof response.data === "object") { - response.data = JSON.stringify(response.data, null, 2); - } else { - response.data = response.data.toString(); - } - const fileName = fileURL.split("/").pop(); - const hash = crypto.createHash("md5").update(response.data).digest("hex"); - const filePath = `${os.tmpdir}/doc-detective/${hash}_${fileName}`; - // If doc-detective temp directory doesn't exist, create it - if (!fs.existsSync(`${os.tmpdir}/doc-detective`)) { - fs.mkdirSync(`${os.tmpdir}/doc-detective`); - } - // If file doesn't exist, write it - if (!fs.existsSync(filePath)) { - fs.writeFileSync(filePath, response.data); - } - return { result: "success", path: filePath }; - } catch (error) { - return { result: "error", message: error }; - } -} - -// Inspect and qualify files as valid inputs -async function qualifyFiles({ config }) { - let dirs = []; - let files = []; - let sequence = []; - - // Determine source sequence - const setup = config.beforeAny; - if (setup) sequence = sequence.concat(setup); - const input = config.input; - sequence = sequence.concat(input); - const cleanup = config.afterAll; - if (cleanup) sequence = sequence.concat(cleanup); - - if (sequence.length === 0) { - log(config, "warning", "No input sources specified."); - return []; - } - - const ignoredDitaMaps = []; - - // Track Heretto output paths for sourceIntegration metadata - if (!config._herettoPathMapping) { - config._herettoPathMapping = {}; - } - - for (let source of sequence) { - log(config, "debug", `source: ${source}`); - - // Check if source is a heretto: reference - if (source.startsWith("heretto:")) { - const herettoName = source.substring(8); // Remove "heretto:" prefix - const herettoConfig = config?.integrations?.heretto?.find( - (h) => h.name === herettoName - ); - - if (!herettoConfig) { - log( - config, - "warning", - `Heretto integration "${herettoName}" not found in config. Skipping.` - ); - continue; - } - - // Load Heretto content if not already loaded - if (!herettoConfig.outputPath) { - try { - const outputPath = await loadHerettoContent(herettoConfig, log, config); - if (outputPath) { - herettoConfig.outputPath = outputPath; - // Store mapping from output path to Heretto integration name - config._herettoPathMapping[outputPath] = herettoName; - log(config, "debug", `Adding Heretto output path: ${outputPath}`); - // Insert the output path into the sequence for processing - const currentIndex = sequence.indexOf(source); - sequence.splice(currentIndex + 1, 0, outputPath); - ignoredDitaMaps.push(outputPath); // DITA maps are already processed in Heretto - } else { - log( - config, - "warning", - `Failed to load Heretto content for "${herettoName}". Skipping.` - ); - } - } catch (error) { - log( - config, - "warning", - `Failed to load Heretto content from "${herettoName}": ${error.message}` - ); - } - } else { - // Already loaded, add to sequence if not already there - if (!sequence.includes(herettoConfig.outputPath)) { - const currentIndex = sequence.indexOf(source); - sequence.splice(currentIndex + 1, 0, herettoConfig.outputPath); - } - } - continue; - } - - // Check if source is a URL - let isURL = source.startsWith("http://") || source.startsWith("https://"); - // If URL, fetch file and place in temp directory - if (isURL) { - const fetch = await fetchFile(source); - if (fetch.result === "error") { - log(config, "warning", fetch.message); - continue; - } - source = fetch.path; - } - // Check if source is a file or directory - let isFile = fs.statSync(source).isFile(); - let isDir = fs.statSync(source).isDirectory(); - - // If ditamap, process with `dita` to build files, then add output directory to dirs array - if ( - isFile && - path.extname(source) === ".ditamap" && - !ignoredDitaMaps.some((ignored) => source.includes(ignored)) && - config.processDitaMaps - ) { - const ditaOutput = await processDitaMap({ config, source }); - if (ditaOutput) { - // Add output directory to to sequence right after the ditamap file - const currentIndex = sequence.indexOf(source); - sequence.splice(currentIndex + 1, 0, ditaOutput); - ignoredDitaMaps.push(ditaOutput); // DITA maps are already processed locally - } - continue; - } - - // Parse input - if (isFile && (await isValidSourceFile({ config, files, source }))) { - // Passes all checks - files.push(path.resolve(source)); - } else if (isDir) { - // Load files from directory - dirs = []; - dirs[0] = source; - for (const dir of dirs) { - const objects = fs.readdirSync(dir); - for (const object of objects) { - const content = path.resolve(dir + "/" + object); - // Exclude node_modules for local installs - if (content.includes("node_modules")) continue; - // Check if file or directory - const isFile = fs.statSync(content).isFile(); - const isDir = fs.statSync(content).isDirectory(); - // Add to files or dirs array - if ( - isFile && - (await isValidSourceFile({ config, files, source: content })) - ) { - files.push(path.resolve(content)); - } else if (isDir && config.recursive) { - // recursive set to true - dirs.push(content); - } - } - } - } - } - return files; -} - -// Process dita map into a set of files -async function processDitaMap({ config, source }) { - // Get MD5 hash of source path to create unique temp directory - const hash = crypto.createHash("md5").update(source).digest("hex"); - const outputDir = `${os.tmpdir}/doc-detective/ditamap_${hash}`; - // If doc-detective temp directory doesn't exist, create it - if (!fs.existsSync(`${os.tmpdir}/doc-detective`)) { - log(config, "debug", `Creating temp directory: ${os.tmpdir}/doc-detective`); - fs.mkdirSync(`${os.tmpdir}/doc-detective`); - } - const ditaVersion = await spawnCommand("dita", ["--version"]); - if (ditaVersion.exitCode !== 0) { - log( - config, - "error", - `'dita' command not found. Make sure it's installed. Error: ${ditaVersion.stderr}` - ); - return null; - } - - log(config, "info", `Processing DITA map: ${source}`); - const ditaOutputDir = await spawnCommand("dita", [ - "-i", - source, - "-f", - "dita", - "-o", - outputDir, - ]); - if (ditaOutputDir.exitCode !== 0) { - log(config, "error", `Failed to process DITA map: ${ditaOutputDir.stderr}`); - return null; - } - return outputDir; -} - -// Check if a source file is valid based on fileType definitions -async function isValidSourceFile({ config, files, source }) { - log(config, "debug", `validation: ${source}`); - // Determine allowed extensions - let allowedExtensions = ["json", "yaml", "yml"]; - config.fileTypes.forEach((fileType) => { - allowedExtensions = allowedExtensions.concat(fileType.extensions); - }); - // Is present in files array already - if (files.indexOf(source) >= 0) return false; - // Is JSON or YAML but isn't a valid spec-formatted JSON object - if ( - path.extname(source) === ".json" || - path.extname(source) === ".yaml" || - path.extname(source) === ".yml" - ) { - const content = await readFile({ fileURLOrPath: source }); - if (typeof content !== "object") { - log( - config, - "debug", - `${source} isn't a valid test specification. Skipping.` - ); - return false; - } - const validation = validate({ - schemaKey: "spec_v3", - object: content, - addDefaults: false, - }); - if (!validation.valid) { - log(config, "warning", validation); - log( - config, - "warning", - `${source} isn't a valid test specification. Skipping.` - ); - return false; - } - // TODO: Move `before` and `after checking out of is and into a broader test validation function - // If any objects in `tests` array have `before` or `after` property, make sure those files exist - for (const test of content.tests) { - if (test.before) { - let beforePath = ""; - if (config.relativePathBase === "file") { - beforePath = path.resolve(path.dirname(source), test.before); - } else { - beforePath = path.resolve(test.before); - } - if (!fs.existsSync(beforePath)) { - log( - config, - "debug", - `${beforePath} is specified to run before a test but isn't a valid file. Skipping ${source}.` - ); - return false; - } - } - if (test.after) { - let afterPath = ""; - if (config.relativePathBase === "file") { - afterPath = path.resolve(path.dirname(source), test.after); - } else { - afterPath = path.resolve(test.after); - } - if (!fs.existsSync(afterPath)) { - log( - config, - "debug", - `${afterPath} is specified to run after a test but isn't a valid file. Skipping ${source}.` - ); - return false; - } - } - } - } - // If extension isn't in list of allowed extensions - const extension = path.extname(source).substring(1); - if (!allowedExtensions.includes(extension)) { - log( - config, - "debug", - `${source} extension isn't specified in a \`config.fileTypes\` object. Skipping.` - ); - return false; - } - - return true; -} - -/** - * Parses raw test content into an array of structured test objects. - * - * Processes input content using inline statement and markup regex patterns defined by {@link fileType}, extracting test and step definitions. Supports detection of test boundaries, ignored sections, and step definitions, including batch markup matches. Converts and validates extracted objects against the test and step schemas, handling both v2 and v3 formats. Returns an array of validated test objects with their associated steps. - * - * @param {Object} options - Options for parsing. - * @param {Object} options.config - Test configuration object. - * @param {string|Object} options.content - Raw file content as a string or object. - * @param {string} options.filePath - Path to the file being parsed. - * @param {Object} options.fileType - File type definition containing parsing rules. - * @returns {Array} Array of parsed and validated test objects. - */ -async function parseContent({ config, content, filePath, fileType }) { - const statements = []; - const statementTypes = [ - "testStart", - "testEnd", - "ignoreStart", - "ignoreEnd", - "step", - ]; - - function findTest({ tests, testId }) { - let test = tests.find((test) => test.testId === testId); - if (!test) { - test = { testId, steps: [] }; - tests.push(test); - } - return test; - } - - function replaceNumericVariables(stringOrObjectSource, values) { - let stringOrObject = JSON.parse(JSON.stringify(stringOrObjectSource)); - if ( - typeof stringOrObject !== "string" && - typeof stringOrObject !== "object" - ) { - throw new Error("Invalid stringOrObject type"); - } - if (typeof values !== "object") { - throw new Error("Invalid values type"); - } - - if (typeof stringOrObject === "string") { - // Replace $n with values[n] - // Find all $n variables in the string - const matches = stringOrObject.match(/\$[0-9]+/g); - if (matches) { - // Check if all variables exist in values - const allExist = matches.every((variable) => { - const index = variable.substring(1); - return ( - Object.hasOwn(values, index) && typeof values[index] !== "undefined" - ); - }); - if (!allExist) { - return null; - } else { - // Perform substitution - stringOrObject = stringOrObject.replace(/\$[0-9]+/g, (variable) => { - const index = variable.substring(1); - return values[index]; - }); - } - } - } - - Object.keys(stringOrObject).forEach((key) => { - if (typeof stringOrObject[key] === "object") { - // Iterate through object and recursively resolve variables - stringOrObject[key] = replaceNumericVariables( - stringOrObject[key], - values - ); - } else if (typeof stringOrObject[key] === "string") { - // Replace $n with values[n] - const matches = stringOrObject[key].match(/\$[0-9]+/g); - if (matches) { - // Check if all variables exist in values - const allExist = matches.every((variable) => { - const index = variable.substring(1); - return ( - Object.hasOwn(values, index) && - typeof values[index] !== "undefined" - ); - }); - if (!allExist) { - delete stringOrObject[key]; - } else { - // Perform substitution - stringOrObject[key] = stringOrObject[key].replace( - /\$[0-9]+/g, - (variable) => { - const index = variable.substring(1); - return values[index]; - } - ); - } - } - } - return key; - }); - return stringOrObject; - } - - // Test for each statement type - statementTypes.forEach((statementType) => { - // If inline statements aren't defined, skip - if ( - typeof fileType.inlineStatements === "undefined" || - typeof fileType.inlineStatements[statementType] === "undefined" - ) - return; - // Check if fileType has inline statements - fileType.inlineStatements[statementType].forEach((statementRegex) => { - const regex = new RegExp(statementRegex, "g"); - const matches = [...content.matchAll(regex)]; - matches.forEach((match) => { - // Add 'type' property to each match - match.type = statementType; - // Add 'sortIndex' property to each match - match.sortIndex = match[1] - ? match.index + match[1].length - : match.index; - }); - statements.push(...matches); - }); - }); - - if (config.detectSteps && fileType.markup) { - fileType.markup.forEach((markup) => { - markup.regex.forEach((pattern) => { - const regex = new RegExp(pattern, "g"); - const matches = [...content.matchAll(regex)]; - if (matches.length > 0 && markup.batchMatches) { - // Combine all matches into a single match - const combinedMatch = { - 1: matches.map((match) => match[1] || match[0]).join(os.EOL), - type: "detectedStep", - markup: markup, - sortIndex: Math.min(...matches.map((match) => match.index)), - }; - statements.push(combinedMatch); - } else if (matches.length > 0) { - matches.forEach((match) => { - // Add 'type' property to each match - match.type = "detectedStep"; - match.markup = markup; - // Add 'sortIndex' property to each match - match.sortIndex = match[1] - ? match.index + match[1].length - : match.index; - }); - statements.push(...matches); - } - }); - }); - } - - // Sort statements by index - statements.sort((a, b) => a.sortIndex - b.sortIndex); - - // TODO: Split above into a separate function - - // Process statements into tests and steps - let tests = []; - let testId = `${crypto.randomUUID()}`; - let ignore = false; - let currentIndex = 0; - - statements.forEach((statement) => { - let test = ""; - let statementContent = ""; - let stepsCleanup = false; - currentIndex = statement.sortIndex; - switch (statement.type) { - case "testStart": - // Test start statement - statementContent = statement[1] || statement[0]; - test = parseObject({ stringifiedObject: statementContent }); - - // If v2 schema, convert to v3 - if (test.id || test.file || test.setup || test.cleanup) { - // Add temporary step to pass validation - if (!test.steps) { - test.steps = [{ action: "goTo", url: "https://doc-detective.com" }]; - stepsCleanup = true; - } - test = transformToSchemaKey({ - object: test, - currentSchema: "test_v2", - targetSchema: "test_v3", - }); - // Remove temporary step - if (stepsCleanup) { - test.steps = []; - stepsCleanup = false; - } - } - - if (test.testId) { - // If the testId already exists, update the variable - testId = `${test.testId}`; - } else { - // If the testId doesn't exist, set it - test.testId = `${testId}`; - } - // Normalize detectSteps field - if (test.detectSteps === "false") { - test.detectSteps = false; - } else if (test.detectSteps === "true") { - test.detectSteps = true; - } - // If the test doesn't have steps, add an empty array - if (!test.steps) { - test.steps = []; - } - tests.push(test); - break; - case "testEnd": - // Test end statement - testId = `${crypto.randomUUID()}`; - ignore = false; - break; - case "ignoreStart": - // Ignore start statement - ignore = true; - break; - case "ignoreEnd": - // Ignore end statement - ignore = false; - break; - case "detectedStep": - // Transform detected content into a step - test = findTest({ tests, testId }); - if (typeof test.detectSteps !== "undefined" && !test.detectSteps) { - break; - } - if (statement?.markup?.actions) { - statement.markup.actions.forEach((action) => { - let step = {}; - if (typeof action === "string") { - if (action === "runCode") return; - // If action is string, build step using simple syntax - step[action] = statement[1] || statement[0]; - if ( - config.origin && - (action === "goTo" || action === "checkLink") - ) { - step[action].origin = config.origin; - } - // Attach sourceIntegration metadata for screenshot steps from Heretto - if (action === "screenshot" && config._herettoPathMapping) { - const herettoIntegration = findHerettoIntegration(config, filePath); - if (herettoIntegration) { - // Convert simple screenshot value to object with sourceIntegration - const screenshotPath = step[action]; - step[action] = { - path: screenshotPath, - sourceIntegration: { - type: "heretto", - integrationName: herettoIntegration, - filePath: screenshotPath, - contentPath: filePath, - }, - }; - } - } - } else { - // Substitute variables $n with match[n] - // TODO: Make key substitution recursive - step = replaceNumericVariables(action, statement); - - // Attach sourceIntegration metadata for screenshot steps from Heretto - if (step.screenshot && config._herettoPathMapping) { - const herettoIntegration = findHerettoIntegration(config, filePath); - if (herettoIntegration) { - // Ensure screenshot is an object - if (typeof step.screenshot === "string") { - step.screenshot = { path: step.screenshot }; - } else if (typeof step.screenshot === "boolean") { - step.screenshot = {}; - } - // Attach sourceIntegration - step.screenshot.sourceIntegration = { - type: "heretto", - integrationName: herettoIntegration, - filePath: step.screenshot.path || "", - contentPath: filePath, - }; - } - } - } - - // Normalize step field formats - if (step.httpRequest) { - // Parse headers from line-separated string values - // Example string: "Content-Type: application/json\nAuthorization: Bearer token" - if (typeof step.httpRequest.request.headers === "string") { - try { - const headers = {}; - step.httpRequest.request.headers - .split("\n") - .forEach((header) => { - const colonIndex = header.indexOf(":"); - if (colonIndex === -1) return; - const key = header.substring(0, colonIndex).trim(); - const value = header.substring(colonIndex + 1).trim(); - if (key && value) { - headers[key] = value; - } - }); - step.httpRequest.request.headers = headers; - } catch (error) {} - } - // Parse JSON-as-string body - if ( - typeof step.httpRequest.request.body === "string" && - (step.httpRequest.request.body.trim().startsWith("{") || - step.httpRequest.request.body.trim().startsWith("[")) - ) { - try { - step.httpRequest.request.body = JSON.parse( - step.httpRequest.request.body - ); - } catch (error) {} - } - } - - // Make sure is valid v3 step schema - const valid = validate({ - schemaKey: "step_v3", - object: step, - addDefaults: false, - }); - if (!valid) { - log( - config, - "warning", - `Step ${JSON.stringify(step)} isn't a valid step. Skipping.` - ); - return false; - } - step = valid.object; - test.steps.push(step); - }); - } - break; - case "step": - // Step statement - test = findTest({ tests, testId }); - statementContent = statement[1] || statement[0]; - let step = parseObject({ stringifiedObject: statementContent }); - // Make sure is valid v3 step schema - const validation = validate({ - schemaKey: "step_v3", - object: step, - addDefaults: false, - }); - if (!validation.valid) { - log( - config, - "warning", - `Step ${JSON.stringify(step)} isn't a valid step. Skipping.` - ); - return false; - } - step = validation.object; - test.steps.push(step); - break; - default: - break; - } - }); - - tests.forEach((test) => { - // Validate test object - const validation = validate({ - schemaKey: "test_v3", - object: test, - addDefaults: false, - }); - if (!validation.valid) { - log( - config, - "warning", - `Couldn't convert some steps in ${filePath} to a valid test. Skipping. Errors: ${validation.errors}` - ); - return false; - } - test = validation.object; - }); - - return tests; -} - -// Parse files for tests -async function parseTests({ config, files }) { - let specs = []; - - // Loop through files - for (const file of files) { - log(config, "debug", `file: ${file}`); - const extension = path.extname(file).slice(1); - let content = ""; - content = await readFile({ fileURLOrPath: file }); - - if (typeof content === "object") { - // Resolve to catch any relative setup or cleanup paths - content = await resolvePaths({ - config: config, - object: content, - filePath: file, - }); - - for (const test of content.tests) { - // If any objects in `tests` array have `before` property, add `tests[0].steps` of before to the beginning of the object's `steps` array. - if (test.before) { - const setup = await readFile({ fileURLOrPath: test.before }); - test.steps = setup.tests[0].steps.concat(test.steps); - } - // If any objects in `tests` array have `after` property, add `tests[0].steps` of after to the end of the object's `steps` array. - if (test.after) { - const cleanup = await readFile({ fileURLOrPath: test.after }); - test.steps = test.steps.concat(cleanup.tests[0].steps); - } - } - // Validate each step - for (const test of content.tests) { - // Filter out steps that don't pass validation - test.steps.forEach((step) => { - const validation = validate({ - schemaKey: `step_v3`, - object: { ...step }, - addDefaults: false, - }); - if (!validation.valid) { - log( - config, - "warning", - `Step ${step} isn't a valid step. Skipping.` - ); - return false; - } - return true; - }); - } - const validation = validate({ - schemaKey: "spec_v3", - object: content, - addDefaults: false, - }); - if (!validation.valid) { - log(config, "warning", validation); - log( - config, - "warning", - `After applying setup and cleanup steps, ${file} isn't a valid test specification. Skipping.` - ); - return false; - } - // Make sure that object is now a valid v3 spec - content = validation.object; - // Resolve previously unapplied defaults - content = await resolvePaths({ - config: config, - object: content, - filePath: file, - }); - specs.push(content); - } else { - // Process non-object - // Generate a specId that includes more of the file path to avoid collisions - // when different files share the same basename - let id = generateSpecId(file); - let spec = { specId: id, contentPath: file, tests: [] }; - const fileType = config.fileTypes.find((fileType) => - fileType.extensions.includes(extension) - ); - - // Process executables - if (fileType.runShell) { - // Substitute all instances of $1 with the file path - let runShell = JSON.stringify(fileType.runShell); - runShell = runShell.replace(/\$1/g, file); - runShell = JSON.parse(runShell); - - const test = { - steps: [ - { - runShell, - }, - ], - }; - - // Validate test - const validation = validate({ - schemaKey: "test_v3", - object: test, - addDefaults: false, - }); - if (!validation.valid) { - log( - config, - "warning", - `Failed to convert ${file} to a runShell step: ${validation.errors}. Skipping.` - ); - continue; - } - - spec.tests.push(test); - continue; - } - - // Process content - const tests = await parseContent({ - config: config, - content: content, - fileType: fileType, - filePath: file, - }); - spec.tests.push(...tests); - - // Remove tests with no steps - spec.tests = spec.tests.filter( - (test) => test.steps && test.steps.length > 0 - ); - - // Push spec to specs, if it is valid - const validation = validate({ - schemaKey: "spec_v3", - object: spec, - addDefaults: false, - }); - if (!validation.valid) { - log( - config, - "warning", - `Tests from ${file} don't create a valid test specification. Skipping.` - ); - } else { - // Resolve paths - spec = await resolvePaths({ - config: config, - object: spec, - filePath: file, - }); - specs.push(spec); - } - } - } - return specs; -} - -async function outputResults(path, results, config) { - let data = JSON.stringify(results, null, 2); - fs.writeFile(path, data, (err) => { - if (err) throw err; - }); - log(config, "info", "RESULTS:"); - log(config, "info", results); - log(config, "info", `See results at ${path}`); - log(config, "info", "Cleaning up and finishing post-processing."); -} - -/** - * Loads environment variables from a specified .env file. - * - * @async - * @param {string} envsFile - Path to the environment variables file. - * @returns {Promise} An object containing the operation result. - * @returns {string} returns.status - "PASS" if environment variables were loaded successfully, "FAIL" otherwise. - * @returns {string} returns.description - A description of the operation result. - */ -async function loadEnvs(envsFile) { - const fileExists = fs.existsSync(envsFile); - if (fileExists) { - require("dotenv").config({ path: envsFile, override: true }); - return { status: "PASS", description: "Envs set." }; - } else { - return { status: "FAIL", description: "Invalid file." }; - } -} - -async function log(config, level, message) { - let logLevelMatch = false; - if (config.logLevel === "error" && level === "error") { - logLevelMatch = true; - } else if ( - config.logLevel === "warning" && - (level === "error" || level === "warning") - ) { - logLevelMatch = true; - } else if ( - config.logLevel === "info" && - (level === "error" || level === "warning" || level === "info") - ) { - logLevelMatch = true; - } else if ( - config.logLevel === "debug" && - (level === "error" || - level === "warning" || - level === "info" || - level === "debug") - ) { - logLevelMatch = true; - } - - if (logLevelMatch) { - if (typeof message === "string") { - let logMessage = `(${level.toUpperCase()}) ${message}`; - console.log(logMessage); - } else if (typeof message === "object") { - let logMessage = `(${level.toUpperCase()})`; - console.log(logMessage); - console.log(JSON.stringify(message, null, 2)); - } - } -} - -function replaceEnvs(stringOrObject) { - if (!stringOrObject) return stringOrObject; - if (typeof stringOrObject === "object") { - // Iterate through object and recursively resolve variables - Object.keys(stringOrObject).forEach((key) => { - // Resolve all variables in key value - stringOrObject[key] = replaceEnvs(stringOrObject[key]); - }); - } else if (typeof stringOrObject === "string") { - // Load variable from string - variableRegex = new RegExp(/\$[a-zA-Z0-9_]+/, "g"); - matches = stringOrObject.match(variableRegex); - // If no matches, return string - if (!matches) return stringOrObject; - // Iterate matches - matches.forEach((match) => { - // Check if is declared variable - value = process.env[match.substring(1)]; - if (value) { - // If match is the entire string instead of just being a substring, try to convert value to object - try { - if ( - match.length === stringOrObject.length && - typeof JSON.parse(stringOrObject) === "object" - ) { - value = JSON.parse(value); - } - } catch {} - // Attempt to load additional variables in value - value = replaceEnvs(value); - // Replace match with variable value - if (typeof value === "string") { - // Replace match with value. Supports whole- and sub-string matches. - stringOrObject = stringOrObject.replace(match, value); - } else if (typeof value === "object") { - // If value is an object, replace match with object - stringOrObject = value; - } - } - }); - } - return stringOrObject; -} - -function timestamp() { - let timestamp = new Date(); - return `${timestamp.getFullYear()}${("0" + (timestamp.getMonth() + 1)).slice( - -2 - )}${("0" + timestamp.getDate()).slice(-2)}-${( - "0" + timestamp.getHours() - ).slice(-2)}${("0" + timestamp.getMinutes()).slice(-2)}${( - "0" + timestamp.getSeconds() - ).slice(-2)}`; -} - -// Perform a native command in the current working directory. -/** - * Executes a command in a child process using the `spawn` function from the `child_process` module. - * @param {string} cmd - The command to execute. - * @param {string[]} args - The arguments to pass to the command. - * @param {object} options - The options for the command execution. - * @param {boolean} options.workingDirectory - Directory in which to execute the command. - * @param {boolean} options.debug - Whether to enable debug mode. - * @returns {Promise} A promise that resolves to an object containing the stdout, stderr, and exit code of the command. - */ -async function spawnCommand(cmd, args = [], options) { - // Set default options - if (!options) options = {}; - - // Set shell (bash/cmd) based on OS - let shell = "bash"; - let command = ["-c"]; - if (process.platform === "win32") { - shell = "cmd"; - command = ["/c"]; - } - - // Combine command and arguments - let fullCommand = [cmd, ...args].join(" "); - command.push(fullCommand); - - // Set spawnOptions based on OS - let spawnOptions = {}; - let cleanupNodeModules = false; - if (process.platform === "win32") { - spawnOptions.shell = true; - spawnOptions.windowsHide = true; - } - if (options.cwd) { - spawnOptions.cwd = options.cwd; - } - - const runCommand = spawn(shell, command, spawnOptions); - runCommand.on("error", (error) => {}); - - // Capture stdout - let stdout = ""; - for await (const chunk of runCommand.stdout) { - stdout += chunk; - if (options.debug) console.log(chunk.toString()); - } - // Remove trailing newline - stdout = stdout.replace(/\n$/, ""); - - // Capture stderr - let stderr = ""; - for await (const chunk of runCommand.stderr) { - stderr += chunk; - if (options.debug) console.log(chunk.toString()); - } - // Remove trailing newline - stderr = stderr.replace(/\n$/, ""); - - // Capture exit code - const exitCode = await new Promise((resolve, reject) => { - runCommand.on("close", resolve); - }); - - return { stdout, stderr, exitCode }; -} - -async function inContainer() { - if (process.env.IN_CONTAINER === "true") return true; - if (process.platform === "linux") { - result = await spawnCommand( - `grep -sq "docker\|lxc\|kubepods" /proc/1/cgroup` - ); - if (result.exitCode === 0) return true; - } - return false; -} - -function calculatePercentageDifference(text1, text2) { - const distance = llevenshteinDistance(text1, text2); - const maxLength = Math.max(text1.length, text2.length); - const percentageDiff = (distance / maxLength) * 100; - return percentageDiff.toFixed(2); // Returns the percentage difference as a string with two decimal places -} - -function llevenshteinDistance(s, t) { - if (!s.length) return t.length; - if (!t.length) return s.length; - - const arr = []; - - for (let i = 0; i <= t.length; i++) { - arr[i] = [i]; - } - - for (let j = 0; j <= s.length; j++) { - arr[0][j] = j; - } - - for (let i = 1; i <= t.length; i++) { - for (let j = 1; j <= s.length; j++) { - arr[i][j] = Math.min( - arr[i - 1][j] + 1, // deletion - arr[i][j - 1] + 1, // insertion - arr[i - 1][j - 1] + (s[j - 1] === t[i - 1] ? 0 : 1) // substitution - ); - } - } - - return arr[t.length][s.length]; -} diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..7ca8287 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,1274 @@ +import fs from "fs"; +import os from "os"; +import crypto from "crypto"; +import YAML from "yaml"; +import axios from "axios"; +import path from "path"; +import { spawn, ChildProcessWithoutNullStreams } from "child_process"; +import { validate, resolvePaths, transformToSchemaKey, readFile } from "doc-detective-common"; +import { loadHerettoContent } from "./heretto"; +import { setReplaceEnvs } from "./openapi"; +import { setLogFunction } from "./resolve"; +import type { + Config, + FileType, + DetectedSpec, + DetectedTest, + Step, + LogLevel, + MarkupPattern, + QualifiedFile, + SpawnResult, +} from "./types"; + +// Initialize circular dependency connections +// These will be called after module load to connect log and replaceEnvs + +/** + * Finds which Heretto integration a file belongs to based on its path. + * @param config - Doc Detective config with _herettoPathMapping + * @param filePath - Path to check + * @returns Heretto integration name or null if not from Heretto + */ +export function findHerettoIntegration(config: Config, filePath: string): string | null { + if (!config._herettoPathMapping) return null; + + const normalizedFilePath = path.resolve(filePath); + + for (const [outputPath, integrationName] of Object.entries(config._herettoPathMapping)) { + const normalizedOutputPath = path.resolve(outputPath); + if (normalizedFilePath.startsWith(normalizedOutputPath)) { + return integrationName; + } + } + + return null; +} + +/** + * Checks if a URL is relative (not absolute). + * @param url - The URL to check + * @returns True if the URL is relative + */ +export function isRelativeUrl(url: string): boolean { + try { + new URL(url); + // If no error is thrown, it's a complete URL + return false; + } catch (_error) { + // If URL constructor throws an error, it's a relative URL + return true; + } +} + +/** + * Generates a unique specId from a file path that is safe for storage/URLs. + * Uses relative path from cwd when possible to provide uniqueness while + * avoiding collisions from files with the same basename in different directories. + * @param filePath - Absolute or relative file path + * @returns A safe specId derived from the file path + */ +function generateSpecId(filePath: string): string { + const absolutePath = path.resolve(filePath); + const cwd = process.cwd(); + + let relativePath: string; + if (absolutePath.startsWith(cwd)) { + relativePath = path.relative(cwd, absolutePath); + } else { + relativePath = absolutePath; + } + + const normalizedPath = relativePath + .split(path.sep) + .join("/") + .replace(/^\.\//, "") + .replace(/[^a-zA-Z0-9._\-\/]/g, "_"); + + return normalizedPath; +} + +/** + * Parse XML-style attributes to an object + * Example: 'wait=500' becomes { wait: 500 } + * Example: 'testId="myTestId" detectSteps=false' becomes { testId: "myTestId", detectSteps: false } + */ +function parseXmlAttributes({ stringifiedObject }: { stringifiedObject: string }): Record | null { + if (typeof stringifiedObject !== "string") { + return null; + } + + // Trim the string + const str = stringifiedObject.trim(); + + // Check if it looks like JSON or YAML - if so, return null to let JSON/YAML parsers handle it + if (str.startsWith("{") || str.startsWith("[")) { + return null; + } + + // Check if it looks like YAML (key: value pattern outside of quotes) + const yamlPattern = /^\w+:\s/; + if (yamlPattern.test(str)) { + return null; + } + // Check if it looks like a YAML array (starts with '-') + if (str.startsWith("-")) { + return null; + } + + // Parse XML-style attributes + const result: Record = {}; + const attrRegex = /([\w.]+)=(?:"([^"]*)"|'([^']*)'|(\S+))/g; + let match: RegExpExecArray | null; + let hasMatches = false; + + while ((match = attrRegex.exec(str)) !== null) { + hasMatches = true; + const keyPath = match[1]; + let value: unknown = + match[2] !== undefined ? match[2] : match[3] !== undefined ? match[3] : match[4]; + + // Try to parse as boolean + if (value === "true") { + value = true; + } else if (value === "false") { + value = false; + } else if (!isNaN(value as number) && value !== "") { + // Try to parse as number + value = Number(value); + } + + // Handle dot notation for nested objects + if (keyPath.includes(".")) { + const keys = keyPath.split("."); + let current: Record = result; + + for (let i = 0; i < keys.length - 1; i++) { + const key = keys[i]; + if (!current[key] || typeof current[key] !== "object") { + current[key] = {}; + } + current = current[key] as Record; + } + + current[keys[keys.length - 1]] = value; + } else { + result[keyPath] = value; + } + } + + return hasMatches ? result : null; +} + +/** + * Parse a JSON or YAML object from a string + */ +function parseObject({ stringifiedObject }: { stringifiedObject: unknown }): unknown { + if (typeof stringifiedObject === "string") { + // First, try to parse as XML attributes + const xmlAttrs = parseXmlAttributes({ stringifiedObject }); + if (xmlAttrs !== null) { + return xmlAttrs; + } + + // Try to parse as JSON first + try { + const json = JSON.parse(stringifiedObject); + return json; + } catch (_jsonError) { + // JSON parsing failed - check if this looks like escaped/double-encoded JSON + const trimmedString = stringifiedObject.trim(); + const looksLikeEscapedJson = + (trimmedString.startsWith("{") || trimmedString.startsWith("[")) && + trimmedString.includes('\\"'); + + if (looksLikeEscapedJson) { + let stringToParse: string; + try { + stringToParse = JSON.parse('"' + stringifiedObject + '"'); + } catch { + stringToParse = stringifiedObject.replace(/\\"/g, '"'); + } + try { + const json = JSON.parse(stringToParse); + return json; + } catch { + // Fall through to YAML parsing + } + } + + // Try YAML as final fallback + try { + const yaml = YAML.parse(stringifiedObject); + return yaml; + } catch (_yamlError) { + throw new Error("Invalid JSON or YAML format"); + } + } + } + return stringifiedObject; +} + +/** + * Delete all contents of doc-detective temp directory + */ +export function cleanTemp(): void { + const tempDir = `${os.tmpdir()}/doc-detective`; + if (fs.existsSync(tempDir)) { + fs.readdirSync(tempDir).forEach((file) => { + const curPath = `${tempDir}/${file}`; + fs.unlinkSync(curPath); + }); + } +} + +interface FetchFileResult { + result: "success" | "error"; + path?: string; + message?: unknown; +} + +/** + * Fetch a file from a URL and save to a temp directory + */ +export async function fetchFile(fileURL: string): Promise { + try { + const response = await axios.get(fileURL); + let data: string; + if (typeof response.data === "object") { + data = JSON.stringify(response.data, null, 2); + } else { + data = response.data.toString(); + } + const fileName = fileURL.split("/").pop() || "file"; + const hash = crypto.createHash("md5").update(data).digest("hex"); + const filePath = `${os.tmpdir()}/doc-detective/${hash}_${fileName}`; + // If doc-detective temp directory doesn't exist, create it + if (!fs.existsSync(`${os.tmpdir()}/doc-detective`)) { + fs.mkdirSync(`${os.tmpdir()}/doc-detective`); + } + // If file doesn't exist, write it + if (!fs.existsSync(filePath)) { + fs.writeFileSync(filePath, data); + } + return { result: "success", path: filePath }; + } catch (error) { + return { result: "error", message: error }; + } +} + +/** + * Inspect and qualify files as valid inputs + */ +export async function qualifyFiles({ config }: { config: Config }): Promise { + let dirs: string[] = []; + let files: string[] = []; + let sequence: string[] = []; + + // Determine source sequence + const setup = (config as Record).beforeAny as string[] | undefined; + if (setup) sequence = sequence.concat(setup); + const input = config.input; + if (input) { + if (Array.isArray(input)) { + sequence = sequence.concat(input); + } else { + sequence.push(input); + } + } + const cleanup = (config as Record).afterAll as string[] | undefined; + if (cleanup) sequence = sequence.concat(cleanup); + + if (sequence.length === 0) { + log(config, "warning", "No input sources specified."); + return []; + } + + const ignoredDitaMaps: string[] = []; + + // Track Heretto output paths for sourceIntegration metadata + if (!config._herettoPathMapping) { + config._herettoPathMapping = {}; + } + + for (let i = 0; i < sequence.length; i++) { + let source = sequence[i]; + log(config, "debug", `source: ${source}`); + + // Check if source is a heretto: reference + if (source.startsWith("heretto:")) { + const herettoName = source.substring(8); // Remove "heretto:" prefix + const herettoConfig = config?.integrations?.heretto?.find((h) => h.name === herettoName); + + if (!herettoConfig) { + log(config, "warning", `Heretto integration "${herettoName}" not found in config. Skipping.`); + continue; + } + + // Load Heretto content if not already loaded + const herettoConfigWithOutput = herettoConfig as typeof herettoConfig & { outputPath?: string }; + if (!herettoConfigWithOutput.outputPath) { + try { + const outputPath = await loadHerettoContent(herettoConfig, log, config); + if (outputPath) { + herettoConfigWithOutput.outputPath = outputPath; + // Store mapping from output path to Heretto integration name + config._herettoPathMapping![outputPath] = herettoName; + log(config, "debug", `Adding Heretto output path: ${outputPath}`); + // Insert the output path into the sequence for processing + sequence.splice(i + 1, 0, outputPath); + ignoredDitaMaps.push(outputPath); + } else { + log(config, "warning", `Failed to load Heretto content for "${herettoName}". Skipping.`); + } + } catch (error) { + log(config, "warning", `Failed to load Heretto content from "${herettoName}": ${(error as Error).message}`); + } + } else { + // Already loaded, add to sequence if not already there + if (!sequence.includes(herettoConfigWithOutput.outputPath)) { + sequence.splice(i + 1, 0, herettoConfigWithOutput.outputPath); + } + } + continue; + } + + // Check if source is a URL + const isURL = source.startsWith("http://") || source.startsWith("https://"); + if (isURL) { + const fetch = await fetchFile(source); + if (fetch.result === "error") { + log(config, "warning", fetch.message); + continue; + } + source = fetch.path!; + } + + // Check if source is a file or directory + const stat = fs.statSync(source); + const isFile = stat.isFile(); + const isDir = stat.isDirectory(); + + // If ditamap, process with `dita` to build files, then add output directory to dirs array + const configWithProcessDitaMaps = config as Config & { processDitaMaps?: boolean }; + if ( + isFile && + path.extname(source) === ".ditamap" && + !ignoredDitaMaps.some((ignored) => source.includes(ignored)) && + configWithProcessDitaMaps.processDitaMaps + ) { + const ditaOutput = await processDitaMap({ config, source }); + if (ditaOutput) { + sequence.splice(i + 1, 0, ditaOutput); + ignoredDitaMaps.push(ditaOutput); + } + continue; + } + + // Parse input + if (isFile && (await isValidSourceFile({ config, files, source }))) { + files.push(path.resolve(source)); + } else if (isDir) { + dirs = []; + dirs[0] = source; + for (const dir of dirs) { + const objects = fs.readdirSync(dir); + for (const object of objects) { + const content = path.resolve(dir + "/" + object); + // Exclude node_modules for local installs + if (content.includes("node_modules")) continue; + // Check if file or directory + const contentStat = fs.statSync(content); + const contentIsFile = contentStat.isFile(); + const contentIsDir = contentStat.isDirectory(); + // Add to files or dirs array + if (contentIsFile && (await isValidSourceFile({ config, files, source: content }))) { + files.push(path.resolve(content)); + } else if (contentIsDir && config.recursive) { + dirs.push(content); + } + } + } + } + } + return files; +} + +/** + * Process dita map into a set of files + */ +async function processDitaMap({ config, source }: { config: Config; source: string }): Promise { + const hash = crypto.createHash("md5").update(source).digest("hex"); + const outputDir = `${os.tmpdir()}/doc-detective/ditamap_${hash}`; + // If doc-detective temp directory doesn't exist, create it + if (!fs.existsSync(`${os.tmpdir()}/doc-detective`)) { + log(config, "debug", `Creating temp directory: ${os.tmpdir()}/doc-detective`); + fs.mkdirSync(`${os.tmpdir()}/doc-detective`); + } + const ditaVersion = await spawnCommand("dita", ["--version"]); + if (ditaVersion.exitCode !== 0) { + log(config, "error", `'dita' command not found. Make sure it's installed. Error: ${ditaVersion.stderr}`); + return null; + } + + log(config, "info", `Processing DITA map: ${source}`); + const ditaOutputDir = await spawnCommand("dita", ["-i", source, "-f", "dita", "-o", outputDir]); + if (ditaOutputDir.exitCode !== 0) { + log(config, "error", `Failed to process DITA map: ${ditaOutputDir.stderr}`); + return null; + } + return outputDir; +} + +/** + * Check if a source file is valid based on fileType definitions + */ +async function isValidSourceFile({ + config, + files, + source, +}: { + config: Config; + files: string[]; + source: string; +}): Promise { + log(config, "debug", `validation: ${source}`); + // Determine allowed extensions + let allowedExtensions = ["json", "yaml", "yml"]; + const fileTypes = config.fileTypes as FileType[] | Record | undefined; + if (fileTypes) { + if (Array.isArray(fileTypes)) { + fileTypes.forEach((fileType) => { + allowedExtensions = allowedExtensions.concat(fileType.extensions); + }); + } else { + Object.values(fileTypes).forEach((fileType) => { + allowedExtensions = allowedExtensions.concat(fileType.extensions); + }); + } + } + + // Is present in files array already + if (files.indexOf(source) >= 0) return false; + + // Is JSON or YAML but isn't a valid spec-formatted JSON object + if ( + path.extname(source) === ".json" || + path.extname(source) === ".yaml" || + path.extname(source) === ".yml" + ) { + const content = await readFile({ fileURLOrPath: source }); + if (typeof content !== "object") { + log(config, "debug", `${source} isn't a valid test specification. Skipping.`); + return false; + } + const validation = validate({ + schemaKey: "spec_v3", + object: content, + addDefaults: false, + }); + if (!validation.valid) { + log(config, "warning", validation); + log(config, "warning", `${source} isn't a valid test specification. Skipping.`); + return false; + } + + // Check before and after files exist + const contentWithTests = content as { tests?: Array<{ before?: string; after?: string }> }; + const configWithRelPathBase = config as Config & { relativePathBase?: string }; + if (contentWithTests.tests) { + for (const test of contentWithTests.tests) { + if (test.before) { + let beforePath = ""; + if (configWithRelPathBase.relativePathBase === "file") { + beforePath = path.resolve(path.dirname(source), test.before); + } else { + beforePath = path.resolve(test.before); + } + if (!fs.existsSync(beforePath)) { + log( + config, + "debug", + `${beforePath} is specified to run before a test but isn't a valid file. Skipping ${source}.` + ); + return false; + } + } + if (test.after) { + let afterPath = ""; + if (configWithRelPathBase.relativePathBase === "file") { + afterPath = path.resolve(path.dirname(source), test.after); + } else { + afterPath = path.resolve(test.after); + } + if (!fs.existsSync(afterPath)) { + log( + config, + "debug", + `${afterPath} is specified to run after a test but isn't a valid file. Skipping ${source}.` + ); + return false; + } + } + } + } + } + + // If extension isn't in list of allowed extensions + const extension = path.extname(source).substring(1); + if (!allowedExtensions.includes(extension)) { + log(config, "debug", `${source} extension isn't specified in a \`config.fileTypes\` object. Skipping.`); + return false; + } + + return true; +} + +interface StatementMatch { + type: string; + sortIndex: number; + markup?: MarkupPattern; + [key: number]: string; + [key: string]: unknown; + index?: number; +} + +/** + * Parses raw test content into an array of structured test objects. + */ +async function parseContent({ + config, + content, + filePath, + fileType, +}: { + config: Config; + content: string; + filePath: string; + fileType: FileType; +}): Promise { + const statements: StatementMatch[] = []; + const statementTypes = ["testStart", "testEnd", "ignoreStart", "ignoreEnd", "step"]; + + function findTest({ tests, testId }: { tests: DetectedTest[]; testId: string }): DetectedTest { + let test = tests.find((t) => t.testId === testId); + if (!test) { + test = { testId, steps: [] }; + tests.push(test); + } + return test; + } + + function replaceNumericVariables( + stringOrObjectSource: unknown, + values: Record + ): unknown { + let stringOrObject = JSON.parse(JSON.stringify(stringOrObjectSource)); + if (typeof stringOrObject !== "string" && typeof stringOrObject !== "object") { + throw new Error("Invalid stringOrObject type"); + } + if (typeof values !== "object") { + throw new Error("Invalid values type"); + } + + if (typeof stringOrObject === "string") { + const matches = stringOrObject.match(/\$[0-9]+/g); + if (matches) { + const allExist = matches.every((variable) => { + const index = variable.substring(1); + return Object.hasOwn(values, index) && typeof values[index] !== "undefined"; + }); + if (!allExist) { + return null; + } else { + stringOrObject = stringOrObject.replace(/\$[0-9]+/g, (variable) => { + const index = variable.substring(1); + return String(values[index]); + }); + } + } + } + + if (typeof stringOrObject === "object" && stringOrObject !== null) { + Object.keys(stringOrObject).forEach((key) => { + if (typeof stringOrObject[key] === "object") { + stringOrObject[key] = replaceNumericVariables(stringOrObject[key], values); + } else if (typeof stringOrObject[key] === "string") { + const matches = stringOrObject[key].match(/\$[0-9]+/g); + if (matches) { + const allExist = matches.every((variable: string) => { + const index = variable.substring(1); + return Object.hasOwn(values, index) && typeof values[index] !== "undefined"; + }); + if (!allExist) { + delete stringOrObject[key]; + } else { + stringOrObject[key] = stringOrObject[key].replace(/\$[0-9]+/g, (variable: string) => { + const index = variable.substring(1); + return String(values[index]); + }); + } + } + } + }); + } + return stringOrObject; + } + + // Test for each statement type + statementTypes.forEach((statementType) => { + if ( + typeof fileType.inlineStatements === "undefined" || + typeof fileType.inlineStatements[statementType as keyof typeof fileType.inlineStatements] === "undefined" + ) + return; + + const patterns = fileType.inlineStatements[statementType as keyof typeof fileType.inlineStatements]; + patterns.forEach((statementRegex) => { + const regex = new RegExp(statementRegex, "g"); + const matches = [...content.matchAll(regex)]; + matches.forEach((match) => { + const statementMatch: StatementMatch = { + type: statementType, + sortIndex: match[1] ? match.index! + match[1].length : match.index!, + ...match, + }; + statements.push(statementMatch); + }); + }); + }); + + if (config.detectSteps && fileType.markup) { + fileType.markup.forEach((markup) => { + markup.regex.forEach((pattern) => { + const regex = new RegExp(pattern, "g"); + const matches = [...content.matchAll(regex)]; + const markupWithBatch = markup as MarkupPattern & { batchMatches?: boolean }; + if (matches.length > 0 && markupWithBatch.batchMatches) { + const combinedMatch: StatementMatch = { + 1: matches.map((match) => match[1] || match[0]).join(os.EOL), + type: "detectedStep", + markup: markup, + sortIndex: Math.min(...matches.map((match) => match.index!)), + }; + statements.push(combinedMatch); + } else if (matches.length > 0) { + matches.forEach((match) => { + const statementMatch: StatementMatch = { + type: "detectedStep", + markup: markup, + sortIndex: match[1] ? match.index! + match[1].length : match.index!, + ...match, + }; + statements.push(statementMatch); + }); + } + }); + }); + } + + // Sort statements by index + statements.sort((a, b) => a.sortIndex - b.sortIndex); + + // Process statements into tests and steps + const tests: DetectedTest[] = []; + let testId = `${crypto.randomUUID()}`; + let ignore = false; + + statements.forEach((statement) => { + let test: DetectedTest; + let statementContent: string; + let stepsCleanup = false; + + switch (statement.type) { + case "testStart": + statementContent = statement[1] || statement[0]; + const parsedTest = parseObject({ stringifiedObject: statementContent }); + + // Skip if parseObject returned a non-object (e.g., plain string like "ignore start") + // This can happen when regex patterns overlap (e.g., "test ignore start" matches both + // testStart and ignoreStart patterns). The original JS code silently ignored this + // case due to non-strict mode behavior. + if (typeof parsedTest !== "object" || parsedTest === null) { + break; + } + test = parsedTest as DetectedTest; + + // If v2 schema, convert to v3 + const testWithV2 = test as DetectedTest & { id?: string; file?: string; setup?: string; cleanup?: string }; + if (testWithV2.id || testWithV2.file || testWithV2.setup || testWithV2.cleanup) { + if (!test.steps) { + test.steps = [{ action: "goTo", url: "https://doc-detective.com" } as Step]; + stepsCleanup = true; + } + test = transformToSchemaKey({ + object: test, + currentSchema: "test_v2", + targetSchema: "test_v3", + }) as DetectedTest; + if (stepsCleanup) { + test.steps = []; + stepsCleanup = false; + } + } + + if (test.testId) { + testId = `${test.testId}`; + } else { + test.testId = `${testId}`; + } + + // Normalize detectSteps field + const testWithDetectSteps = test as DetectedTest & { detectSteps?: boolean | string }; + if (testWithDetectSteps.detectSteps === "false") { + testWithDetectSteps.detectSteps = false; + } else if (testWithDetectSteps.detectSteps === "true") { + testWithDetectSteps.detectSteps = true; + } + + if (!test.steps) { + test.steps = []; + } + tests.push(test); + break; + + case "testEnd": + testId = `${crypto.randomUUID()}`; + ignore = false; + break; + + case "ignoreStart": + ignore = true; + break; + + case "ignoreEnd": + ignore = false; + break; + + case "detectedStep": + test = findTest({ tests, testId }); + const testWithDetect = test as DetectedTest & { detectSteps?: boolean }; + if (typeof testWithDetect.detectSteps !== "undefined" && !testWithDetect.detectSteps) { + break; + } + const markupWithActions = statement.markup as MarkupPattern & { actions?: Array> }; + if (markupWithActions?.actions) { + markupWithActions.actions.forEach((action) => { + let step: Step = {}; + const configWithOrigin = config as Config & { origin?: string }; + if (typeof action === "string") { + if (action === "runCode") return; + step[action] = statement[1] || statement[0]; + if (configWithOrigin.origin && (action === "goTo" || action === "checkLink")) { + (step[action] as Record).origin = configWithOrigin.origin; + } + // Attach sourceIntegration metadata for screenshot steps from Heretto + if (action === "screenshot" && config._herettoPathMapping) { + const herettoIntegration = findHerettoIntegration(config, filePath); + if (herettoIntegration) { + const screenshotPath = step[action] as string; + step[action] = { + path: screenshotPath, + sourceIntegration: { + type: "heretto", + integrationName: herettoIntegration, + filePath: screenshotPath, + contentPath: filePath, + }, + }; + } + } + } else { + step = replaceNumericVariables(action, statement) as Step; + + // Attach sourceIntegration metadata for screenshot steps from Heretto + if (step.screenshot && config._herettoPathMapping) { + const herettoIntegration = findHerettoIntegration(config, filePath); + if (herettoIntegration) { + if (typeof step.screenshot === "string") { + step.screenshot = { path: step.screenshot }; + } else if (typeof step.screenshot === "boolean") { + step.screenshot = {}; + } + const screenshot = step.screenshot as Record; + screenshot.sourceIntegration = { + type: "heretto", + integrationName: herettoIntegration, + filePath: screenshot.path || "", + contentPath: filePath, + }; + } + } + } + + // Normalize step field formats + if (step.httpRequest) { + const httpRequest = step.httpRequest as Record; + const request = httpRequest.request as Record | undefined; + if (request) { + if (typeof request.headers === "string") { + try { + const headers: Record = {}; + (request.headers as string).split("\n").forEach((header) => { + const colonIndex = header.indexOf(":"); + if (colonIndex === -1) return; + const key = header.substring(0, colonIndex).trim(); + const value = header.substring(colonIndex + 1).trim(); + if (key && value) { + headers[key] = value; + } + }); + request.headers = headers; + } catch (_error) {} + } + if ( + typeof request.body === "string" && + ((request.body as string).trim().startsWith("{") || + (request.body as string).trim().startsWith("[")) + ) { + try { + request.body = JSON.parse(request.body as string); + } catch (_error) {} + } + } + } + + // Make sure is valid v3 step schema + const valid = validate({ + schemaKey: "step_v3", + object: step, + addDefaults: false, + }); + if (!valid) { + log(config, "warning", `Step ${JSON.stringify(step)} isn't a valid step. Skipping.`); + return; + } + step = valid.object as Step; + test.steps.push(step); + }); + } + break; + + case "step": + test = findTest({ tests, testId }); + statementContent = statement[1] || statement[0]; + let step = parseObject({ stringifiedObject: statementContent }) as Step; + const validation = validate({ + schemaKey: "step_v3", + object: step, + addDefaults: false, + }); + if (!validation.valid) { + log(config, "warning", `Step ${JSON.stringify(step)} isn't a valid step. Skipping.`); + return; + } + step = validation.object as Step; + test.steps.push(step); + break; + + default: + break; + } + }); + + tests.forEach((test) => { + const validation = validate({ + schemaKey: "test_v3", + object: test, + addDefaults: false, + }); + if (!validation.valid) { + log( + config, + "warning", + `Couldn't convert some steps in ${filePath} to a valid test. Skipping. Errors: ${validation.errors}` + ); + return; + } + }); + + return tests; +} + +/** + * Parse files for tests + */ +export async function parseTests({ + config, + files, +}: { + config: Config; + files: string[]; +}): Promise { + const specs: DetectedSpec[] = []; + + for (const file of files) { + log(config, "debug", `file: ${file}`); + const extension = path.extname(file).slice(1); + let content = await readFile({ fileURLOrPath: file }); + + if (typeof content === "object") { + content = await resolvePaths({ + config: config, + object: content, + filePath: file, + }); + + const contentWithTests = content as { tests?: Array<{ before?: string; after?: string; steps: Step[] }> }; + if (contentWithTests.tests) { + for (const test of contentWithTests.tests) { + if (test.before) { + const setup = await readFile({ fileURLOrPath: test.before }); + const setupWithTests = setup as { tests?: Array<{ steps: Step[] }> }; + if (setupWithTests.tests?.[0]?.steps) { + test.steps = setupWithTests.tests[0].steps.concat(test.steps); + } + } + if (test.after) { + const cleanup = await readFile({ fileURLOrPath: test.after }); + const cleanupWithTests = cleanup as { tests?: Array<{ steps: Step[] }> }; + if (cleanupWithTests.tests?.[0]?.steps) { + test.steps = test.steps.concat(cleanupWithTests.tests[0].steps); + } + } + } + + for (const test of contentWithTests.tests) { + test.steps.forEach((step) => { + const validation = validate({ + schemaKey: `step_v3`, + object: { ...step }, + addDefaults: false, + }); + if (!validation.valid) { + log(config, "warning", `Step ${step} isn't a valid step. Skipping.`); + return false; + } + return true; + }); + } + } + + const validation = validate({ + schemaKey: "spec_v3", + object: content, + addDefaults: false, + }); + if (!validation.valid) { + log(config, "warning", validation); + log( + config, + "warning", + `After applying setup and cleanup steps, ${file} isn't a valid test specification. Skipping.` + ); + continue; + } + + content = validation.object; + content = await resolvePaths({ + config: config, + object: content, + filePath: file, + }); + specs.push(content as DetectedSpec); + } else { + const id = generateSpecId(file); + let spec: DetectedSpec = { specId: id, file, tests: [] }; + + const fileTypes = config.fileTypes as FileType[] | Record | undefined; + let fileType: FileType | undefined; + if (fileTypes) { + if (Array.isArray(fileTypes)) { + fileType = fileTypes.find((ft) => ft.extensions.includes(extension)); + } else { + fileType = Object.values(fileTypes).find((ft) => ft.extensions.includes(extension)); + } + } + + if (!fileType) continue; + + // Process executables + const fileTypeWithRunShell = fileType as FileType & { runShell?: Record }; + if (fileTypeWithRunShell.runShell) { + let runShell = JSON.stringify(fileTypeWithRunShell.runShell); + runShell = runShell.replace(/\$1/g, file); + const runShellParsed = JSON.parse(runShell); + + const test: DetectedTest = { + steps: [{ runShell: runShellParsed }], + }; + + const validation = validate({ + schemaKey: "test_v3", + object: test, + addDefaults: false, + }); + if (!validation.valid) { + log(config, "warning", `Failed to convert ${file} to a runShell step: ${validation.errors}. Skipping.`); + continue; + } + + spec.tests.push(test); + continue; + } + + // Process content + const tests = await parseContent({ + config: config, + content: content as string, + fileType: fileType, + filePath: file, + }); + spec.tests.push(...tests); + + // Remove tests with no steps + spec.tests = spec.tests.filter((test) => test.steps && test.steps.length > 0); + + // Push spec to specs, if it is valid + const validation = validate({ + schemaKey: "spec_v3", + object: spec, + addDefaults: false, + }); + if (!validation.valid) { + log(config, "warning", `Tests from ${file} don't create a valid test specification. Skipping.`); + } else { + spec = await resolvePaths({ + config: config, + object: spec, + filePath: file, + }) as DetectedSpec; + specs.push(spec); + } + } + } + return specs; +} + +/** + * Output results to a file + */ +export async function outputResults(outputPath: string, results: unknown, config: Config): Promise { + const data = JSON.stringify(results, null, 2); + fs.writeFile(outputPath, data, (err) => { + if (err) throw err; + }); + log(config, "info", "RESULTS:"); + log(config, "info", results); + log(config, "info", `See results at ${outputPath}`); + log(config, "info", "Cleaning up and finishing post-processing."); +} + +interface LoadEnvsResult { + status: "PASS" | "FAIL"; + description: string; +} + +/** + * Loads environment variables from a specified .env file. + */ +export async function loadEnvs(envsFile: string): Promise { + const fileExists = fs.existsSync(envsFile); + if (fileExists) { + // eslint-disable-next-line @typescript-eslint/no-require-imports + require("dotenv").config({ path: envsFile, override: true }); + return { status: "PASS", description: "Envs set." }; + } else { + return { status: "FAIL", description: "Invalid file." }; + } +} + +/** + * Log a message based on the configured log level + */ +export function log(config: Config, level: LogLevel, message: unknown): void { + let logLevelMatch = false; + if (config.logLevel === "error" && level === "error") { + logLevelMatch = true; + } else if (config.logLevel === "warning" && (level === "error" || level === "warning")) { + logLevelMatch = true; + } else if (config.logLevel === "info" && (level === "error" || level === "warning" || level === "info")) { + logLevelMatch = true; + } else if ( + config.logLevel === "debug" && + (level === "error" || level === "warning" || level === "info" || level === "debug") + ) { + logLevelMatch = true; + } + + if (logLevelMatch) { + if (typeof message === "string") { + const logMessage = `(${level.toUpperCase()}) ${message}`; + console.log(logMessage); + } else if (typeof message === "object") { + const logMessage = `(${level.toUpperCase()})`; + console.log(logMessage); + console.log(JSON.stringify(message, null, 2)); + } + } +} + +/** + * Replace environment variables in a string or object + */ +export function replaceEnvs(stringOrObject: unknown): unknown { + if (!stringOrObject) return stringOrObject; + if (typeof stringOrObject === "object" && stringOrObject !== null) { + const obj = stringOrObject as Record; + Object.keys(obj).forEach((key) => { + obj[key] = replaceEnvs(obj[key]); + }); + } else if (typeof stringOrObject === "string") { + const variableRegex = new RegExp(/\$[a-zA-Z0-9_]+/, "g"); + const matches = stringOrObject.match(variableRegex); + if (!matches) return stringOrObject; + + let result: unknown = stringOrObject; + matches.forEach((match) => { + const value = process.env[match.substring(1)]; + if (value) { + let parsedValue: unknown = value; + try { + if (match.length === (result as string).length && typeof JSON.parse(result as string) === "object") { + parsedValue = JSON.parse(value); + } + } catch {} + parsedValue = replaceEnvs(parsedValue); + if (typeof parsedValue === "string") { + result = (result as string).replace(match, parsedValue); + } else if (typeof parsedValue === "object") { + result = parsedValue; + } + } + }); + return result; + } + return stringOrObject; +} + +/** + * Generate a timestamp string + */ +export function timestamp(): string { + const ts = new Date(); + return `${ts.getFullYear()}${("0" + (ts.getMonth() + 1)).slice(-2)}${("0" + ts.getDate()).slice(-2)}-${( + "0" + ts.getHours() + ).slice(-2)}${("0" + ts.getMinutes()).slice(-2)}${("0" + ts.getSeconds()).slice(-2)}`; +} + +interface SpawnOptions { + cwd?: string; + debug?: boolean; +} + +/** + * Executes a command in a child process using spawn + */ +export async function spawnCommand( + cmd: string, + args: string[] = [], + options?: SpawnOptions +): Promise { + if (!options) options = {}; + + // Set shell (bash/cmd) based on OS + let shell = "bash"; + let command: string[] = ["-c"]; + if (process.platform === "win32") { + shell = "cmd"; + command = ["/c"]; + } + + // Combine command and arguments + const fullCommand = [cmd, ...args].join(" "); + command.push(fullCommand); + + // Set spawnOptions based on OS + const spawnOptions: { shell?: boolean; windowsHide?: boolean; cwd?: string } = {}; + if (process.platform === "win32") { + spawnOptions.shell = true; + spawnOptions.windowsHide = true; + } + if (options.cwd) { + spawnOptions.cwd = options.cwd; + } + + const runCommand: ChildProcessWithoutNullStreams = spawn(shell, command, spawnOptions); + runCommand.on("error", (_error) => {}); + + // Capture stdout + let stdout = ""; + for await (const chunk of runCommand.stdout) { + stdout += chunk; + if (options.debug) console.log(chunk.toString()); + } + stdout = stdout.replace(/\n$/, ""); + + // Capture stderr + let stderr = ""; + for await (const chunk of runCommand.stderr) { + stderr += chunk; + if (options.debug) console.log(chunk.toString()); + } + stderr = stderr.replace(/\n$/, ""); + + // Capture exit code + const exitCode = await new Promise((resolve) => { + runCommand.on("close", resolve); + }); + + return { stdout, stderr, code: exitCode, exitCode }; +} + +/** + * Check if running inside a container + */ +export async function inContainer(): Promise { + if (process.env.IN_CONTAINER === "true") return true; + if (process.platform === "linux") { + const result = await spawnCommand(`grep -sq "docker\\|lxc\\|kubepods" /proc/1/cgroup`); + if (result.exitCode === 0) return true; + } + return false; +} + +/** + * Calculate percentage difference between two strings using Levenshtein distance + */ +export function calculatePercentageDifference(text1: string, text2: string): string { + const distance = levenshteinDistance(text1, text2); + const maxLength = Math.max(text1.length, text2.length); + const percentageDiff = (distance / maxLength) * 100; + return percentageDiff.toFixed(2); +} + +function levenshteinDistance(s: string, t: string): number { + if (!s.length) return t.length; + if (!t.length) return s.length; + + const arr: number[][] = []; + + for (let i = 0; i <= t.length; i++) { + arr[i] = [i]; + } + + for (let j = 0; j <= s.length; j++) { + arr[0][j] = j; + } + + for (let i = 1; i <= t.length; i++) { + for (let j = 1; j <= s.length; j++) { + arr[i][j] = Math.min( + arr[i - 1][j] + 1, + arr[i][j - 1] + 1, + arr[i - 1][j - 1] + (s[j - 1] === t[i - 1] ? 0 : 1) + ); + } + } + + return arr[t.length][s.length]; +} + +// Initialize circular dependencies after exports are defined +setReplaceEnvs(replaceEnvs); +setLogFunction(log); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..fbe73cc --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "lib": ["ES2022"], + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "src/**/*.test.js"] +}