diff --git a/src/commands/batch/cli.test.ts b/src/commands/batch/cli.test.ts index 8071e46e1..0eb957d1f 100644 --- a/src/commands/batch/cli.test.ts +++ b/src/commands/batch/cli.test.ts @@ -66,7 +66,7 @@ test('batch structured interaction target is projected to positionals, not devic const result = await runCliCapture([ 'batch', '--steps', - '[{"command":"press","input":{"target":{"kind":"point","x":10,"y":20},"count":2}}]', + '[{"command":"press","input":{"target":{"kind":"point","x":10,"y":20},"count":2,"platform":"ios","udid":"sim-1"}}]', '--json', ]); @@ -76,6 +76,21 @@ test('batch structured interaction target is projected to positionals, not devic assert.deepEqual(step?.positionals, ['10', '20']); assert.equal(step?.flags?.target, undefined); assert.equal(step?.flags?.count, 2); + assert.equal(step?.flags?.platform, 'ios'); + assert.equal(step?.flags?.udid, 'sim-1'); +}); + +test('batch rejects invalid structured step input before daemon projection', async () => { + const result = await runCliCapture([ + 'batch', + '--steps', + '[{"command":"focus","input":{"x":10}}]', + ]); + + assert.equal(result.code, 1); + assert.equal(result.calls.length, 0); + assert.match(result.stderr, /Batch step 1 focus input is invalid: Expected y to be set\./); + assert.doesNotMatch(result.stderr, /undefined/); }); test('batch rejects structured replay steps before daemon dispatch', async () => { diff --git a/src/commands/batch/projection.ts b/src/commands/batch/projection.ts index f689ef15a..c4e7cd819 100644 --- a/src/commands/batch/projection.ts +++ b/src/commands/batch/projection.ts @@ -14,7 +14,11 @@ const batchCommandNames = STRUCTURED_BATCH_COMMAND_NAMES satisfies readonly Daem export type BatchCommandName = (typeof batchCommandNames)[number]; -type PrepareDaemonCommandRequest = (command: string, input: CommandInput) => DaemonCommandRequest; +type PrepareDaemonCommandRequest = ( + command: string, + input: CommandInput, + stepNumber: number, +) => DaemonCommandRequest; export function createBatchDaemonWriter( prepareDaemonCommandRequest: PrepareDaemonCommandRequest, @@ -49,24 +53,12 @@ function readBatchDaemonStep( const command = readBatchStepCommand(record, stepNumber); const input = readBatchStepInput(record, stepNumber); const runtime = readBatchStepRuntime(record, stepNumber); - const prepared = prepareBatchStep(command, input, prepareDaemonCommandRequest); - return { - ...prepared, - runtime: runtime ?? prepared.runtime, - }; -} - -function prepareBatchStep( - command: BatchCommandName, - input: CommandInput, - prepareDaemonCommandRequest: PrepareDaemonCommandRequest, -): DaemonBatchStep { - const prepared = prepareDaemonCommandRequest(command, input); + const prepared = prepareDaemonCommandRequest(command, input, stepNumber); return { command: prepared.command, positionals: prepared.positionals, flags: buildFlags(prepared.options), - runtime: prepared.options.runtime, + runtime: runtime ?? prepared.options.runtime, }; } diff --git a/src/commands/command-metadata.ts b/src/commands/command-metadata.ts index 6c13f5e37..4b7bc14d5 100644 --- a/src/commands/command-metadata.ts +++ b/src/commands/command-metadata.ts @@ -24,10 +24,11 @@ export function listCommandMetadata(): AnyCommandMetadata[] { export function listMcpCommandMetadata(): AnyCommandMetadata[] { return listMcpExposedCommandNames().map((name) => { - if (!isCommandName(name)) { + const metadata = findCommandMetadata(name); + if (!metadata) { throw new Error(`Missing command metadata for MCP-exposed command: ${name}`); } - return getCommandMetadata(name); + return metadata; }); } @@ -39,6 +40,6 @@ export function isCommandName(name: string): name is CommandName { return commandMetadataMap.has(name as CommandName); } -function getCommandMetadata(name: CommandName): AnyCommandMetadata { - return commandMetadataMap.get(name)!; +export function findCommandMetadata(name: string): AnyCommandMetadata | undefined { + return commandMetadataMap.get(name as CommandName); } diff --git a/src/commands/command-projection.ts b/src/commands/command-projection.ts index fc8ce9613..83f48ff89 100644 --- a/src/commands/command-projection.ts +++ b/src/commands/command-projection.ts @@ -13,6 +13,8 @@ import { reactNativeDaemonWriters } from './react-native/index.ts'; import { recordingDaemonWriters } from './recording/index.ts'; import { replayDaemonWriters } from './replay/index.ts'; import { systemDaemonWriters } from './system/index.ts'; +import { findCommandMetadata } from './command-metadata.ts'; +import { AppError } from '../utils/errors.ts'; const daemonWriters = { ...appDaemonWriters, @@ -36,12 +38,27 @@ export type { BatchCommandName }; function prepareBatchDaemonCommandRequest( command: string, input: CommandInput, + stepNumber: number, ): DaemonCommandRequest { const writer = (daemonWriters as Readonly>)[command]; if (!writer) { throw new Error(`Missing daemon writer for batch command: ${command}`); } - return writer(input); + const metadata = findCommandMetadata(command); + if (!metadata) { + throw new Error(`Missing command metadata for batch command: ${command}`); + } + try { + return writer(metadata.readInput(input) as CommandInput); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new AppError( + 'INVALID_ARGS', + `Batch step ${stepNumber} ${command} input is invalid: ${message}`, + undefined, + err, + ); + } } export function prepareDaemonCommandRequest(