fix(cli): accept JSON files for web capture and screenshot comparisons (#1397)

compare-web-captures and compare-web-screenshots parsed their arguments as
inline JSON only, unlike every other JSON-input command. Screenshot
artifacts and scenario captures embed base64 PNG bytes, so a typical
viewport screenshot exceeds the command-line length limit (128 KiB per
argument on Linux, 1 MiB total on macOS) and could not be compared from
the CLI at all, while MCP accepts it. A file path was reported as
malformed JSON, and screenshot schema failures returned no issues.

Each argument now accepts inline JSON or a JSON file path through
parseCliJsonInput; file failures keep its path diagnostics, malformed
inline JSON keeps the field issue, and screenshot validation reports
projected schema issues. The tests share one CLI JSON process fixture.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kaoru0822andClaude Opus 5.5 authored and GitHub committed 2026-10-09 19:29:42 -07:00
1 parent 2b6e9ec03f
commit 6875d4a64c
6 files changed
+238 -95

No files matched your search

+78 -36
View File
@@ -15,13 +15,14 @@ import {
type AnalysisInputIssue,
} from "../domain/analysisErrorCore.js";
import { browserCaptureComparisonInputSchema } from "../domain/browserCaptureComparison.js";
import { projectInputIssues } from "../domain/inputIssueProjection.js";
import { discoverWebMcpToolsInputSchema } from "../domain/webMcpDiscovery.js";
import {
captureWebScreenshotInputSchema,
compareWebScreenshotsInputSchema,
} from "../domain/webScreenshot.js";
import type { JsonValue } from "../domain/jsonValue.js";
import { safeParseJson } from "../domain/safeJson.js";
import { parseCliJsonInput } from "../cliJsonInput.js";
import type { Logger } from "pino";
import { CLI_COMMANDS } from "../cliCommandNames.js";
@@ -79,46 +80,39 @@ const registerCaptureDiff = (
description:
"Compare two normalized passive captures or recorded browser scenarios",
args: z.object({
beforeJson: z.string().describe("Earlier normalized web capture JSON"),
afterJson: z.string().describe("Later normalized web capture JSON"),
beforeJson: z
.string()
.describe("Earlier normalized web capture JSON or JSON file path"),
afterJson: z
.string()
.describe("Later normalized web capture JSON or JSON file path"),
}),
options: z.object({
normalizationJson: z
.string()
.default('{"rules":[]}')
.describe(
"Recorded literal normalization policy for browser scenarios",
"Recorded literal normalization policy JSON or JSON file path for browser scenarios",
),
}),
run: ({ args, options }) =>
logCliCommand(logger, "compare-web-captures", async () => {
const before = safeParseJson(args.beforeJson);
const after = safeParseJson(args.afterJson);
const normalization = safeParseJson(options.normalizationJson);
if (!before.ok)
return inputError("compare_web_captures", [
invalidJsonIssue("before"),
]);
if (!after.ok)
return inputError("compare_web_captures", [
invalidJsonIssue("after"),
]);
if (!normalization.ok)
return inputError("compare_web_captures", [
invalidJsonIssue("normalization"),
]);
const inputs = await parseJsonArguments("compare_web_captures", {
before: args.beforeJson,
after: args.afterJson,
normalization: options.normalizationJson,
});
if (!inputs.ok) return inputs.error;
const { before, after, normalization } = inputs.value;
const scenarioComparison =
browserCaptureComparisonInputSchema.safeParse({
before_scenario: before.value,
after_scenario: after.value,
normalization: normalization.value,
before_scenario: before,
after_scenario: after,
normalization,
});
const parsed = scenarioComparison.success
? scenarioComparison
: browserCaptureComparisonInputSchema.safeParse({
before: before.value,
after: after.value,
});
: browserCaptureComparisonInputSchema.safeParse({ before, after });
if (!parsed.success) return inputError("compare_web_captures");
const result = await compareWebCaptureEvidence(
new CdpBrowserProvider(),
@@ -168,8 +162,12 @@ const registerScreenshotDiff = (
cli.command(CLI_COMMANDS.compareWebScreenshots, {
description: "Compare two self-verifying PNG artifact JSON values",
args: z.object({
beforeJson: z.string().describe("Earlier screenshot artifact JSON"),
afterJson: z.string().describe("Later screenshot artifact JSON"),
beforeJson: z
.string()
.describe("Earlier screenshot artifact JSON or JSON file path"),
afterJson: z
.string()
.describe("Later screenshot artifact JSON or JSON file path"),
}),
options: z.object({
channelThreshold: z
@@ -182,12 +180,27 @@ const registerScreenshotDiff = (
}),
run: ({ args, options }) =>
logCliCommand(logger, "compare-web-screenshots", async () => {
const parsed = compareWebScreenshotsInputSchema.safeParse({
before: parseJson(args.beforeJson),
after: parseJson(args.afterJson),
channel_threshold: options.channelThreshold,
const operation = "compare_web_screenshots";
const inputs = await parseJsonArguments(operation, {
before: args.beforeJson,
after: args.afterJson,
});
if (!parsed.success) return inputError("compare_web_screenshots");
if (!inputs.ok) return inputs.error;
const { before, after } = inputs.value;
const input = {
before,
after,
channel_threshold: options.channelThreshold,
};
const parsed = compareWebScreenshotsInputSchema.safeParse(input);
if (!parsed.success)
return browserCliError(
new AnalysisInputError(
operation,
{ cause: parsed.error },
projectInputIssues(parsed.error.issues, input),
),
);
const result = await compareWebScreenshotEvidence(
new CdpBrowserProvider(),
parsed.data,
@@ -197,11 +210,40 @@ const registerScreenshotDiff = (
});
};
const parseJson = (value: string): unknown => {
const parsed = safeParseJson(value);
return parsed.ok ? parsed.value : undefined;
/**
* Parse each named inline JSON argument or local JSON file, like other
* JSON-input commands. Screenshot artifacts and scenario captures embed PNG
* bytes that can exceed the host's command-line length limit.
*/
const parseJsonArguments = async (
operation: string,
fields: Readonly<Record<string, string>>,
): Promise<
| { readonly ok: true; readonly value: Readonly<Record<string, unknown>> }
| { readonly ok: false; readonly error: JsonValue }
> => {
const values: Record<string, unknown> = {};
for (const [field, value] of Object.entries(fields)) {
const parsed = await parseCliJsonInput(value, operation);
if (!parsed.ok)
return isFileInputFailure(parsed.error)
? parsed
: {
ok: false,
error: inputError(operation, [invalidJsonIssue(field)]),
};
values[field] = parsed.value;
}
return { ok: true, value: values };
};
/** File read and decode failures name the selected path; inline JSON does not. */
const isFileInputFailure = (error: JsonValue): boolean =>
typeof error === "object" &&
error !== null &&
!Array.isArray(error) &&
"input_path" in error;
const invalidJsonIssue = (field: string): AnalysisInputIssue => ({
path: [field],
reason: "invalid_format",