diff --git a/COMMAND_OWNERSHIP.md b/COMMAND_OWNERSHIP.md index fceaf885f..516385a71 100644 --- a/COMMAND_OWNERSHIP.md +++ b/COMMAND_OWNERSHIP.md @@ -92,6 +92,33 @@ Their semantics should live in `agent-device/commands` as they migrate. and selector targets. - `pinch`: runtime `interactions.pinch` implemented behind the typed backend primitive. +- `devices`: runtime `admin.devices` implemented through typed backend + inventory primitives. +- `boot`: runtime `admin.boot` implemented through a typed backend boot + primitive. +- `ensure-simulator`: runtime `admin.ensureSimulator` implemented with typed + simulator options and result shape. +- `install`: runtime `admin.install` implemented with structured install + sources and local path policy enforcement. +- `reinstall`: runtime `admin.reinstall` implemented with structured install + sources and local path policy enforcement. +- `install-from-source`: runtime `admin.installFromSource` implemented with the + same structured source resolver used by install/reinstall. +- `batch`: runtime router command implemented; nested steps are dispatched + through `createCommandRouter()` so policy and error formatting run per step. +- `record`: runtime `record` router/API command implemented with typed record + start/stop result unions. +- `trace`: runtime `trace` router/API command implemented with typed trace + start/stop result unions. +- `logs`: runtime `diagnostics.logs` implemented with bounded, paginated, + best-effort redacted log entries. +- `network`: runtime `diagnostics.network` implemented with bounded, + structured, best-effort redacted network entries. +- `perf`: runtime `diagnostics.perf` implemented with typed metric entries. +- `replay`: still daemon/CLI owned; runtime router migration is deferred until + it can reuse the real `.ad` parser and healing semantics. +- `test`: still daemon/CLI owned; runtime router migration is deferred until it + can share daemon replay-suite semantics end to end. ## Boundary Requirements @@ -147,17 +174,9 @@ the portable command runtime. ## Later Capability-Gated Runtime Commands -These commands should migrate only after the runtime, backend capability, and IO -contracts are established for their behavior. - -- `batch` -- `logs` -- `network` -- `perf` -- `record` -- `replay` -- `test` -- `trace` +All currently identified capability-gated diagnostics have runtime command +contracts. New diagnostics should follow the `diagnostics.*` namespace with +bounded result windows and backend-specific support. ## Compatibility Helper Subpaths diff --git a/src/__tests__/runtime-admin-router.test.ts b/src/__tests__/runtime-admin-router.test.ts new file mode 100644 index 000000000..096bb67d9 --- /dev/null +++ b/src/__tests__/runtime-admin-router.test.ts @@ -0,0 +1,397 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import type { AgentDeviceBackend, BackendInstallSource } from '../backend.ts'; +import type { ArtifactAdapter, FileInputRef } from '../io.ts'; +import { createAgentDevice, localCommandPolicy, restrictedCommandPolicy } from '../runtime.ts'; +import { createCommandRouter } from '../commands/index.ts'; + +const artifacts = { + resolveInput: async (ref: FileInputRef) => ({ + path: ref.kind === 'path' ? ref.path : `/tmp/uploaded/${ref.id}.app`, + cleanup: ref.kind === 'uploadedArtifact' ? async () => {} : undefined, + }), + reserveOutput: async (ref, options) => ({ + path: ref?.kind === 'path' ? ref.path : `/tmp/${options.field}${options.ext}`, + visibility: options.visibility ?? 'client-visible', + publish: async () => undefined, + }), + createTempFile: async (options) => ({ + path: `/tmp/${options.prefix}${options.ext}`, + visibility: 'internal', + cleanup: async () => {}, + }), +} satisfies ArtifactAdapter; + +test('admin runtime commands call typed backend primitives', async () => { + const calls: string[] = []; + let installSource: BackendInstallSource | undefined; + const device = createAgentDevice({ + backend: createAdminBackend(calls, (source) => { + installSource = source; + }), + artifacts, + policy: localCommandPolicy(), + }); + + const devices = await device.admin.devices({ filter: { platform: 'ios' } }); + assert.equal(devices.kind, 'adminDevices'); + assert.equal(devices.devices[0]?.id, 'SIM-1'); + + const boot = await device.admin.boot({ target: { id: 'SIM-1' } }); + assert.equal(boot.kind, 'deviceBooted'); + + const simulator = await device.admin.ensureSimulator({ + device: 'iPhone 16', + runtime: 'iOS 18', + boot: true, + }); + assert.equal(simulator.udid, 'SIM-1'); + + const installed = await device.admin.install({ + app: 'com.example.app', + source: { kind: 'path', path: '/tmp/Example.app' }, + }); + assert.equal(installed.kind, 'appInstalled'); + assert.deepEqual(installSource, { kind: 'path', path: '/tmp/Example.app' }); + + const reinstalled = await device.admin.reinstall({ + app: 'com.example.app', + source: { kind: 'url', url: 'https://example.test/Example.app.zip' }, + }); + assert.equal(reinstalled.kind, 'appReinstalled'); + + const installedFromSource = await device.admin.installFromSource({ + source: { kind: 'url', url: 'https://example.test/Other.app.zip' }, + }); + assert.equal(installedFromSource.kind, 'appInstalledFromSource'); + + assert.deepEqual(calls, [ + 'listDevices', + 'bootDevice', + 'ensureSimulator', + 'installApp', + 'reinstallApp', + 'installApp', + ]); +}); + +test('admin install blocks local paths under restricted policy but accepts uploaded artifacts', async () => { + let sourceSeen: BackendInstallSource | undefined; + const device = createAgentDevice({ + backend: createAdminBackend([], (source) => { + sourceSeen = source; + }), + artifacts, + policy: restrictedCommandPolicy(), + }); + + await assert.rejects( + () => + device.admin.install({ + app: 'com.example.app', + source: { kind: 'path', path: '/tmp/Example.app' }, + }), + /Local source paths are not allowed/, + ); + + await device.admin.install({ + app: 'com.example.app', + source: { kind: 'uploadedArtifact', id: 'artifact-1' }, + }); + assert.deepEqual(sourceSeen, { kind: 'path', path: '/tmp/uploaded/artifact-1.app' }); +}); + +test('admin install cleans materialized input when backend source resolution fails', async () => { + let cleanupCalled = false; + let installCalled = false; + const device = createAgentDevice({ + backend: { + platform: 'ios', + resolveInstallSource: async () => { + throw new Error('backend source resolution failed'); + }, + installApp: async () => { + installCalled = true; + return {}; + }, + }, + artifacts: { + ...artifacts, + resolveInput: async (ref: FileInputRef) => ({ + path: ref.kind === 'path' ? ref.path : `/tmp/uploaded/${ref.id}.app`, + cleanup: + ref.kind === 'uploadedArtifact' + ? async () => { + cleanupCalled = true; + } + : undefined, + }), + }, + policy: restrictedCommandPolicy(), + }); + + await assert.rejects( + () => + device.admin.install({ + app: 'com.example.app', + source: { kind: 'uploadedArtifact', id: 'artifact-1' }, + }), + /backend source resolution failed/, + ); + + assert.equal(cleanupCalled, true); + assert.equal(installCalled, false); +}); + +test('router batch preserves per-step failures and enforces per-command policy', async () => { + const router = createCommandRouter({ + createRuntime: () => + createAgentDevice({ + backend: { + platform: 'ios', + openApp: async () => {}, + installApp: async () => ({ bundleId: 'com.example.app' }), + }, + artifacts, + policy: restrictedCommandPolicy(), + }), + }); + + const response = await router.dispatch({ + command: 'batch', + options: { + steps: [ + { command: 'apps.open', options: { app: 'com.example.app' } }, + { + command: 'admin.install', + options: { app: 'com.example.app', source: { kind: 'path', path: '/tmp/app.zip' } }, + }, + { command: 'apps.open', options: { app: 'com.example.other' } }, + ], + }, + }); + + assert.equal(response.ok, true); + assert.equal(response.ok && isResultKind(response.data, 'batch') ? response.data.executed : 0, 2); + assert.equal(response.ok && isResultKind(response.data, 'batch') ? response.data.failed : 0, 1); + const failed = + response.ok && isResultKind(response.data, 'batch') ? response.data.results[1] : null; + assert.equal(failed?.ok, false); + assert.equal(failed?.ok === false ? failed.error.code : undefined, 'INVALID_ARGS'); + + const nested = await router.dispatch({ + command: 'batch', + options: { + steps: [{ command: 'batch', options: { steps: [] } }], + }, + }); + assert.equal(nested.ok, false); + assert.equal(nested.ok ? undefined : nested.error.code, 'INVALID_ARGS'); +}); + +test('router batch can continue after failure and inherits command context', async () => { + const sessionsSeen: unknown[] = []; + const appsOpened: string[] = []; + const router = createCommandRouter({ + createRuntime: (request) => { + sessionsSeen.push(request.options?.session); + return createAgentDevice({ + backend: { + platform: 'ios', + openApp: async (_context, target) => { + if (target.app === 'bad') throw new Error('open failed'); + if (target.app) appsOpened.push(target.app); + }, + }, + artifacts, + policy: restrictedCommandPolicy(), + }); + }, + }); + + const response = await router.dispatch({ + command: 'batch', + options: { + session: 'parent-session', + stopOnError: false, + maxSteps: 2, + steps: [ + { command: 'apps.open', options: { app: 'bad' } }, + { command: 'apps.open', options: { app: 'good' } }, + ], + }, + }); + + assert.equal(response.ok, true); + assert.equal(response.ok && isResultKind(response.data, 'batch') ? response.data.executed : 0, 2); + assert.equal(response.ok && isResultKind(response.data, 'batch') ? response.data.failed : 0, 1); + assert.deepEqual(appsOpened, ['good']); + assert.deepEqual(sessionsSeen, ['parent-session', 'parent-session']); +}); + +test('record and trace runtime commands call typed backend lifecycle primitives', async () => { + const calls: unknown[] = []; + const device = createAgentDevice({ + backend: { + platform: 'ios', + startRecording: async (_context, options) => { + calls.push({ command: 'startRecording', options }); + return { path: options?.outPath ?? '/tmp/recording.mp4' }; + }, + stopTrace: async (_context, options) => { + calls.push({ command: 'stopTrace', options }); + return { outPath: options?.outPath ?? '/tmp/trace.log' }; + }, + }, + artifacts, + policy: localCommandPolicy(), + }); + + const recording = await device.recording.record({ + action: 'start', + out: { kind: 'path', path: '/tmp/out.mp4' }, + fps: 30, + quality: 7, + hideTouches: true, + }); + assert.equal(recording.kind, 'recordingStarted'); + + const trace = await device.recording.trace({ + action: 'stop', + out: { kind: 'path', path: '/tmp/out.trace' }, + }); + assert.equal(trace.kind, 'traceStopped'); + + assert.deepEqual(calls, [ + { + command: 'startRecording', + options: { outPath: '/tmp/out.mp4', fps: 30, quality: 7, showTouches: false }, + }, + { command: 'stopTrace', options: { outPath: '/tmp/out.trace' } }, + ]); +}); + +test('record output paths are policy-gated', async () => { + const device = createAgentDevice({ + backend: { + platform: 'ios', + startRecording: async () => ({ path: '/tmp/recording.mp4' }), + }, + artifacts, + policy: restrictedCommandPolicy(), + }); + + await assert.rejects( + () => + device.recording.record({ + action: 'start', + out: { kind: 'path', path: '/tmp/out.mp4' }, + }), + /Local output paths are not allowed/, + ); +}); + +test('record keeps successful reserved outputs available after publish', async () => { + let cleanupCalled = false; + const device = createAgentDevice({ + backend: { + platform: 'ios', + startRecording: async (_context, options) => ({ path: options?.outPath }), + }, + artifacts: { + ...artifacts, + reserveOutput: async (_ref, options) => ({ + path: `/tmp/${options.field}${options.ext}`, + visibility: options.visibility ?? 'client-visible', + publish: async () => ({ + kind: 'artifact', + field: options.field, + artifactId: 'recording-1', + fileName: 'recording.mp4', + }), + cleanup: async () => { + cleanupCalled = true; + }, + }), + }, + policy: restrictedCommandPolicy(), + }); + + const result = await device.recording.record({ + action: 'start', + out: { kind: 'downloadableArtifact', fileName: 'recording.mp4' }, + }); + + assert.equal(result.artifact?.kind, 'artifact'); + assert.equal(cleanupCalled, false); +}); + +test('router replay and test stay planned until phase 7 migration is complete', async () => { + const router = createCommandRouter({ + createRuntime: () => + createAgentDevice({ + backend: { platform: 'ios' }, + artifacts, + policy: restrictedCommandPolicy(), + }), + }); + + const replay = await router.dispatch({ + command: 'replay', + options: { steps: [{ command: 'apps.open', options: { app: 'com.example.app' } }] }, + } as never); + assert.equal(replay.ok, false); + assert.equal(replay.ok ? undefined : replay.error.code, 'NOT_IMPLEMENTED'); + + const suite = await router.dispatch({ + command: 'test', + options: { + tests: [{ name: 'opens app', steps: [{ command: 'apps.open', options: { app: 'suite' } }] }], + }, + } as never); + assert.equal(suite.ok, false); + assert.equal(suite.ok ? undefined : suite.error.code, 'NOT_IMPLEMENTED'); +}); + +function isResultKind( + value: unknown, + kind: TKind, +): value is { kind: TKind } & Record { + return Boolean(value && typeof value === 'object' && (value as { kind?: unknown }).kind === kind); +} + +function createAdminBackend( + calls: string[], + onInstallSource?: (source: BackendInstallSource) => void, +): AgentDeviceBackend { + return { + platform: 'ios', + listDevices: async () => { + calls.push('listDevices'); + return [{ id: 'SIM-1', name: 'iPhone 16', platform: 'ios', kind: 'simulator' }]; + }, + bootDevice: async () => { + calls.push('bootDevice'); + }, + ensureSimulator: async (_context, options) => { + calls.push('ensureSimulator'); + return { + udid: 'SIM-1', + device: options.device, + runtime: options.runtime ?? 'iOS 18', + created: false, + booted: true, + }; + }, + installApp: async (_context, target) => { + calls.push('installApp'); + onInstallSource?.(target.source); + return { bundleId: target.app ?? 'com.example.app' }; + }, + reinstallApp: async (_context, target) => { + calls.push('reinstallApp'); + onInstallSource?.(target.source); + return { bundleId: target.app ?? 'com.example.app' }; + }, + }; +} diff --git a/src/__tests__/runtime-conformance.test.ts b/src/__tests__/runtime-conformance.test.ts index af01da093..6c4df4da3 100644 --- a/src/__tests__/runtime-conformance.test.ts +++ b/src/__tests__/runtime-conformance.test.ts @@ -50,6 +50,16 @@ test('command conformance suites run against a fixture backend', async () => { assert.equal(calls.includes('getAppState'), true); assert.equal(calls.includes('pushFile'), true); assert.equal(calls.includes('triggerAppEvent'), true); + assert.equal(calls.includes('listDevices'), true); + assert.equal(calls.includes('bootDevice'), true); + assert.equal(calls.includes('ensureSimulator'), true); + assert.equal(calls.includes('installApp'), true); + assert.equal(calls.includes('reinstallApp'), true); + assert.equal(calls.includes('startRecording'), true); + assert.equal(calls.includes('stopTrace'), true); + assert.equal(calls.includes('readLogs'), true); + assert.equal(calls.includes('dumpNetwork'), true); + assert.equal(calls.includes('measurePerf'), true); }); test('assertCommandConformance throws when a suite fails', async () => { @@ -153,6 +163,57 @@ function createFixtureBackend(calls: string[]): AgentDeviceBackend { triggerAppEvent: async () => { calls.push('triggerAppEvent'); }, + listDevices: async () => { + calls.push('listDevices'); + return [{ id: 'SIM-1', name: 'iPhone 16', platform: 'ios', kind: 'simulator' }]; + }, + bootDevice: async () => { + calls.push('bootDevice'); + }, + ensureSimulator: async (_context, options) => { + calls.push('ensureSimulator'); + return { + udid: 'SIM-1', + device: options.device, + runtime: options.runtime ?? 'iOS 18', + created: false, + booted: true, + }; + }, + installApp: async () => { + calls.push('installApp'); + return { bundleId: 'com.example.app' }; + }, + reinstallApp: async () => { + calls.push('reinstallApp'); + return { bundleId: 'com.example.app' }; + }, + startRecording: async () => { + calls.push('startRecording'); + return { path: '/tmp/recording.mp4' }; + }, + stopTrace: async () => { + calls.push('stopTrace'); + return { outPath: '/tmp/trace.log' }; + }, + readLogs: async () => { + calls.push('readLogs'); + return { + entries: [{ timestamp: '2026-04-16T00:00:00.000Z', level: 'info', message: 'ready' }], + }; + }, + dumpNetwork: async () => { + calls.push('dumpNetwork'); + return { + entries: [{ method: 'GET', url: 'https://example.test/health', status: 200 }], + }; + }, + measurePerf: async () => { + calls.push('measurePerf'); + return { + metrics: [{ name: 'cpu', value: 3.5, unit: '%' }], + }; + }, }; } diff --git a/src/__tests__/runtime-diagnostics-router.test.ts b/src/__tests__/runtime-diagnostics-router.test.ts new file mode 100644 index 000000000..411a87a99 --- /dev/null +++ b/src/__tests__/runtime-diagnostics-router.test.ts @@ -0,0 +1,160 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import type { AgentDeviceBackend, BackendCommandContext } from '../backend.ts'; +import type { ArtifactAdapter } from '../io.ts'; +import { + createAgentDevice, + createMemorySessionStore, + restrictedCommandPolicy, +} from '../runtime.ts'; +import { createCommandRouter } from '../commands/index.ts'; + +const artifacts = { + resolveInput: async () => ({ path: '/tmp/input' }), + reserveOutput: async (_ref, options) => ({ + path: `/tmp/${options.field}${options.ext}`, + visibility: options.visibility ?? 'client-visible', + publish: async () => undefined, + }), + createTempFile: async (options) => ({ + path: `/tmp/${options.prefix}${options.ext}`, + visibility: 'internal', + cleanup: async () => {}, + }), +} satisfies ArtifactAdapter; + +test('diagnostics runtime commands call typed backend primitives and redact sensitive data', async () => { + const contexts: BackendCommandContext[] = []; + const device = createAgentDevice({ + backend: createDiagnosticsBackend(contexts), + artifacts, + sessions: createMemorySessionStore([ + { name: 'default', appId: 'app-1', appBundleId: 'com.example.app' }, + ]), + policy: restrictedCommandPolicy(), + }); + + const logs = await device.observability.logs({ + session: 'default', + limit: 10, + levels: ['info'], + search: 'ready', + }); + assert.equal(logs.kind, 'diagnosticsLogs'); + assert.equal(logs.redacted, true); + assert.match(logs.entries[0]?.message ?? '', /token=\[REDACTED\]/); + assert.equal(logs.entries[0]?.metadata?.authorization, '[REDACTED]'); + + const network = await device.observability.network({ + session: 'default', + include: 'all', + limit: 5, + }); + assert.equal(network.kind, 'diagnosticsNetwork'); + assert.equal(network.redacted, true); + assert.match(network.entries[0]?.url ?? '', /token=%5BREDACTED%5D/); + assert.equal(network.entries[0]?.requestHeaders?.Authorization, '[REDACTED]'); + assert.deepEqual(JSON.parse(network.entries[0]?.requestBody ?? '{}'), { + token: '[REDACTED]', + nested: { + apiKey: '[REDACTED]', + items: [{ password: '[REDACTED]' }], + }, + }); + assert.deepEqual(JSON.parse(network.entries[0]?.responseBody ?? '{}'), { + ok: true, + session: { + authorization: '[REDACTED]', + }, + items: [{ secret: '[REDACTED]' }], + }); + + const perf = await device.observability.perf({ session: 'default', sampleMs: 100 }); + assert.equal(perf.kind, 'diagnosticsPerf'); + assert.equal(perf.redacted, false); + assert.equal(perf.metrics[0]?.name, 'cpu'); + + assert.deepEqual( + contexts.map((context) => ({ appId: context.appId, appBundleId: context.appBundleId })), + [ + { appId: 'app-1', appBundleId: 'com.example.app' }, + { appId: 'app-1', appBundleId: 'com.example.app' }, + { appId: 'app-1', appBundleId: 'com.example.app' }, + ], + ); +}); + +test('diagnostics commands validate bounded windows and router dispatches diagnostics namespace', async () => { + const router = createCommandRouter({ + createRuntime: () => + createAgentDevice({ + backend: createDiagnosticsBackend([]), + artifacts, + policy: restrictedCommandPolicy(), + }), + }); + + const ok = await router.dispatch({ + command: 'diagnostics.network', + options: { limit: 1, include: 'summary' }, + }); + assert.equal(ok.ok, true); + assert.equal(ok.ok && 'kind' in ok.data ? ok.data.kind : undefined, 'diagnosticsNetwork'); + const data = + ok.ok && 'kind' in ok.data && ok.data.kind === 'diagnosticsNetwork' ? ok.data : undefined; + assert.equal(data?.entries[0]?.requestHeaders, undefined); + + const invalid = await router.dispatch({ + command: 'diagnostics.logs', + options: { limit: 501 }, + }); + assert.equal(invalid.ok, false); + assert.equal(invalid.ok ? undefined : invalid.error.code, 'INVALID_ARGS'); +}); + +function createDiagnosticsBackend(contexts: BackendCommandContext[]): AgentDeviceBackend { + return { + platform: 'ios', + readLogs: async (context) => { + contexts.push(context); + return { + backend: 'fixture', + redacted: false, + entries: [ + { + timestamp: '2026-04-16T00:00:00.000Z', + level: 'info', + message: 'ready token=secret', + metadata: { authorization: 'Bearer secret' }, + }, + ], + }; + }, + dumpNetwork: async (context) => { + contexts.push(context); + return { + backend: 'fixture', + entries: [ + { + method: 'POST', + url: 'https://example.test/path?token=secret', + status: 200, + requestHeaders: { Authorization: 'Bearer secret' }, + responseHeaders: { 'content-type': 'application/json' }, + requestBody: + '{"token":"secret","nested":{"apiKey":"top-secret","items":[{"password":"hidden"}]}}', + responseBody: + '{"ok":true,"session":{"authorization":"Bearer secret"},"items":[{"secret":"classified"}]}', + }, + ], + }; + }, + measurePerf: async (context) => { + contexts.push(context); + return { + backend: 'fixture', + metrics: [{ name: 'cpu', value: 12.5, unit: '%' }], + }; + }, + }; +} diff --git a/src/__tests__/runtime-public.test.ts b/src/__tests__/runtime-public.test.ts index 7989dc861..f206e1758 100644 --- a/src/__tests__/runtime-public.test.ts +++ b/src/__tests__/runtime-public.test.ts @@ -44,6 +44,47 @@ const backend = { pushFile: async () => {}, triggerAppEvent: async () => {}, pressHome: async () => {}, + readLogs: async () => ({ entries: [{ message: 'ready' }] }), + dumpNetwork: async () => ({ entries: [{ method: 'GET', url: 'https://example.test' }] }), + measurePerf: async () => ({ metrics: [{ name: 'cpu', value: 1, unit: '%' }] }), +} satisfies AgentDeviceBackend; + +const routerAlignmentBackend = { + ...backend, + captureSnapshot: async () => ({ nodes: [] }), + tap: async () => {}, + fill: async () => {}, + focus: async () => {}, + longPress: async () => {}, + swipe: async () => {}, + scroll: async () => {}, + pinch: async () => {}, + pressBack: async () => {}, + rotate: async () => {}, + setKeyboard: async () => ({ visible: true }), + getClipboard: async () => ({ text: '' }), + setClipboard: async () => {}, + openSettings: async () => {}, + handleAlert: async () => ({ kind: 'alertStatus', alert: null }), + openAppSwitcher: async () => {}, + listDevices: async () => [], + bootDevice: async () => {}, + ensureSimulator: async () => ({ + udid: 'SIM-1', + device: 'iPhone 16', + runtime: 'iOS 18', + created: false, + booted: true, + }), + installApp: async () => ({}), + reinstallApp: async () => ({}), + startRecording: async () => ({}), + stopRecording: async () => ({}), + startTrace: async () => ({}), + stopTrace: async () => ({}), + readLogs: async () => ({ entries: [] }), + dumpNetwork: async () => ({ entries: [] }), + measurePerf: async () => ({ metrics: [] }), } satisfies AgentDeviceBackend; const artifacts = { @@ -79,6 +120,9 @@ test('package root exposes command runtime skeleton', async () => { assert.equal(typeof device.interactions.click, 'function'); assert.equal(typeof device.system.back, 'function'); assert.equal(typeof device.apps.open, 'function'); + assert.equal(typeof device.admin.install, 'function'); + assert.equal(typeof device.recording.record, 'function'); + assert.equal(typeof device.observability.logs, 'function'); const result = await device.capture.screenshot({}); assert.equal(result.path, '/tmp/path.png'); }); @@ -168,6 +212,41 @@ test('public runtime policy helpers expose local and restricted defaults', async assert.equal((await store.get('default'))?.name, 'default'); }); +test('runtime router command map stays aligned with implemented catalog entries', async () => { + const catalogRuntimeCommands = commandCatalog + .filter( + (entry) => + entry.status === 'implemented' && + (entry.command.includes('.') || entry.command === 'record' || entry.command === 'trace'), + ) + .map((entry) => entry.command); + const router = createCommandRouter({ + createRuntime: () => + createAgentDevice({ + backend: routerAlignmentBackend, + artifacts, + policy: localCommandPolicy(), + }), + }); + for (const command of catalogRuntimeCommands) { + const result = await router.dispatch({ command, options: {} } as never); + assert.notEqual(result.ok ? undefined : result.error.code, 'NOT_IMPLEMENTED', command); + assert.notEqual(result.ok ? undefined : result.error.code, 'UNSUPPORTED_OPERATION', command); + } + assert.equal( + commandCatalog.some((entry) => entry.command === 'batch' && entry.status === 'implemented'), + true, + ); + assert.equal( + commandCatalog.some((entry) => entry.command === 'replay' && entry.status === 'planned'), + true, + ); + assert.equal( + commandCatalog.some((entry) => entry.command === 'test' && entry.status === 'planned'), + true, + ); +}); + test('local artifact adapter marks command outputs and temp files by visibility', async () => { const adapter = createLocalArtifactAdapter(); const output = await adapter.reserveOutput(undefined, { @@ -380,11 +459,18 @@ test('public backend, commands, io, and conformance subpaths are importable', () assert.equal(typeof commands.system.settings, 'function'); assert.equal(typeof commands.system.alert, 'function'); assert.equal(typeof commands.system.appSwitcher, 'function'); + assert.equal(typeof commands.admin.devices, 'function'); + assert.equal(typeof commands.admin.install, 'function'); + assert.equal(typeof commands.recording.record, 'function'); + assert.equal(typeof commands.recording.trace, 'function'); + assert.equal(typeof commands.diagnostics.logs, 'function'); + assert.equal(typeof commands.diagnostics.network, 'function'); + assert.equal(typeof commands.diagnostics.perf, 'function'); assert.equal( commandCatalog.some((entry) => entry.command === 'click' && entry.status === 'implemented'), true, ); - assert.equal(commandConformanceSuites.length, 5); + assert.equal(commandConformanceSuites.length, 8); assert.equal(typeof runCommandConformance, 'function'); assert.equal(target.name, 'fake'); }); @@ -470,8 +556,24 @@ test('command router dispatches implemented runtime commands and normalizes erro 1, ); + const batch = await router.dispatch({ + command: 'batch', + options: { + steps: [{ command: 'apps.open', options: { app: 'com.example.app' } }], + }, + }); + assert.equal(batch.ok, true); + assert.equal(batch.ok && 'kind' in batch.data ? batch.data.kind : undefined, 'batch'); + + const logs = await router.dispatch({ + command: 'diagnostics.logs', + options: { limit: 10 }, + }); + assert.equal(logs.ok, true); + assert.equal(logs.ok && 'kind' in logs.data ? logs.data.kind : undefined, 'diagnosticsLogs'); + const planned = await router.dispatch({ - command: 'alert', + command: 'session', options: {}, } as never); assert.equal(planned.ok, false); diff --git a/src/backend.ts b/src/backend.ts index b17233f29..151ecba1d 100644 --- a/src/backend.ts +++ b/src/backend.ts @@ -230,9 +230,178 @@ export type BackendAppEvent = { payload?: Record; }; +export type BackendDeviceFilter = { + platform?: AgentDeviceBackendPlatform | 'apple'; + target?: 'mobile' | 'tv' | 'desktop'; + kind?: 'simulator' | 'emulator' | 'device' | 'desktop'; +}; + +export type BackendDeviceInfo = { + id: string; + name: string; + platform: AgentDeviceBackendPlatform; + target?: 'mobile' | 'tv' | 'desktop'; + kind?: 'simulator' | 'emulator' | 'device' | 'desktop'; + booted?: boolean; + details?: Record; +}; + +export type BackendDeviceTarget = { + id?: string; + name?: string; + platform?: AgentDeviceBackendPlatform; + target?: 'mobile' | 'tv' | 'desktop'; + headless?: boolean; +}; + +export type BackendEnsureSimulatorOptions = { + device: string; + runtime?: string; + boot?: boolean; + reuseExisting?: boolean; +}; + +export type BackendEnsureSimulatorResult = { + udid: string; + device: string; + runtime: string; + created: boolean; + booted: boolean; + simulatorSetPath?: string | null; +}; + +export type BackendInstallSource = + | { + kind: 'path'; + path: string; + } + | { + kind: 'uploadedArtifact'; + id: string; + } + | { + kind: 'url'; + url: string; + }; + export type BackendInstallTarget = { - app: string; - artifactPath: string; + app?: string; + source: BackendInstallSource; +}; + +export type BackendInstallResult = Record & { + appId?: string; + appName?: string; + bundleId?: string; + packageName?: string; + launchTarget?: string; + installablePath?: string; + archivePath?: string; +}; + +export type BackendRecordingOptions = { + outPath?: string; + fps?: number; + quality?: number; + showTouches?: boolean; +}; + +export type BackendRecordingResult = Record & { + path?: string; + telemetryPath?: string; + warning?: string; +}; + +export type BackendTraceOptions = { + outPath?: string; +}; + +export type BackendTraceResult = Record & { + outPath?: string; +}; + +export type BackendDiagnosticsTimeWindow = { + since?: string; + until?: string; +}; + +export type BackendDiagnosticsPageOptions = BackendDiagnosticsTimeWindow & { + cursor?: string; + limit?: number; +}; + +export type BackendLogEntry = { + timestamp?: string; + level?: 'debug' | 'info' | 'warn' | 'error' | string; + message: string; + source?: string; + metadata?: Record; +}; + +export type BackendReadLogsOptions = BackendDiagnosticsPageOptions & { + levels?: readonly string[]; + search?: string; + source?: string; +}; + +export type BackendReadLogsResult = { + entries: readonly BackendLogEntry[]; + nextCursor?: string; + timeWindow?: BackendDiagnosticsTimeWindow; + backend?: string; + redacted?: boolean; + notes?: readonly string[]; +}; + +export type BackendNetworkIncludeMode = 'summary' | 'headers' | 'body' | 'all'; + +export type BackendNetworkEntry = { + timestamp?: string; + method?: string; + url?: string; + status?: number; + durationMs?: number; + requestHeaders?: Record; + responseHeaders?: Record; + requestBody?: string; + responseBody?: string; + metadata?: Record; +}; + +export type BackendDumpNetworkOptions = BackendDiagnosticsPageOptions & { + include?: BackendNetworkIncludeMode; +}; + +export type BackendDumpNetworkResult = { + entries: readonly BackendNetworkEntry[]; + nextCursor?: string; + timeWindow?: BackendDiagnosticsTimeWindow; + backend?: string; + redacted?: boolean; + notes?: readonly string[]; +}; + +export type BackendPerfMetric = { + name: string; + value?: number; + unit?: string; + status?: 'ok' | 'unavailable' | 'error'; + message?: string; + metadata?: Record; +}; + +export type BackendMeasurePerfOptions = BackendDiagnosticsTimeWindow & { + sampleMs?: number; + metrics?: readonly string[]; +}; + +export type BackendMeasurePerfResult = { + metrics: readonly BackendPerfMetric[]; + startedAt?: string; + endedAt?: string; + backend?: string; + redacted?: boolean; + notes?: readonly string[]; }; export type BackendShellResult = { @@ -368,10 +537,58 @@ export type AgentDeviceBackend = { context: BackendCommandContext, event: BackendAppEvent, ): Promise; + listDevices?( + context: BackendCommandContext, + filter?: BackendDeviceFilter, + ): Promise; + bootDevice?( + context: BackendCommandContext, + target?: BackendDeviceTarget, + ): Promise; + ensureSimulator?( + context: BackendCommandContext, + options: BackendEnsureSimulatorOptions, + ): Promise; + resolveInstallSource?( + context: BackendCommandContext, + source: BackendInstallSource, + ): Promise; installApp?( context: BackendCommandContext, target: BackendInstallTarget, - ): Promise; + ): Promise; + reinstallApp?( + context: BackendCommandContext, + target: BackendInstallTarget, + ): Promise; + startRecording?( + context: BackendCommandContext, + options?: BackendRecordingOptions, + ): Promise; + stopRecording?( + context: BackendCommandContext, + options?: BackendRecordingOptions, + ): Promise; + startTrace?( + context: BackendCommandContext, + options?: BackendTraceOptions, + ): Promise; + stopTrace?( + context: BackendCommandContext, + options?: BackendTraceOptions, + ): Promise; + readLogs?( + context: BackendCommandContext, + options?: BackendReadLogsOptions, + ): Promise; + dumpNetwork?( + context: BackendCommandContext, + options?: BackendDumpNetworkOptions, + ): Promise; + measurePerf?( + context: BackendCommandContext, + options?: BackendMeasurePerfOptions, + ): Promise; }; export function hasBackendCapability( diff --git a/src/cli/commands/generic.ts b/src/cli/commands/generic.ts index 7e3277f71..1d4aff758 100644 --- a/src/cli/commands/generic.ts +++ b/src/cli/commands/generic.ts @@ -206,7 +206,7 @@ export const genericClientCommandHandlers = { action: readStartStop(positionals[0], 'record'), path: positionals[1], fps: flags.fps, - quality: readRecordingQuality(flags.quality), + quality: flags.quality as RecordOptions['quality'], hideTouches: flags.hideTouches, }), ), @@ -348,12 +348,6 @@ function readStartStop(value: string | undefined, command: string): 'start' | 's throw new AppError('INVALID_ARGS', `${command} requires start|stop`); } -function readRecordingQuality(value: number | undefined): RecordOptions['quality'] { - if (value === undefined) return undefined; - if ([5, 6, 7, 8, 9, 10].includes(value)) return value as RecordOptions['quality']; - throw new AppError('INVALID_ARGS', `Invalid quality: ${value}`); -} - function readLogsAction( value: string | undefined, ): 'path' | 'start' | 'stop' | 'doctor' | 'mark' | 'clear' | undefined { diff --git a/src/commands/admin.ts b/src/commands/admin.ts new file mode 100644 index 000000000..5be9bd065 --- /dev/null +++ b/src/commands/admin.ts @@ -0,0 +1,330 @@ +import type { + BackendActionResult, + BackendCommandContext, + BackendDeviceFilter, + BackendDeviceInfo, + BackendDeviceTarget, + BackendEnsureSimulatorOptions, + BackendEnsureSimulatorResult, + BackendInstallResult, + BackendInstallSource, +} from '../backend.ts'; +import type { AgentDeviceRuntime, CommandContext } from '../runtime.ts'; +import { AppError } from '../utils/errors.ts'; +import { successText } from '../utils/success-text.ts'; +import type { RuntimeCommand } from './index.ts'; +import { resolveCommandInput } from './io-policy.ts'; +import { toBackendContext } from './selector-read-utils.ts'; + +export type AdminDevicesCommandOptions = CommandContext & { + filter?: BackendDeviceFilter; +}; + +export type AdminDevicesCommandResult = { + kind: 'adminDevices'; + devices: readonly BackendDeviceInfo[]; +}; + +export type AdminBootCommandOptions = CommandContext & { + target?: BackendDeviceTarget; +}; + +export type AdminBootCommandResult = { + kind: 'deviceBooted'; + target?: BackendDeviceTarget; + backendResult?: Record; + message?: string; +}; + +export type AdminEnsureSimulatorCommandOptions = CommandContext & BackendEnsureSimulatorOptions; + +export type AdminEnsureSimulatorCommandResult = { + kind: 'simulatorEnsured'; +} & BackendEnsureSimulatorResult; + +export type AdminInstallCommandOptions = CommandContext & { + app: string; + source: BackendInstallSource; +}; + +export type AdminReinstallCommandOptions = AdminInstallCommandOptions; + +export type AdminInstallFromSourceCommandOptions = CommandContext & { + app?: string; + source: BackendInstallSource; +}; + +export type AdminInstallCommandResult = { + kind: 'appInstalled' | 'appReinstalled' | 'appInstalledFromSource'; + app?: string; + source: BackendInstallSource; + appId?: string; + appName?: string; + bundleId?: string; + packageName?: string; + launchTarget?: string; + installablePath?: string; + archivePath?: string; + backendResult?: Record; + message?: string; +}; + +export const devicesCommand: RuntimeCommand< + AdminDevicesCommandOptions | undefined, + AdminDevicesCommandResult +> = async (runtime, options = {}): Promise => { + if (!runtime.backend.listDevices) { + throw new AppError('UNSUPPORTED_OPERATION', 'admin.devices is not supported by this backend'); + } + return { + kind: 'adminDevices', + devices: await runtime.backend.listDevices(toBackendContext(runtime, options), options.filter), + }; +}; + +export const bootCommand: RuntimeCommand< + AdminBootCommandOptions | undefined, + AdminBootCommandResult +> = async (runtime, options = {}): Promise => { + if (!runtime.backend.bootDevice) { + throw new AppError('UNSUPPORTED_OPERATION', 'admin.boot is not supported by this backend'); + } + const target = normalizeDeviceTarget(options.target); + const backendResult = await runtime.backend.bootDevice( + toBackendContext(runtime, options), + target, + ); + const formattedBackendResult = toBackendResult(backendResult); + return { + kind: 'deviceBooted', + ...(target ? { target } : {}), + ...(formattedBackendResult ? { backendResult: formattedBackendResult } : {}), + ...successText('Booted device'), + }; +}; + +export const ensureSimulatorCommand: RuntimeCommand< + AdminEnsureSimulatorCommandOptions, + AdminEnsureSimulatorCommandResult +> = async (runtime, options): Promise => { + if (!runtime.backend.ensureSimulator) { + throw new AppError( + 'UNSUPPORTED_OPERATION', + 'admin.ensureSimulator is not supported by this backend', + ); + } + const device = requireText(options.device, 'device'); + const result = await runtime.backend.ensureSimulator(toBackendContext(runtime, options), { + device, + ...(options.runtime ? { runtime: requireText(options.runtime, 'runtime') } : {}), + ...(options.boot !== undefined ? { boot: options.boot } : {}), + ...(options.reuseExisting !== undefined ? { reuseExisting: options.reuseExisting } : {}), + }); + return { + kind: 'simulatorEnsured', + ...result, + }; +}; + +export const installCommand: RuntimeCommand< + AdminInstallCommandOptions, + AdminInstallCommandResult +> = async (runtime, options): Promise => + await runInstallCommand(runtime, options, 'install'); + +export const reinstallCommand: RuntimeCommand< + AdminReinstallCommandOptions, + AdminInstallCommandResult +> = async (runtime, options): Promise => + await runInstallCommand(runtime, options, 'reinstall'); + +export const installFromSourceCommand: RuntimeCommand< + AdminInstallFromSourceCommandOptions, + AdminInstallCommandResult +> = async (runtime, options): Promise => + await runInstallCommand(runtime, options, 'installFromSource'); + +async function runInstallCommand( + runtime: AgentDeviceRuntime, + options: + | AdminInstallCommandOptions + | AdminReinstallCommandOptions + | AdminInstallFromSourceCommandOptions, + mode: 'install' | 'reinstall' | 'installFromSource', +): Promise { + const methodName = mode === 'reinstall' ? 'reinstallApp' : 'installApp'; + const method = runtime.backend[methodName]; + if (!method) { + throw new AppError('UNSUPPORTED_OPERATION', `admin.${mode} is not supported by this backend`); + } + + const app = + 'app' in options && options.app !== undefined ? requireText(options.app, 'app') : undefined; + if (mode !== 'installFromSource' && !app) { + throw new AppError('INVALID_ARGS', `admin.${mode} requires app`); + } + + const context = toBackendContext(runtime, options); + const resolved = await resolveInstallSource(runtime, context, options.source); + try { + const result = await method.call(runtime.backend, context, { + ...(app ? { app } : {}), + source: resolved.source, + }); + return formatInstallResult(mode, app, resolved.source, result); + } finally { + await resolved.cleanup?.(); + } +} + +async function resolveInstallSource( + runtime: AgentDeviceRuntime, + context: BackendCommandContext, + source: BackendInstallSource | undefined, +): Promise<{ source: BackendInstallSource; cleanup?: () => Promise }> { + const normalized = normalizeInstallSource(source); + const localResolved = await resolveLocalInstallSource(runtime, normalized); + try { + const backendResolved = runtime.backend.resolveInstallSource + ? await runtime.backend.resolveInstallSource(context, localResolved.source) + : localResolved.source; + return { + source: normalizeInstallSource(backendResolved), + ...(localResolved.cleanup ? { cleanup: localResolved.cleanup } : {}), + }; + } catch (error) { + if (localResolved.cleanup) { + try { + await localResolved.cleanup(); + } catch { + // Best-effort cleanup; preserve the original install source resolution failure. + } + } + throw error; + } +} + +async function resolveLocalInstallSource( + runtime: AgentDeviceRuntime, + source: BackendInstallSource, +): Promise<{ source: BackendInstallSource; cleanup?: () => Promise }> { + if (source.kind === 'url') return { source }; + const resolved = await resolveCommandInput(runtime, source, { + usage: 'admin.install', + field: 'source', + }); + return { + source: { kind: 'path', path: resolved.path }, + ...(resolved.cleanup ? { cleanup: resolved.cleanup } : {}), + }; +} + +function normalizeInstallSource(source: BackendInstallSource | undefined): BackendInstallSource { + if (!source || typeof source !== 'object') { + throw new AppError('INVALID_ARGS', 'install source is required'); + } + if (source.kind === 'path') { + return { kind: 'path', path: requireText(source.path, 'source.path') }; + } + if (source.kind === 'uploadedArtifact') { + return { kind: 'uploadedArtifact', id: requireText(source.id, 'source.id') }; + } + if (source.kind === 'url') { + const url = requireText(source.url, 'source.url'); + assertHttpUrl(url); + return { kind: 'url', url }; + } + throw new AppError('INVALID_ARGS', 'install source kind must be path, uploadedArtifact, or url'); +} + +function assertHttpUrl(url: string): void { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + throw new AppError('INVALID_ARGS', `Invalid install source URL: ${url}`); + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new AppError('INVALID_ARGS', 'Install source URL must use http or https'); + } +} + +function normalizeDeviceTarget( + target: BackendDeviceTarget | undefined, +): BackendDeviceTarget | undefined { + if (!target) return undefined; + const id = normalizeOptionalText(target.id, 'target.id'); + const name = normalizeOptionalText(target.name, 'target.name'); + const normalized = { + ...(id ? { id } : {}), + ...(name ? { name } : {}), + ...(target.platform ? { platform: target.platform } : {}), + ...(target.target ? { target: target.target } : {}), + ...(target.headless !== undefined ? { headless: target.headless } : {}), + }; + return Object.keys(normalized).length > 0 ? normalized : undefined; +} + +function formatInstallResult( + mode: 'install' | 'reinstall' | 'installFromSource', + app: string | undefined, + source: BackendInstallSource, + result: BackendInstallResult, +): AdminInstallCommandResult { + const backendResult = toBackendResult(result); + const kind = + mode === 'reinstall' + ? 'appReinstalled' + : mode === 'installFromSource' + ? 'appInstalledFromSource' + : 'appInstalled'; + const appName = readOptionalString(result, 'appName'); + const appId = readOptionalString(result, 'appId'); + const bundleId = readOptionalString(result, 'bundleId'); + const packageName = readOptionalString(result, 'packageName'); + const launchTarget = readOptionalString(result, 'launchTarget'); + const installablePath = readOptionalString(result, 'installablePath'); + const archivePath = readOptionalString(result, 'archivePath'); + return { + kind, + ...(app ? { app } : {}), + source, + ...(appId ? { appId } : {}), + ...(appName ? { appName } : {}), + ...(bundleId ? { bundleId } : {}), + ...(packageName ? { packageName } : {}), + ...(launchTarget ? { launchTarget } : {}), + ...(installablePath ? { installablePath } : {}), + ...(archivePath ? { archivePath } : {}), + ...(backendResult ? { backendResult } : {}), + ...successText( + `${mode === 'reinstall' ? 'Reinstalled' : 'Installed'}: ${appName ?? launchTarget ?? app ?? formatSource(source)}`, + ), + }; +} + +function normalizeOptionalText(value: string | undefined, field: string): string | undefined { + if (value === undefined) return undefined; + return requireText(value, field); +} + +function requireText(value: string | undefined, field: string): string { + const text = value?.trim(); + if (!text) throw new AppError('INVALID_ARGS', `${field} must be a non-empty string`); + return text; +} + +function readOptionalString(record: Record, field: string): string | undefined { + const value = record[field]; + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +function formatSource(source: BackendInstallSource): string { + if (source.kind === 'path') return source.path; + if (source.kind === 'uploadedArtifact') return source.id; + return source.url; +} + +function toBackendResult(result: BackendActionResult): Record | undefined { + return result && typeof result === 'object' ? result : undefined; +} diff --git a/src/commands/capture-snapshot.ts b/src/commands/capture-snapshot.ts index 8767b8f4c..7eb19d431 100644 --- a/src/commands/capture-snapshot.ts +++ b/src/commands/capture-snapshot.ts @@ -9,6 +9,7 @@ import type { RuntimeCommand, SnapshotCommandOptions, } from './index.ts'; +import { now } from './selector-read-utils.ts'; export type SnapshotCommandResult = { nodes: SnapshotNode[]; @@ -251,18 +252,10 @@ function buildSnapshotWarnings(params: { } } - return uniqueStrings(warnings); + return Array.from(new Set(warnings)); } function isLikelyStaleSnapshotDrop(previousCount: number, currentCount: number): boolean { if (previousCount < 12) return false; return currentCount <= Math.floor(previousCount * 0.2); } - -function now(runtime: AgentDeviceRuntime): number { - return runtime.clock?.now() ?? Date.now(); -} - -function uniqueStrings(values: string[]): string[] { - return Array.from(new Set(values)); -} diff --git a/src/commands/catalog.ts b/src/commands/catalog.ts index 5e1fdcd42..e9bc7625e 100644 --- a/src/commands/catalog.ts +++ b/src/commands/catalog.ts @@ -18,14 +18,26 @@ export const commandCatalog: readonly CommandCatalogEntry[] = [ { command: 'diff screenshot', category: 'portable-runtime', status: 'implemented' }, { command: 'snapshot', category: 'portable-runtime', status: 'implemented' }, { command: 'diff snapshot', category: 'portable-runtime', status: 'implemented' }, + { command: 'capture.screenshot', category: 'portable-runtime', status: 'implemented' }, + { command: 'capture.diffScreenshot', category: 'portable-runtime', status: 'implemented' }, + { command: 'capture.snapshot', category: 'portable-runtime', status: 'implemented' }, + { command: 'capture.diffSnapshot', category: 'portable-runtime', status: 'implemented' }, { command: 'find read-only', category: 'portable-runtime', status: 'implemented' }, { command: 'get', category: 'portable-runtime', status: 'implemented' }, { command: 'is', category: 'portable-runtime', status: 'implemented' }, { command: 'wait', category: 'portable-runtime', status: 'implemented' }, + { command: 'selectors.find', category: 'portable-runtime', status: 'implemented' }, + { command: 'selectors.get', category: 'portable-runtime', status: 'implemented' }, + { command: 'selectors.is', category: 'portable-runtime', status: 'implemented' }, + { command: 'selectors.wait', category: 'portable-runtime', status: 'implemented' }, { command: 'alert', category: 'portable-runtime', status: 'planned' }, { command: 'click', category: 'portable-runtime', status: 'implemented' }, { command: 'press', category: 'portable-runtime', status: 'implemented' }, { command: 'fill', category: 'portable-runtime', status: 'implemented' }, + { command: 'interactions.click', category: 'portable-runtime', status: 'implemented' }, + { command: 'interactions.press', category: 'portable-runtime', status: 'implemented' }, + { command: 'interactions.fill', category: 'portable-runtime', status: 'implemented' }, + { command: 'interactions.typeText', category: 'portable-runtime', status: 'implemented' }, { command: 'longpress', category: 'portable-runtime', status: 'planned' }, { command: 'swipe', category: 'portable-runtime', status: 'planned' }, { command: 'focus', category: 'portable-runtime', status: 'planned' }, @@ -64,23 +76,32 @@ export const commandCatalog: readonly CommandCatalogEntry[] = [ { command: 'system.alert', category: 'portable-runtime', status: 'implemented' }, { command: 'push', category: 'portable-runtime', status: 'planned' }, { command: 'trigger-app-event', category: 'portable-runtime', status: 'planned' }, - { command: 'devices', category: 'backend-admin', status: 'planned' }, - { command: 'boot', category: 'backend-admin', status: 'planned' }, - { command: 'ensure-simulator', category: 'backend-admin', status: 'planned' }, - { command: 'install', category: 'backend-admin', status: 'planned' }, - { command: 'reinstall', category: 'backend-admin', status: 'planned' }, - { command: 'install-from-source', category: 'backend-admin', status: 'planned' }, + { command: 'devices', category: 'backend-admin', status: 'implemented' }, + { command: 'boot', category: 'backend-admin', status: 'implemented' }, + { command: 'ensure-simulator', category: 'backend-admin', status: 'implemented' }, + { command: 'install', category: 'backend-admin', status: 'implemented' }, + { command: 'reinstall', category: 'backend-admin', status: 'implemented' }, + { command: 'install-from-source', category: 'backend-admin', status: 'implemented' }, + { command: 'admin.devices', category: 'backend-admin', status: 'implemented' }, + { command: 'admin.boot', category: 'backend-admin', status: 'implemented' }, + { command: 'admin.ensureSimulator', category: 'backend-admin', status: 'implemented' }, + { command: 'admin.install', category: 'backend-admin', status: 'implemented' }, + { command: 'admin.reinstall', category: 'backend-admin', status: 'implemented' }, + { command: 'admin.installFromSource', category: 'backend-admin', status: 'implemented' }, { command: 'session', category: 'transport-session', status: 'planned' }, { command: 'connect', category: 'environment', status: 'planned' }, { command: 'disconnect', category: 'environment', status: 'planned' }, { command: 'connection', category: 'environment', status: 'planned' }, { command: 'metro', category: 'environment', status: 'planned' }, - { command: 'record', category: 'capability-gated', status: 'planned' }, - { command: 'trace', category: 'capability-gated', status: 'planned' }, + { command: 'record', category: 'capability-gated', status: 'implemented' }, + { command: 'trace', category: 'capability-gated', status: 'implemented' }, { command: 'replay', category: 'capability-gated', status: 'planned' }, { command: 'test', category: 'capability-gated', status: 'planned' }, - { command: 'batch', category: 'capability-gated', status: 'planned' }, - { command: 'logs', category: 'capability-gated', status: 'planned' }, - { command: 'network', category: 'capability-gated', status: 'planned' }, - { command: 'perf', category: 'capability-gated', status: 'planned' }, + { command: 'batch', category: 'capability-gated', status: 'implemented' }, + { command: 'logs', category: 'capability-gated', status: 'implemented' }, + { command: 'network', category: 'capability-gated', status: 'implemented' }, + { command: 'perf', category: 'capability-gated', status: 'implemented' }, + { command: 'diagnostics.logs', category: 'capability-gated', status: 'implemented' }, + { command: 'diagnostics.network', category: 'capability-gated', status: 'implemented' }, + { command: 'diagnostics.perf', category: 'capability-gated', status: 'implemented' }, ]; diff --git a/src/commands/diagnostics-format.ts b/src/commands/diagnostics-format.ts new file mode 100644 index 000000000..6eb0191c4 --- /dev/null +++ b/src/commands/diagnostics-format.ts @@ -0,0 +1,252 @@ +import type { + BackendDumpNetworkResult, + BackendMeasurePerfResult, + BackendNetworkIncludeMode, + BackendReadLogsResult, +} from '../backend.ts'; +import type { + DiagnosticsLogsCommandResult, + DiagnosticsNetworkCommandResult, + DiagnosticsPerfCommandResult, +} from './diagnostics.ts'; + +const PAYLOAD_MAX_CHARS = 2048; +const MESSAGE_MAX_CHARS = 4096; +const SECRET_KEY_PATTERN = /(?:authorization|cookie|token|secret|password|passwd|api[-_]?key)/i; + +export function formatLogsResult(result: BackendReadLogsResult): DiagnosticsLogsCommandResult { + let redacted = result.redacted === true; + const entries = result.entries.map((entry) => { + const message = redactAndTruncate(entry.message, MESSAGE_MAX_CHARS); + const metadata = redactUnknown(entry.metadata); + redacted ||= message.redacted || metadata.redacted; + return { + ...(entry.timestamp ? { timestamp: entry.timestamp } : {}), + ...(entry.level ? { level: entry.level } : {}), + message: message.value ?? '', + ...(entry.source ? { source: entry.source } : {}), + ...(metadata.value ? { metadata: metadata.value as Record } : {}), + }; + }); + return { + kind: 'diagnosticsLogs', + entries, + ...(result.nextCursor ? { nextCursor: result.nextCursor } : {}), + ...(result.timeWindow ? { timeWindow: result.timeWindow } : {}), + ...(result.backend ? { backend: result.backend } : {}), + redacted, + ...(result.notes ? { notes: result.notes } : {}), + }; +} + +export function formatNetworkResult( + result: BackendDumpNetworkResult, + include: BackendNetworkIncludeMode, +): DiagnosticsNetworkCommandResult { + let redacted = result.redacted === true; + const entries = result.entries.map((entry) => { + const url = entry.url ? redactUrl(entry.url) : undefined; + const requestHeaders = + include === 'headers' || include === 'all' ? redactHeaders(entry.requestHeaders) : undefined; + const responseHeaders = + include === 'headers' || include === 'all' ? redactHeaders(entry.responseHeaders) : undefined; + const requestBody = + include === 'body' || include === 'all' ? redactPayload(entry.requestBody) : undefined; + const responseBody = + include === 'body' || include === 'all' ? redactPayload(entry.responseBody) : undefined; + const metadata = redactUnknown(entry.metadata); + redacted ||= + (url?.redacted ?? false) || + (requestHeaders?.redacted ?? false) || + (responseHeaders?.redacted ?? false) || + (requestBody?.redacted ?? false) || + (responseBody?.redacted ?? false) || + metadata.redacted; + return { + ...(entry.timestamp ? { timestamp: entry.timestamp } : {}), + ...(entry.method ? { method: entry.method } : {}), + ...(url ? { url: url.value } : {}), + ...(entry.status !== undefined ? { status: entry.status } : {}), + ...(entry.durationMs !== undefined ? { durationMs: entry.durationMs } : {}), + ...(requestHeaders?.value ? { requestHeaders: requestHeaders.value } : {}), + ...(responseHeaders?.value ? { responseHeaders: responseHeaders.value } : {}), + ...(requestBody?.value !== undefined ? { requestBody: requestBody.value } : {}), + ...(responseBody?.value !== undefined ? { responseBody: responseBody.value } : {}), + ...(metadata.value ? { metadata: metadata.value as Record } : {}), + }; + }); + return { + kind: 'diagnosticsNetwork', + entries, + ...(result.nextCursor ? { nextCursor: result.nextCursor } : {}), + ...(result.timeWindow ? { timeWindow: result.timeWindow } : {}), + ...(result.backend ? { backend: result.backend } : {}), + redacted, + ...(result.notes ? { notes: result.notes } : {}), + }; +} + +export function formatPerfResult(result: BackendMeasurePerfResult): DiagnosticsPerfCommandResult { + let redacted = result.redacted === true; + return { + kind: 'diagnosticsPerf', + metrics: result.metrics.map((metric) => { + const message = redactAndTruncate(metric.message, MESSAGE_MAX_CHARS); + const metadata = redactUnknown(metric.metadata); + redacted ||= message.redacted || metadata.redacted; + return { + name: metric.name, + ...(metric.value !== undefined ? { value: metric.value } : {}), + ...(metric.unit ? { unit: metric.unit } : {}), + ...(metric.status ? { status: metric.status } : {}), + ...(message.value !== undefined ? { message: message.value } : {}), + ...(metadata.value ? { metadata: metadata.value as Record } : {}), + }; + }), + ...(result.startedAt ? { startedAt: result.startedAt } : {}), + ...(result.endedAt ? { endedAt: result.endedAt } : {}), + ...(result.backend ? { backend: result.backend } : {}), + redacted, + ...(result.notes ? { notes: result.notes } : {}), + }; +} + +function redactHeaders(headers: Record | undefined): { + value?: Record; + redacted: boolean; +} { + if (!headers) return { redacted: false }; + let redacted = false; + const next: Record = {}; + for (const [key, value] of Object.entries(headers)) { + if (SECRET_KEY_PATTERN.test(key)) { + next[key] = '[REDACTED]'; + redacted = true; + } else { + const result = redactAndTruncate(value, PAYLOAD_MAX_CHARS); + next[key] = result.value ?? ''; + redacted ||= result.redacted; + } + } + return { value: next, redacted }; +} + +function redactUrl(url: string): { value: string; redacted: boolean } { + try { + const parsed = new URL(url); + let redacted = false; + for (const key of Array.from(parsed.searchParams.keys())) { + if (SECRET_KEY_PATTERN.test(key)) { + parsed.searchParams.set(key, '[REDACTED]'); + redacted = true; + } + } + return { value: parsed.toString(), redacted }; + } catch { + return redactAndTruncate(url, PAYLOAD_MAX_CHARS) as { value: string; redacted: boolean }; + } +} + +function redactPayload(value: string | undefined): { value?: string; redacted: boolean } { + if (value === undefined) return { redacted: false }; + const structured = redactJsonPayload(value); + return structured ?? redactAndTruncate(value, PAYLOAD_MAX_CHARS); +} + +function redactJsonPayload(value: string): { value?: string; redacted: boolean } | undefined { + try { + const parsed = JSON.parse(value); + const result = redactValue(parsed, redactText); + return truncateRedacted(JSON.stringify(result.value), PAYLOAD_MAX_CHARS, result.redacted); + } catch { + return undefined; + } +} + +function redactUnknown(value: unknown): { value?: unknown; redacted: boolean } { + return redactValue(value, (entry) => redactAndTruncate(entry, PAYLOAD_MAX_CHARS)); +} + +function redactValue( + value: unknown, + redactString: (value: string) => { value?: string; redacted: boolean }, +): { value?: unknown; redacted: boolean } { + if (value === undefined) return { redacted: false }; + if (typeof value === 'string') return redactString(value); + if (!value || typeof value !== 'object') return { value, redacted: false }; + if (Array.isArray(value)) { + let redacted = false; + const next = value.map((entry) => { + const result = redactValue(entry, redactString); + redacted ||= result.redacted; + return result.value; + }); + return { value: next, redacted }; + } + let redacted = false; + const next: Record = {}; + for (const [key, entry] of Object.entries(value)) { + if (SECRET_KEY_PATTERN.test(key)) { + next[key] = '[REDACTED]'; + redacted = true; + continue; + } + const result = redactValue(entry, redactString); + next[key] = result.value; + redacted ||= result.redacted; + } + return { value: next, redacted }; +} + +function redactAndTruncate( + value: string | undefined, + maxChars: number, +): { value?: string; redacted: boolean } { + if (value === undefined) return { redacted: false }; + const result = redactText(value); + return truncateRedacted(result.value, maxChars, result.redacted); +} + +function redactText(value: string): { value: string; redacted: boolean } { + let redacted = false; + let next = value.replaceAll( + /(authorization|token|secret|password|passwd|api[-_]?key)=([^&\s]+)/gi, + (_match, key) => { + redacted = true; + return `${String(key)}=[REDACTED]`; + }, + ); + next = next.replaceAll( + /("(?:authorization|cookie|token|secret|password|passwd|api[-_]?key)"\s*:\s*")([^"]*)(")/gi, + (_match, prefix, _value, suffix) => { + redacted = true; + return `${String(prefix)}[REDACTED]${String(suffix)}`; + }, + ); + next = next.replaceAll(/\b(Bearer\s+)([^\s",;]+)/gi, (_match, prefix) => { + redacted = true; + return `${String(prefix)}[REDACTED]`; + }); + next = next.replaceAll( + /((?:authorization|cookie|token|secret|password|passwd|api[-_]?key)\s*[:=]\s*)([^\s,;]+)/gi, + (_match, prefix) => { + redacted = true; + return `${String(prefix)}[REDACTED]`; + }, + ); + return { value: next, redacted }; +} + +function truncateRedacted( + value: string | undefined, + maxChars: number, + redacted: boolean, +): { value?: string; redacted: boolean } { + if (value === undefined) return { redacted }; + let next = value; + if (next.length > maxChars) { + next = `${next.slice(0, maxChars)}...[truncated]`; + redacted = true; + } + return { value: next, redacted }; +} diff --git a/src/commands/diagnostics.ts b/src/commands/diagnostics.ts new file mode 100644 index 000000000..1ded0b973 --- /dev/null +++ b/src/commands/diagnostics.ts @@ -0,0 +1,248 @@ +import type { + BackendCommandContext, + BackendDiagnosticsPageOptions, + BackendDiagnosticsTimeWindow, + BackendDumpNetworkOptions, + BackendLogEntry, + BackendMeasurePerfOptions, + BackendNetworkEntry, + BackendNetworkIncludeMode, + BackendPerfMetric, + BackendReadLogsOptions, +} from '../backend.ts'; +import type { AgentDeviceRuntime, CommandContext } from '../runtime.ts'; +import { AppError } from '../utils/errors.ts'; +import { requireIntInRange } from '../utils/validation.ts'; +import { formatLogsResult, formatNetworkResult, formatPerfResult } from './diagnostics-format.ts'; +import type { RuntimeCommand } from './index.ts'; +import { toBackendContext } from './selector-read-utils.ts'; + +export type DiagnosticsPageOptions = CommandContext & { + appId?: string; + appBundleId?: string; + since?: string; + until?: string; + cursor?: string; + limit?: number; +}; + +export type DiagnosticsLogsCommandOptions = DiagnosticsPageOptions & { + levels?: readonly string[]; + search?: string; + source?: string; +}; + +export type DiagnosticsNetworkCommandOptions = DiagnosticsPageOptions & { + include?: BackendNetworkIncludeMode; +}; + +export type DiagnosticsPerfCommandOptions = CommandContext & { + appId?: string; + appBundleId?: string; + since?: string; + until?: string; + sampleMs?: number; + metrics?: readonly string[]; +}; + +export type DiagnosticsLogsCommandResult = { + kind: 'diagnosticsLogs'; + entries: readonly BackendLogEntry[]; + nextCursor?: string; + timeWindow?: BackendDiagnosticsTimeWindow; + backend?: string; + redacted: boolean; + notes?: readonly string[]; +}; + +export type DiagnosticsNetworkCommandResult = { + kind: 'diagnosticsNetwork'; + entries: readonly BackendNetworkEntry[]; + nextCursor?: string; + timeWindow?: BackendDiagnosticsTimeWindow; + backend?: string; + redacted: boolean; + notes?: readonly string[]; +}; + +export type DiagnosticsPerfCommandResult = { + kind: 'diagnosticsPerf'; + metrics: readonly BackendPerfMetric[]; + startedAt?: string; + endedAt?: string; + backend?: string; + redacted: boolean; + notes?: readonly string[]; +}; + +const LOG_LIMIT_DEFAULT = 100; +const LOG_LIMIT_MAX = 500; +const NETWORK_LIMIT_DEFAULT = 25; +const NETWORK_LIMIT_MAX = 200; +const PERF_SAMPLE_MIN_MS = 100; +const PERF_SAMPLE_MAX_MS = 60_000; +const PERF_METRICS_MAX = 20; + +export const logsCommand: RuntimeCommand< + DiagnosticsLogsCommandOptions | undefined, + DiagnosticsLogsCommandResult +> = async (runtime, options = {}): Promise => { + if (!runtime.backend.readLogs) { + throw new AppError( + 'UNSUPPORTED_OPERATION', + 'diagnostics.logs is not supported by this backend', + ); + } + const result = await runtime.backend.readLogs( + await toDiagnosticsBackendContext(runtime, options), + normalizeReadLogsOptions(options), + ); + return formatLogsResult(result); +}; + +export const networkCommand: RuntimeCommand< + DiagnosticsNetworkCommandOptions | undefined, + DiagnosticsNetworkCommandResult +> = async (runtime, options = {}): Promise => { + if (!runtime.backend.dumpNetwork) { + throw new AppError( + 'UNSUPPORTED_OPERATION', + 'diagnostics.network is not supported by this backend', + ); + } + const normalizedOptions = normalizeDumpNetworkOptions(options); + const result = await runtime.backend.dumpNetwork( + await toDiagnosticsBackendContext(runtime, options), + normalizedOptions, + ); + return formatNetworkResult(result, normalizedOptions.include ?? 'summary'); +}; + +export const perfCommand: RuntimeCommand< + DiagnosticsPerfCommandOptions | undefined, + DiagnosticsPerfCommandResult +> = async (runtime, options = {}): Promise => { + if (!runtime.backend.measurePerf) { + throw new AppError( + 'UNSUPPORTED_OPERATION', + 'diagnostics.perf is not supported by this backend', + ); + } + const result = await runtime.backend.measurePerf( + await toDiagnosticsBackendContext(runtime, options), + normalizeMeasurePerfOptions(options), + ); + return formatPerfResult(result); +}; + +async function toDiagnosticsBackendContext( + runtime: AgentDeviceRuntime, + options: CommandContext & { appId?: string; appBundleId?: string }, +): Promise { + const context = toBackendContext(runtime, options); + const session = options.session ? await runtime.sessions.get(options.session) : undefined; + return { + ...context, + ...((options.appId ?? session?.appId) ? { appId: options.appId ?? session?.appId } : {}), + ...((options.appBundleId ?? session?.appBundleId) + ? { appBundleId: options.appBundleId ?? session?.appBundleId } + : {}), + }; +} + +function normalizeReadLogsOptions(options: DiagnosticsLogsCommandOptions): BackendReadLogsOptions { + return { + ...normalizePageOptions(options, LOG_LIMIT_DEFAULT, LOG_LIMIT_MAX, 'logs limit'), + ...(options.levels !== undefined + ? { levels: normalizeStringList(options.levels, 'levels') } + : {}), + ...(options.search !== undefined ? { search: requireText(options.search, 'search') } : {}), + ...(options.source !== undefined ? { source: requireText(options.source, 'source') } : {}), + }; +} + +function normalizeDumpNetworkOptions( + options: DiagnosticsNetworkCommandOptions, +): BackendDumpNetworkOptions { + return { + ...normalizePageOptions(options, NETWORK_LIMIT_DEFAULT, NETWORK_LIMIT_MAX, 'network limit'), + include: normalizeNetworkInclude(options.include), + }; +} + +function normalizeMeasurePerfOptions( + options: DiagnosticsPerfCommandOptions, +): BackendMeasurePerfOptions { + return { + ...normalizeTimeWindow(options), + ...(options.sampleMs !== undefined + ? { + sampleMs: requireIntInRange( + options.sampleMs, + 'sampleMs', + PERF_SAMPLE_MIN_MS, + PERF_SAMPLE_MAX_MS, + ), + } + : {}), + ...(options.metrics !== undefined + ? { metrics: normalizeStringList(options.metrics, 'metrics', PERF_METRICS_MAX) } + : {}), + }; +} + +function normalizePageOptions( + options: DiagnosticsPageOptions, + defaultLimit: number, + maxLimit: number, + limitName: string, +): BackendDiagnosticsPageOptions { + return { + ...normalizeTimeWindow(options), + ...(options.cursor !== undefined ? { cursor: requireText(options.cursor, 'cursor') } : {}), + limit: + options.limit === undefined + ? defaultLimit + : requireIntInRange(options.limit, limitName, 1, maxLimit), + }; +} + +function normalizeTimeWindow(options: { + since?: string; + until?: string; +}): BackendDiagnosticsTimeWindow { + return { + ...(options.since !== undefined ? { since: requireText(options.since, 'since') } : {}), + ...(options.until !== undefined ? { until: requireText(options.until, 'until') } : {}), + }; +} + +function normalizeNetworkInclude( + include: BackendNetworkIncludeMode | undefined, +): BackendNetworkIncludeMode { + if (include === undefined) return 'summary'; + if (include === 'summary' || include === 'headers' || include === 'body' || include === 'all') { + return include; + } + throw new AppError('INVALID_ARGS', 'network include must be summary, headers, body, or all'); +} + +function normalizeStringList( + values: readonly string[], + field: string, + maxItems = 50, +): readonly string[] { + if (!Array.isArray(values)) { + throw new AppError('INVALID_ARGS', `${field} must be an array of strings`); + } + if (values.length > maxItems) { + throw new AppError('INVALID_ARGS', `${field} must contain at most ${maxItems} entries`); + } + return values.map((value, index) => requireText(value, `${field}[${index}]`)); +} + +function requireText(value: string, field: string): string { + const text = value.trim(); + if (!text) throw new AppError('INVALID_ARGS', `${field} must be a non-empty string`); + return text; +} diff --git a/src/commands/index.ts b/src/commands/index.ts index f2946151f..da73bb6e8 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -112,6 +112,43 @@ import { type TriggerAppEventCommandOptions, type TriggerAppEventCommandResult, } from './apps.ts'; +import { + bootCommand, + devicesCommand, + ensureSimulatorCommand, + installCommand, + installFromSourceCommand, + reinstallCommand, + type AdminBootCommandOptions, + type AdminBootCommandResult, + type AdminDevicesCommandOptions, + type AdminDevicesCommandResult, + type AdminEnsureSimulatorCommandOptions, + type AdminEnsureSimulatorCommandResult, + type AdminInstallCommandOptions, + type AdminInstallCommandResult, + type AdminInstallFromSourceCommandOptions, + type AdminReinstallCommandOptions, +} from './admin.ts'; +import { + recordCommand, + traceCommand, + type RecordingRecordCommandOptions, + type RecordingRecordCommandResult, + type RecordingTraceCommandOptions, + type RecordingTraceCommandResult, +} from './recording.ts'; +import { + logsCommand, + networkCommand, + perfCommand, + type DiagnosticsLogsCommandOptions, + type DiagnosticsLogsCommandResult, + type DiagnosticsNetworkCommandOptions, + type DiagnosticsNetworkCommandResult, + type DiagnosticsPerfCommandOptions, + type DiagnosticsPerfCommandResult, +} from './diagnostics.ts'; export type { ScreenshotCommandResult } from './capture-screenshot.ts'; export type { @@ -201,11 +238,40 @@ export type { TriggerAppEventCommandOptions, TriggerAppEventCommandResult, } from './apps.ts'; +export type { + AdminBootCommandOptions, + AdminBootCommandResult, + AdminDevicesCommandOptions, + AdminDevicesCommandResult, + AdminEnsureSimulatorCommandOptions, + AdminEnsureSimulatorCommandResult, + AdminInstallCommandOptions, + AdminInstallCommandResult, + AdminInstallFromSourceCommandOptions, + AdminReinstallCommandOptions, +} from './admin.ts'; +export type { + RecordingRecordCommandOptions, + RecordingRecordCommandResult, + RecordingTraceCommandOptions, + RecordingTraceCommandResult, +} from './recording.ts'; +export type { + DiagnosticsLogsCommandOptions, + DiagnosticsLogsCommandResult, + DiagnosticsNetworkCommandOptions, + DiagnosticsNetworkCommandResult, + DiagnosticsPerfCommandOptions, + DiagnosticsPerfCommandResult, +} from './diagnostics.ts'; export { ref, selector } from './selector-read.ts'; export { commandCatalog } from './catalog.ts'; export type { CommandCatalogEntry } from './catalog.ts'; export { createCommandRouter } from './router.ts'; export type { + BatchCommandOptions, + BatchCommandResult, + BatchCommandStepResult, CommandRouter, CommandRouterConfig, CommandRouterRequest, @@ -294,6 +360,32 @@ export type AgentDeviceCommands = { push: RuntimeCommand; triggerEvent: RuntimeCommand; }; + admin: { + devices: RuntimeCommand; + boot: RuntimeCommand; + ensureSimulator: RuntimeCommand< + AdminEnsureSimulatorCommandOptions, + AdminEnsureSimulatorCommandResult + >; + install: RuntimeCommand; + reinstall: RuntimeCommand; + installFromSource: RuntimeCommand< + AdminInstallFromSourceCommandOptions, + AdminInstallCommandResult + >; + }; + recording: { + record: RuntimeCommand; + trace: RuntimeCommand; + }; + diagnostics: { + logs: RuntimeCommand; + network: RuntimeCommand< + DiagnosticsNetworkCommandOptions | undefined, + DiagnosticsNetworkCommandResult + >; + perf: RuntimeCommand; + }; }; export type BoundAgentDeviceCommands = { @@ -379,6 +471,31 @@ export type BoundAgentDeviceCommands = { push: BoundRuntimeCommand; triggerEvent: BoundRuntimeCommand; }; + admin: { + devices: (options?: AdminDevicesCommandOptions) => Promise; + boot: (options?: AdminBootCommandOptions) => Promise; + ensureSimulator: BoundRuntimeCommand< + AdminEnsureSimulatorCommandOptions, + AdminEnsureSimulatorCommandResult + >; + install: BoundRuntimeCommand; + reinstall: BoundRuntimeCommand; + installFromSource: BoundRuntimeCommand< + AdminInstallFromSourceCommandOptions, + AdminInstallCommandResult + >; + }; + recording: { + record: BoundRuntimeCommand; + trace: BoundRuntimeCommand; + }; + observability: { + logs: (options?: DiagnosticsLogsCommandOptions) => Promise; + network: ( + options?: DiagnosticsNetworkCommandOptions, + ) => Promise; + perf: (options?: DiagnosticsPerfCommandOptions) => Promise; + }; }; export const commands: AgentDeviceCommands = { @@ -428,6 +545,23 @@ export const commands: AgentDeviceCommands = { push: pushAppCommand, triggerEvent: triggerAppEventCommand, }, + admin: { + devices: devicesCommand, + boot: bootCommand, + ensureSimulator: ensureSimulatorCommand, + install: installCommand, + reinstall: reinstallCommand, + installFromSource: installFromSourceCommand, + }, + recording: { + record: recordCommand, + trace: traceCommand, + }, + diagnostics: { + logs: logsCommand, + network: networkCommand, + perf: perfCommand, + }, }; export function bindCommands(runtime: AgentDeviceRuntime): BoundAgentDeviceCommands { @@ -486,5 +620,22 @@ export function bindCommands(runtime: AgentDeviceRuntime): BoundAgentDeviceComma push: (options) => commands.apps.push(runtime, options), triggerEvent: (options) => commands.apps.triggerEvent(runtime, options), }, + admin: { + devices: (options) => commands.admin.devices(runtime, options), + boot: (options) => commands.admin.boot(runtime, options), + ensureSimulator: (options) => commands.admin.ensureSimulator(runtime, options), + install: (options) => commands.admin.install(runtime, options), + reinstall: (options) => commands.admin.reinstall(runtime, options), + installFromSource: (options) => commands.admin.installFromSource(runtime, options), + }, + recording: { + record: (options) => commands.recording.record(runtime, options), + trace: (options) => commands.recording.trace(runtime, options), + }, + observability: { + logs: (options) => commands.diagnostics.logs(runtime, options), + network: (options) => commands.diagnostics.network(runtime, options), + perf: (options) => commands.diagnostics.perf(runtime, options), + }, }; } diff --git a/src/commands/recording.ts b/src/commands/recording.ts new file mode 100644 index 000000000..1652ae01d --- /dev/null +++ b/src/commands/recording.ts @@ -0,0 +1,199 @@ +import type { + BackendRecordingOptions, + BackendRecordingResult, + BackendTraceOptions, + BackendTraceResult, +} from '../backend.ts'; +import type { ArtifactDescriptor, FileOutputRef } from '../io.ts'; +import type { CommandContext } from '../runtime.ts'; +import { AppError } from '../utils/errors.ts'; +import { successText } from '../utils/success-text.ts'; +import { requireIntInRange } from '../utils/validation.ts'; +import type { RuntimeCommand } from './index.ts'; +import { reserveCommandOutput } from './io-policy.ts'; +import { toBackendContext } from './selector-read-utils.ts'; + +export type RecordingRecordCommandOptions = CommandContext & { + action: 'start' | 'stop'; + out?: FileOutputRef; + fps?: number; + quality?: number; + hideTouches?: boolean; +}; + +export type RecordingTraceCommandOptions = CommandContext & { + action: 'start' | 'stop'; + out?: FileOutputRef; +}; + +export type RecordingRecordCommandResult = { + kind: 'recordingStarted' | 'recordingStopped'; + action: 'start' | 'stop'; + path?: string; + telemetryPath?: string; + artifact?: ArtifactDescriptor; + backendResult?: Record; + warning?: string; + message?: string; +}; + +export type RecordingTraceCommandResult = { + kind: 'traceStarted' | 'traceStopped'; + action: 'start' | 'stop'; + outPath?: string; + artifact?: ArtifactDescriptor; + backendResult?: Record; + message?: string; +}; + +export const recordCommand: RuntimeCommand< + RecordingRecordCommandOptions, + RecordingRecordCommandResult +> = async (runtime, options): Promise => { + const action = requireAction(options.action, 'record'); + const method = + action === 'start' ? runtime.backend.startRecording : runtime.backend.stopRecording; + if (!method) { + throw new AppError( + 'UNSUPPORTED_OPERATION', + `record ${action} is not supported by this backend`, + ); + } + + const output = options.out + ? await reserveCommandOutput(runtime, options.out, { + field: 'path', + ext: '.mp4', + }) + : undefined; + try { + const backendOptions = normalizeRecordingOptions(options, output?.path); + const result = await method.call( + runtime.backend, + toBackendContext(runtime, options), + backendOptions, + ); + const artifact = await output?.publish(); + return formatRecordingResult(action, result, artifact); + } catch (error) { + await output?.cleanup?.(); + throw error; + } +}; + +export const traceCommand: RuntimeCommand< + RecordingTraceCommandOptions, + RecordingTraceCommandResult +> = async (runtime, options): Promise => { + const action = requireAction(options.action, 'trace'); + const method = action === 'start' ? runtime.backend.startTrace : runtime.backend.stopTrace; + if (!method) { + throw new AppError('UNSUPPORTED_OPERATION', `trace ${action} is not supported by this backend`); + } + + const output = options.out + ? await reserveCommandOutput(runtime, options.out, { + field: 'outPath', + ext: '.trace', + }) + : undefined; + try { + const backendOptions: BackendTraceOptions = { + ...(output?.path ? { outPath: output.path } : {}), + }; + const result = await method.call( + runtime.backend, + toBackendContext(runtime, options), + backendOptions, + ); + const artifact = await output?.publish(); + return formatTraceResult(action, result, artifact); + } catch (error) { + await output?.cleanup?.(); + throw error; + } +}; + +function normalizeRecordingOptions( + options: RecordingRecordCommandOptions, + outPath: string | undefined, +): BackendRecordingOptions { + const fps = options.fps === undefined ? undefined : requireIntInRange(options.fps, 'fps', 1, 60); + const quality = + options.quality === undefined + ? undefined + : requireIntInRange(options.quality, 'quality', 5, 10); + return { + ...(outPath ? { outPath } : {}), + ...(fps !== undefined ? { fps } : {}), + ...(quality !== undefined ? { quality } : {}), + ...(options.hideTouches !== undefined ? { showTouches: options.hideTouches !== true } : {}), + }; +} + +function requireAction(action: string, command: string): 'start' | 'stop' { + if (action === 'start' || action === 'stop') return action; + throw new AppError('INVALID_ARGS', `${command} action must be start or stop`); +} + +function formatRecordingResult( + action: 'start' | 'stop', + result: BackendRecordingResult, + artifact: ArtifactDescriptor | undefined, +): RecordingRecordCommandResult { + return { + ...(typeof result.path === 'string' ? { path: result.path } : {}), + ...(typeof result.telemetryPath === 'string' ? { telemetryPath: result.telemetryPath } : {}), + ...(typeof result.warning === 'string' ? { warning: result.warning } : {}), + ...formatLifecycleResult(action, result, artifact, { + startKind: 'recordingStarted', + stopKind: 'recordingStopped', + startMessage: 'Recording started', + stopMessage: 'Recording stopped', + }), + }; +} + +function formatTraceResult( + action: 'start' | 'stop', + result: BackendTraceResult, + artifact: ArtifactDescriptor | undefined, +): RecordingTraceCommandResult { + return { + ...(typeof result.outPath === 'string' ? { outPath: result.outPath } : {}), + ...formatLifecycleResult(action, result, artifact, { + startKind: 'traceStarted', + stopKind: 'traceStopped', + startMessage: 'Trace started', + stopMessage: 'Trace stopped', + }), + }; +} + +function formatLifecycleResult< + TKind extends RecordingRecordCommandResult['kind'] | RecordingTraceCommandResult['kind'], +>( + action: 'start' | 'stop', + result: Record, + artifact: ArtifactDescriptor | undefined, + options: { + startKind: TKind; + stopKind: TKind; + startMessage: string; + stopMessage: string; + }, +): { + kind: TKind; + action: 'start' | 'stop'; + artifact?: ArtifactDescriptor; + backendResult: Record; + message?: string; +} { + return { + kind: action === 'start' ? options.startKind : options.stopKind, + action, + ...(artifact ? { artifact } : {}), + backendResult: result, + ...successText(action === 'start' ? options.startMessage : options.stopMessage), + }; +} diff --git a/src/commands/router-orchestration.ts b/src/commands/router-orchestration.ts new file mode 100644 index 000000000..6d82b9d8f --- /dev/null +++ b/src/commands/router-orchestration.ts @@ -0,0 +1,133 @@ +import type { CommandContext } from '../runtime.ts'; +import { AppError, type NormalizedError } from '../utils/errors.ts'; +import type { CommandRouter, CommandRouterRequest, CommandRouterResult } from './router-types.ts'; + +export type BatchCommandOptions = CommandContext & { + steps: readonly CommandRouterRequest[]; + stopOnError?: boolean; + maxSteps?: number; +}; + +export type BatchCommandStepResult = + | { + step: number; + command: string; + ok: true; + data: CommandRouterResult; + durationMs: number; + } + | { + step: number; + command: string; + ok: false; + error: NormalizedError; + durationMs: number; + }; + +export type BatchCommandResult = { + kind: 'batch'; + total: number; + executed: number; + failed: number; + totalDurationMs: number; + results: readonly BatchCommandStepResult[]; +}; + +const ROUTER_BATCH_MAX_STEPS = 50; + +export async function dispatchBatchCommand( + request: Extract, { command: 'batch' }>, + dispatch: CommandRouter['dispatch'], +): Promise { + const steps = normalizeRouterSteps(request.options.steps, request.options.maxSteps); + const startedAt = Date.now(); + const results: BatchCommandStepResult[] = []; + for (let index = 0; index < steps.length; index += 1) { + const step = inheritBatchContext(steps[index]!, request.options, request.context); + const stepStartedAt = Date.now(); + const response = await dispatch(step); + const durationMs = Date.now() - stepStartedAt; + if (response.ok) { + results.push({ + step: index + 1, + command: step.command, + ok: true, + data: response.data, + durationMs, + }); + continue; + } + results.push({ + step: index + 1, + command: step.command, + ok: false, + error: response.error, + durationMs, + }); + if (request.options.stopOnError !== false) break; + } + return { + kind: 'batch', + total: steps.length, + executed: results.length, + failed: results.filter((result) => !result.ok).length, + totalDurationMs: Date.now() - startedAt, + results, + }; +} + +function normalizeRouterSteps( + steps: readonly CommandRouterRequest[] | undefined, + maxStepsOption: number | undefined, +): readonly CommandRouterRequest[] { + const maxSteps = maxStepsOption ?? ROUTER_BATCH_MAX_STEPS; + if (!Number.isInteger(maxSteps) || maxSteps < 1 || maxSteps > ROUTER_BATCH_MAX_STEPS) { + throw new AppError( + 'INVALID_ARGS', + `batch maxSteps must be an integer between 1 and ${ROUTER_BATCH_MAX_STEPS}`, + ); + } + if (!Array.isArray(steps) || steps.length === 0) { + throw new AppError('INVALID_ARGS', 'batch requires a non-empty steps array'); + } + if (steps.length > maxSteps) { + throw new AppError( + 'INVALID_ARGS', + `batch has ${steps.length} steps; max allowed is ${maxSteps}`, + ); + } + for (let index = 0; index < steps.length; index += 1) { + const step = steps[index]; + if (!step || typeof step !== 'object' || typeof step.command !== 'string') { + throw new AppError('INVALID_ARGS', `Invalid batch step at index ${index}`); + } + if (step.command === 'batch') { + throw new AppError('INVALID_ARGS', `Batch step ${index + 1} cannot run ${step.command}`); + } + } + return steps; +} + +function inheritBatchContext( + step: CommandRouterRequest, + parentOptions: CommandContext, + parentContext: TContext | undefined, +): CommandRouterRequest { + return { + ...step, + context: step.context ?? parentContext, + options: { + ...copyCommandContext(parentOptions), + ...(step.options ?? {}), + }, + } as CommandRouterRequest; +} + +function copyCommandContext(options: CommandContext): CommandContext { + return { + ...(options.session ? { session: options.session } : {}), + ...(options.requestId ? { requestId: options.requestId } : {}), + ...(options.signal ? { signal: options.signal } : {}), + ...(options.metadata ? { metadata: options.metadata } : {}), + }; +} diff --git a/src/commands/router-types.ts b/src/commands/router-types.ts new file mode 100644 index 000000000..442d950cf --- /dev/null +++ b/src/commands/router-types.ts @@ -0,0 +1,234 @@ +import type { AgentDeviceRuntime } from '../runtime.ts'; +import type { NormalizedError } from '../utils/errors.ts'; +import type { ScreenshotCommandResult } from './capture-screenshot.ts'; +import type { + DiffScreenshotCommandOptions, + DiffScreenshotCommandResult, +} from './capture-diff-screenshot.ts'; +import type { DiffSnapshotCommandResult, SnapshotCommandResult } from './capture-snapshot.ts'; +import type { + FindReadCommandOptions, + FindReadCommandResult, + GetCommandOptions, + GetCommandResult, + IsCommandOptions, + IsCommandResult, + WaitCommandOptions, + WaitCommandResult, +} from './selector-read.ts'; +import type { + ClickCommandOptions, + FillCommandOptions, + FillCommandResult, + FocusCommandOptions, + FocusCommandResult, + LongPressCommandOptions, + LongPressCommandResult, + PinchCommandOptions, + PinchCommandResult, + PressCommandOptions, + PressCommandResult, + ScrollCommandOptions, + ScrollCommandResult, + SwipeCommandOptions, + SwipeCommandResult, + TypeTextCommandOptions, + TypeTextCommandResult, +} from './interactions.ts'; +import type { + SystemAlertCommandOptions, + SystemAlertCommandResult, + SystemAppSwitcherCommandOptions, + SystemAppSwitcherCommandResult, + SystemBackCommandOptions, + SystemBackCommandResult, + SystemClipboardCommandOptions, + SystemClipboardCommandResult, + SystemHomeCommandOptions, + SystemHomeCommandResult, + SystemKeyboardCommandOptions, + SystemKeyboardCommandResult, + SystemRotateCommandOptions, + SystemRotateCommandResult, + SystemSettingsCommandOptions, + SystemSettingsCommandResult, +} from './system.ts'; +import type { + CloseAppCommandOptions, + CloseAppCommandResult, + GetAppStateCommandOptions, + GetAppStateCommandResult, + ListAppsCommandOptions, + ListAppsCommandResult, + OpenAppCommandOptions, + OpenAppCommandResult, + PushAppCommandOptions, + PushAppCommandResult, + TriggerAppEventCommandOptions, + TriggerAppEventCommandResult, +} from './apps.ts'; +import type { + AdminBootCommandOptions, + AdminBootCommandResult, + AdminDevicesCommandOptions, + AdminDevicesCommandResult, + AdminEnsureSimulatorCommandOptions, + AdminEnsureSimulatorCommandResult, + AdminInstallCommandOptions, + AdminInstallCommandResult, + AdminInstallFromSourceCommandOptions, + AdminReinstallCommandOptions, +} from './admin.ts'; +import type { + RecordingRecordCommandOptions, + RecordingRecordCommandResult, + RecordingTraceCommandOptions, + RecordingTraceCommandResult, +} from './recording.ts'; +import type { + DiagnosticsLogsCommandOptions, + DiagnosticsLogsCommandResult, + DiagnosticsNetworkCommandOptions, + DiagnosticsNetworkCommandResult, + DiagnosticsPerfCommandOptions, + DiagnosticsPerfCommandResult, +} from './diagnostics.ts'; +import type { + DiffSnapshotCommandOptions, + ScreenshotCommandOptions, + SnapshotCommandOptions, +} from './index.ts'; +import type { BatchCommandOptions, BatchCommandResult } from './router-orchestration.ts'; + +export type CommandRouterRequest = + | { command: 'capture.screenshot'; options: ScreenshotCommandOptions; context?: TContext } + | { + command: 'capture.diffScreenshot'; + options: DiffScreenshotCommandOptions; + context?: TContext; + } + | { command: 'capture.snapshot'; options: SnapshotCommandOptions; context?: TContext } + | { command: 'capture.diffSnapshot'; options: DiffSnapshotCommandOptions; context?: TContext } + | { command: 'selectors.find'; options: FindReadCommandOptions; context?: TContext } + | { command: 'selectors.get'; options: GetCommandOptions; context?: TContext } + | { command: 'selectors.is'; options: IsCommandOptions; context?: TContext } + | { command: 'selectors.wait'; options: WaitCommandOptions; context?: TContext } + | { command: 'interactions.click'; options: ClickCommandOptions; context?: TContext } + | { command: 'interactions.press'; options: PressCommandOptions; context?: TContext } + | { command: 'interactions.fill'; options: FillCommandOptions; context?: TContext } + | { command: 'interactions.typeText'; options: TypeTextCommandOptions; context?: TContext } + | { command: 'interactions.focus'; options: FocusCommandOptions; context?: TContext } + | { command: 'interactions.longPress'; options: LongPressCommandOptions; context?: TContext } + | { command: 'interactions.swipe'; options: SwipeCommandOptions; context?: TContext } + | { command: 'interactions.scroll'; options: ScrollCommandOptions; context?: TContext } + | { command: 'interactions.pinch'; options: PinchCommandOptions; context?: TContext } + | { command: 'system.back'; options?: SystemBackCommandOptions; context?: TContext } + | { command: 'system.home'; options?: SystemHomeCommandOptions; context?: TContext } + | { command: 'system.rotate'; options: SystemRotateCommandOptions; context?: TContext } + | { command: 'system.keyboard'; options?: SystemKeyboardCommandOptions; context?: TContext } + | { command: 'system.clipboard'; options: SystemClipboardCommandOptions; context?: TContext } + | { command: 'system.settings'; options?: SystemSettingsCommandOptions; context?: TContext } + | { command: 'system.alert'; options?: SystemAlertCommandOptions; context?: TContext } + | { + command: 'system.appSwitcher'; + options?: SystemAppSwitcherCommandOptions; + context?: TContext; + } + | { command: 'apps.open'; options: OpenAppCommandOptions; context?: TContext } + | { command: 'apps.close'; options?: CloseAppCommandOptions; context?: TContext } + | { command: 'apps.list'; options?: ListAppsCommandOptions; context?: TContext } + | { command: 'apps.state'; options: GetAppStateCommandOptions; context?: TContext } + | { command: 'apps.push'; options: PushAppCommandOptions; context?: TContext } + | { + command: 'apps.triggerEvent'; + options: TriggerAppEventCommandOptions; + context?: TContext; + } + | { command: 'admin.devices'; options?: AdminDevicesCommandOptions; context?: TContext } + | { command: 'admin.boot'; options?: AdminBootCommandOptions; context?: TContext } + | { + command: 'admin.ensureSimulator'; + options: AdminEnsureSimulatorCommandOptions; + context?: TContext; + } + | { command: 'admin.install'; options: AdminInstallCommandOptions; context?: TContext } + | { command: 'admin.reinstall'; options: AdminReinstallCommandOptions; context?: TContext } + | { + command: 'admin.installFromSource'; + options: AdminInstallFromSourceCommandOptions; + context?: TContext; + } + | { command: 'record'; options: RecordingRecordCommandOptions; context?: TContext } + | { command: 'trace'; options: RecordingTraceCommandOptions; context?: TContext } + | { command: 'diagnostics.logs'; options?: DiagnosticsLogsCommandOptions; context?: TContext } + | { + command: 'diagnostics.network'; + options?: DiagnosticsNetworkCommandOptions; + context?: TContext; + } + | { command: 'diagnostics.perf'; options?: DiagnosticsPerfCommandOptions; context?: TContext } + | { command: 'batch'; options: BatchCommandOptions; context?: TContext }; + +export type CommandRouterResult = + | ScreenshotCommandResult + | DiffScreenshotCommandResult + | SnapshotCommandResult + | DiffSnapshotCommandResult + | FindReadCommandResult + | GetCommandResult + | IsCommandResult + | WaitCommandResult + | PressCommandResult + | FillCommandResult + | TypeTextCommandResult + | FocusCommandResult + | LongPressCommandResult + | SwipeCommandResult + | ScrollCommandResult + | PinchCommandResult + | SystemBackCommandResult + | SystemHomeCommandResult + | SystemRotateCommandResult + | SystemKeyboardCommandResult + | SystemClipboardCommandResult + | SystemSettingsCommandResult + | SystemAlertCommandResult + | SystemAppSwitcherCommandResult + | OpenAppCommandResult + | CloseAppCommandResult + | ListAppsCommandResult + | GetAppStateCommandResult + | PushAppCommandResult + | TriggerAppEventCommandResult + | AdminDevicesCommandResult + | AdminBootCommandResult + | AdminEnsureSimulatorCommandResult + | AdminInstallCommandResult + | RecordingRecordCommandResult + | RecordingTraceCommandResult + | DiagnosticsLogsCommandResult + | DiagnosticsNetworkCommandResult + | DiagnosticsPerfCommandResult + | BatchCommandResult; + +export type CommandRouterResponse = + | { + ok: true; + data: CommandRouterResult; + } + | { + ok: false; + error: NormalizedError; + }; + +export type CommandRouter = { + dispatch(request: CommandRouterRequest): Promise; +}; + +export type CommandRouterConfig = { + createRuntime( + request: CommandRouterRequest, + ): AgentDeviceRuntime | Promise; + beforeDispatch?(request: CommandRouterRequest): void | Promise; + formatError?(error: unknown, request: CommandRouterRequest): NormalizedError; +}; diff --git a/src/commands/router.ts b/src/commands/router.ts index d7fb69684..aea1993a4 100644 --- a/src/commands/router.ts +++ b/src/commands/router.ts @@ -1,31 +1,9 @@ import type { AgentDeviceRuntime } from '../runtime.ts'; -import { AppError, normalizeAgentDeviceError, type NormalizedError } from '../utils/errors.ts'; -import { screenshotCommand, type ScreenshotCommandResult } from './capture-screenshot.ts'; -import { - diffScreenshotCommand, - type DiffScreenshotCommandOptions, - type DiffScreenshotCommandResult, -} from './capture-diff-screenshot.ts'; -import { - diffSnapshotCommand, - snapshotCommand, - type DiffSnapshotCommandResult, - type SnapshotCommandResult, -} from './capture-snapshot.ts'; -import { - findCommand, - getCommand, - isCommand, - waitCommand, - type FindReadCommandOptions, - type FindReadCommandResult, - type GetCommandOptions, - type GetCommandResult, - type IsCommandOptions, - type IsCommandResult, - type WaitCommandOptions, - type WaitCommandResult, -} from './selector-read.ts'; +import { AppError, normalizeAgentDeviceError } from '../utils/errors.ts'; +import { screenshotCommand } from './capture-screenshot.ts'; +import { diffScreenshotCommand } from './capture-diff-screenshot.ts'; +import { diffSnapshotCommand, snapshotCommand } from './capture-snapshot.ts'; +import { findCommand, getCommand, isCommand, waitCommand } from './selector-read.ts'; import { clickCommand, fillCommand, @@ -36,23 +14,6 @@ import { scrollCommand, swipeCommand, typeTextCommand, - type ClickCommandOptions, - type FillCommandOptions, - type FillCommandResult, - type FocusCommandOptions, - type FocusCommandResult, - type LongPressCommandOptions, - type LongPressCommandResult, - type PinchCommandOptions, - type PinchCommandResult, - type PressCommandOptions, - type PressCommandResult, - type ScrollCommandOptions, - type ScrollCommandResult, - type SwipeCommandOptions, - type SwipeCommandResult, - type TypeTextCommandOptions, - type TypeTextCommandResult, } from './interactions.ts'; import { alertCommand, @@ -63,22 +24,6 @@ import { keyboardCommand, rotateCommand, settingsCommand, - type SystemAlertCommandOptions, - type SystemAlertCommandResult, - type SystemAppSwitcherCommandOptions, - type SystemAppSwitcherCommandResult, - type SystemBackCommandOptions, - type SystemBackCommandResult, - type SystemClipboardCommandOptions, - type SystemClipboardCommandResult, - type SystemHomeCommandOptions, - type SystemHomeCommandResult, - type SystemKeyboardCommandOptions, - type SystemKeyboardCommandResult, - type SystemRotateCommandOptions, - type SystemRotateCommandResult, - type SystemSettingsCommandOptions, - type SystemSettingsCommandResult, } from './system.ts'; import { closeAppCommand, @@ -87,293 +32,144 @@ import { openAppCommand, pushAppCommand, triggerAppEventCommand, - type CloseAppCommandOptions, - type CloseAppCommandResult, - type GetAppStateCommandOptions, - type GetAppStateCommandResult, - type ListAppsCommandOptions, - type ListAppsCommandResult, - type OpenAppCommandOptions, - type OpenAppCommandResult, - type PushAppCommandOptions, - type PushAppCommandResult, - type TriggerAppEventCommandOptions, - type TriggerAppEventCommandResult, } from './apps.ts'; -import type { - DiffSnapshotCommandOptions, - ScreenshotCommandOptions, - SnapshotCommandOptions, -} from './index.ts'; +import { + bootCommand, + devicesCommand, + ensureSimulatorCommand, + installCommand, + installFromSourceCommand, + reinstallCommand, +} from './admin.ts'; +import { recordCommand, traceCommand } from './recording.ts'; +import { logsCommand, networkCommand, perfCommand } from './diagnostics.ts'; import { commandCatalog } from './catalog.ts'; +import { dispatchBatchCommand } from './router-orchestration.ts'; +import type { + CommandRouter, + CommandRouterConfig, + CommandRouterRequest, + CommandRouterResponse, + CommandRouterResult, +} from './router-types.ts'; -export type CommandRouterRequest = - | { - command: 'capture.screenshot'; - options: ScreenshotCommandOptions; - context?: TContext; - } - | { - command: 'capture.diffScreenshot'; - options: DiffScreenshotCommandOptions; - context?: TContext; - } - | { - command: 'capture.snapshot'; - options: SnapshotCommandOptions; - context?: TContext; - } - | { - command: 'capture.diffSnapshot'; - options: DiffSnapshotCommandOptions; - context?: TContext; - } - | { - command: 'selectors.find'; - options: FindReadCommandOptions; - context?: TContext; - } - | { - command: 'selectors.get'; - options: GetCommandOptions; - context?: TContext; - } - | { - command: 'selectors.is'; - options: IsCommandOptions; - context?: TContext; - } - | { - command: 'selectors.wait'; - options: WaitCommandOptions; - context?: TContext; - } - | { - command: 'interactions.click'; - options: ClickCommandOptions; - context?: TContext; - } - | { - command: 'interactions.press'; - options: PressCommandOptions; - context?: TContext; - } - | { - command: 'interactions.fill'; - options: FillCommandOptions; - context?: TContext; - } - | { - command: 'interactions.typeText'; - options: TypeTextCommandOptions; - context?: TContext; - } - | { - command: 'interactions.focus'; - options: FocusCommandOptions; - context?: TContext; - } - | { - command: 'interactions.longPress'; - options: LongPressCommandOptions; - context?: TContext; - } - | { - command: 'interactions.swipe'; - options: SwipeCommandOptions; - context?: TContext; - } - | { - command: 'interactions.scroll'; - options: ScrollCommandOptions; - context?: TContext; - } - | { - command: 'interactions.pinch'; - options: PinchCommandOptions; - context?: TContext; - } - | { - command: 'system.back'; - options?: SystemBackCommandOptions; - context?: TContext; - } - | { - command: 'system.home'; - options?: SystemHomeCommandOptions; - context?: TContext; - } - | { - command: 'system.rotate'; - options: SystemRotateCommandOptions; - context?: TContext; - } - | { - command: 'system.keyboard'; - options?: SystemKeyboardCommandOptions; - context?: TContext; - } - | { - command: 'system.clipboard'; - options: SystemClipboardCommandOptions; - context?: TContext; - } - | { - command: 'system.settings'; - options?: SystemSettingsCommandOptions; - context?: TContext; - } - | { - command: 'system.alert'; - options?: SystemAlertCommandOptions; - context?: TContext; - } - | { - command: 'system.appSwitcher'; - options?: SystemAppSwitcherCommandOptions; - context?: TContext; - } - | { - command: 'apps.open'; - options: OpenAppCommandOptions; - context?: TContext; - } - | { - command: 'apps.close'; - options?: CloseAppCommandOptions; - context?: TContext; - } - | { - command: 'apps.list'; - options?: ListAppsCommandOptions; - context?: TContext; - } - | { - command: 'apps.state'; - options: GetAppStateCommandOptions; - context?: TContext; - } - | { - command: 'apps.push'; - options: PushAppCommandOptions; - context?: TContext; - } - | { - command: 'apps.triggerEvent'; - options: TriggerAppEventCommandOptions; - context?: TContext; - }; - -export type CommandRouterResult = - | ScreenshotCommandResult - | DiffScreenshotCommandResult - | SnapshotCommandResult - | DiffSnapshotCommandResult - | FindReadCommandResult - | GetCommandResult - | IsCommandResult - | WaitCommandResult - | PressCommandResult - | FillCommandResult - | TypeTextCommandResult - | FocusCommandResult - | LongPressCommandResult - | SwipeCommandResult - | ScrollCommandResult - | PinchCommandResult - | SystemBackCommandResult - | SystemHomeCommandResult - | SystemRotateCommandResult - | SystemKeyboardCommandResult - | SystemClipboardCommandResult - | SystemSettingsCommandResult - | SystemAlertCommandResult - | SystemAppSwitcherCommandResult - | OpenAppCommandResult - | CloseAppCommandResult - | ListAppsCommandResult - | GetAppStateCommandResult - | PushAppCommandResult - | TriggerAppEventCommandResult; +export type { + CommandRouter, + CommandRouterConfig, + CommandRouterRequest, + CommandRouterResponse, + CommandRouterResult, +} from './router-types.ts'; +export type { + BatchCommandOptions, + BatchCommandResult, + BatchCommandStepResult, +} from './router-orchestration.ts'; -export type CommandRouterResponse = - | { - ok: true; - data: CommandRouterResult; - } - | { - ok: false; - error: NormalizedError; - }; +type RuntimeRouterRequest = Exclude< + CommandRouterRequest, + { command: 'batch' } +>; -export type CommandRouter = { - dispatch(request: CommandRouterRequest): Promise; -}; +type RuntimeRouterCommandName = RuntimeRouterRequest['command']; -export type CommandRouterConfig = { - createRuntime( - request: CommandRouterRequest, - ): AgentDeviceRuntime | Promise; - beforeDispatch?(request: CommandRouterRequest): void | Promise; - formatError?(error: unknown, request: CommandRouterRequest): NormalizedError; -}; +type RuntimeRouterDispatcher = ( + runtime: AgentDeviceRuntime, + request: Extract, { command: TCommand }>, +) => Promise; export function createCommandRouter( config: CommandRouterConfig, ): CommandRouter { - return { - dispatch: async (request) => { - try { - assertRouterCommandImplemented(request); - await config.beforeDispatch?.(request); - const runtime = await config.createRuntime(request); - return { ok: true, data: await dispatchRuntimeCommand(runtime, request) }; - } catch (error) { + const dispatch = async ( + request: CommandRouterRequest, + ): Promise => { + try { + assertRouterCommandImplemented(request); + await config.beforeDispatch?.(request); + if (request.command === 'batch') { return { - ok: false, - error: config.formatError?.(error, request) ?? normalizeAgentDeviceError(error), + ok: true, + data: await dispatchBatchCommand(request, dispatch), }; } - }, + const runtime = await config.createRuntime(request); + return { ok: true, data: await dispatchRuntimeCommand(runtime, request) }; + } catch (error) { + return { + ok: false, + error: config.formatError?.(error, request) ?? normalizeAgentDeviceError(error), + }; + } + }; + + return { + dispatch, }; } -const implementedRouterCommands = new Set([ - 'capture.screenshot', - 'capture.diffScreenshot', - 'capture.snapshot', - 'capture.diffSnapshot', - 'selectors.find', - 'selectors.get', - 'selectors.is', - 'selectors.wait', - 'interactions.click', - 'interactions.press', - 'interactions.fill', - 'interactions.typeText', - 'interactions.focus', - 'interactions.longPress', - 'interactions.swipe', - 'interactions.scroll', - 'interactions.pinch', - 'system.back', - 'system.home', - 'system.rotate', - 'system.keyboard', - 'system.clipboard', - 'system.settings', - 'system.alert', - 'system.appSwitcher', - 'apps.open', - 'apps.close', - 'apps.list', - 'apps.state', - 'apps.push', - 'apps.triggerEvent', -]); +function createRuntimeDispatcher( + command: (runtime: AgentDeviceRuntime, options: TOptions) => Promise, +): RuntimeRouterDispatcher { + return async ( + runtime: AgentDeviceRuntime, + request: Extract, { command: TCommand }>, + ): Promise => await command(runtime, request.options as TOptions); +} + +const runtimeRouterDispatchers = { + 'capture.screenshot': createRuntimeDispatcher(screenshotCommand), + 'capture.diffScreenshot': createRuntimeDispatcher(diffScreenshotCommand), + 'capture.snapshot': createRuntimeDispatcher(snapshotCommand), + 'capture.diffSnapshot': createRuntimeDispatcher(diffSnapshotCommand), + 'selectors.find': createRuntimeDispatcher(findCommand), + 'selectors.get': createRuntimeDispatcher(getCommand), + 'selectors.is': createRuntimeDispatcher(isCommand), + 'selectors.wait': createRuntimeDispatcher(waitCommand), + 'interactions.click': createRuntimeDispatcher(clickCommand), + 'interactions.press': createRuntimeDispatcher(pressCommand), + 'interactions.fill': createRuntimeDispatcher(fillCommand), + 'interactions.typeText': createRuntimeDispatcher(typeTextCommand), + 'interactions.focus': createRuntimeDispatcher(focusCommand), + 'interactions.longPress': createRuntimeDispatcher(longPressCommand), + 'interactions.swipe': createRuntimeDispatcher(swipeCommand), + 'interactions.scroll': createRuntimeDispatcher(scrollCommand), + 'interactions.pinch': createRuntimeDispatcher(pinchCommand), + 'system.back': createRuntimeDispatcher(backCommand), + 'system.home': createRuntimeDispatcher(homeCommand), + 'system.rotate': createRuntimeDispatcher(rotateCommand), + 'system.keyboard': createRuntimeDispatcher(keyboardCommand), + 'system.clipboard': createRuntimeDispatcher(clipboardCommand), + 'system.settings': createRuntimeDispatcher(settingsCommand), + 'system.alert': createRuntimeDispatcher(alertCommand), + 'system.appSwitcher': createRuntimeDispatcher(appSwitcherCommand), + 'apps.open': createRuntimeDispatcher(openAppCommand), + 'apps.close': createRuntimeDispatcher(closeAppCommand), + 'apps.list': createRuntimeDispatcher(listAppsCommand), + 'apps.state': createRuntimeDispatcher(getAppStateCommand), + 'apps.push': createRuntimeDispatcher(pushAppCommand), + 'apps.triggerEvent': createRuntimeDispatcher(triggerAppEventCommand), + 'admin.devices': createRuntimeDispatcher(devicesCommand), + 'admin.boot': createRuntimeDispatcher(bootCommand), + 'admin.ensureSimulator': createRuntimeDispatcher(ensureSimulatorCommand), + 'admin.install': createRuntimeDispatcher(installCommand), + 'admin.reinstall': createRuntimeDispatcher(reinstallCommand), + 'admin.installFromSource': createRuntimeDispatcher(installFromSourceCommand), + record: createRuntimeDispatcher(recordCommand), + trace: createRuntimeDispatcher(traceCommand), + 'diagnostics.logs': createRuntimeDispatcher(logsCommand), + 'diagnostics.network': createRuntimeDispatcher(networkCommand), + 'diagnostics.perf': createRuntimeDispatcher(perfCommand), +} satisfies { + [K in RuntimeRouterCommandName]: RuntimeRouterDispatcher; +}; + +function isRuntimeRouterCommandName(command: string): command is RuntimeRouterCommandName { + return Object.hasOwn(runtimeRouterDispatchers, command); +} function assertRouterCommandImplemented(request: { command: string }): void { - if (implementedRouterCommands.has(request.command)) return; + if (request.command === 'batch' || isRuntimeRouterCommandName(request.command)) return; const catalogEntry = commandCatalog.find((entry) => entry.command === request.command); if (catalogEntry?.status === 'planned') { throw new AppError( @@ -389,70 +185,14 @@ function assertRouterCommandImplemented(request: { command: string }): void { async function dispatchRuntimeCommand( runtime: AgentDeviceRuntime, - request: CommandRouterRequest, + request: RuntimeRouterRequest, ): Promise { - switch (request.command) { - case 'capture.screenshot': - return await screenshotCommand(runtime, request.options); - case 'capture.diffScreenshot': - return await diffScreenshotCommand(runtime, request.options); - case 'capture.snapshot': - return await snapshotCommand(runtime, request.options); - case 'capture.diffSnapshot': - return await diffSnapshotCommand(runtime, request.options); - case 'selectors.find': - return await findCommand(runtime, request.options); - case 'selectors.get': - return await getCommand(runtime, request.options); - case 'selectors.is': - return await isCommand(runtime, request.options); - case 'selectors.wait': - return await waitCommand(runtime, request.options); - case 'interactions.click': - return await clickCommand(runtime, request.options); - case 'interactions.press': - return await pressCommand(runtime, request.options); - case 'interactions.fill': - return await fillCommand(runtime, request.options); - case 'interactions.typeText': - return await typeTextCommand(runtime, request.options); - case 'interactions.focus': - return await focusCommand(runtime, request.options); - case 'interactions.longPress': - return await longPressCommand(runtime, request.options); - case 'interactions.swipe': - return await swipeCommand(runtime, request.options); - case 'interactions.scroll': - return await scrollCommand(runtime, request.options); - case 'interactions.pinch': - return await pinchCommand(runtime, request.options); - case 'system.back': - return await backCommand(runtime, request.options); - case 'system.home': - return await homeCommand(runtime, request.options); - case 'system.rotate': - return await rotateCommand(runtime, request.options); - case 'system.keyboard': - return await keyboardCommand(runtime, request.options); - case 'system.clipboard': - return await clipboardCommand(runtime, request.options); - case 'system.settings': - return await settingsCommand(runtime, request.options); - case 'system.alert': - return await alertCommand(runtime, request.options); - case 'system.appSwitcher': - return await appSwitcherCommand(runtime, request.options); - case 'apps.open': - return await openAppCommand(runtime, request.options); - case 'apps.close': - return await closeAppCommand(runtime, request.options); - case 'apps.list': - return await listAppsCommand(runtime, request.options); - case 'apps.state': - return await getAppStateCommand(runtime, request.options); - case 'apps.push': - return await pushAppCommand(runtime, request.options); - case 'apps.triggerEvent': - return await triggerAppEventCommand(runtime, request.options); + const dispatcher = runtimeRouterDispatchers[request.command]; + if (!dispatcher) { + throw new AppError( + 'UNSUPPORTED_OPERATION', + `Router command ${request.command} is not a runtime command`, + ); } + return await dispatcher(runtime, request as never); } diff --git a/src/daemon/handlers/interaction-common.ts b/src/daemon/handlers/interaction-common.ts index 428a4057e..b5df60480 100644 --- a/src/daemon/handlers/interaction-common.ts +++ b/src/daemon/handlers/interaction-common.ts @@ -1,4 +1,4 @@ -import { dispatchCommand, type CommandFlags } from '../../core/dispatch.ts'; +import type { CommandFlags } from '../../core/dispatch.ts'; import type { DaemonCommandContext } from '../context.ts'; import { recordTouchVisualizationEvent } from '../recording-gestures.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; @@ -63,92 +63,6 @@ function buildTouchMessage( return undefined; } -export async function dispatchRecordedTouchInteraction(params: { - session: SessionState; - sessionStore: SessionStore; - requestCommand: string; - requestPositionals: string[]; - flags: CommandFlags | undefined; - contextFromFlags: ContextFromFlags; - interactionCommand: string; - interactionPositionals: string[]; - outPath: string | undefined; - afterDispatch?: (data: Record | undefined) => void | Promise; - buildPayloads: (data: Record | undefined) => - | { - result: Record; - responseData?: Record; - } - | Promise<{ - result: Record; - responseData?: Record; - }>; -}): Promise { - const { - session, - sessionStore, - requestCommand, - requestPositionals, - flags, - contextFromFlags, - interactionCommand, - interactionPositionals, - outPath, - afterDispatch, - buildPayloads, - } = params; - const interaction = await dispatchInteractionCommand({ - session, - flags, - contextFromFlags, - command: interactionCommand, - positionals: interactionPositionals, - outPath, - }); - await afterDispatch?.(interaction.data); - const { result, responseData = result } = await buildPayloads(interaction.data); - return finalizeTouchInteraction({ - session, - sessionStore, - command: requestCommand, - positionals: requestPositionals, - flags, - result, - responseData, - actionStartedAt: interaction.actionStartedAt, - actionFinishedAt: interaction.actionFinishedAt, - }); -} - -async function dispatchInteractionCommand(params: { - session: SessionState; - flags: CommandFlags | undefined; - contextFromFlags: ContextFromFlags; - command: string; - positionals: string[]; - outPath: string | undefined; -}): Promise<{ - data: Record | undefined; - actionStartedAt: number; - actionFinishedAt: number; -}> { - const { session, flags, contextFromFlags, command, positionals, outPath } = params; - const actionStartedAt = Date.now(); - const dispatchContext = { - ...contextFromFlags(flags, session.appBundleId, session.trace?.outPath), - }; - const rawData = await dispatchCommand( - session.device, - command, - positionals, - outPath, - dispatchContext, - ); - const actionFinishedAt = Date.now(); - const data = rawData && typeof rawData === 'object' ? rawData : undefined; - return { data, actionStartedAt, actionFinishedAt }; -} - export function finalizeTouchInteraction(params: { session: SessionState; sessionStore: SessionStore; diff --git a/src/daemon/handlers/interaction-runtime.ts b/src/daemon/handlers/interaction-runtime.ts index 40c8eaace..12fbd5039 100644 --- a/src/daemon/handlers/interaction-runtime.ts +++ b/src/daemon/handlers/interaction-runtime.ts @@ -7,6 +7,7 @@ import type { import { createAgentDevice, localCommandPolicy } from '../../runtime.ts'; import { AppError } from '../../utils/errors.ts'; import type { SessionState } from '../types.ts'; +import { createUnsupportedArtifactAdapter } from '../runtime-artifacts.ts'; import type { InteractionHandlerParams } from './interaction-common.ts'; import type { CaptureSnapshotForSession } from './interaction-snapshot.ts'; @@ -19,26 +20,7 @@ export function createInteractionRuntime( if (!session) throw new AppError('SESSION_NOT_FOUND', 'No active session. Run open first.'); return createAgentDevice({ backend: createInteractionBackend({ ...params, session }), - artifacts: { - resolveInput: async () => { - throw new AppError( - 'UNSUPPORTED_OPERATION', - 'interaction commands do not resolve input artifacts', - ); - }, - reserveOutput: async () => { - throw new AppError( - 'UNSUPPORTED_OPERATION', - 'interaction commands do not reserve output artifacts', - ); - }, - createTempFile: async () => { - throw new AppError( - 'UNSUPPORTED_OPERATION', - 'interaction commands do not create temporary files', - ); - }, - }, + artifacts: createUnsupportedArtifactAdapter('interaction commands', { plural: true }), sessions: { get: (name) => name === params.sessionName diff --git a/src/daemon/handlers/interaction-touch-targets.ts b/src/daemon/handlers/interaction-touch-targets.ts index 3e6b3e995..4f9383ea3 100644 --- a/src/daemon/handlers/interaction-touch-targets.ts +++ b/src/daemon/handlers/interaction-touch-targets.ts @@ -99,25 +99,9 @@ export function parseFillTarget(positionals: string[]): ParsedFillTarget { }; } -export function pressResultExtra(result: PressCommandResult): Record { - if (result.kind === 'ref') { - return { - ref: stripAtPrefix(result.target?.kind === 'ref' ? result.target.ref : undefined), - refLabel: result.refLabel, - selectorChain: result.selectorChain, - }; - } - if (result.kind === 'selector') { - return { - selector: result.target?.kind === 'selector' ? result.target.selector : undefined, - selectorChain: result.selectorChain, - refLabel: result.refLabel, - }; - } - return {}; -} - -export function fillResultExtra(result: FillCommandResult): Record { +export function interactionResultExtra( + result: PressCommandResult | FillCommandResult, +): Record { if (result.kind === 'ref') { return { ref: stripAtPrefix(result.target?.kind === 'ref' ? result.target.ref : undefined), diff --git a/src/daemon/handlers/interaction-touch.ts b/src/daemon/handlers/interaction-touch.ts index 40e30f08f..ec293f1c2 100644 --- a/src/daemon/handlers/interaction-touch.ts +++ b/src/daemon/handlers/interaction-touch.ts @@ -26,11 +26,10 @@ import { } from './interaction-android-escape.ts'; import { createInteractionRuntime } from './interaction-runtime.ts'; import { - fillResultExtra, formatPressTargetLabel, + interactionResultExtra, parseFillTarget, parsePressTarget, - pressResultExtra, stripAtPrefix, } from './interaction-touch-targets.ts'; @@ -135,7 +134,7 @@ async function dispatchPressViaRuntime( fallbackY: result.point.y, referenceFrame, extra: { - ...pressResultExtra(result), + ...interactionResultExtra(result), ...resultButtonTag, }, }); @@ -186,7 +185,7 @@ async function dispatchFillViaRuntime( fallbackY: result.point.y, referenceFrame, extra: { - ...fillResultExtra(result), + ...interactionResultExtra(result), text: parsedTarget.text, }, }); diff --git a/src/daemon/request-router.ts b/src/daemon/request-router.ts index a17fbf85b..c4d916108 100644 --- a/src/daemon/request-router.ts +++ b/src/daemon/request-router.ts @@ -91,12 +91,6 @@ function contextFromFlags( }; } -function normalizeAliasedCommands(req: DaemonRequest): DaemonRequest { - // Keep this hook for future daemon-level aliases. click is intentionally preserved - // as-is so handlers can distinguish it from press-only behavior such as --button. - return req; -} - function scopeRequestSession(req: DaemonRequest): DaemonRequest { const isolation = resolveSessionIsolationMode( req.meta?.sessionIsolation ?? req.flags?.sessionIsolation, @@ -556,24 +550,23 @@ export function createRequestHandler( const { logPath, token, sessionStore, leaseRegistry, trackDownloadableArtifact } = deps; async function handleRequest(req: DaemonRequest): Promise { - const normalizedReq = normalizeAliasedCommands(req); - const debug = Boolean(normalizedReq.meta?.debug || normalizedReq.flags?.verbose); + const debug = Boolean(req.meta?.debug || req.flags?.verbose); return await withDiagnosticsScope( { - session: normalizedReq.session, - requestId: normalizedReq.meta?.requestId, - command: normalizedReq.command, + session: req.session, + requestId: req.meta?.requestId, + command: req.command, debug, logPath, }, async () => { - if (normalizedReq.token !== token) { + if (req.token !== token) { const unauthorizedError = normalizeError(new AppError('UNAUTHORIZED', 'Invalid token')); return { ok: false, error: unauthorizedError }; } try { - const scopedReq = scopeRequestSession(normalizedReq); + const scopedReq = scopeRequestSession(req); emitDiagnostic({ level: 'info', phase: 'request_start', diff --git a/src/daemon/runtime-artifacts.ts b/src/daemon/runtime-artifacts.ts new file mode 100644 index 000000000..7e5ec0d1d --- /dev/null +++ b/src/daemon/runtime-artifacts.ts @@ -0,0 +1,20 @@ +import type { ArtifactAdapter } from '../io.ts'; +import { AppError } from '../utils/errors.ts'; + +export function createUnsupportedArtifactAdapter( + label: string, + options: { plural?: boolean } = {}, +): ArtifactAdapter { + const verb = options.plural === true ? 'do' : 'does'; + return { + resolveInput: async () => { + throw new AppError('UNSUPPORTED_OPERATION', `${label} ${verb} not resolve input artifacts`); + }, + reserveOutput: async () => { + throw new AppError('UNSUPPORTED_OPERATION', `${label} ${verb} not reserve output artifacts`); + }, + createTempFile: async () => { + throw new AppError('UNSUPPORTED_OPERATION', `${label} ${verb} not create temporary files`); + }, + }; +} diff --git a/src/daemon/selector-runtime.ts b/src/daemon/selector-runtime.ts index 9eb9b967a..f1fb79786 100644 --- a/src/daemon/selector-runtime.ts +++ b/src/daemon/selector-runtime.ts @@ -30,6 +30,7 @@ import { refSnapshotFlagGuardResponse } from './handlers/interaction-flags.ts'; import type { IsCommandOptions } from '../commands/selector-read.ts'; import { isSupportedPredicate } from './is-predicates.ts'; import type { ContextFromFlags } from './handlers/interaction-common.ts'; +import { createUnsupportedArtifactAdapter } from './runtime-artifacts.ts'; import { getActiveAndroidSnapshotFreshness } from './android-snapshot-freshness.ts'; import { buildFindRecordResult, @@ -220,26 +221,7 @@ function createSelectorRuntimeForDevice(params: { }) { return createAgentDevice({ backend: createSelectorBackend(params), - artifacts: { - resolveInput: async () => { - throw new AppError( - 'UNSUPPORTED_OPERATION', - 'selector commands do not resolve input artifacts', - ); - }, - reserveOutput: async () => { - throw new AppError( - 'UNSUPPORTED_OPERATION', - 'selector commands do not reserve output artifacts', - ); - }, - createTempFile: async () => { - throw new AppError( - 'UNSUPPORTED_OPERATION', - 'selector commands do not create temporary files', - ); - }, - }, + artifacts: createUnsupportedArtifactAdapter('selector commands', { plural: true }), sessions: { get: (name) => (name === params.sessionName ? toCommandSession(params.session) : undefined), set: (record) => { diff --git a/src/daemon/snapshot-runtime.ts b/src/daemon/snapshot-runtime.ts index 689fd8622..c9dea0e40 100644 --- a/src/daemon/snapshot-runtime.ts +++ b/src/daemon/snapshot-runtime.ts @@ -6,6 +6,7 @@ import { AppError } from '../utils/errors.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from './types.ts'; import { SessionStore } from './session-store.ts'; import { errorResponse } from './handlers/response.ts'; +import { createUnsupportedArtifactAdapter } from './runtime-artifacts.ts'; import { captureSnapshot, resolveSnapshotScope } from './handlers/snapshot-capture.ts'; import { buildSnapshotSession, @@ -128,17 +129,7 @@ function createSnapshotRuntime(params: { device, snapshotScope, }), - artifacts: { - resolveInput: async () => { - throw new AppError('UNSUPPORTED_OPERATION', 'snapshot does not resolve input artifacts'); - }, - reserveOutput: async () => { - throw new AppError('UNSUPPORTED_OPERATION', 'snapshot does not reserve output artifacts'); - }, - createTempFile: async () => { - throw new AppError('UNSUPPORTED_OPERATION', 'snapshot does not create temporary files'); - }, - }, + artifacts: createUnsupportedArtifactAdapter('snapshot'), sessions: { get: (name) => name === sessionName ? toCommandSessionRecord(sessionStore.get(sessionName)) : undefined, diff --git a/src/index.ts b/src/index.ts index d16272db2..f6241603f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -39,9 +39,20 @@ export type { BackendCapabilitySet, BackendClipboardTextResult, BackendCommandContext, + BackendDiagnosticsPageOptions, + BackendDiagnosticsTimeWindow, BackendDeviceOrientation, + BackendDeviceFilter, + BackendDeviceInfo, + BackendDeviceTarget, + BackendDumpNetworkOptions, + BackendDumpNetworkResult, BackendEscapeHatches, + BackendEnsureSimulatorOptions, + BackendEnsureSimulatorResult, BackendFillOptions, + BackendInstallResult, + BackendInstallSource, BackendInstallTarget, BackendFindTextResult, BackendKeyboardOptions, @@ -50,8 +61,18 @@ export type { BackendOpenOptions, BackendOpenTarget, BackendPinchOptions, + BackendLogEntry, + BackendMeasurePerfOptions, + BackendMeasurePerfResult, + BackendNetworkEntry, + BackendNetworkIncludeMode, + BackendPerfMetric, BackendPushInput, + BackendReadLogsOptions, + BackendReadLogsResult, BackendReadTextResult, + BackendRecordingOptions, + BackendRecordingResult, BackendRunnerCommand, BackendScrollOptions, BackendScrollTarget, @@ -64,6 +85,8 @@ export type { BackendSnapshotResult, BackendSwipeOptions, BackendTapOptions, + BackendTraceOptions, + BackendTraceResult, } from './backend.ts'; export type { @@ -92,6 +115,15 @@ export type { CommandRouterRequest, CommandRouterResponse, CommandRouterResult, + BatchCommandOptions, + BatchCommandResult, + BatchCommandStepResult, + DiagnosticsLogsCommandOptions, + DiagnosticsLogsCommandResult, + DiagnosticsNetworkCommandOptions, + DiagnosticsNetworkCommandResult, + DiagnosticsPerfCommandOptions, + DiagnosticsPerfCommandResult, CloseAppCommandOptions, CloseAppCommandResult, GetAppStateCommandOptions, @@ -102,6 +134,20 @@ export type { OpenAppCommandResult, PushAppCommandOptions, PushAppCommandResult, + AdminBootCommandOptions, + AdminBootCommandResult, + AdminDevicesCommandOptions, + AdminDevicesCommandResult, + AdminEnsureSimulatorCommandOptions, + AdminEnsureSimulatorCommandResult, + AdminInstallCommandOptions, + AdminInstallCommandResult, + AdminInstallFromSourceCommandOptions, + AdminReinstallCommandOptions, + RecordingRecordCommandOptions, + RecordingRecordCommandResult, + RecordingTraceCommandOptions, + RecordingTraceCommandResult, RuntimeCommand, SelectorSnapshotOptions, TriggerAppEventCommandOptions, diff --git a/src/runtime.ts b/src/runtime.ts index 3e62e643c..262384251 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -98,9 +98,7 @@ export function createAgentDevice(config: AgentDeviceRuntimeConfig): AgentDevice export function createMemorySessionStore( records: readonly CommandSessionRecord[] = [], ): CommandSessionStore { - const sessions = new Map( - records.map((record) => [record.name, cloneDefinedSessionRecord(record)]), - ); + const sessions = new Map(records.map((record) => [record.name, cloneSessionRecord(record)])); return { get: (name) => cloneSessionRecord(sessions.get(name)), set: (record) => { @@ -109,14 +107,10 @@ export function createMemorySessionStore( delete: (name) => { sessions.delete(name); }, - list: () => Array.from(sessions.values(), cloneDefinedSessionRecord), + list: () => Array.from(sessions.values(), (record) => cloneSessionRecord(record)), }; } -function cloneDefinedSessionRecord(record: CommandSessionRecord): CommandSessionRecord { - return cloneSessionRecord(record); -} - function cloneSessionRecord(record: CommandSessionRecord): CommandSessionRecord; function cloneSessionRecord(record: undefined): undefined; function cloneSessionRecord( diff --git a/src/testing/conformance.ts b/src/testing/conformance.ts index 333b07f5c..301385e3a 100644 --- a/src/testing/conformance.ts +++ b/src/testing/conformance.ts @@ -8,6 +8,7 @@ export type ConformanceRuntimeFactory = () => AgentDeviceRuntime | Promise; visibleSelector: string; @@ -69,6 +70,7 @@ export type CommandConformanceSuite = { export const defaultCommandConformanceFixtures: CommandConformanceFixtures = { session: 'default', app: 'com.example.app', + installSourcePath: '/tmp/example.app', appEventName: 'example.ready', appPushPayload: { aps: { alert: 'hello' } }, visibleSelector: 'label=Continue', @@ -434,12 +436,139 @@ export const appsConformanceSuite = createCommandConformanceSuite({ ], }); +export const adminConformanceSuite = createCommandConformanceSuite({ + name: 'admin', + cases: [ + { + name: 'lists devices', + command: 'admin.devices', + run: async (runtime) => { + const result = await commands.admin.devices(runtime, {}); + assert.equal(result.kind, 'adminDevices'); + assert.ok(Array.isArray(result.devices)); + }, + }, + { + name: 'boots devices', + command: 'admin.boot', + run: async (runtime) => { + const result = await commands.admin.boot(runtime, {}); + assert.equal(result.kind, 'deviceBooted'); + }, + }, + { + name: 'ensures simulators', + command: 'admin.ensureSimulator', + run: async (runtime) => { + const result = await commands.admin.ensureSimulator(runtime, { + device: 'iPhone 16', + runtime: 'iOS 18', + }); + assert.equal(result.kind, 'simulatorEnsured'); + }, + }, + { + name: 'installs apps from structured sources', + command: 'admin.install', + run: async (runtime, fixtures) => { + const result = await commands.admin.install(runtime, { + app: fixtures.app, + source: { kind: 'path', path: fixtures.installSourcePath }, + }); + assert.equal(result.kind, 'appInstalled'); + }, + }, + { + name: 'reinstalls apps from structured sources', + command: 'admin.reinstall', + run: async (runtime, fixtures) => { + const result = await commands.admin.reinstall(runtime, { + app: fixtures.app, + source: { kind: 'path', path: fixtures.installSourcePath }, + }); + assert.equal(result.kind, 'appReinstalled'); + }, + }, + { + name: 'installs apps from source resolver', + command: 'admin.installFromSource', + run: async (runtime, fixtures) => { + const result = await commands.admin.installFromSource(runtime, { + source: { kind: 'path', path: fixtures.installSourcePath }, + }); + assert.equal(result.kind, 'appInstalledFromSource'); + }, + }, + ], +}); + +export const recordingConformanceSuite = createCommandConformanceSuite({ + name: 'recording', + cases: [ + { + name: 'starts recording', + command: 'record', + run: async (runtime) => { + const result = await commands.recording.record(runtime, { action: 'start' }); + assert.equal(result.kind, 'recordingStarted'); + }, + }, + { + name: 'stops traces', + command: 'trace', + run: async (runtime) => { + const result = await commands.recording.trace(runtime, { action: 'stop' }); + assert.equal(result.kind, 'traceStopped'); + }, + }, + ], +}); + +export const diagnosticsConformanceSuite = createCommandConformanceSuite({ + name: 'diagnostics', + cases: [ + { + name: 'reads paginated logs', + command: 'diagnostics.logs', + run: async (runtime) => { + const result = await commands.diagnostics.logs(runtime, { limit: 10 }); + assert.equal(result.kind, 'diagnosticsLogs'); + assert.ok(Array.isArray(result.entries)); + }, + }, + { + name: 'dumps structured network entries', + command: 'diagnostics.network', + run: async (runtime) => { + const result = await commands.diagnostics.network(runtime, { + limit: 10, + include: 'summary', + }); + assert.equal(result.kind, 'diagnosticsNetwork'); + assert.ok(Array.isArray(result.entries)); + }, + }, + { + name: 'measures perf metrics', + command: 'diagnostics.perf', + run: async (runtime) => { + const result = await commands.diagnostics.perf(runtime, { sampleMs: 100 }); + assert.equal(result.kind, 'diagnosticsPerf'); + assert.ok(Array.isArray(result.metrics)); + }, + }, + ], +}); + export const commandConformanceSuites: readonly CommandConformanceSuite[] = [ captureConformanceSuite, selectorConformanceSuite, interactionConformanceSuite, systemConformanceSuite, appsConformanceSuite, + adminConformanceSuite, + recordingConformanceSuite, + diagnosticsConformanceSuite, ]; export async function runCommandConformance( diff --git a/website/docs/docs/client-api.md b/website/docs/docs/client-api.md index 5f911eb8a..75f95af90 100644 --- a/website/docs/docs/client-api.md +++ b/website/docs/docs/client-api.md @@ -130,20 +130,28 @@ Implemented runtime namespaces are currently: - `capture`: `screenshot`, `diffScreenshot`, `snapshot`, `diffSnapshot` - `selectors`: `find`, `get`, `getText`, `getAttrs`, `is`, `isVisible`, `isHidden`, `wait`, `waitForText` -- `interactions`: `click`, `press`, `fill`, `typeText` +- `interactions`: `click`, `press`, `fill`, `typeText`, `focus`, `longPress`, `swipe`, `scroll`, `pinch` - `apps`: `open`, `close`, `list`, `state`, `push`, `triggerEvent` +- `admin`: `devices`, `boot`, `ensureSimulator`, `install`, `reinstall`, `installFromSource` +- `recording`: `record`, `trace` +- `observability`: `logs`, `network`, `perf` (`createCommandRouter()` dispatches these as `diagnostics.logs`, `diagnostics.network`, and `diagnostics.perf`) Commands that have not migrated are tracked in `commandCatalog` instead of being exposed as throwing methods. Backend authors can use `runCommandConformance()` or `assertCommandConformance()` from -`agent-device/testing/conformance` to verify capture, selector, interaction, and app -semantics against a prepared fixture app or test backend. +`agent-device/testing/conformance` to verify capture, selector, interaction, app, +admin, recording, and diagnostics semantics against a prepared fixture app or +test backend. Use `createCommandRouter()` from `agent-device/commands` as the recommended transport boundary for hosted adapters. The router applies command dispatch, error normalization, and per-request runtime construction without exposing -daemon internals. +daemon internals. Router-level `batch` dispatches its nested steps through the +same router path so command policy and error formatting still run for each step. +Diagnostics payload redaction is best-effort: structured JSON bodies are +redacted recursively, and non-JSON payloads are sanitized with string-pattern +fallbacks before truncation. ## Command methods