mirror of
https://github.com/morluto/rea.git
synced 2026-11-11 01:13:39 +00:00
* fix(cli): advertise domain constraints for selector and path options Android class, method, and mapping fields, ADB setting keys, and browser endpoints used bare strings in --schema. They now reuse the domain fields, so empty selectors, relative mapping paths, and malformed setting keys fail in the advertised schema. * fix(cli): keep selector limits when argument parsing rejects them Empty browser and Electron selectors now fail in the CLI parser, so their diagnostics are VALIDATION_ERROR with the one-character minimum. ADB setting keys stay on the selector repair path, which restores maximum 256 and the identifier pattern. * test(cli): assert empty-selector codes without frozen prose Empty browser and Electron selectors still fail as VALIDATION_ERROR too_small on the named field. The tests no longer copy Zod's sentence. * test(cli): compare endpoint diagnostics with the domain schema Browser and Inspector tests take custom issue text from the schema that owns it, and empty capture comparison must name both missing inspection fields. * fix(cli): remove duplicate merged schema import --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
317 lines
11 KiB
TypeScript
317 lines
11 KiB
TypeScript
import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js";
|
|
import { Cli, z } from "incur";
|
|
|
|
import { browserCliError } from "./browserCliError.js";
|
|
import { createBrowserObservationProvider } from "../composition/browserObservation.js";
|
|
import {
|
|
browserScopeOptions,
|
|
selectedCommandTimeout,
|
|
} from "../cliObservationOptions.js";
|
|
import {
|
|
captureWebScreenshot,
|
|
compareWebCaptureEvidence,
|
|
compareWebScreenshotEvidence,
|
|
discoverWebMcpTools,
|
|
} from "../application/BrowserObservationService.js";
|
|
import { CdpBrowserProvider } from "../browser/CdpBrowserProvider.js";
|
|
import { logCliCommand } from "../cliLogging.js";
|
|
import {
|
|
AnalysisInputError,
|
|
type AnalysisInputIssue,
|
|
} from "../domain/analysisErrorCore.js";
|
|
import { compareBrowserScenariosInputSchema } from "../domain/browserScenarioDiffValues.js";
|
|
import { browserScenarioCaptureSchema } from "../domain/browserScenarioCapture.js";
|
|
import { compareWebCapturesInputSchema } from "../domain/webCaptureDiffSchemas.js";
|
|
import {
|
|
analysisInputErrorFromIssues,
|
|
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 { parseCliJsonInput } from "../cliJsonInput.js";
|
|
import type { Logger } from "pino";
|
|
import { cliDomainInput } from "./domainInput.js";
|
|
import { cliDecimalField } from "./options.js";
|
|
import { CLI_COMMANDS } from "../cliCommandNames.js";
|
|
import { withCommandCancellation } from "./commandCancellation.js";
|
|
import { finishAnalysisCommand } from "./finishAnalysisCommand.js";
|
|
|
|
/** Register WebMCP, capture-diff, and screenshot CLI equivalents. */
|
|
export const registerAdvancedBrowserCommands = (
|
|
cli: ReturnType<typeof Cli.create>,
|
|
logger: Logger,
|
|
): void => {
|
|
registerWebMcp(cli, logger);
|
|
registerCaptureDiff(cli, logger);
|
|
registerScreenshot(cli, logger);
|
|
registerScreenshotDiff(cli, logger);
|
|
};
|
|
|
|
const registerWebMcp = (
|
|
cli: ReturnType<typeof Cli.create>,
|
|
logger: Logger,
|
|
): void => {
|
|
cli.command(CLI_COMMANDS.discoverWebMcpTools, {
|
|
description: "Passively discover page-declared WebMCP tools",
|
|
args: z.object({
|
|
endpoint: cliDomainInput(
|
|
discoverWebMcpToolsInputSchema.shape.cdp_endpoint,
|
|
),
|
|
targetId: discoverWebMcpToolsInputSchema.shape.target_id.describe(
|
|
"Target ID from list-browser-targets",
|
|
),
|
|
}),
|
|
options: z.object({
|
|
...browserScopeOptions,
|
|
observationMs: cliDecimalField(
|
|
discoverWebMcpToolsInputSchema.shape.observation_ms,
|
|
).describe("Observation duration in milliseconds"),
|
|
}),
|
|
run: ({ args, options }) =>
|
|
withCommandCancellation((signal) =>
|
|
logCliCommand(logger, "discover-webmcp-tools", async () => {
|
|
const provider = createBrowserObservationProvider();
|
|
const input = {
|
|
cdp_endpoint: args.endpoint,
|
|
allowed_origins: options.allowedOrigins,
|
|
target_id: args.targetId,
|
|
observation_ms: options.observationMs,
|
|
...selectedCommandTimeout(options.commandTimeoutMs),
|
|
};
|
|
const parsed = discoverWebMcpToolsInputSchema.safeParse(input);
|
|
if (!parsed.success)
|
|
return inputError(
|
|
"discover_webmcp_tools",
|
|
projectInputIssues(parsed.error.issues, input),
|
|
);
|
|
return await finishAnalysisCommand(
|
|
() => discoverWebMcpTools(provider, parsed.data, { signal }),
|
|
{
|
|
close: async () => {
|
|
await provider.close?.();
|
|
},
|
|
},
|
|
provider.identity().id,
|
|
"discover_webmcp_tools",
|
|
signal,
|
|
).then(projectCliAnalysisResult);
|
|
}),
|
|
),
|
|
});
|
|
};
|
|
|
|
const registerCaptureDiff = (
|
|
cli: ReturnType<typeof Cli.create>,
|
|
logger: Logger,
|
|
): void => {
|
|
cli.command(CLI_COMMANDS.compareWebCaptures, {
|
|
description:
|
|
"Compare two normalized passive captures or recorded browser scenarios",
|
|
args: z.object({
|
|
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 JSON or JSON file path for browser scenarios",
|
|
),
|
|
}),
|
|
run: ({ args, options }) =>
|
|
logCliCommand(logger, "compare-web-captures", async () => {
|
|
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;
|
|
// Passive snapshots own the inspection wrapper. Otherwise recognize
|
|
// scenario-owned fields even when the scenario manifest is missing.
|
|
const scenarioInput = [before, after].some(
|
|
(capture) =>
|
|
typeof capture === "object" &&
|
|
capture !== null &&
|
|
!("inspection" in capture) &&
|
|
Object.keys(browserScenarioCaptureSchema.shape).some(
|
|
(field) => field in capture,
|
|
),
|
|
);
|
|
const input = scenarioInput
|
|
? { before_scenario: before, after_scenario: after, normalization }
|
|
: { before, after };
|
|
const parsed = scenarioInput
|
|
? compareBrowserScenariosInputSchema.safeParse(input)
|
|
: compareWebCapturesInputSchema.safeParse(input);
|
|
if (!parsed.success)
|
|
return inputError(
|
|
"compare_web_captures",
|
|
projectInputIssues(parsed.error.issues, input),
|
|
);
|
|
const result = await compareWebCaptureEvidence(
|
|
new CdpBrowserProvider(),
|
|
parsed.data,
|
|
);
|
|
return result.ok ? result.value : browserCliError(result.error);
|
|
}),
|
|
});
|
|
};
|
|
|
|
const registerScreenshot = (
|
|
cli: ReturnType<typeof Cli.create>,
|
|
logger: Logger,
|
|
): void => {
|
|
cli.command(CLI_COMMANDS.captureWebScreenshot, {
|
|
description:
|
|
"Capture one visible page viewport within the supplied origin scope",
|
|
args: z.object({
|
|
endpoint: cliDomainInput(
|
|
captureWebScreenshotInputSchema.shape.cdp_endpoint,
|
|
),
|
|
targetId: captureWebScreenshotInputSchema.shape.target_id.describe(
|
|
"Target ID from list-browser-targets",
|
|
),
|
|
}),
|
|
options: z.object({
|
|
...browserScopeOptions,
|
|
}),
|
|
run: ({ args, options }) =>
|
|
withCommandCancellation((signal) =>
|
|
logCliCommand(logger, "capture-web-screenshot", async () => {
|
|
const provider = createBrowserObservationProvider();
|
|
const input = {
|
|
cdp_endpoint: args.endpoint,
|
|
allowed_origins: options.allowedOrigins,
|
|
target_id: args.targetId,
|
|
...selectedCommandTimeout(options.commandTimeoutMs),
|
|
};
|
|
const parsed = captureWebScreenshotInputSchema.safeParse(input);
|
|
if (!parsed.success)
|
|
return inputError(
|
|
"capture_web_screenshot",
|
|
projectInputIssues(parsed.error.issues, input),
|
|
);
|
|
return await finishAnalysisCommand(
|
|
() => captureWebScreenshot(provider, parsed.data, { signal }),
|
|
{
|
|
close: async () => {
|
|
await provider.close?.();
|
|
},
|
|
},
|
|
provider.identity().id,
|
|
"capture_web_screenshot",
|
|
signal,
|
|
).then(projectCliAnalysisResult);
|
|
}),
|
|
),
|
|
});
|
|
};
|
|
|
|
const registerScreenshotDiff = (
|
|
cli: ReturnType<typeof Cli.create>,
|
|
logger: Logger,
|
|
): void => {
|
|
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 or JSON file path"),
|
|
afterJson: z
|
|
.string()
|
|
.describe("Later screenshot artifact JSON or JSON file path"),
|
|
}),
|
|
options: z.object({
|
|
channelThreshold: cliDecimalField(
|
|
compareWebScreenshotsInputSchema.shape.channel_threshold,
|
|
).describe("Per-channel difference threshold for changed pixels"),
|
|
}),
|
|
run: ({ args, options }) =>
|
|
logCliCommand(logger, "compare-web-screenshots", async () => {
|
|
const operation = "compare_web_screenshots";
|
|
const inputs = await parseJsonArguments(operation, {
|
|
before: args.beforeJson,
|
|
after: args.afterJson,
|
|
});
|
|
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(
|
|
analysisInputErrorFromIssues(
|
|
operation,
|
|
parsed.error.issues,
|
|
input,
|
|
{ cause: parsed.error },
|
|
),
|
|
);
|
|
const result = await compareWebScreenshotEvidence(
|
|
new CdpBrowserProvider(),
|
|
parsed.data,
|
|
);
|
|
return result.ok ? result.value : browserCliError(result.error);
|
|
}),
|
|
});
|
|
};
|
|
|
|
/**
|
|
* 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",
|
|
expected: "JSON",
|
|
});
|
|
|
|
const inputError = (
|
|
operation: string,
|
|
issues: readonly AnalysisInputIssue[] = [],
|
|
): JsonValue =>
|
|
browserCliError(new AnalysisInputError(operation, undefined, issues));
|