fix(cli): fail when JSON input files cannot be parsed
This commit is contained in:
parent
af48fe8791
commit
3201f0538d
|
|
@ -0,0 +1,94 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { isCliOperationFailure } from "./cliLogging.js";
|
||||
import {
|
||||
analysisCliErrorEnvelopeSchema,
|
||||
analysisErrorProjectionSchema,
|
||||
} from "./contracts/errorSchemas.js";
|
||||
import { AnalysisInputError } from "./domain/analysisErrorCore.js";
|
||||
import { projectAnalysisError } from "./domain/analysisErrorProjection.js";
|
||||
|
||||
const projection = projectAnalysisError(new AnalysisInputError("test-input"));
|
||||
const envelope = { error: "Application workflow failed", ...projection };
|
||||
|
||||
describe("CLI JSON input failure recognition", () => {
|
||||
it.each([
|
||||
{},
|
||||
{ input_path: "input.json" },
|
||||
{ input_reason: "invalid-json" },
|
||||
{ input_path: "input.json", input_reason: "invalid-json" },
|
||||
{ input_path: "missing.json", input_reason: "read-failed" },
|
||||
{ input_path: "", input_reason: "read-failed" },
|
||||
])("recognizes a complete envelope with metadata %j", (metadata) => {
|
||||
const value = { ...envelope, ...metadata };
|
||||
expect(analysisCliErrorEnvelopeSchema.parse(value)).toEqual(value);
|
||||
expect(isCliOperationFailure(value)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ input_path: null },
|
||||
{ input_path: 1 },
|
||||
{ input_path: ["input.json"] },
|
||||
{ input_reason: null },
|
||||
{ input_reason: 1 },
|
||||
{ input_reason: "" },
|
||||
{ input_reason: "unknown" },
|
||||
{ input_reason: ["invalid-json"] },
|
||||
{ unknown_field: "extra" },
|
||||
{ input_path: "input.json", input_reason: "invalid-json", extra: true },
|
||||
{ error: "" },
|
||||
{ error: 1 },
|
||||
{ retryable: "false" },
|
||||
{ remediation: { action: "retry", extra: true } },
|
||||
])("rejects malformed or unknown envelope fields %j", (changes) => {
|
||||
const value = { ...envelope, ...changes };
|
||||
expect(analysisCliErrorEnvelopeSchema.safeParse(value).success).toBe(false);
|
||||
expect(isCliOperationFailure(value)).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["code", "category", "message", "retryable", "remediation"])(
|
||||
"requires the complete canonical projection, including %s",
|
||||
(field) => {
|
||||
const value = Object.fromEntries(
|
||||
Object.entries({ ...envelope, input_reason: "invalid-json" }).filter(
|
||||
([key]) => key !== field,
|
||||
),
|
||||
);
|
||||
expect(analysisCliErrorEnvelopeSchema.safeParse(value).success).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isCliOperationFailure(value)).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ error: "ordinary data" },
|
||||
{ code: "invalid_request", message: "ordinary data" },
|
||||
{
|
||||
error: "ordinary data",
|
||||
code: "invalid_request",
|
||||
input_path: "input.json",
|
||||
},
|
||||
{ error: { code: "invalid_request" } },
|
||||
{ result: envelope },
|
||||
{ input_path: "input.json", input_reason: "invalid-json" },
|
||||
null,
|
||||
[],
|
||||
])("keeps ordinary or incomplete error-like data successful: %j", (value) => {
|
||||
expect(isCliOperationFailure(value)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps CLI metadata out of the strict canonical error schema", () => {
|
||||
expect(analysisErrorProjectionSchema.parse(projection)).toEqual(projection);
|
||||
for (const metadata of [
|
||||
{ error: "Application workflow failed" },
|
||||
{ input_path: "input.json" },
|
||||
{ input_reason: "invalid-json" },
|
||||
{ extra: true },
|
||||
])
|
||||
expect(
|
||||
analysisErrorProjectionSchema.safeParse({ ...projection, ...metadata })
|
||||
.success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -60,14 +60,21 @@ export const analysisErrorProjectionSchema = z.discriminatedUnion("code", [
|
|||
generic("execution_failure"),
|
||||
]);
|
||||
|
||||
/** Legacy CLI envelope: a human label plus the canonical projected error fields. */
|
||||
/** CLI label and input diagnostics surrounding the strict canonical error projection. */
|
||||
export const analysisCliErrorEnvelopeSchema = z
|
||||
.object({
|
||||
error: z.string().min(1),
|
||||
input_path: z.string().optional(),
|
||||
input_reason: z.enum(["invalid-json", "read-failed"]).optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.superRefine((value, context) => {
|
||||
const { error: _label, ...projection } = value;
|
||||
const {
|
||||
error: _label,
|
||||
input_path: _inputPath,
|
||||
input_reason: _inputReason,
|
||||
...projection
|
||||
} = value;
|
||||
const parsed = analysisErrorProjectionSchema.safeParse(projection);
|
||||
if (!parsed.success) {
|
||||
context.addIssue({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,159 @@
|
|||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect } from "vitest";
|
||||
|
||||
import { parseCliJsonInput } from "../../../src/cliJsonInput.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { cliTest } from "../../support/cli/cliFixture.js";
|
||||
|
||||
const JSON_COMMANDS = [
|
||||
["trace-application-feature", "trace-application-feature"],
|
||||
["trace-javascript-semantics", "trace-javascript-semantics"],
|
||||
["compare-application-versions", "compare-application-versions"],
|
||||
["compare-source-to-bundle", "compare-source-to-bundle"],
|
||||
["compare-javascript-export-shapes", "compare-javascript-export-shapes"],
|
||||
[
|
||||
"build-reconstruction-obligation-ledger",
|
||||
"build-reconstruction-obligation-ledger",
|
||||
],
|
||||
["evaluate-reconstruction-coverage", "evaluate-reconstruction-coverage"],
|
||||
["import-managed-reconstruction", "import-managed-reconstruction"],
|
||||
["verify-managed-native-boundaries", "verify-managed-native-boundaries"],
|
||||
["project-managed-application-graph", "project-managed-application-graph"],
|
||||
["capture-browser-scenario", "capture_browser_scenario"],
|
||||
["capture-electron-scenario", "capture_electron_scenario"],
|
||||
["reconcile-javascript-runtime", "reconcile_javascript_runtime"],
|
||||
] as const;
|
||||
|
||||
describe("compiled CLI JSON input failure status", () => {
|
||||
for (const [command, operation] of JSON_COMMANDS)
|
||||
for (const kind of ["inline", "file", "missing"] as const)
|
||||
cliTest(
|
||||
`${command} rejects malformed or unreadable ${kind} input before dispatch`,
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-cli-json-status-");
|
||||
const input = kind === "inline" ? "{" : join(root, "input.json");
|
||||
if (kind === "file") await writeFile(input, "{");
|
||||
const parsed = await parseCliJsonInput(input, operation);
|
||||
if (parsed.ok)
|
||||
throw new Error("Expected JSON input to fail before dispatch");
|
||||
|
||||
const result = await cli.run({
|
||||
arguments: [command, input, "--json"],
|
||||
environment: {
|
||||
HOME: root,
|
||||
XDG_CONFIG_HOME: root,
|
||||
XDG_CACHE_HOME: root,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.stdout).toBe(
|
||||
`${JSON.stringify(parsed.error, null, 2)}\n`,
|
||||
);
|
||||
expect(result.json).toMatchObject({
|
||||
code: "invalid_request",
|
||||
category: "invalid_input",
|
||||
...(kind === "missing" ? {} : { details: { operation } }),
|
||||
...(kind === "inline"
|
||||
? {}
|
||||
: {
|
||||
input_path: input,
|
||||
input_reason:
|
||||
kind === "file" ? "invalid-json" : "read-failed",
|
||||
}),
|
||||
});
|
||||
expect(result.stderr).toBe("");
|
||||
expect(result.exitCode).toBe(1);
|
||||
},
|
||||
);
|
||||
|
||||
for (const [format, flags] of [
|
||||
["JSON", ["--json"]],
|
||||
["JSONL", ["--format", "jsonl"]],
|
||||
["YAML", ["--format", "yaml"]],
|
||||
["default", []],
|
||||
] as const)
|
||||
cliTest(
|
||||
`keeps the failure status with ${format} output`,
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-cli-json-format-");
|
||||
const input = join(root, "missing.json");
|
||||
const result = await cli.run({
|
||||
arguments: ["compare-javascript-export-shapes", input, ...flags],
|
||||
environment: {
|
||||
HOME: root,
|
||||
XDG_CONFIG_HOME: root,
|
||||
XDG_CACHE_HOME: root,
|
||||
},
|
||||
});
|
||||
for (const diagnostic of [
|
||||
"invalid_request",
|
||||
"invalid_input",
|
||||
input,
|
||||
"read-failed",
|
||||
])
|
||||
expect(result.stdout).toContain(diagnostic);
|
||||
expect(result.stderr).toBe("");
|
||||
expect(result.exitCode).toBe(1);
|
||||
},
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"rejects an empty file path without losing its diagnostic",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-cli-json-empty-");
|
||||
const result = await cli.run({
|
||||
arguments: ["compare-javascript-export-shapes", "", "--json"],
|
||||
environment: {
|
||||
HOME: root,
|
||||
XDG_CONFIG_HOME: root,
|
||||
XDG_CACHE_HOME: root,
|
||||
},
|
||||
});
|
||||
expect(result.json).toMatchObject({
|
||||
code: "invalid_request",
|
||||
input_path: "",
|
||||
input_reason: "read-failed",
|
||||
});
|
||||
expect(result.exitCode).toBe(1);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("compiled JSON parser and logger success seam", () => {
|
||||
for (const kind of ["inline", "file"] as const)
|
||||
cliTest(
|
||||
`preserves valid ${kind} data and a zero exit status without running a workflow`,
|
||||
async ({ processes }) => {
|
||||
const root = await createTestTempDirectory("rea-cli-json-success-");
|
||||
const value = {
|
||||
error: "source data",
|
||||
code: "invalid_request",
|
||||
values: [null, false, 0],
|
||||
};
|
||||
const json = JSON.stringify(value);
|
||||
const input = kind === "inline" ? json : join(root, "input.json");
|
||||
if (kind === "file") await writeFile(input, json);
|
||||
const result = await processes.run(
|
||||
process.execPath,
|
||||
[
|
||||
"--input-type=module",
|
||||
"-e",
|
||||
`import { parseCliJsonInput } from "./dist/cliJsonInput.js";
|
||||
import { logCliCommand } from "./dist/cliLogging.js";
|
||||
import { silentLogger } from "./dist/logger.js";
|
||||
const parsed = await parseCliJsonInput(process.argv[1], "test-input");
|
||||
const output = await logCliCommand(silentLogger, "test-input", async () =>
|
||||
parsed.ok ? parsed.value : parsed.error);
|
||||
console.log(JSON.stringify(output));`,
|
||||
input,
|
||||
],
|
||||
{ env: { HOME: root, XDG_CONFIG_HOME: root, XDG_CACHE_HOME: root } },
|
||||
);
|
||||
expect(result.stdout).toBe(`${json}\n`);
|
||||
expect(result.stderr).toBe("");
|
||||
expect(result.exitCode).toBe(0);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
import pino from "pino";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { parseCliJsonInput } from "../../src/cliJsonInput.js";
|
||||
import { logCliCommand } from "../../src/cliLogging.js";
|
||||
|
||||
const originalExitCode = process.exitCode;
|
||||
|
||||
afterEach(() => {
|
||||
process.exitCode = originalExitCode;
|
||||
});
|
||||
|
||||
describe("CLI JSON input logging status", () => {
|
||||
it.each([
|
||||
{},
|
||||
{ input_path: "input.json", input_reason: "invalid-json" },
|
||||
{ input_path: "missing.json", input_reason: "read-failed" },
|
||||
{ input_path: "", input_reason: "read-failed" },
|
||||
])(
|
||||
"preserves failure diagnostics and logs an error for %j",
|
||||
async (metadata) => {
|
||||
const parsed = await parseCliJsonInput("{", "test-input");
|
||||
if (parsed.ok) throw new Error("Expected malformed inline JSON to fail");
|
||||
const value = Object.assign({}, parsed.error, metadata);
|
||||
const serialized = JSON.stringify(value);
|
||||
const lines: string[] = [];
|
||||
const logger = pino(
|
||||
{ level: "info" },
|
||||
{ write: (line) => lines.push(line) },
|
||||
);
|
||||
process.exitCode = undefined;
|
||||
|
||||
const output = await logCliCommand(
|
||||
logger,
|
||||
"test-input",
|
||||
async () => value,
|
||||
);
|
||||
|
||||
expect(output).toBe(value);
|
||||
expect(JSON.stringify(output)).toBe(serialized);
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(lines).toHaveLength(1);
|
||||
expect(JSON.parse(lines.join(""))).toMatchObject({
|
||||
level: 50,
|
||||
command: "test-input",
|
||||
status: "error",
|
||||
msg: "CLI command failed",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("leaves ordinary parsed JSON successful and unchanged", async () => {
|
||||
const parsed = await parseCliJsonInput(
|
||||
'{"error":"source data","code":"invalid_request"}',
|
||||
"test-input",
|
||||
);
|
||||
if (!parsed.ok) throw new Error("Expected valid JSON to parse");
|
||||
const lines: string[] = [];
|
||||
const logger = pino(
|
||||
{ level: "info" },
|
||||
{ write: (line) => lines.push(line) },
|
||||
);
|
||||
process.exitCode = undefined;
|
||||
|
||||
expect(
|
||||
await logCliCommand(logger, "test-input", async () => parsed.value),
|
||||
).toBe(parsed.value);
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
expect(lines).toHaveLength(1);
|
||||
expect(JSON.parse(lines.join(""))).toMatchObject({
|
||||
level: 30,
|
||||
status: "ok",
|
||||
msg: "CLI command completed",
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue