Skip to content

Commit 4117a27

Browse files
committed
fix: include file path in JSON parse errors for INPUT.json (#1181)
- Wrap JSON.parse in resolveInput() (apify actor call path) with try/catch that includes the full file path - Wrap JSON.parse in getInputOverride() --input-file path with the same fix - Wrap JSON.parse in RunCommand (apify run path) with try/catch + file path - Cast inputJson to Record<string,unknown> after array guard to satisfy TS - Fix incorrect node:path/win32 import in run.test.ts (should be node:path) - Add tests for all three JSON parse error paths
1 parent 6745bcf commit 4117a27

4 files changed

Lines changed: 94 additions & 16 deletions

File tree

src/commands/run.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -593,15 +593,21 @@ export class RunCommand extends ApifyCommand<typeof RunCommand> {
593593

594594
if (mime.getExtension(existingInput.contentType!) === 'json') {
595595
// Step 4: validate the input
596-
const inputJson = JSON.parse(existingInput.body.toString('utf-8'));
596+
let inputJson: unknown;
597+
598+
try {
599+
inputJson = JSON.parse(existingInput.body.toString('utf-8'));
600+
} catch (err) {
601+
throw new Error(`Cannot parse JSON input file at path "${inputFilePath}".\n ${(err as Error).message}`);
602+
}
597603

598604
if (Array.isArray(inputJson)) {
599605
throw new Error('The input in your storage is invalid. It should be an object, not an array.');
600606
}
601607

602608
const fullInput = {
603609
...defaults,
604-
...inputJson,
610+
...(inputJson as Record<string, unknown>),
605611
};
606612

607613
const errors = validateInputUsingValidator(compiledInputSchema, inputSchema, fullInput);

src/lib/commands/resolve-input.ts

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import mime from 'mime';
77
import { cachedStdinInput } from '../../entrypoints/_shared.js';
88
import { CommandExitCodes } from '../consts.js';
99
import { error } from '../outputs.js';
10-
import { getLocalInput } from '../utils.js';
10+
import { getLocalInput, getLocalKeyValueStorePath } from '../utils.js';
1111

1212
interface InputOverrideOptions {
1313
schemaHint?: string;
@@ -31,7 +31,12 @@ export function resolveInput(cwd: string, inputOverride: Record<string, unknown>
3131
const ext = mime.getExtension(localInput.contentType!);
3232

3333
if (ext === 'json') {
34-
inputToUse = JSON.parse(localInput.body.toString('utf8'));
34+
try {
35+
inputToUse = JSON.parse(localInput.body.toString('utf8'));
36+
} catch (err) {
37+
const filePath = path.join(cwd, getLocalKeyValueStorePath(), localInput.fileName!);
38+
throw new Error(`Cannot parse JSON input file at path "${filePath}".\n ${(err as Error).message}`);
39+
}
3540
contentType = 'application/json';
3641
} else {
3742
inputToUse = localInput.body as never;
@@ -165,26 +170,41 @@ export async function getInputOverride(
165170
// Try reading the file, and if that fails, try reading it as JSON
166171

167172
let fsError: unknown;
173+
let fileContent: string | undefined;
168174

169175
try {
170-
const fileContent = await readFile(fullPath, 'utf8');
171-
const parsed = JSON.parse(fileContent);
176+
fileContent = await readFile(fullPath, 'utf8');
177+
} catch (err) {
178+
fsError = err;
179+
}
172180

173-
if (Array.isArray(parsed)) {
181+
if (fileContent !== undefined) {
182+
try {
183+
const parsed = JSON.parse(fileContent);
184+
185+
if (Array.isArray(parsed)) {
186+
error({
187+
message: withSchemaHint(
188+
'The provided input is invalid. It should be an object, not an array.',
189+
schemaHint,
190+
),
191+
});
192+
process.exitCode = CommandExitCodes.InvalidInput;
193+
return false;
194+
}
195+
196+
input = parsed;
197+
source = inputFileFlag;
198+
} catch (err) {
174199
error({
175200
message: withSchemaHint(
176-
'The provided input is invalid. It should be an object, not an array.',
201+
`Cannot parse JSON input file at path "${fullPath}".\n ${(err as Error).message}`,
177202
schemaHint,
178203
),
179204
});
180205
process.exitCode = CommandExitCodes.InvalidInput;
181206
return false;
182207
}
183-
184-
input = parsed;
185-
source = inputFileFlag;
186-
} catch (err) {
187-
fsError = err;
188208
}
189209

190210
if (fsError) {

test/local/commands/run.test.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2-
import { dirname } from 'node:path/win32';
2+
import { dirname } from 'node:path';
33

44
import { ACTOR_ENV_VARS, APIFY_ENV_VARS } from '@apify/consts';
55

@@ -306,6 +306,18 @@ writeFileSync(String.raw\`${joinPath('result.txt')}\`, 'hello world');
306306
expect(lastErrorMessage()).toMatch(/Field awesome must be boolean/i);
307307
});
308308

309+
it('throws with the input file path when stored input JSON is malformed', async () => {
310+
writeFileSync(inputPath, '{"awesome": ', { flag: 'w' });
311+
copyFileSync(defaultsInputSchemaPath, inputSchemaPath);
312+
313+
await testRunCommand(RunCommand, {});
314+
315+
const stderr = lastErrorMessage();
316+
expect(stderr).toContain('Cannot parse JSON input file at path');
317+
expect(stderr).toContain('INPUT.json');
318+
expect(stderr).toContain('Unexpected end of JSON input');
319+
});
320+
309321
it('throws when passing manual input, but local file has correct input', async () => {
310322
writeFileSync(inputPath, '{"awesome": true}', { flag: 'w' });
311323
copyFileSync(defaultsInputSchemaPath, inputSchemaPath);

test/local/lib/resolve-input.test.ts

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,25 @@
1+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
2+
import { tmpdir } from 'node:os';
3+
import { join, resolve } from 'node:path';
14
import process from 'node:process';
25

36
import { afterEach, describe, expect, it } from 'vitest';
47

5-
import { getInputOverride } from '../../../src/lib/commands/resolve-input.js';
8+
import { getInputOverride, resolveInput } from '../../../src/lib/commands/resolve-input.js';
69
import { CommandExitCodes } from '../../../src/lib/consts.js';
10+
import { getLocalKeyValueStorePath } from '../../../src/lib/utils.js';
711
import { useConsoleSpy } from '../../__setup__/hooks/useConsoleSpy.js';
812

913
const SCHEMA_HINT = 'Run "apify actors info apify/hello-world --input" to inspect the Actor input schema.';
1014

1115
const { logMessages } = useConsoleSpy();
16+
const tempDirs: string[] = [];
1217

1318
describe('getInputOverride', () => {
14-
afterEach(() => {
19+
afterEach(async () => {
1520
process.exitCode = undefined;
21+
22+
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
1623
});
1724

1825
it('does not append schema hint to --input file path errors', async () => {
@@ -52,4 +59,37 @@ describe('getInputOverride', () => {
5259
expect(stderr).toContain('It should be an object, not an array.');
5360
expect(stderr).toContain(SCHEMA_HINT);
5461
});
62+
63+
it('includes the file path when --input-file contains malformed JSON', async () => {
64+
const tempDir = await mkdtemp(join(tmpdir(), 'apify-cli-input-'));
65+
tempDirs.push(tempDir);
66+
const inputPath = join(tempDir, 'bad-input.json');
67+
await writeFile(inputPath, '{"url":');
68+
69+
const result = await getInputOverride(tempDir, undefined, 'bad-input.json');
70+
71+
expect(result).toBe(false);
72+
expect(process.exitCode).toBe(CommandExitCodes.InvalidInput);
73+
const stderr = logMessages.error.join('\n');
74+
expect(stderr).toContain(`Cannot parse JSON input file at path "${resolve(tempDir, 'bad-input.json')}".`);
75+
expect(stderr).toContain('Unexpected end of JSON input');
76+
});
77+
});
78+
79+
describe('resolveInput', () => {
80+
afterEach(async () => {
81+
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
82+
});
83+
84+
it('includes the file path when stored INPUT.json contains malformed JSON', async () => {
85+
const tempDir = await mkdtemp(join(tmpdir(), 'apify-cli-input-'));
86+
tempDirs.push(tempDir);
87+
const kvStorePath = join(tempDir, getLocalKeyValueStorePath());
88+
await mkdir(kvStorePath, { recursive: true });
89+
await writeFile(join(kvStorePath, 'INPUT.json'), '{"url":');
90+
91+
expect(() => resolveInput(tempDir, undefined)).toThrow(
92+
`Cannot parse JSON input file at path "${join(kvStorePath, 'INPUT.json')}".`,
93+
);
94+
});
5595
});

0 commit comments

Comments
 (0)