From e89206d57ccc5cea8b78b2f75fc2f1f30833a791 Mon Sep 17 00:00:00 2001 From: kuntal1461 Date: Sun, 30 Aug 2026 02:47:15 +0530 Subject: [PATCH] feat: add apify actors doctor for local Actor diagnostics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds apify actors doctor — an offline, read-only pre-flight command that checks the local Actor project before deployment. Reuses canonical Apify validators from @apify/json_schemas and @apify/input_schema. Checks: actor.json presence, JSON parsing, canonical Actor schema, Actor name constraints, and all referenced schema files (input, output, dataset, KVS), including both singular and plural/alias forms. Also improves shared schema resolution in src/lib/input_schema.ts and src/commands/validate-schema.ts to recognise inputSchema/outputSchema aliases and storages.datasets plural form consistently across commands. Closes #1366 --- docs/reference.md | 20 +- scripts/generate-cli-docs.ts | 1 + src/commands/actors/_index.ts | 2 + src/commands/actors/doctor.ts | 295 ++++++++ src/commands/validate-schema.ts | 102 +-- src/lib/input_schema.ts | 120 ++- test/local/commands/actors/doctor.test.ts | 844 ++++++++++++++++++++++ 7 files changed, 1334 insertions(+), 50 deletions(-) create mode 100644 src/commands/actors/doctor.ts create mode 100644 test/local/commands/actors/doctor.test.ts diff --git a/docs/reference.md b/docs/reference.md index 1b0c1d0e6..b1aabd9a8 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -432,9 +432,9 @@ DESCRIPTION path. When no path is provided, validates all schemas found in '.actor/actor.json': - - Input schema (from "input" key or default locations) - - Dataset schema (from "storages.dataset") - - Output schema (from "output") + - Input schema (from "input" or "inputSchema" key, or default locations) + - Dataset schema (from "storages.dataset" or "storages.datasets") + - Output schema (from "output" or "outputSchema") - Key-Value Store schema (from "storages.keyValueStore") USAGE @@ -445,6 +445,18 @@ ARGUMENTS validates all schemas in '.actor/actor.json'. ``` +##### `apify actors doctor` + +```sh +DESCRIPTION + Run local diagnostics on the Actor project in the current directory. + Checks actor.json structure, schema references, and schema validity. No + network calls are made. + +USAGE + $ apify actors doctor +``` + ##### `apify actor` ```sh @@ -680,6 +692,8 @@ SUBCOMMANDS actors call Executes Actor remotely using your authenticated account. actors build Creates a new build of the Actor. + actors doctor Run local diagnostics on the Actor project in the + current directory. ``` ##### `apify actors ls` diff --git a/scripts/generate-cli-docs.ts b/scripts/generate-cli-docs.ts index d1e22bb0b..740d13c16 100644 --- a/scripts/generate-cli-docs.ts +++ b/scripts/generate-cli-docs.ts @@ -26,6 +26,7 @@ const categories: Record = { { command: Commands.init }, { command: Commands.run }, { command: Commands.validateSchema }, + { command: Commands.actorsDoctor }, { command: Commands.actor }, { command: Commands.actorCalculateMemory }, diff --git a/src/commands/actors/_index.ts b/src/commands/actors/_index.ts index e6a08ddcf..f27000ab7 100644 --- a/src/commands/actors/_index.ts +++ b/src/commands/actors/_index.ts @@ -1,6 +1,7 @@ import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; import { ActorsBuildCommand } from './build.js'; import { ActorsCallCommand } from './call.js'; +import { ActorsDoctorCommand } from './doctor.js'; import { ActorsInfoCommand } from './info.js'; import { ActorsLsCommand } from './ls.js'; import { ActorsPullCommand } from './pull.js'; @@ -31,6 +32,7 @@ export class ActorsIndexCommand extends ApifyCommand ActorsInfoCommand, ActorsCallCommand, ActorsBuildCommand, + ActorsDoctorCommand, ]; async run() { diff --git a/src/commands/actors/doctor.ts b/src/commands/actors/doctor.ts new file mode 100644 index 000000000..cf5319af9 --- /dev/null +++ b/src/commands/actors/doctor.ts @@ -0,0 +1,295 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import process from 'node:process'; + +import chalk from 'chalk'; + +import { validateInputSchema } from '@apify/input_schema'; +import { getActorSchemaValidator } from '@apify/json_schemas'; + +import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; +import { CommandExitCodes, DEPRECATED_LOCAL_CONFIG_NAME, LOCAL_CONFIG_PATH } from '../../lib/consts.js'; +import { + readDatasetSchemas, + readInputSchema, + readOutputSchema, + readStorageSchema, + validateDatasetSchema, + validateKvsSchema, + validateOutputSchema, +} from '../../lib/input_schema.js'; +import { simpleLog } from '../../lib/outputs.js'; +import { Ajv2019, validateActorName } from '../../lib/utils.js'; + +type DiagnosticSeverity = 'error' | 'warning' | 'pass'; + +interface Diagnostic { + severity: DiagnosticSeverity; + code: string; + message: string; +} + +// Strip ASCII control chars (0x00–0x1F, 0x7F) from terminal output. +// This prevents escape-sequence injection from project-controlled values +// such as actor names or schema paths in actor.json. +function sanitizeForTerminal(value: string): string { + // eslint-disable-next-line no-control-regex + return value.replace(/[\u0000-\u001f\u007f]/g, ''); +} + +function renderDiagnostics(diagnostics: Diagnostic[]): void { + const lines = diagnostics.map((d) => { + const icon = + d.severity === 'pass' ? chalk.green('✓') : d.severity === 'warning' ? chalk.yellow('⚠') : chalk.red('✗'); + return ` ${icon} ${sanitizeForTerminal(d.message)}`; + }); + + simpleLog({ message: lines.join('\n') }); +} + +async function gatherDiagnostics(cwd: string): Promise { + const diagnostics: Diagnostic[] = []; + + const deprecatedConfigPath = join(cwd, DEPRECATED_LOCAL_CONFIG_NAME); + const actorJsonPath = join(cwd, LOCAL_CONFIG_PATH); + + if (existsSync(deprecatedConfigPath) && !existsSync(actorJsonPath)) { + diagnostics.push({ + severity: 'warning', + code: 'DEPRECATED_CONFIG', + message: `Deprecated "apify.json" detected. Run "apify actors push" to trigger automatic migration to ".actor/actor.json".`, + }); + } + + if (!existsSync(actorJsonPath)) { + diagnostics.push({ + severity: 'error', + code: 'ACTOR_JSON_NOT_FOUND', + message: `".actor/actor.json" not found. Run "apify actors pull" or "apify create" to initialise an Actor project.`, + }); + return diagnostics; + } + + diagnostics.push({ severity: 'pass', code: 'ACTOR_JSON_FOUND', message: `".actor/actor.json" found.` }); + + let actorConfig: Record; + + try { + actorConfig = JSON.parse(readFileSync(actorJsonPath, { encoding: 'utf-8' })); + } catch (ex) { + diagnostics.push({ + severity: 'error', + code: 'ACTOR_JSON_PARSE_FAILED', + message: `".actor/actor.json" is not valid JSON: ${(ex as Error).message}`, + }); + return diagnostics; + } + + const validate = getActorSchemaValidator(); + if (!validate(actorConfig)) { + for (const ajvError of validate.errors ?? []) { + const path = ajvError.instancePath ? ` at ${ajvError.instancePath}` : ''; + diagnostics.push({ + severity: 'error', + code: 'ACTOR_JSON_SCHEMA_INVALID', + message: `".actor/actor.json" schema error${path}: ${ajvError.message}`, + }); + } + // All subsequent checks require a valid actor object (access actorConfig.name, .storages, etc.). + // Return early to prevent runtime errors on non-object values such as null, [], "string", 123. + return diagnostics; + } + + diagnostics.push({ severity: 'pass', code: 'ACTOR_JSON_VALID', message: `".actor/actor.json" is valid.` }); + + if (typeof actorConfig.name === 'string') { + try { + validateActorName(actorConfig.name); + diagnostics.push({ + severity: 'pass', + code: 'ACTOR_NAME_VALID', + message: `Actor name "${actorConfig.name}" is valid.`, + }); + } catch (ex) { + diagnostics.push({ + severity: 'error', + code: 'ACTOR_NAME_INVALID', + message: `Actor name "${actorConfig.name}" is invalid: ${(ex as Error).message}`, + }); + } + } + + // Input schema — supports both `input` and `inputSchema` fields + try { + const { inputSchema } = await readInputSchema({ cwd, throwOnMissing: true }); + if (inputSchema) { + try { + const ajv = new Ajv2019({ strict: false }); + validateInputSchema(ajv, inputSchema); + diagnostics.push({ severity: 'pass', code: 'INPUT_SCHEMA_VALID', message: `Input schema is valid.` }); + } catch (ex) { + diagnostics.push({ + severity: 'error', + code: 'INPUT_SCHEMA_INVALID', + message: `Input schema is invalid: ${(ex as Error).message}`, + }); + } + } + } catch (ex) { + if (ex instanceof SyntaxError) { + diagnostics.push({ + severity: 'error', + code: 'INPUT_SCHEMA_PARSE_FAILED', + message: `Input schema file contains malformed JSON: ${ex.message}`, + }); + } else { + diagnostics.push({ + severity: 'error', + code: 'INPUT_SCHEMA_REF_MISSING', + message: (ex as Error).message, + }); + } + } + + // Dataset schemas — supports both `storages.dataset` and `storages.datasets` + const datasetEntries = readDatasetSchemas({ cwd }); + if (datasetEntries) { + for (const entry of datasetEntries) { + const label = entry.form === 'singular' ? 'Dataset schema' : `Dataset schema "${entry.name}"`; + if (entry.errorCode === 'ref-missing') { + diagnostics.push({ + severity: 'error', + code: 'DATASET_SCHEMA_REF_MISSING', + message: entry.errorMessage!, + }); + } else if (entry.errorCode === 'parse-failed') { + diagnostics.push({ + severity: 'error', + code: 'DATASET_SCHEMA_PARSE_FAILED', + message: entry.errorMessage!, + }); + } else if (entry.schema) { + try { + validateDatasetSchema(entry.schema); + diagnostics.push({ severity: 'pass', code: 'DATASET_SCHEMA_VALID', message: `${label} is valid.` }); + } catch (ex) { + diagnostics.push({ + severity: 'error', + code: 'DATASET_SCHEMA_INVALID', + message: `${label} is invalid: ${(ex as Error).message}`, + }); + } + } + } + } + + // Output schema — supports both `output` and `outputSchema` fields + try { + const result = readOutputSchema({ cwd, throwOnMissing: true }); + if (result) { + try { + validateOutputSchema(result.outputSchema); + diagnostics.push({ severity: 'pass', code: 'OUTPUT_SCHEMA_VALID', message: `Output schema is valid.` }); + } catch (ex) { + diagnostics.push({ + severity: 'error', + code: 'OUTPUT_SCHEMA_INVALID', + message: `Output schema is invalid: ${(ex as Error).message}`, + }); + } + } + } catch (ex) { + if (ex instanceof SyntaxError) { + diagnostics.push({ + severity: 'error', + code: 'OUTPUT_SCHEMA_PARSE_FAILED', + message: `Output schema file contains malformed JSON: ${ex.message}`, + }); + } else { + diagnostics.push({ + severity: 'error', + code: 'OUTPUT_SCHEMA_REF_MISSING', + message: (ex as Error).message, + }); + } + } + + // Key-Value Store schema — supports `storages.keyValueStore` + try { + const result = readStorageSchema({ cwd, key: 'keyValueStore', label: 'Key-Value Store', throwOnMissing: true }); + if (result) { + try { + validateKvsSchema(result.schema); + diagnostics.push({ + severity: 'pass', + code: 'KVS_SCHEMA_VALID', + message: `Key-Value Store schema is valid.`, + }); + } catch (ex) { + diagnostics.push({ + severity: 'error', + code: 'KVS_SCHEMA_INVALID', + message: `Key-Value Store schema is invalid: ${(ex as Error).message}`, + }); + } + } + } catch (ex) { + if (ex instanceof SyntaxError) { + diagnostics.push({ + severity: 'error', + code: 'KVS_SCHEMA_PARSE_FAILED', + message: `Key-Value Store schema file contains malformed JSON: ${ex.message}`, + }); + } else { + diagnostics.push({ + severity: 'error', + code: 'KVS_SCHEMA_REF_MISSING', + message: (ex as Error).message, + }); + } + } + + return diagnostics; +} + +export class ActorsDoctorCommand extends ApifyCommand { + static override name = 'doctor' as const; + + static override description = + `Run local diagnostics on the Actor project in the current directory.\n` + + `Checks actor.json structure, schema references, and schema validity. No network calls are made.`; + + static override group = 'Apify Console'; + + static override docsUrl = 'https://docs.apify.com/cli/docs/reference#apify-actors-doctor'; + + static override examples = [ + { + description: 'Check the Actor project in the current directory', + command: 'apify actors doctor', + }, + ]; + + async run() { + const cwd = process.cwd(); + const diagnostics = await gatherDiagnostics(cwd); + + renderDiagnostics(diagnostics); + + const errorCount = diagnostics.filter((d) => d.severity === 'error').length; + const warningCount = diagnostics.filter((d) => d.severity === 'warning').length; + + if (errorCount === 0 && warningCount === 0) { + simpleLog({ message: chalk.green('\nNo issues found.') }); + } else { + const parts: string[] = []; + if (errorCount > 0) parts.push(chalk.red(`${errorCount} error${errorCount !== 1 ? 's' : ''}`)); + if (warningCount > 0) parts.push(chalk.yellow(`${warningCount} warning${warningCount !== 1 ? 's' : ''}`)); + simpleLog({ message: `\n${parts.join(', ')}` }); + } + + if (errorCount > 0) { + process.exitCode = CommandExitCodes.InvalidActorJson; + } + } +} diff --git a/src/commands/validate-schema.ts b/src/commands/validate-schema.ts index 4940f197a..453584cab 100644 --- a/src/commands/validate-schema.ts +++ b/src/commands/validate-schema.ts @@ -7,7 +7,9 @@ import { Args } from '../lib/command-framework/args.js'; import { CommandExitCodes, LOCAL_CONFIG_PATH } from '../lib/consts.js'; import { readAndValidateInputSchema, + readDatasetSchemas, readInputSchema, + readOutputSchema, readStorageSchema, validateDatasetSchema, validateKvsSchema, @@ -24,9 +26,9 @@ export class ValidateSchemaCommand extends ApifyCommand readStorageSchema({ cwd, key: 'dataset', label: 'Dataset', throwOnMissing: true }), - validate: validateDatasetSchema, - }, - { - label: 'Output', - read: () => - readStorageSchema({ - cwd, - key: 'output', - label: 'Output', - getRef: (config) => config?.output, - throwOnMissing: true, - }), - validate: validateOutputSchema, - }, - { - label: 'Key-Value Store', - read: () => readStorageSchema({ cwd, key: 'keyValueStore', label: 'Key-Value Store', throwOnMissing: true }), - validate: validateKvsSchema, - }, - ]; - - for (const { label, read, validate } of storageSchemas) { - try { - const result = read(); - - if (result) { - foundAny = true; - - const location = result.schemaPath ? `at ${result.schemaPath}` : `embedded in '${LOCAL_CONFIG_PATH}'`; - info({ message: `Validating ${label} schema ${location}` }); - - validate(result.schema); - success({ message: `${label} schema is valid.` }); + // Dataset schemas — supports both `storages.dataset` and `storages.datasets` + const datasetEntries = readDatasetSchemas({ cwd }); + if (datasetEntries) { + for (const entry of datasetEntries) { + foundAny = true; + if (entry.errorCode) { + hasErrors = true; + error({ message: entry.errorMessage! }); + } else if (entry.schema) { + const label = entry.form === 'singular' ? 'Dataset schema' : `Dataset schema "${entry.name}"`; + const location = entry.schemaPath ? `at ${entry.schemaPath}` : `embedded in '${LOCAL_CONFIG_PATH}'`; + info({ message: `Validating ${label} ${location}` }); + try { + validateDatasetSchema(entry.schema); + success({ message: `${label} is valid.` }); + } catch (err) { + hasErrors = true; + error({ message: (err as Error).message }); + } } - } catch (err) { + } + } + + // Output schema — supports both `output` and `outputSchema` fields + try { + const result = readOutputSchema({ cwd, throwOnMissing: true }); + if (result) { + foundAny = true; + const location = result.outputSchemaPath + ? `at ${result.outputSchemaPath}` + : `embedded in '${LOCAL_CONFIG_PATH}'`; + info({ message: `Validating Output schema ${location}` }); + validateOutputSchema(result.outputSchema); + success({ message: 'Output schema is valid.' }); + } + } catch (err) { + foundAny = true; + hasErrors = true; + error({ message: (err as Error).message }); + } + + // Key-Value Store schema + try { + const result = readStorageSchema({ cwd, key: 'keyValueStore', label: 'Key-Value Store', throwOnMissing: true }); + if (result) { foundAny = true; - hasErrors = true; - error({ message: (err as Error).message }); + const location = result.schemaPath ? `at ${result.schemaPath}` : `embedded in '${LOCAL_CONFIG_PATH}'`; + info({ message: `Validating Key-Value Store schema ${location}` }); + validateKvsSchema(result.schema); + success({ message: 'Key-Value Store schema is valid.' }); } + } catch (err) { + foundAny = true; + hasErrors = true; + error({ message: (err as Error).message }); } if (!foundAny) { diff --git a/src/lib/input_schema.ts b/src/lib/input_schema.ts index 47b32af98..9d1f2e2cd 100644 --- a/src/lib/input_schema.ts +++ b/src/lib/input_schema.ts @@ -79,6 +79,39 @@ export const readInputSchema = async ({ }; } + // `inputSchema` is an alias for `input` supported by the canonical actor schema + if (typeof localConfig?.inputSchema === 'object' && localConfig.inputSchema !== null) { + return { + inputSchema: localConfig.inputSchema as Record, + inputSchemaPath: null, + }; + } + + if (typeof localConfig?.inputSchema === 'string') { + const fullPath = join(cwd, ACTOR_SPECIFICATION_FOLDER, localConfig.inputSchema); + const schema = getJsonFileContent(fullPath); + + if (!schema) { + if (throwOnMissing) { + throw new Error(`Input schema file not found at ${fullPath} (referenced in '${LOCAL_CONFIG_PATH}').`); + } + + warning({ + message: `Input schema file not found at ${fullPath} (referenced in '${LOCAL_CONFIG_PATH}').`, + }); + + return { + inputSchema: null, + inputSchemaPath: fullPath, + }; + } + + return { + inputSchema: schema, + inputSchemaPath: fullPath, + }; + } + for (const path of DEFAULT_INPUT_SCHEMA_PATHS) { const fullPath = join(cwd, path); if (existsSync(fullPath)) { @@ -211,15 +244,22 @@ export const readDatasetSchema = ({ /** * Read the Output schema from the Actor config. - * Thin wrapper around `readStorageSchema` — reads `output` from the top-level config - * rather than `storages.`. + * Supports both the `output` and `outputSchema` fields (canonical actor schema aliases). */ export const readOutputSchema = ({ cwd, + throwOnMissing = false, }: { cwd: string; + throwOnMissing?: boolean; }): { outputSchema: Record; outputSchemaPath: string | null } | null => { - const result = readStorageSchema({ cwd, key: 'output', label: 'Output', getRef: (config) => config?.output }); + const result = readStorageSchema({ + cwd, + key: 'output', + label: 'Output', + getRef: (config) => config?.output ?? config?.outputSchema, + throwOnMissing, + }); if (!result) { return null; @@ -323,6 +363,80 @@ export function validateKvsSchema(schema: Record): void { } } +export interface DatasetEntry { + name: string; + /** 'singular' when from storages.dataset; 'plural' when from storages.datasets. */ + form: 'singular' | 'plural'; + schema: Record | null; + schemaPath: string | null; + errorCode?: 'ref-missing' | 'parse-failed'; + errorMessage?: string; +} + +function resolveDatasetRef(name: string, form: 'singular' | 'plural', ref: unknown, cwd: string): DatasetEntry { + if (typeof ref === 'object' && ref !== null) { + return { name, form, schema: ref as Record, schemaPath: null }; + } + + if (typeof ref === 'string') { + const fullPath = join(cwd, ACTOR_SPECIFICATION_FOLDER, ref); + + let schema: Record | undefined; + try { + schema = getJsonFileContent(fullPath); + } catch (ex) { + return { + name, + form, + schema: null, + schemaPath: fullPath, + errorCode: 'parse-failed', + errorMessage: `Dataset schema "${name}" at ${fullPath} contains malformed JSON: ${(ex as SyntaxError).message}`, + }; + } + + if (!schema) { + return { + name, + form, + schema: null, + schemaPath: fullPath, + errorCode: 'ref-missing', + errorMessage: `Dataset schema "${name}" not found at ${fullPath} (referenced in '${LOCAL_CONFIG_PATH}').`, + }; + } + + return { name, form, schema, schemaPath: fullPath }; + } + + return { name, form, schema: null, schemaPath: null }; +} + +/** + * Reads dataset schema entries from the Actor config. + * Supports both `storages.dataset` (singular) and `storages.datasets` (plural with named entries). + * These two forms are mutually exclusive per the canonical actor schema. + * Returns null when no dataset schema is configured. + */ +export function readDatasetSchemas({ cwd }: { cwd: string }): DatasetEntry[] | null { + const localConfig = getLocalConfig(cwd); + const storages = localConfig?.storages as Record | undefined; + + if (!storages) return null; + + if ('dataset' in storages && storages.dataset !== undefined) { + return [resolveDatasetRef('default', 'singular', storages.dataset, cwd)]; + } + + if ('datasets' in storages && typeof storages.datasets === 'object' && storages.datasets !== null) { + const datasetsConfig = storages.datasets as Record; + const results = Object.entries(datasetsConfig).map(([name, ref]) => resolveDatasetRef(name, 'plural', ref, cwd)); + return results.length > 0 ? results : null; + } + + return null; +} + // Lots of code copied from @apify-packages/actor, this really should be moved to the shared input_schema package export const getAjvValidator = (inputSchema: any, ajvInstance: Ajv) => { const copyOfSchema = structuredClone(inputSchema); diff --git a/test/local/commands/actors/doctor.test.ts b/test/local/commands/actors/doctor.test.ts new file mode 100644 index 000000000..2ed1d2708 --- /dev/null +++ b/test/local/commands/actors/doctor.test.ts @@ -0,0 +1,844 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { basename, join } from 'node:path'; + +import { ActorsDoctorCommand } from '../../../../src/commands/actors/doctor.js'; +import { testRunCommand } from '../../../../src/lib/command-framework/apify-command.js'; +import { CommandExitCodes } from '../../../../src/lib/consts.js'; +import { validDatasetSchemaPath } from '../../../__setup__/dataset-schemas/paths.js'; +import { useConsoleSpy } from '../../../__setup__/hooks/useConsoleSpy.js'; +import { useTempPath } from '../../../__setup__/hooks/useTempPath.js'; +import { invalidInputSchemaPath, validInputSchemaPath } from '../../../__setup__/input-schemas/paths.js'; +import { validKvsSchemaPath } from '../../../__setup__/kvs-schemas/paths.js'; +import { validOutputSchemaPath } from '../../../__setup__/output-schemas/paths.js'; + +const { logMessages } = useConsoleSpy(); + +const { joinPath, beforeAllCalls, afterAllCalls } = useTempPath('actors-doctor', { + create: true, + remove: true, + cwd: true, + cwdParent: false, +}); + +beforeEach(async () => { + await beforeAllCalls(); + process.exitCode = undefined; +}); + +afterEach(async () => { + await afterAllCalls(); + process.exitCode = undefined; +}); + +async function writeActorJson(basePath: string, content: Record) { + const actorDir = join(basePath, '.actor'); + await mkdir(actorDir, { recursive: true }); + await writeFile(join(actorDir, 'actor.json'), JSON.stringify(content, null, '\t')); +} + +async function writeActorJsonRaw(basePath: string, raw: string) { + const actorDir = join(basePath, '.actor'); + await mkdir(actorDir, { recursive: true }); + await writeFile(join(actorDir, 'actor.json'), raw); +} + +async function copySchemaFile(srcPath: string, destDir: string, destName: string): Promise { + const content = await readFile(srcPath, 'utf-8'); + await writeFile(join(destDir, destName), content); + return `./${destName}`; +} + +const VALID_ACTOR_BASE = { actorSpecification: 1, name: 'my-actor', version: '0.1' }; +const INVALID_DATASET = { fields: {}, views: {} }; // missing actorSpecification +const INVALID_OUTPUT = { properties: {} }; // missing actorOutputSchemaVersion +const INVALID_KVS = { collections: {} }; // missing actorKeyValueStoreSchemaVersion + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function allOutput() { + return logMessages.error.join('\n'); +} + +// --------------------------------------------------------------------------- +// Missing actor.json +// --------------------------------------------------------------------------- + +describe('apify actors doctor', () => { + describe('missing actor.json', () => { + it('reports ACTOR_JSON_NOT_FOUND error and exits 5 when .actor/actor.json is absent', async () => { + await testRunCommand(ActorsDoctorCommand, {}); + + expect(allOutput()).toContain('.actor/actor.json" not found'); + expect(process.exitCode).toBe(CommandExitCodes.InvalidActorJson); + }); + + it('emits DEPRECATED_CONFIG warning when only apify.json exists', async () => { + await writeFile(join(joinPath(), 'apify.json'), JSON.stringify({ name: 'old-actor' })); + + await testRunCommand(ActorsDoctorCommand, {}); + + expect(allOutput()).toContain('Deprecated "apify.json"'); + expect(allOutput()).toContain('.actor/actor.json" not found'); + }); + + it('deprecated config message does not claim doctor will migrate', async () => { + await writeFile(join(joinPath(), 'apify.json'), JSON.stringify({ name: 'old-actor' })); + + await testRunCommand(ActorsDoctorCommand, {}); + + expect(allOutput()).not.toContain('run any actors command'); + expect(allOutput()).toContain('apify actors push'); + }); + }); + + // --------------------------------------------------------------------------- + // Malformed actor.json + // --------------------------------------------------------------------------- + + describe('malformed actor.json', () => { + it('reports ACTOR_JSON_PARSE_FAILED error for invalid JSON', async () => { + const actorDir = join(joinPath(), '.actor'); + await mkdir(actorDir, { recursive: true }); + await writeFile(join(actorDir, 'actor.json'), '{ invalid json }'); + + await testRunCommand(ActorsDoctorCommand, {}); + + expect(allOutput()).toContain('is not valid JSON'); + expect(process.exitCode).toBe(CommandExitCodes.InvalidActorJson); + }); + }); + + // --------------------------------------------------------------------------- + // actor.json schema validation + // --------------------------------------------------------------------------- + + describe('actor.json schema validation', () => { + it('reports ACTOR_JSON_SCHEMA_INVALID errors for a schema-invalid actor.json', async () => { + await writeActorJson(joinPath(), { actorSpecification: 1 }); // missing name and version + + await testRunCommand(ActorsDoctorCommand, {}); + + expect(allOutput()).toContain('schema error'); + expect(process.exitCode).toBe(CommandExitCodes.InvalidActorJson); + }); + + it('passes for a valid minimal actor.json', async () => { + await writeActorJson(joinPath(), VALID_ACTOR_BASE); + + await testRunCommand(ActorsDoctorCommand, {}); + + expect(allOutput()).toContain('.actor/actor.json" is valid'); + expect(process.exitCode).not.toBe(CommandExitCodes.InvalidActorJson); + }); + }); + + // --------------------------------------------------------------------------- + // Actor name validation + // --------------------------------------------------------------------------- + + describe('actor name validation', () => { + it('reports ACTOR_NAME_INVALID when name is too short', async () => { + await writeActorJson(joinPath(), { ...VALID_ACTOR_BASE, name: 'ab' }); + + await testRunCommand(ActorsDoctorCommand, {}); + + expect(allOutput()).toContain('Actor name "ab" is invalid'); + expect(process.exitCode).toBe(CommandExitCodes.InvalidActorJson); + }); + + it('passes for a valid actor name', async () => { + await writeActorJson(joinPath(), VALID_ACTOR_BASE); + + await testRunCommand(ActorsDoctorCommand, {}); + + expect(allOutput()).toContain('Actor name "my-actor" is valid'); + }); + }); + + // --------------------------------------------------------------------------- + // Input schema — `input` field + // --------------------------------------------------------------------------- + + describe('input schema via `input` field', () => { + it('reports INPUT_SCHEMA_REF_MISSING when referenced file is absent', async () => { + await writeActorJson(joinPath(), { ...VALID_ACTOR_BASE, input: './missing.json' }); + + await testRunCommand(ActorsDoctorCommand, {}); + + expect(allOutput()).toContain('missing.json'); + expect(process.exitCode).toBe(CommandExitCodes.InvalidActorJson); + }); + + it('reports INPUT_SCHEMA_PARSE_FAILED for a malformed JSON file — distinct from ref-missing', async () => { + const actorDir = join(joinPath(), '.actor'); + await mkdir(actorDir, { recursive: true }); + await writeFile(join(actorDir, 'input_schema.json'), '{ bad json'); + await writeFile( + join(actorDir, 'actor.json'), + JSON.stringify({ ...VALID_ACTOR_BASE, input: './input_schema.json' }), + ); + + await testRunCommand(ActorsDoctorCommand, {}); + + expect(allOutput()).toContain('malformed JSON'); + expect(allOutput()).not.toContain('not found'); + expect(process.exitCode).toBe(CommandExitCodes.InvalidActorJson); + }); + + it('reports INPUT_SCHEMA_INVALID for a structurally invalid schema — distinct from parse-failed', async () => { + const actorDir = join(joinPath(), '.actor'); + await mkdir(actorDir, { recursive: true }); + await copySchemaFile(invalidInputSchemaPath, actorDir, 'input_schema.json'); + await writeFile( + join(actorDir, 'actor.json'), + JSON.stringify({ ...VALID_ACTOR_BASE, input: './input_schema.json' }), + ); + + await testRunCommand(ActorsDoctorCommand, {}); + + expect(allOutput()).toContain('Input schema is invalid'); + expect(allOutput()).not.toContain('malformed JSON'); + expect(allOutput()).not.toContain('not found'); + expect(process.exitCode).toBe(CommandExitCodes.InvalidActorJson); + }); + + it('passes for a valid input schema via `input`', async () => { + const actorDir = join(joinPath(), '.actor'); + await mkdir(actorDir, { recursive: true }); + await copySchemaFile(validInputSchemaPath, actorDir, 'input_schema.json'); + await writeFile( + join(actorDir, 'actor.json'), + JSON.stringify({ ...VALID_ACTOR_BASE, input: './input_schema.json' }), + ); + + await testRunCommand(ActorsDoctorCommand, {}); + + expect(allOutput()).toContain('Input schema is valid'); + expect(process.exitCode).not.toBe(CommandExitCodes.InvalidActorJson); + }); + }); + + // --------------------------------------------------------------------------- + // Input schema — `inputSchema` alias + // --------------------------------------------------------------------------- + + describe('input schema via `inputSchema` alias', () => { + it('passes for a valid input schema via `inputSchema`', async () => { + const actorDir = join(joinPath(), '.actor'); + await mkdir(actorDir, { recursive: true }); + await copySchemaFile(validInputSchemaPath, actorDir, 'input_schema.json'); + await writeFile( + join(actorDir, 'actor.json'), + JSON.stringify({ ...VALID_ACTOR_BASE, inputSchema: './input_schema.json' }), + ); + + await testRunCommand(ActorsDoctorCommand, {}); + + expect(allOutput()).toContain('Input schema is valid'); + expect(process.exitCode).not.toBe(CommandExitCodes.InvalidActorJson); + }); + + it('reports INPUT_SCHEMA_REF_MISSING when file referenced by `inputSchema` is absent', async () => { + await writeActorJson(joinPath(), { ...VALID_ACTOR_BASE, inputSchema: './missing.json' }); + + await testRunCommand(ActorsDoctorCommand, {}); + + expect(allOutput()).toContain('missing.json'); + expect(process.exitCode).toBe(CommandExitCodes.InvalidActorJson); + }); + + it('reports INPUT_SCHEMA_PARSE_FAILED for malformed JSON via `inputSchema`', async () => { + const actorDir = join(joinPath(), '.actor'); + await mkdir(actorDir, { recursive: true }); + await writeFile(join(actorDir, 'input_schema.json'), '{ bad json'); + await writeFile( + join(actorDir, 'actor.json'), + JSON.stringify({ ...VALID_ACTOR_BASE, inputSchema: './input_schema.json' }), + ); + + await testRunCommand(ActorsDoctorCommand, {}); + + expect(allOutput()).toContain('malformed JSON'); + expect(allOutput()).not.toContain('not found'); + expect(process.exitCode).toBe(CommandExitCodes.InvalidActorJson); + }); + + it('reports INPUT_SCHEMA_INVALID for a structurally invalid schema via `inputSchema`', async () => { + const actorDir = join(joinPath(), '.actor'); + await mkdir(actorDir, { recursive: true }); + await copySchemaFile(invalidInputSchemaPath, actorDir, 'input_schema.json'); + await writeFile( + join(actorDir, 'actor.json'), + JSON.stringify({ ...VALID_ACTOR_BASE, inputSchema: './input_schema.json' }), + ); + + await testRunCommand(ActorsDoctorCommand, {}); + + expect(allOutput()).toContain('Input schema is invalid'); + expect(allOutput()).not.toContain('malformed JSON'); + expect(process.exitCode).toBe(CommandExitCodes.InvalidActorJson); + }); + }); + + // --------------------------------------------------------------------------- + // Dataset schema — `storages.dataset` (singular) + // --------------------------------------------------------------------------- + + describe('dataset schema via `storages.dataset`', () => { + it('reports DATASET_SCHEMA_REF_MISSING when referenced file is absent', async () => { + await writeActorJson(joinPath(), { ...VALID_ACTOR_BASE, storages: { dataset: './missing.json' } }); + + await testRunCommand(ActorsDoctorCommand, {}); + + expect(allOutput()).toContain('missing.json'); + expect(process.exitCode).toBe(CommandExitCodes.InvalidActorJson); + }); + + it('reports DATASET_SCHEMA_PARSE_FAILED for malformed JSON — distinct from ref-missing', async () => { + const actorDir = join(joinPath(), '.actor'); + await mkdir(actorDir, { recursive: true }); + await writeFile(join(actorDir, 'dataset.json'), '{ bad json'); + await writeFile( + join(actorDir, 'actor.json'), + JSON.stringify({ ...VALID_ACTOR_BASE, storages: { dataset: './dataset.json' } }), + ); + + await testRunCommand(ActorsDoctorCommand, {}); + + expect(allOutput()).toContain('malformed JSON'); + expect(allOutput()).not.toContain('not found'); + expect(process.exitCode).toBe(CommandExitCodes.InvalidActorJson); + }); + + it('reports DATASET_SCHEMA_INVALID for a structurally invalid dataset schema file', async () => { + const actorDir = join(joinPath(), '.actor'); + await mkdir(actorDir, { recursive: true }); + await writeFile(join(actorDir, 'dataset.json'), JSON.stringify(INVALID_DATASET)); + await writeFile( + join(actorDir, 'actor.json'), + JSON.stringify({ ...VALID_ACTOR_BASE, storages: { dataset: './dataset.json' } }), + ); + + await testRunCommand(ActorsDoctorCommand, {}); + + expect(allOutput()).toContain('Dataset schema is invalid'); + expect(process.exitCode).toBe(CommandExitCodes.InvalidActorJson); + }); + + it('passes for a valid embedded dataset schema', async () => { + const schemaContent = JSON.parse(await readFile(validDatasetSchemaPath, 'utf-8')); + await writeActorJson(joinPath(), { ...VALID_ACTOR_BASE, storages: { dataset: schemaContent } }); + + await testRunCommand(ActorsDoctorCommand, {}); + + expect(allOutput()).toContain('Dataset schema is valid'); + expect(process.exitCode).not.toBe(CommandExitCodes.InvalidActorJson); + }); + }); + + // --------------------------------------------------------------------------- + // Dataset schema — `storages.datasets` (plural with named entries) + // --------------------------------------------------------------------------- + + describe('dataset schemas via `storages.datasets`', () => { + it('passes for a valid datasets entry', async () => { + const schemaContent = JSON.parse(await readFile(validDatasetSchemaPath, 'utf-8')); + await writeActorJson(joinPath(), { + ...VALID_ACTOR_BASE, + storages: { datasets: { default: schemaContent } }, + }); + + await testRunCommand(ActorsDoctorCommand, {}); + + expect(allOutput()).toContain('Dataset schema "default" is valid'); + expect(process.exitCode).not.toBe(CommandExitCodes.InvalidActorJson); + }); + + it('reports DATASET_SCHEMA_REF_MISSING for a missing named datasets file', async () => { + await writeActorJson(joinPath(), { + ...VALID_ACTOR_BASE, + storages: { datasets: { default: './missing.json' } }, + }); + + await testRunCommand(ActorsDoctorCommand, {}); + + expect(allOutput()).toContain('missing.json'); + expect(process.exitCode).toBe(CommandExitCodes.InvalidActorJson); + }); + + it('reports DATASET_SCHEMA_PARSE_FAILED for malformed JSON in a named datasets entry', async () => { + const actorDir = join(joinPath(), '.actor'); + await mkdir(actorDir, { recursive: true }); + await writeFile(join(actorDir, 'default.json'), '{ bad json'); + await writeFile( + join(actorDir, 'actor.json'), + JSON.stringify({ ...VALID_ACTOR_BASE, storages: { datasets: { default: './default.json' } } }), + ); + + await testRunCommand(ActorsDoctorCommand, {}); + + expect(allOutput()).toContain('malformed JSON'); + expect(allOutput()).not.toContain('not found'); + expect(process.exitCode).toBe(CommandExitCodes.InvalidActorJson); + }); + + it('reports DATASET_SCHEMA_INVALID for a structurally invalid named dataset schema', async () => { + const actorDir = join(joinPath(), '.actor'); + await mkdir(actorDir, { recursive: true }); + await writeFile(join(actorDir, 'default.json'), JSON.stringify(INVALID_DATASET)); + await writeFile( + join(actorDir, 'actor.json'), + JSON.stringify({ ...VALID_ACTOR_BASE, storages: { datasets: { default: './default.json' } } }), + ); + + await testRunCommand(ActorsDoctorCommand, {}); + + expect(allOutput()).toContain('Dataset schema "default" is invalid'); + expect(process.exitCode).toBe(CommandExitCodes.InvalidActorJson); + }); + + it('reports the invalid entry while the valid entry passes — both reported', async () => { + const actorDir = join(joinPath(), '.actor'); + await mkdir(actorDir, { recursive: true }); + // Valid entry: embed the schema directly (passes actor.json schema validation) + const schemaContent = JSON.parse(await readFile(validDatasetSchemaPath, 'utf-8')); + // Invalid entry: must be a file reference so actor.json schema passes + await writeFile(join(actorDir, 'errors.json'), JSON.stringify(INVALID_DATASET)); + await writeFile( + join(actorDir, 'actor.json'), + JSON.stringify({ + ...VALID_ACTOR_BASE, + storages: { + datasets: { + default: schemaContent, + errors: './errors.json', + }, + }, + }), + ); + + await testRunCommand(ActorsDoctorCommand, {}); + + expect(allOutput()).toContain('Dataset schema "default" is valid'); + expect(allOutput()).toContain('Dataset schema "errors" is invalid'); + expect(process.exitCode).toBe(CommandExitCodes.InvalidActorJson); + }); + }); + + // --------------------------------------------------------------------------- + // Output schema — `output` field + // --------------------------------------------------------------------------- + + describe('output schema via `output` field', () => { + it('reports OUTPUT_SCHEMA_REF_MISSING when referenced file is absent', async () => { + await writeActorJson(joinPath(), { ...VALID_ACTOR_BASE, output: './missing.json' }); + + await testRunCommand(ActorsDoctorCommand, {}); + + expect(allOutput()).toContain('missing.json'); + expect(process.exitCode).toBe(CommandExitCodes.InvalidActorJson); + }); + + it('reports OUTPUT_SCHEMA_PARSE_FAILED for malformed JSON — distinct from ref-missing', async () => { + const actorDir = join(joinPath(), '.actor'); + await mkdir(actorDir, { recursive: true }); + await writeFile(join(actorDir, 'output.json'), '{ bad json'); + await writeFile(join(actorDir, 'actor.json'), JSON.stringify({ ...VALID_ACTOR_BASE, output: './output.json' })); + + await testRunCommand(ActorsDoctorCommand, {}); + + expect(allOutput()).toContain('malformed JSON'); + expect(allOutput()).not.toContain('not found'); + expect(process.exitCode).toBe(CommandExitCodes.InvalidActorJson); + }); + + it('reports OUTPUT_SCHEMA_INVALID for a structurally invalid output schema file', async () => { + const actorDir = join(joinPath(), '.actor'); + await mkdir(actorDir, { recursive: true }); + await writeFile(join(actorDir, 'output.json'), JSON.stringify(INVALID_OUTPUT)); + await writeFile(join(actorDir, 'actor.json'), JSON.stringify({ ...VALID_ACTOR_BASE, output: './output.json' })); + + await testRunCommand(ActorsDoctorCommand, {}); + + expect(allOutput()).toContain('Output schema is invalid'); + expect(process.exitCode).toBe(CommandExitCodes.InvalidActorJson); + }); + + it('passes for a valid output schema via `output`', async () => { + const actorDir = join(joinPath(), '.actor'); + await mkdir(actorDir, { recursive: true }); + await copySchemaFile(validOutputSchemaPath, actorDir, 'output-schema.json'); + await writeFile( + join(actorDir, 'actor.json'), + JSON.stringify({ ...VALID_ACTOR_BASE, output: './output-schema.json' }), + ); + + await testRunCommand(ActorsDoctorCommand, {}); + + expect(allOutput()).toContain('Output schema is valid'); + expect(process.exitCode).not.toBe(CommandExitCodes.InvalidActorJson); + }); + }); + + // --------------------------------------------------------------------------- + // Output schema — `outputSchema` alias + // --------------------------------------------------------------------------- + + describe('output schema via `outputSchema` alias', () => { + it('passes for a valid output schema via `outputSchema`', async () => { + const actorDir = join(joinPath(), '.actor'); + await mkdir(actorDir, { recursive: true }); + await copySchemaFile(validOutputSchemaPath, actorDir, 'output-schema.json'); + await writeFile( + join(actorDir, 'actor.json'), + JSON.stringify({ ...VALID_ACTOR_BASE, outputSchema: './output-schema.json' }), + ); + + await testRunCommand(ActorsDoctorCommand, {}); + + expect(allOutput()).toContain('Output schema is valid'); + expect(process.exitCode).not.toBe(CommandExitCodes.InvalidActorJson); + }); + + it('reports OUTPUT_SCHEMA_REF_MISSING when file referenced by `outputSchema` is absent', async () => { + await writeActorJson(joinPath(), { ...VALID_ACTOR_BASE, outputSchema: './missing.json' }); + + await testRunCommand(ActorsDoctorCommand, {}); + + expect(allOutput()).toContain('missing.json'); + expect(process.exitCode).toBe(CommandExitCodes.InvalidActorJson); + }); + + it('reports OUTPUT_SCHEMA_PARSE_FAILED for malformed JSON via `outputSchema`', async () => { + const actorDir = join(joinPath(), '.actor'); + await mkdir(actorDir, { recursive: true }); + await writeFile(join(actorDir, 'output.json'), '{ bad json'); + await writeFile( + join(actorDir, 'actor.json'), + JSON.stringify({ ...VALID_ACTOR_BASE, outputSchema: './output.json' }), + ); + + await testRunCommand(ActorsDoctorCommand, {}); + + expect(allOutput()).toContain('malformed JSON'); + expect(allOutput()).not.toContain('not found'); + expect(process.exitCode).toBe(CommandExitCodes.InvalidActorJson); + }); + + it('reports OUTPUT_SCHEMA_INVALID for a structurally invalid schema via `outputSchema`', async () => { + const actorDir = join(joinPath(), '.actor'); + await mkdir(actorDir, { recursive: true }); + await writeFile(join(actorDir, 'output.json'), JSON.stringify(INVALID_OUTPUT)); + await writeFile( + join(actorDir, 'actor.json'), + JSON.stringify({ ...VALID_ACTOR_BASE, outputSchema: './output.json' }), + ); + + await testRunCommand(ActorsDoctorCommand, {}); + + expect(allOutput()).toContain('Output schema is invalid'); + expect(process.exitCode).toBe(CommandExitCodes.InvalidActorJson); + }); + }); + + // --------------------------------------------------------------------------- + // KVS schema + // --------------------------------------------------------------------------- + + describe('KVS schema', () => { + it('reports KVS_SCHEMA_REF_MISSING for a missing KVS schema file', async () => { + await writeActorJson(joinPath(), { ...VALID_ACTOR_BASE, storages: { keyValueStore: './missing.json' } }); + + await testRunCommand(ActorsDoctorCommand, {}); + + expect(allOutput()).toContain('missing.json'); + expect(process.exitCode).toBe(CommandExitCodes.InvalidActorJson); + }); + + it('reports KVS_SCHEMA_PARSE_FAILED for malformed JSON — distinct from ref-missing', async () => { + const actorDir = join(joinPath(), '.actor'); + await mkdir(actorDir, { recursive: true }); + await writeFile(join(actorDir, 'kvs.json'), '{ bad json'); + await writeFile( + join(actorDir, 'actor.json'), + JSON.stringify({ ...VALID_ACTOR_BASE, storages: { keyValueStore: './kvs.json' } }), + ); + + await testRunCommand(ActorsDoctorCommand, {}); + + expect(allOutput()).toContain('malformed JSON'); + expect(allOutput()).not.toContain('not found'); + expect(process.exitCode).toBe(CommandExitCodes.InvalidActorJson); + }); + + it('reports KVS_SCHEMA_INVALID for a structurally invalid KVS schema file', async () => { + const actorDir = join(joinPath(), '.actor'); + await mkdir(actorDir, { recursive: true }); + await writeFile(join(actorDir, 'kvs.json'), JSON.stringify(INVALID_KVS)); + await writeFile( + join(actorDir, 'actor.json'), + JSON.stringify({ ...VALID_ACTOR_BASE, storages: { keyValueStore: './kvs.json' } }), + ); + + await testRunCommand(ActorsDoctorCommand, {}); + + expect(allOutput()).toContain('Key-Value Store schema is invalid'); + expect(process.exitCode).toBe(CommandExitCodes.InvalidActorJson); + }); + }); + + // --------------------------------------------------------------------------- + // Multiple simultaneous diagnostics + // --------------------------------------------------------------------------- + + describe('multiple simultaneous diagnostics', () => { + it('reports all errors without stopping at the first failure', async () => { + // Datasets and output must be file-referenced so actor.json schema validation passes; + // the individual schema validators then flag the content as structurally invalid. + const actorDir = join(joinPath(), '.actor'); + await mkdir(actorDir, { recursive: true }); + await writeFile(join(actorDir, 'dataset-default.json'), JSON.stringify(INVALID_DATASET)); + await writeFile(join(actorDir, 'dataset-errors.json'), JSON.stringify(INVALID_DATASET)); + await writeFile(join(actorDir, 'output.json'), JSON.stringify(INVALID_OUTPUT)); + await writeFile( + join(actorDir, 'actor.json'), + JSON.stringify({ + ...VALID_ACTOR_BASE, + name: 'ab', // invalid name (too short) + inputSchema: { + // embedded invalid input schema (passes actor.json schema) + title: 'Bad', + type: 'object', + schemaVersion: 1, + properties: { q: { title: 'Q', type: 'string', editor: 'spaceEditor' } }, + }, + outputSchema: './output.json', + storages: { + datasets: { + default: './dataset-default.json', + errors: './dataset-errors.json', + }, + }, + }), + ); + + await testRunCommand(ActorsDoctorCommand, {}); + + const out = allOutput(); + expect(out).toContain('Actor name "ab" is invalid'); + expect(out).toContain('Input schema is invalid'); + expect(out).toContain('Dataset schema "default" is invalid'); + expect(out).toContain('Dataset schema "errors" is invalid'); + expect(out).toContain('Output schema is invalid'); + expect(process.exitCode).toBe(CommandExitCodes.InvalidActorJson); + }); + + it('all checks pass for a fully valid Actor project', async () => { + const actorDir = join(joinPath(), '.actor'); + await mkdir(actorDir, { recursive: true }); + + const inputRef = await copySchemaFile(validInputSchemaPath, actorDir, 'input_schema.json'); + const datasetRef = await copySchemaFile(validDatasetSchemaPath, actorDir, 'dataset-schema.json'); + const outputRef = await copySchemaFile(validOutputSchemaPath, actorDir, 'output-schema.json'); + const kvsRef = await copySchemaFile(validKvsSchemaPath, actorDir, 'kvs-schema.json'); + + await writeFile( + join(actorDir, 'actor.json'), + JSON.stringify({ + ...VALID_ACTOR_BASE, + input: inputRef, + output: outputRef, + storages: { + dataset: datasetRef, + keyValueStore: kvsRef, + }, + }), + ); + + await testRunCommand(ActorsDoctorCommand, {}); + + const out = allOutput(); + expect(out).toContain('.actor/actor.json" is valid'); + expect(out).toContain('Actor name "my-actor" is valid'); + expect(out).toContain('Input schema is valid'); + expect(out).toContain('Dataset schema is valid'); + expect(out).toContain('Output schema is valid'); + expect(out).toContain('Key-Value Store schema is valid'); + expect(process.exitCode).not.toBe(CommandExitCodes.InvalidActorJson); + }); + }); + + // --------------------------------------------------------------------------- + // Exit code + // --------------------------------------------------------------------------- + + describe('exit code', () => { + it('exits 0 for a valid project (no errors)', async () => { + await writeActorJson(joinPath(), VALID_ACTOR_BASE); + + await testRunCommand(ActorsDoctorCommand, {}); + + expect(process.exitCode).not.toBe(CommandExitCodes.InvalidActorJson); + }); + + it('exits 0 for warnings-only (deprecated apify.json + actor.json both present)', async () => { + await writeFile(join(joinPath(), 'apify.json'), JSON.stringify({ name: 'old-actor' })); + await writeActorJson(joinPath(), VALID_ACTOR_BASE); + + await testRunCommand(ActorsDoctorCommand, {}); + + // Warning is present but no errors → exit 0 + expect(process.exitCode).not.toBe(CommandExitCodes.InvalidActorJson); + }); + + it('exits InvalidActorJson (5) for any validation error', async () => { + await writeActorJson(joinPath(), { ...VALID_ACTOR_BASE, output: INVALID_OUTPUT }); + + await testRunCommand(ActorsDoctorCommand, {}); + + expect(process.exitCode).toBe(CommandExitCodes.InvalidActorJson); + expect(process.exitCode).toBe(5); + }); + }); + + // --------------------------------------------------------------------------- + // Non-object actor.json root values — crash-safety guard + // --------------------------------------------------------------------------- + + describe('non-object actor.json root values', () => { + it('reports schema error and exits 5 for actor.json = null — no crash', async () => { + await writeActorJsonRaw(joinPath(), 'null'); + + await testRunCommand(ActorsDoctorCommand, {}); + + expect(allOutput()).toContain('schema error'); + expect(process.exitCode).toBe(CommandExitCodes.InvalidActorJson); + }); + + it('reports schema error and exits 5 for actor.json = [] — no crash', async () => { + await writeActorJsonRaw(joinPath(), '[]'); + + await testRunCommand(ActorsDoctorCommand, {}); + + expect(allOutput()).toContain('schema error'); + expect(process.exitCode).toBe(CommandExitCodes.InvalidActorJson); + }); + + it('reports schema error and exits 5 for actor.json = "string" — no crash', async () => { + await writeActorJsonRaw(joinPath(), '"string"'); + + await testRunCommand(ActorsDoctorCommand, {}); + + expect(allOutput()).toContain('schema error'); + expect(process.exitCode).toBe(CommandExitCodes.InvalidActorJson); + }); + + it('reports schema error and exits 5 for actor.json = 123 — no crash', async () => { + await writeActorJsonRaw(joinPath(), '123'); + + await testRunCommand(ActorsDoctorCommand, {}); + + expect(allOutput()).toContain('schema error'); + expect(process.exitCode).toBe(CommandExitCodes.InvalidActorJson); + }); + + it('reports schema error and exits 5 for actor.json = true — no crash', async () => { + await writeActorJsonRaw(joinPath(), 'true'); + + await testRunCommand(ActorsDoctorCommand, {}); + + expect(allOutput()).toContain('schema error'); + expect(process.exitCode).toBe(CommandExitCodes.InvalidActorJson); + }); + + it('does not print a raw stack trace for actor.json = null', async () => { + await writeActorJsonRaw(joinPath(), 'null'); + + await testRunCommand(ActorsDoctorCommand, {}); + + // Diagnostics should be a clean message, not a raw Error stack trace + expect(allOutput()).not.toMatch(/at \w+.*\(.*:\d+:\d+\)/); + }); + }); + + // --------------------------------------------------------------------------- + // Terminal injection — control character sanitization + // --------------------------------------------------------------------------- + + describe('terminal injection', () => { + it('strips ESC sequences from actor name in diagnostic output', async () => { + // Use JSON \\u001b so JSON.parse produces a string with ESC (0x1B). + // A raw ESC byte in JSON text is a SyntaxError; \\u001b is the correct encoding. + const actorDir = join(joinPath(), '.actor'); + await mkdir(actorDir, { recursive: true }); + await writeFile( + join(actorDir, 'actor.json'), + '{"actorSpecification":1,"name":"my-actor\\u001b[31mhack","version":"0.1"}', + ); + + await testRunCommand(ActorsDoctorCommand, {}); + + const out = allOutput(); + // The raw ESC byte must NOT appear in the diagnostic output + expect(out).not.toContain('\x1b'); + // The non-control text (ESC stripped, rest preserved) should be present + expect(out).toContain('my-actor[31mhack'); + }); + + it('strips NUL bytes from actor name in diagnostic output', async () => { + // Use JSON \\u0000 so JSON.parse produces a string with NUL (0x00). + const actorDir = join(joinPath(), '.actor'); + await mkdir(actorDir, { recursive: true }); + await writeFile( + join(actorDir, 'actor.json'), + '{"actorSpecification":1,"name":"my-actor\\u0000hack","version":"0.1"}', + ); + + await testRunCommand(ActorsDoctorCommand, {}); + + const out = allOutput(); + // The NUL byte must NOT appear in the diagnostic output + expect(out).not.toContain('\x00'); + // Text with NUL stripped should be present + expect(out).toContain('my-actorhack'); + }); + }); + + // --------------------------------------------------------------------------- + // Diagnostic summary + // --------------------------------------------------------------------------- + + describe('diagnostic summary', () => { + it('prints "No issues found." for a valid project', async () => { + await writeActorJson(joinPath(), VALID_ACTOR_BASE); + + await testRunCommand(ActorsDoctorCommand, {}); + + expect(allOutput()).toContain('No issues found.'); + }); + + it('prints "1 error" (singular) for a single error', async () => { + // name: 'ab' passes actor.json schema but fails validateActorName → exactly 1 error + await writeActorJson(joinPath(), { ...VALID_ACTOR_BASE, name: 'ab' }); + + await testRunCommand(ActorsDoctorCommand, {}); + + expect(allOutput()).toContain('1 error'); + expect(allOutput()).not.toContain('1 errors'); + }); + + it('prints plural "errors" count for multiple errors', async () => { + await writeActorJson(joinPath(), { + ...VALID_ACTOR_BASE, + output: INVALID_OUTPUT, + storages: { keyValueStore: INVALID_KVS }, + }); + + await testRunCommand(ActorsDoctorCommand, {}); + + expect(allOutput()).toMatch(/\d+ errors/); + }); + }); +});