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,