diff --git a/src/commands/run.ts b/src/commands/run.ts index 050e3e3c6..8c02c1aa3 100644 --- a/src/commands/run.ts +++ b/src/commands/run.ts @@ -593,7 +593,13 @@ export class RunCommand extends ApifyCommand { if (mime.getExtension(existingInput.contentType!) === 'json') { // Step 4: validate the input - const inputJson = JSON.parse(existingInput.body.toString('utf-8')); + let inputJson: unknown; + + try { + inputJson = JSON.parse(existingInput.body.toString('utf-8')); + } catch (err) { + throw new Error(`Cannot parse JSON input file at path "${inputFilePath}".\n ${(err as Error).message}`); + } if (Array.isArray(inputJson)) { throw new Error('The input in your storage is invalid. It should be an object, not an array.'); @@ -601,7 +607,7 @@ export class RunCommand extends ApifyCommand { const fullInput = { ...defaults, - ...inputJson, + ...(inputJson as Record), }; const errors = validateInputUsingValidator(compiledInputSchema, inputSchema, fullInput); diff --git a/src/lib/commands/resolve-input.ts b/src/lib/commands/resolve-input.ts index 0fd04769e..8692353f8 100644 --- a/src/lib/commands/resolve-input.ts +++ b/src/lib/commands/resolve-input.ts @@ -7,7 +7,7 @@ import mime from 'mime'; import { cachedStdinInput } from '../../entrypoints/_shared.js'; import { CommandExitCodes } from '../consts.js'; import { error } from '../outputs.js'; -import { getLocalInput } from '../utils.js'; +import { getLocalInput, getLocalKeyValueStorePath } from '../utils.js'; interface InputOverrideOptions { schemaHint?: string; @@ -31,7 +31,12 @@ export function resolveInput(cwd: string, inputOverride: Record const ext = mime.getExtension(localInput.contentType!); if (ext === 'json') { - inputToUse = JSON.parse(localInput.body.toString('utf8')); + try { + inputToUse = JSON.parse(localInput.body.toString('utf8')); + } catch (err) { + const filePath = path.join(cwd, getLocalKeyValueStorePath(), localInput.fileName!); + throw new Error(`Cannot parse JSON input file at path "${filePath}".\n ${(err as Error).message}`); + } contentType = 'application/json'; } else { inputToUse = localInput.body as never; @@ -165,26 +170,41 @@ export async function getInputOverride( // Try reading the file, and if that fails, try reading it as JSON let fsError: unknown; + let fileContent: string | undefined; try { - const fileContent = await readFile(fullPath, 'utf8'); - const parsed = JSON.parse(fileContent); + fileContent = await readFile(fullPath, 'utf8'); + } catch (err) { + fsError = err; + } - if (Array.isArray(parsed)) { + if (fileContent !== undefined) { + try { + const parsed = JSON.parse(fileContent); + + if (Array.isArray(parsed)) { + error({ + message: withSchemaHint( + 'The provided input is invalid. It should be an object, not an array.', + schemaHint, + ), + }); + process.exitCode = CommandExitCodes.InvalidInput; + return false; + } + + input = parsed; + source = inputFileFlag; + } catch (err) { error({ message: withSchemaHint( - 'The provided input is invalid. It should be an object, not an array.', + `Cannot parse JSON input file at path "${fullPath}".\n ${(err as Error).message}`, schemaHint, ), }); process.exitCode = CommandExitCodes.InvalidInput; return false; } - - input = parsed; - source = inputFileFlag; - } catch (err) { - fsError = err; } if (fsError) { diff --git a/test/local/commands/run.test.ts b/test/local/commands/run.test.ts index bc4e26323..beb007d92 100644 --- a/test/local/commands/run.test.ts +++ b/test/local/commands/run.test.ts @@ -1,5 +1,5 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { dirname } from 'node:path/win32'; +import { dirname } from 'node:path'; import { ACTOR_ENV_VARS, APIFY_ENV_VARS } from '@apify/consts'; @@ -306,6 +306,18 @@ writeFileSync(String.raw\`${joinPath('result.txt')}\`, 'hello world'); expect(lastErrorMessage()).toMatch(/Field awesome must be boolean/i); }); + it('throws with the input file path when stored input JSON is malformed', async () => { + writeFileSync(inputPath, '{"awesome": ', { flag: 'w' }); + copyFileSync(defaultsInputSchemaPath, inputSchemaPath); + + await testRunCommand(RunCommand, {}); + + const stderr = lastErrorMessage(); + expect(stderr).toContain('Cannot parse JSON input file at path'); + expect(stderr).toContain('INPUT.json'); + expect(stderr).toContain('Unexpected end of JSON input'); + }); + it('throws when passing manual input, but local file has correct input', async () => { writeFileSync(inputPath, '{"awesome": true}', { flag: 'w' }); copyFileSync(defaultsInputSchemaPath, inputSchemaPath); diff --git a/test/local/lib/resolve-input.test.ts b/test/local/lib/resolve-input.test.ts index 0213865f4..f2d974d61 100644 --- a/test/local/lib/resolve-input.test.ts +++ b/test/local/lib/resolve-input.test.ts @@ -1,18 +1,25 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; import process from 'node:process'; import { afterEach, describe, expect, it } from 'vitest'; -import { getInputOverride } from '../../../src/lib/commands/resolve-input.js'; +import { getInputOverride, resolveInput } from '../../../src/lib/commands/resolve-input.js'; import { CommandExitCodes } from '../../../src/lib/consts.js'; +import { getLocalKeyValueStorePath } from '../../../src/lib/utils.js'; import { useConsoleSpy } from '../../__setup__/hooks/useConsoleSpy.js'; const SCHEMA_HINT = 'Run "apify actors info apify/hello-world --input" to inspect the Actor input schema.'; const { logMessages } = useConsoleSpy(); +const tempDirs: string[] = []; describe('getInputOverride', () => { - afterEach(() => { + afterEach(async () => { process.exitCode = undefined; + + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); }); it('does not append schema hint to --input file path errors', async () => { @@ -52,4 +59,37 @@ describe('getInputOverride', () => { expect(stderr).toContain('It should be an object, not an array.'); expect(stderr).toContain(SCHEMA_HINT); }); + + it('includes the file path when --input-file contains malformed JSON', async () => { + const tempDir = await mkdtemp(join(tmpdir(), 'apify-cli-input-')); + tempDirs.push(tempDir); + const inputPath = join(tempDir, 'bad-input.json'); + await writeFile(inputPath, '{"url":'); + + const result = await getInputOverride(tempDir, undefined, 'bad-input.json'); + + expect(result).toBe(false); + expect(process.exitCode).toBe(CommandExitCodes.InvalidInput); + const stderr = logMessages.error.join('\n'); + expect(stderr).toContain(`Cannot parse JSON input file at path "${resolve(tempDir, 'bad-input.json')}".`); + expect(stderr).toContain('Unexpected end of JSON input'); + }); +}); + +describe('resolveInput', () => { + afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); + }); + + it('includes the file path when stored INPUT.json contains malformed JSON', async () => { + const tempDir = await mkdtemp(join(tmpdir(), 'apify-cli-input-')); + tempDirs.push(tempDir); + const kvStorePath = join(tempDir, getLocalKeyValueStorePath()); + await mkdir(kvStorePath, { recursive: true }); + await writeFile(join(kvStorePath, 'INPUT.json'), '{"url":'); + + expect(() => resolveInput(tempDir, undefined)).toThrow( + `Cannot parse JSON input file at path "${join(kvStorePath, 'INPUT.json')}".`, + ); + }); });