From b31fc5d5fbc5dc379238c9b839220eb17fa1ef17 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 7 Nov 2026 16:17:09 +0800 Subject: [PATCH] fix(cli): advertise domain constraints for selector and path options (#9181) * 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 --- src/cli/adbCommands.ts | 6 +- src/cli/androidCommands.ts | 32 ++-- src/cli/argumentErrors.ts | 3 +- src/cli/browserAdvancedCommands.ts | 21 +- src/cli/browserCommands.ts | 37 ++-- src/cli/domainInput.ts | 9 + src/cli/electronCommands.ts | 14 +- src/cli/javascriptRuntimeCommands.ts | 15 +- src/cli/webRuntimeCommands.ts | 30 +-- tests/boundary/cli/adbCli.test.ts | 10 + .../cli/browserInputDiagnostics.test.ts | 181 +++++++++++++----- .../boundary/cli/cliDomainFieldSchema.test.ts | 165 ++++++++++++++++ tests/boundary/cli/electronCli.test.ts | 20 +- 13 files changed, 418 insertions(+), 125 deletions(-) create mode 100644 src/cli/domainInput.ts create mode 100644 tests/boundary/cli/cliDomainFieldSchema.test.ts diff --git a/src/cli/adbCommands.ts b/src/cli/adbCommands.ts index 1773750901..869ceeba55 100644 --- a/src/cli/adbCommands.ts +++ b/src/cli/adbCommands.ts @@ -237,10 +237,8 @@ export const registerAdbCommands = ( description: "Read one settings value from system, secure, or global", args: z.object({ serial, - namespace: z - .enum(["system", "secure", "global"]) - .describe("Settings namespace to read"), - key: z.string().min(1).describe("Setting key to read"), + namespace: adbInputSchemas.read_adb_setting.shape.namespace, + key: adbInputSchemas.read_adb_setting.shape.key, }), run: ({ args }) => execute(CLI_COMMANDS.readAdbSetting, "read_adb_setting", { diff --git a/src/cli/androidCommands.ts b/src/cli/androidCommands.ts index cb377fc6e5..b14264747e 100644 --- a/src/cli/androidCommands.ts +++ b/src/cli/androidCommands.ts @@ -21,6 +21,7 @@ import { AnalysisInputError } from "../domain/analysisErrorCore.js"; import { parseAnalysisInput } from "../application/AnalysisInput.js"; import { androidJniSelectionSchema } from "../domain/android/androidJniBindings.js"; import { androidJniDeliverySchema } from "../domain/android/androidJniQuery.js"; +import { androidInputSchemas } from "../domain/android/androidAnalysis.js"; import { cliIntegerArgument } from "./options.js"; /** Expose the same explicit APK requests through one-shot CLI commands. */ @@ -66,14 +67,10 @@ export const registerAndroidCommands = ( ).then(projectCliAnalysisResult); }), ); - const path = z.string().describe("Local standalone APK path"); - const className = z.string().describe("Exact fully qualified class name"); - const mappingPath = z - .string() - .optional() - .describe( - "Absolute local R8/ProGuard mapping.txt; names are annotated, never replaced", - ); + const path = androidInputSchemas.inspect_android_package.shape.path; + const className = androidInputSchemas.inspect_android_class.shape.class_name; + const mappingPath = + androidInputSchemas.inspect_android_class.shape.mapping_path; const mappingInput = (path: string | undefined) => path === undefined ? {} : { mapping_path: path }; cli.command(CLI_COMMANDS.inspectAndroidJniBindings, { @@ -195,11 +192,7 @@ export const registerAndroidCommands = ( description: "Search APK class names; empty query inventories all classes", args: z.object({ path, - query: z - .string() - .describe( - "Case-sensitive class-name substring; empty lists all classes", - ), + query: androidInputSchemas.search_android_classes.shape.query, }), run: ({ args }) => execute( @@ -231,7 +224,10 @@ export const registerAndroidCommands = ( args: z.object({ path, className, - methodName: z.string().describe("Exact declared method name"), + methodName: + androidInputSchemas.inspect_android_method.shape.method_name.describe( + "Exact declared method name", + ), }), options: z.object({ overloadIndex: cliIntegerArgument(0) @@ -264,12 +260,8 @@ export const registerAndroidCommands = ( "Trace APK using nodes for a defined class/method or an externally invoked DEX method", args: z.object({ path, className }), options: z.object({ - methodName: z - .string() - .optional() - .describe( - "Defined or directly referenced external method; omit for APK class references", - ), + methodName: + androidInputSchemas.trace_android_references.shape.method_name, overloadIndex: cliIntegerArgument(0) .optional() .describe( diff --git a/src/cli/argumentErrors.ts b/src/cli/argumentErrors.ts index f213b861a8..24d57d7469 100644 --- a/src/cli/argumentErrors.ts +++ b/src/cli/argumentErrors.ts @@ -25,7 +25,8 @@ const isAdbSelector = (command: string, field: string): boolean => (["pull-adb-file", "push-adb-file"].includes(command) && field === "device-path") || (command === "start-adb-activity" && - ["action", "component", "data-uri"].includes(field)); + ["action", "component", "data-uri"].includes(field)) || + (command === "read-adb-setting" && field === "key"); /** Preserve parser correction fields without embedding its serialized cause. */ export const presentCliArgumentErrors = (cli: CliInstance): void => { diff --git a/src/cli/browserAdvancedCommands.ts b/src/cli/browserAdvancedCommands.ts index 3bce3bd49a..230de44fcd 100644 --- a/src/cli/browserAdvancedCommands.ts +++ b/src/cli/browserAdvancedCommands.ts @@ -34,6 +34,7 @@ import { 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"; @@ -57,10 +58,12 @@ const registerWebMcp = ( cli.command(CLI_COMMANDS.discoverWebMcpTools, { description: "Passively discover page-declared WebMCP tools", args: z.object({ - endpoint: z - .string() - .describe("Loopback CDP HTTP endpoint or browser WebSocket URL"), - targetId: z.string().describe("Target ID from list-browser-targets"), + endpoint: cliDomainInput( + discoverWebMcpToolsInputSchema.shape.cdp_endpoint, + ), + targetId: discoverWebMcpToolsInputSchema.shape.target_id.describe( + "Target ID from list-browser-targets", + ), }), options: z.object({ ...browserScopeOptions, @@ -172,10 +175,12 @@ const registerScreenshot = ( description: "Capture one visible page viewport within the supplied origin scope", args: z.object({ - endpoint: z - .string() - .describe("Loopback CDP HTTP endpoint or browser WebSocket URL"), - targetId: z.string().describe("Target ID from list-browser-targets"), + endpoint: cliDomainInput( + captureWebScreenshotInputSchema.shape.cdp_endpoint, + ), + targetId: captureWebScreenshotInputSchema.shape.target_id.describe( + "Target ID from list-browser-targets", + ), }), options: z.object({ ...browserScopeOptions, diff --git a/src/cli/browserCommands.ts b/src/cli/browserCommands.ts index 32ba64a6ab..610c1484dc 100644 --- a/src/cli/browserCommands.ts +++ b/src/cli/browserCommands.ts @@ -25,6 +25,7 @@ import { analyzeWebBundleInputSchema } from "../domain/webBundleAnalysis.js"; import { observeWebSessionInputSchema } from "../domain/browserSession.js"; import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js"; import type { Logger } from "pino"; +import { cliDomainInput } from "./domainInput.js"; import { cliDecimalField } from "./options.js"; import { CLI_COMMANDS } from "../cliCommandNames.js"; import { @@ -55,9 +56,9 @@ const registerTargetList = ( description: "List page targets from a selected loopback CDP browser, optionally filtered by origin", args: z.object({ - endpoint: z - .string() - .describe("Loopback CDP HTTP endpoint or browser WebSocket URL"), + endpoint: cliDomainInput( + listBrowserTargetsInputSchema.shape.cdp_endpoint, + ), }), options: z.object({ allowedOrigins: browserScopeOptions.allowedOrigins, @@ -103,10 +104,12 @@ const registerPageInspection = ( description: "Passively inspect one page through CDP, defaulting to its current origin", args: z.object({ - endpoint: z - .string() - .describe("Loopback CDP HTTP endpoint or browser WebSocket URL"), - targetId: z.string().describe("Target ID from list-browser-targets"), + endpoint: cliDomainInput( + listBrowserTargetsInputSchema.shape.cdp_endpoint, + ), + targetId: observeWebSessionInputSchema.shape.target_id.describe( + "Target ID from list-browser-targets", + ), }), options: browserPageInspectionOptions, run: ({ args, options }) => @@ -160,10 +163,12 @@ const registerBundleAnalysis = ( description: "Capture and statically analyze a page bundle, defaulting to the selected target origin", args: z.object({ - endpoint: z - .string() - .describe("Loopback CDP HTTP endpoint or browser WebSocket URL"), - targetId: z.string().describe("Target ID from list-browser-targets"), + endpoint: cliDomainInput( + listBrowserTargetsInputSchema.shape.cdp_endpoint, + ), + targetId: observeWebSessionInputSchema.shape.target_id.describe( + "Target ID from list-browser-targets", + ), }), options: z.object({ ...browserScopeOptions, @@ -250,10 +255,12 @@ const registerObservationSession = ( cli.command(CLI_COMMANDS.observeWebSession, { description: "Observe user-driven page navigation during a chosen window", args: z.object({ - endpoint: z - .string() - .describe("Loopback CDP HTTP endpoint or browser WebSocket URL"), - targetId: z.string().describe("Target ID from list-browser-targets"), + endpoint: cliDomainInput( + listBrowserTargetsInputSchema.shape.cdp_endpoint, + ), + targetId: observeWebSessionInputSchema.shape.target_id.describe( + "Target ID from list-browser-targets", + ), }), options: z.object({ ...browserScopeOptions, diff --git a/src/cli/domainInput.ts b/src/cli/domainInput.ts new file mode 100644 index 0000000000..9776383841 --- /dev/null +++ b/src/cli/domainInput.ts @@ -0,0 +1,9 @@ +import { z } from "incur"; + +/** + * CLI argv keeps the domain field's inbound text. A service transform still + * runs when that service parses the same field, and `--schema` matches the + * input view advertised by tools/list. + */ +export const cliDomainInput = (field: z.ZodType): z.ZodType => + field instanceof z.ZodPipe ? (field.def.in as z.ZodType) : field; diff --git a/src/cli/electronCommands.ts b/src/cli/electronCommands.ts index dd2604239a..3a86e6314b 100644 --- a/src/cli/electronCommands.ts +++ b/src/cli/electronCommands.ts @@ -16,6 +16,7 @@ import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import { projectInputIssues } from "../domain/inputIssueProjection.js"; import type { JsonValue } from "../domain/jsonValue.js"; import type { Logger } from "pino"; +import { cliDomainInput } from "./domainInput.js"; import { CLI_COMMANDS } from "../cliCommandNames.js"; import { createElectronFuseService } from "../composition/electronFuses.js"; import { parseCliJsonInput, resolveCliJsonPaths } from "../cliJsonInput.js"; @@ -243,7 +244,9 @@ const registerElectronTargetList = ( description: "List local file pages exposed by the selected Electron CDP endpoint", args: z.object({ - endpoint: z.string().describe("Literal-loopback Electron CDP endpoint"), + endpoint: cliDomainInput( + listElectronTargetsInputSchema.shape.cdp_endpoint, + ).describe("Literal-loopback Electron CDP endpoint"), }), run: ({ args }) => withCommandCancellation((signal) => @@ -286,8 +289,13 @@ const registerElectronPageInspection = ( cli.command(CLI_COMMANDS.inspectElectronPage, { description: "Passively inspect one Electron file page", args: z.object({ - endpoint: z.string().describe("Literal-loopback Electron CDP endpoint"), - targetId: z.string().describe("Target ID from list-electron-targets"), + endpoint: cliDomainInput( + listElectronTargetsInputSchema.shape.cdp_endpoint, + ).describe("Literal-loopback Electron CDP endpoint"), + targetId: z + .string() + .min(1) + .describe("Target ID from list-electron-targets"), }), options: electronPageInspectionOptions, run: ({ args, options }) => diff --git a/src/cli/javascriptRuntimeCommands.ts b/src/cli/javascriptRuntimeCommands.ts index 7b3e949f93..f9831233c1 100644 --- a/src/cli/javascriptRuntimeCommands.ts +++ b/src/cli/javascriptRuntimeCommands.ts @@ -7,6 +7,7 @@ import { } from "../application/javascript/JavaScriptRuntimeObservationService.js"; import { createJavaScriptRuntimeObservationProvider } from "../composition/javascriptRuntimeObservation.js"; import { V8InspectorAdmission } from "../inspector/V8InspectorProvider.js"; +import { cliDomainInput } from "./domainInput.js"; import { cliDecimalField } from "./options.js"; import { CLI_COMMANDS } from "../cliCommandNames.js"; import { logCliCommand } from "../cliLogging.js"; @@ -49,7 +50,9 @@ export const registerJavaScriptRuntimeObservationCommands = ( cli.command(CLI_COMMANDS.listJavaScriptRuntimeTargets, { description: "List Node/Electron V8 Inspector targets", args: z.object({ - endpoint: z.string().describe("Literal-loopback Inspector endpoint"), + endpoint: cliDomainInput( + listJavaScriptRuntimeTargetsInputSchema.shape.inspector_endpoint, + ).describe("Literal-loopback Inspector endpoint"), }), run: ({ args }) => withCommandCancellation((signal) => @@ -90,10 +93,12 @@ export const registerJavaScriptRuntimeObservationCommands = ( cli.command(CLI_COMMANDS.observeJavaScriptRuntime, { description: "Passively observe one exact Node/Electron Inspector target", args: z.object({ - endpoint: z.string().describe("Literal-loopback Inspector endpoint"), - targetId: z - .string() - .describe("Target from list-javascript-runtime-targets"), + endpoint: cliDomainInput( + listJavaScriptRuntimeTargetsInputSchema.shape.inspector_endpoint, + ).describe("Literal-loopback Inspector endpoint"), + targetId: observeJavaScriptRuntimeInputSchema.shape.target_id.describe( + "Target from list-javascript-runtime-targets", + ), }), options: observeOptionsSchema, run: ({ args, options }) => diff --git a/src/cli/webRuntimeCommands.ts b/src/cli/webRuntimeCommands.ts index b9278dd5ec..ef4a2020c7 100644 --- a/src/cli/webRuntimeCommands.ts +++ b/src/cli/webRuntimeCommands.ts @@ -1,6 +1,9 @@ import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js"; import { Cli, z } from "incur"; import { createWebRuntimeService } from "../composition/webRuntime.js"; +import { cliDomainInput } from "./domainInput.js"; +import { observeWebExecutionInputSchema } from "../domain/webExecution.js"; +import { inspectWebEventListenersInputSchema } from "../domain/webEventListeners.js"; import { browserCliError } from "./browserCliError.js"; import { browserScopeOptions, @@ -9,7 +12,6 @@ import { import { logCliCommand } from "../cliLogging.js"; import { CLI_COMMANDS } from "../cliCommandNames.js"; import { cliDecimalField } from "./options.js"; -import { observeWebExecutionInputSchema } from "../domain/webExecution.js"; import type { Logger } from "pino"; import type { ProgressReporter } from "../application/ProgressReporter.js"; import { withCommandCancellation } from "./commandCancellation.js"; @@ -31,10 +33,12 @@ export const registerWebRuntimeCommands = ( description: "Observe precise page execution; resets counters and temporarily disables optimization", args: z.object({ - endpoint: z.string().describe("Existing loopback browser CDP endpoint"), - targetId: z - .string() - .describe("Selected page target ID from list-browser-targets"), + endpoint: cliDomainInput( + observeWebExecutionInputSchema.shape.cdp_endpoint, + ).describe("Existing loopback browser CDP endpoint"), + targetId: observeWebExecutionInputSchema.shape.target_id.describe( + "Selected page target ID from list-browser-targets", + ), }), options: z.object({ ...browserScopeOptions, @@ -74,13 +78,15 @@ export const registerWebRuntimeCommands = ( description: "Inspect native listener source locations on the first main-document CSS match", args: z.object({ - endpoint: z.string().describe("Existing loopback browser CDP endpoint"), - targetId: z - .string() - .describe("Selected page target ID from list-browser-targets"), - selector: z - .string() - .describe("Native CSS selector; inspect the first main-document match"), + endpoint: cliDomainInput( + inspectWebEventListenersInputSchema.shape.cdp_endpoint, + ).describe("Existing loopback browser CDP endpoint"), + targetId: inspectWebEventListenersInputSchema.shape.target_id.describe( + "Selected page target ID from list-browser-targets", + ), + selector: inspectWebEventListenersInputSchema.shape.selector.describe( + "Native CSS selector; inspect the first main-document match", + ), }), options: z.object({ ...browserScopeOptions }), run: ({ args, options }) => diff --git a/tests/boundary/cli/adbCli.test.ts b/tests/boundary/cli/adbCli.test.ts index 01b77ff3b8..fc6fcfc2fc 100644 --- a/tests/boundary/cli/adbCli.test.ts +++ b/tests/boundary/cli/adbCli.test.ts @@ -409,6 +409,16 @@ it("retains safe structured constraints for ADB selectors", async () => { path: "data-uri", maximum: 2048, }, + { + argv: ["read-adb-setting", "S", "system", "k".repeat(257)], + path: "key", + maximum: 256, + }, + { + argv: ["read-adb-setting", "S", "system", "bad key"], + path: "key", + expected: "regex", + }, ]; for (const { argv, path, expected, maximum } of cases) { const result = await serve([...argv, "--json"], { diff --git a/tests/boundary/cli/browserInputDiagnostics.test.ts b/tests/boundary/cli/browserInputDiagnostics.test.ts index 16b1182fd2..39e5c1106b 100644 --- a/tests/boundary/cli/browserInputDiagnostics.test.ts +++ b/tests/boundary/cli/browserInputDiagnostics.test.ts @@ -1,11 +1,49 @@ import { describe, expect } from "vitest"; +import type { z } from "zod"; +import { + inspectWebPageInputSchema, + listBrowserTargetsInputSchema, +} from "../../../src/domain/browserObservation.js"; +import { listJavaScriptRuntimeTargetsInputSchema } from "../../../src/domain/javascript/javascriptRuntimeObservation.js"; +import { analyzeWebBundleInputSchema } from "../../../src/domain/webBundleAnalysis.js"; +import { discoverWebMcpToolsInputSchema } from "../../../src/domain/webMcpDiscovery.js"; import { workspaceCliTest as cliTest } from "../../support/cli/workspaceCliFixture.js"; const malformedEndpoint = "not a url"; const browserEndpoint = "http://127.0.0.1:9222"; const inspectorEndpoint = "http://127.0.0.1:9229"; +/** The domain schema owns custom issue text. Tests compare the CLI to that issue. */ +const schemaIssue = ( + schema: z.ZodType, + value: unknown, + path: readonly (string | number)[], +) => { + const parsed = schema.safeParse(value); + if (parsed.success) + throw new Error(`domain schema accepted ${path.join(".")}`); + const issue = parsed.error.issues.find( + (item) => item.path.join(".") === path.join("."), + ); + if (issue === undefined) + throw new Error( + `domain schema omitted ${path.join(".")}: ${JSON.stringify(parsed.error.issues)}`, + ); + return issue; +}; + +const malformedEndpointInput = (operation: string) => + operation === "list_browser_targets" + ? { cdp_endpoint: malformedEndpoint } + : { + cdp_endpoint: malformedEndpoint, + target_id: "target", + ...(operation === "analyze_web_bundle" + ? { include_script_sources: true } + : {}), + }; + const invalidInputs = [ { command: "list-browser-targets", @@ -15,15 +53,8 @@ const invalidInputs = [ issue: { path: ["cdp_endpoint"], reason: "invalid_value", - message: "Invalid CDP endpoint URL", }, - }, - { - command: "inspect-web-page", - arguments: [browserEndpoint, ""], - operation: "inspect_web_page", - error: "Browser observation failed", - issue: { path: ["target_id"], reason: "out_of_range", minimum: 1 }, + schema: listBrowserTargetsInputSchema, }, { command: "analyze-web-bundle", @@ -33,33 +64,8 @@ const invalidInputs = [ issue: { path: ["cdp_endpoint"], reason: "invalid_value", - message: "Invalid CDP endpoint URL", }, - }, - { - command: "observe-web-session", - arguments: [browserEndpoint, ""], - operation: "observe_web_session", - error: "Browser observation failed", - issue: { path: ["target_id"], reason: "out_of_range", minimum: 1 }, - }, - { - command: "list-javascript-runtime-targets", - arguments: [""], - operation: "list_javascript_runtime_targets", - error: "JavaScript runtime observation failed", - issue: { - path: ["inspector_endpoint"], - reason: "out_of_range", - minimum: 1, - }, - }, - { - command: "observe-javascript-runtime", - arguments: [inspectorEndpoint, ""], - operation: "observe_javascript_runtime", - error: "JavaScript runtime observation failed", - issue: { path: ["target_id"], reason: "out_of_range", minimum: 1 }, + schema: analyzeWebBundleInputSchema, }, { command: "discover-webmcp-tools", @@ -69,15 +75,36 @@ const invalidInputs = [ issue: { path: ["cdp_endpoint"], reason: "invalid_value", - message: "Invalid CDP endpoint URL", }, + schema: discoverWebMcpToolsInputSchema, + }, +]; + +const parserRejections = [ + { + command: "inspect-web-page", + arguments: [browserEndpoint, ""], + path: "targetId", + }, + { + command: "observe-web-session", + arguments: [browserEndpoint, ""], + path: "targetId", + }, + { + command: "list-javascript-runtime-targets", + arguments: [""], + path: "endpoint", + }, + { + command: "observe-javascript-runtime", + arguments: [inspectorEndpoint, ""], + path: "targetId", }, { command: "capture-web-screenshot", arguments: [browserEndpoint, ""], - operation: "capture_web_screenshot", - error: "Browser observation failed", - issue: { path: ["target_id"], reason: "out_of_range", minimum: 1 }, + path: "targetId", }, ] as const; @@ -97,6 +124,15 @@ describe("inspection branch diagnostics", () => { environment: { ...workspace.environment, REA_LOG_LEVEL: "silent" }, }); expect(result.exitCode).toBe(1); + const owner = schemaIssue( + inspectWebPageInputSchema, + { + cdp_endpoint: malformedEndpoint, + target_id: "target", + include_script_sources: flags.includes("--include-script-sources"), + }, + ["cdp_endpoint"], + ); expect(result.json).toMatchObject({ code: "invalid_request", details: { @@ -105,7 +141,7 @@ describe("inspection branch diagnostics", () => { { path: ["cdp_endpoint"], reason: "invalid_value", - message: "Invalid CDP endpoint URL", + message: owner.message, }, ], }, @@ -124,6 +160,11 @@ describe("browser and Inspector CLI input diagnostics", () => { environment: { ...workspace.environment, REA_LOG_LEVEL: "silent" }, }); + const owner = schemaIssue( + input.schema, + malformedEndpointInput(input.operation), + input.issue.path, + ); expect(result.exitCode).toBe(1); expect(result.json).toMatchObject({ error: input.error, @@ -141,7 +182,9 @@ describe("browser and Inspector CLI input diagnostics", () => { }, details: { operation: input.operation, - issues: expect.arrayContaining([input.issue]), + issues: expect.arrayContaining([ + { ...input.issue, message: owner.message }, + ]), }, }); expect(result.stdout).not.toContain(malformedEndpoint); @@ -163,6 +206,15 @@ describe("browser and Inspector CLI input diagnostics", () => { environment: { ...workspace.environment, REA_LOG_LEVEL: "debug" }, }); + const owner = schemaIssue( + command === "list-browser-targets" + ? listBrowserTargetsInputSchema + : listJavaScriptRuntimeTargetsInputSchema, + command === "list-browser-targets" + ? { cdp_endpoint: endpoint } + : { inspector_endpoint: endpoint }, + [field], + ); expect(result.exitCode).toBe(1); expect(result.json).toMatchObject({ code: "invalid_request", @@ -171,8 +223,7 @@ describe("browser and Inspector CLI input diagnostics", () => { { path: [field], reason: "invalid_value", - message: - "CDP endpoint must be an explicit-port HTTP URL on 127.0.0.1 or ::1", + message: owner.message, }, ], }, @@ -183,7 +234,9 @@ describe("browser and Inspector CLI input diagnostics", () => { expect(output).not.toContain("rea-diagnostic-secret"); }, ); +}); +describe("browser correction facts", () => { cliTest( "retains authored cross-field correction guidance", async ({ cli, workspace }) => { @@ -199,6 +252,16 @@ describe("browser and Inspector CLI input diagnostics", () => { environment: { ...workspace.environment, REA_LOG_LEVEL: "silent" }, }); + const owner = schemaIssue( + inspectWebPageInputSchema, + { + cdp_endpoint: browserEndpoint, + target_id: "target", + include_script_sources: false, + include_storage_fingerprints: true, + }, + ["include_storage_fingerprints"], + ); expect(result.exitCode).toBe(1); expect(result.json).toMatchObject({ code: "invalid_request", @@ -208,7 +271,7 @@ describe("browser and Inspector CLI input diagnostics", () => { { path: ["include_storage_fingerprints"], reason: "invalid_value", - message: "Storage fingerprints require storage key capture", + message: owner.message, }, ]), }, @@ -231,7 +294,14 @@ describe("browser and Inspector CLI input diagnostics", () => { details: { operation: "compare_web_captures", issues: expect.arrayContaining([ - expect.objectContaining({ reason: expect.any(String) }), + expect.objectContaining({ + path: ["before", "inspection"], + reason: "missing_argument", + }), + expect.objectContaining({ + path: ["after", "inspection"], + reason: "missing_argument", + }), ]), }, }); @@ -262,3 +332,26 @@ describe("browser and Inspector CLI input diagnostics", () => { }, ); }); + +describe("empty browser and Inspector selectors", () => { + cliTest.for(parserRejections)( + "$command rejects an empty selector in argument parsing", + async (input, { cli, workspace }) => { + const result = await cli.run({ + arguments: [input.command, ...input.arguments, "--json"], + cwd: workspace.root, + environment: { ...workspace.environment, REA_LOG_LEVEL: "silent" }, + }); + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject({ + code: "VALIDATION_ERROR", + fieldErrors: [ + { + code: "too_small", + path: input.path, + }, + ], + }); + }, + ); +}); diff --git a/tests/boundary/cli/cliDomainFieldSchema.test.ts b/tests/boundary/cli/cliDomainFieldSchema.test.ts new file mode 100644 index 0000000000..6f953dcd55 --- /dev/null +++ b/tests/boundary/cli/cliDomainFieldSchema.test.ts @@ -0,0 +1,165 @@ +import { Ajv2020 } from "ajv/dist/2020.js"; +import type { AnySchema } from "ajv"; +import { Cli, z } from "incur"; +import { expect, it } from "vitest"; + +import { createCli } from "../../../src/cli.js"; + +type CliInstance = ReturnType; +type CommandMap = NonNullable>; + +const registeredCommands: WeakMap = Cli.toCommands; + +const serve = async (cli: CliInstance, argv: readonly string[]) => { + let stdout = ""; + let exitCode = 0; + await cli.serve([...argv], { + env: {}, + exit: (code) => { + exitCode = code; + }, + stdout: (text) => { + stdout += text; + }, + }); + expect(exitCode, argv.join(" ")).toBe(0); + return z + .object({ + args: z + .object({ properties: z.record(z.string(), z.unknown()) }) + .optional(), + options: z + .object({ properties: z.record(z.string(), z.unknown()) }) + .optional(), + }) + .parse(JSON.parse(stdout)); +}; + +const absoluteMapping = + process.platform === "win32" ? "C:\\mapping.txt" : "/tmp/mapping.txt"; + +it("advertises the domain constraints for android, adb settings, and browser selectors", async () => { + const cli = createCli({}); + const ajv = new Ajv2020({ strict: false, validateFormats: false }); + const validate = async ( + command: string, + group: "args" | "options", + value: Record, + ) => { + const advertised = await serve(cli, [command, "--schema", "--json"]); + const schema = advertised[group]; + expect(schema, command).toBeDefined(); + return ajv.compile(schema as AnySchema)(value); + }; + + expect( + await validate("inspect-android-class", "args", { + path: "", + className: "com.example.Main", + }), + ).toBe(false); + expect( + await validate("inspect-android-class", "args", { + path: "app.apk", + className: "", + }), + ).toBe(false); + expect( + await validate("inspect-android-class", "args", { + path: "app.apk", + className: "com.example.Main", + }), + ).toBe(true); + expect( + await validate("inspect-android-class", "options", { + mappingPath: "mapping.txt", + }), + ).toBe(false); + expect( + await validate("inspect-android-class", "options", { + mappingPath: absoluteMapping, + }), + ).toBe(true); + expect( + await validate("inspect-android-method", "args", { + path: "app.apk", + className: "com.example.Main", + methodName: "", + }), + ).toBe(false); + + const setting = { + serial: "emulator-5554", + namespace: "system", + }; + expect( + await validate("read-adb-setting", "args", { + ...setting, + key: "key with space", + }), + ).toBe(false); + expect( + await validate("read-adb-setting", "args", { + ...setting, + key: "k".repeat(257), + }), + ).toBe(false); + expect( + await validate("read-adb-setting", "args", { + ...setting, + key: "airplane_mode_on", + }), + ).toBe(true); + + const listener = { + targetId: "page", + selector: "button", + }; + expect( + await validate("inspect-web-event-listeners", "args", { + ...listener, + endpoint: "", + }), + ).toBe(false); + expect( + await validate("inspect-web-event-listeners", "args", { + endpoint: "http://127.0.0.1:9222", + targetId: "", + selector: "button", + }), + ).toBe(false); + expect( + await validate("inspect-web-event-listeners", "args", { + endpoint: "http://127.0.0.1:9222", + ...listener, + selector: "", + }), + ).toBe(false); + expect( + await validate("inspect-web-event-listeners", "args", { + endpoint: "http://127.0.0.1:9222", + ...listener, + }), + ).toBe(true); + expect( + await validate("capture-web-screenshot", "args", { + endpoint: "", + targetId: "page", + }), + ).toBe(false); + expect( + await validate("capture-web-screenshot", "args", { + endpoint: "http://127.0.0.1:9222", + targetId: "page", + }), + ).toBe(true); + + const parser = registeredCommands.get(cli)?.get("inspect-android-class"); + expect(parser && "args" in parser && parser.args instanceof z.ZodObject).toBe( + true, + ); + if (parser && "args" in parser && parser.args instanceof z.ZodObject) + expect( + parser.args.safeParse({ path: "app.apk", className: "" }).success, + ).toBe(false); +}); diff --git a/tests/boundary/cli/electronCli.test.ts b/tests/boundary/cli/electronCli.test.ts index d5e2e274a5..4b846b6485 100644 --- a/tests/boundary/cli/electronCli.test.ts +++ b/tests/boundary/cli/electronCli.test.ts @@ -148,19 +148,13 @@ it( expect(invalidPageInspect.signal).toBeNull(); expect(invalidPageInspect.exitCode).toBe(1); expect(invalidPageInspect.json).toMatchObject({ - error: "Electron analysis failed", - code: "invalid_request", - category: "invalid_input", - details: { - operation: "inspect_electron_page", - issues: [ - { - path: ["target_id"], - reason: "out_of_range", - minimum: 1, - }, - ], - }, + code: "VALIDATION_ERROR", + fieldErrors: [ + { + code: "too_small", + path: "targetId", + }, + ], }); }, INTEGRATION_TEST_TIMEOUT_MS,