diff --git a/AGENTS.md b/AGENTS.md index 086e9b95a..a1ca9cd5c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,7 +66,8 @@ Single-context repo. Read `CONTEXT.md` for domain language and testing/architect ## Routing - Keep `src/daemon.ts` as a thin router. -- Keep command names and daemon routing groups centralized in `src/command-catalog.ts`; do not re-create command string sets in handlers or request policy modules. +- Keep command names centralized in `src/command-catalog.ts`; do not re-create command identity sets in handlers or request policy modules. +- Keep daemon routing and request-policy traits centralized in `src/daemon/daemon-command-registry.ts`; request modules should consume its predicates instead of recreating command string sets. See `docs/adr/0003-daemon-command-registry.md`. - Keep command input/output contracts in the command modules: - command surface and shared schemas: `src/commands/command-surface.ts`, `src/commands/command-contract.ts`, `src/commands/command-input.ts` - typed client command execution: `src/commands/client-command-contracts.ts` @@ -76,7 +77,7 @@ Single-context repo. Read `CONTEXT.md` for domain language and testing/architect - CLI/client/runtime output projection: `src/commands/cli-output.ts`, `src/commands/client-output.ts`, `src/commands/runtime-output.ts` - Do not reintroduce CLI-shaped command adapters or schemas as a second source of truth. CLI, Node.js, and MCP should project from command contracts. - Keep `src/daemon/request-router.ts` as request orchestration: auth, diagnostics scope, request admission, locking, handler chain, and fallback dispatch. -- New daemon handler-family commands must update the relevant `DAEMON_COMMAND_GROUPS.*Handler` entry and the handler module's exported `*_COMMAND_HANDLERS` coverage table; `src/daemon/__tests__/request-handler-catalog.test.ts` guards drift and overlap. +- New daemon handler-family commands must update `src/daemon/daemon-command-registry.ts` with the route and request-policy traits. `src/daemon/__tests__/daemon-command-registry.test.ts` guards route and policy traits; handler catalog tests keep executable handler sanity checks. - Put request policies in focused request modules: - tenant/lease/selector/lock admission: `src/daemon/request-admission.ts` - artifact/error finalization: `src/daemon/request-finalization.ts` @@ -89,7 +90,7 @@ Single-context repo. Read `CONTEXT.md` for domain language and testing/architect - snapshot/wait/alert/settings: `src/daemon/handlers/snapshot.ts` - find: `src/daemon/handlers/find.ts` - record/trace: `src/daemon/handlers/record-trace.ts` -- Generic passthrough (press/scroll/type) is daemon fallback only after handlers return null. +- Commands routed as generic in `src/daemon/daemon-command-registry.ts` fall through to daemon fallback dispatch after specialized handlers return null. ## Toolchain Snapshot - Package manager: `pnpm` only. Do not add or restore `package-lock.json`. @@ -274,9 +275,9 @@ Command-only flags (like `find --first`) that do not flow to the platform layer - Shared action helpers: `src/daemon/action-utils.ts` - Snapshot shaping + labels: `src/daemon/snapshot-processing.ts` - Handler context helpers: `src/daemon/context.ts`, `src/daemon/device-ready.ts` -- Request routing/policy: `src/daemon/request-router.ts`, `src/daemon/request-admission.ts`, `src/daemon/request-generic-dispatch.ts` +- Request routing/policy: `src/daemon/daemon-command-registry.ts`, `src/daemon/request-router.ts`, `src/daemon/request-admission.ts`, `src/daemon/request-generic-dispatch.ts` - Dispatcher + capability map: `src/core/dispatch.ts`, `src/core/dispatch-context.ts`, `src/core/dispatch-interactions.ts`, `src/core/capabilities.ts` -- Command catalog + command surface: `src/command-catalog.ts`, `src/commands/command-surface.ts`, `src/commands/command-contract.ts`, `src/commands/client-command-contracts.ts` +- Command identity + command surface: `src/command-catalog.ts`, `src/commands/command-surface.ts`, `src/commands/command-contract.ts`, `src/commands/client-command-contracts.ts` - CLI grammar: `src/commands/cli-grammar.ts`, `src/commands/cli-grammar/*` - Daemon request projection: `src/commands/command-projection.ts` - Platform backends: `src/platforms/ios/*`, `ios-runner/*`, `src/platforms/android/*` diff --git a/CONTEXT.md b/CONTEXT.md index 74c790d4b..56e6a281d 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -14,7 +14,8 @@ - Modality: broad supported device family, such as mobile, tv, or desktop. - Session: daemon-owned state for a selected target and opened app or surface. - Command surface: catalog of public command identity, interface exposure, adapter policy, and shared command metadata across CLI, Node.js, MCP, and batch entrypoints. -- Runner command traits: the iOS XCTest runner's per-command-type classification across three independent axes — interaction (gates the foreground-guard and stabilization preflight), read-only (gates the session-invalidating retry; the alert command is read-only only for its `get` action), and runner-lifecycle (skips the app-activation preflight). One source of truth keyed by command type, distinct from the daemon-side Command surface. +- Daemon command registry: daemon-side source of truth for command route ownership and request-policy traits, including admission exemptions, session locking, selector validation, replay-scoped actions, recording invalidation, Android dialog guards, and request provider device resolution. +- Runner command traits: the iOS XCTest runner's per-command-type classification across three independent axes — interaction (gates the foreground-guard and stabilization preflight), read-only (gates the session-invalidating retry; the alert command is read-only only for its `get` action), and runner-lifecycle (skips the app-activation preflight). One source of truth keyed by command type, distinct from the public command surface and daemon command registry. ## Testing Principles diff --git a/docs/adr/0003-daemon-command-registry.md b/docs/adr/0003-daemon-command-registry.md new file mode 100644 index 000000000..c4d3e5941 --- /dev/null +++ b/docs/adr/0003-daemon-command-registry.md @@ -0,0 +1,55 @@ +# ADR 0003: Daemon Command Registry + +## Status + +Accepted + +## Context + +Daemon request handling depends on command traits that are not part of the public command surface: +which handler route owns a command, whether tenant lease admission applies, whether session +execution should lock, whether selector validation applies, whether replay can run an action in the +current session scope, whether invalid recordings block the request, whether Android blocking-dialog +recovery applies, and how request-scoped providers resolve a device. + +Those traits used to be spread across `src/command-catalog.ts`, request-policy modules, and +handler-local coverage tables. That made `src/command-catalog.ts` carry daemon-only behavior next +to public command identity, and it required duplicate command sets to stay aligned by convention. + +## Decision + +Keep public command identity in `src/command-catalog.ts` and public input/output contracts in +`src/commands/**`. + +Add `src/daemon/daemon-command-registry.ts` as the daemon-side source of truth for command route +ownership and daemon request-policy traits. Request modules consume predicate functions from the +registry instead of recreating command string sets. Handler modules own execution logic only; they do +not export duplicate coverage tables to prove route membership. + +The daemon registry is internal-only. It must not define CLI grammar, Node.js client options, MCP +schemas, user-facing help, or platform capability support. Those remain owned by the command +contract, projection, help, and capability modules. + +## Alternatives Considered + +- Keep daemon groups in `src/command-catalog.ts`: this keeps one command-name file, but it mixes + public command identity with daemon runtime policy and makes the catalog grow for internal-only + routing decisions. +- Keep handler-local coverage tables: this makes each handler self-describing, but creates a second + route membership source that can drift from the router and request-policy modules. +- Put route checks directly in request modules: this is locally simple, but scatters command + classification across admission, locking, provider scoping, replay, recording, and generic + dispatch. + +## Consequences + +Adding or moving a daemon-handled command requires updating the daemon command registry with its +route and request-policy traits. The registry tests pin the trait decisions, while provider-backed +integration scenarios verify important request-policy behavior through the real daemon request path. + +The registry file is intentionally a dense internal contract. Its interface should stay small: +callers ask daemon-policy questions through named predicates rather than reading or mutating command +sets. + +`AGENTS.md` should contain only the operating rule and relevant file pointers for agents. This ADR +owns the rationale so future changes do not need to infer it from agent instructions. diff --git a/src/__tests__/cli-grammar.test.ts b/src/__tests__/cli-grammar.test.ts index d05e8dd1d..d4997b151 100644 --- a/src/__tests__/cli-grammar.test.ts +++ b/src/__tests__/cli-grammar.test.ts @@ -1,6 +1,5 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; -import { DAEMON_COMMAND_GROUPS, PUBLIC_COMMANDS } from '../command-catalog.ts'; import { readInputFromCli } from '../commands/cli-grammar.ts'; import type { CliFlags } from '../utils/cli-flags.ts'; @@ -10,12 +9,6 @@ const BASE_FLAGS: CliFlags = { version: false, }; -test('command catalog owns daemon routing groups', () => { - assert.equal(DAEMON_COMMAND_GROUPS.snapshot.has(PUBLIC_COMMANDS.wait), true); - assert.equal(DAEMON_COMMAND_GROUPS.observability.has(PUBLIC_COMMANDS.logs), true); - assert.equal(DAEMON_COMMAND_GROUPS.replay.has(PUBLIC_COMMANDS.test), true); -}); - test('wait grammar preserves CLI bare text forms', () => { const options = readInputFromCli('wait', ['Continue', '1500'], BASE_FLAGS); assert.equal(options.text, 'Continue'); diff --git a/src/command-catalog.ts b/src/command-catalog.ts index fece45cd7..f0e8875f8 100644 --- a/src/command-catalog.ts +++ b/src/command-catalog.ts @@ -152,128 +152,6 @@ const CAPABILITY_EXEMPT_CLI_COMMANDS = commandSet( PUBLIC_COMMANDS.trace, ); -export const DAEMON_COMMAND_GROUPS = { - inventory: commandSet( - INTERNAL_COMMANDS.sessionList, - PUBLIC_COMMANDS.devices, - PUBLIC_COMMANDS.apps, - ), - state: commandSet(PUBLIC_COMMANDS.boot, PUBLIC_COMMANDS.appState), - observability: commandSet(PUBLIC_COMMANDS.perf, PUBLIC_COMMANDS.logs, PUBLIC_COMMANDS.network), - replay: commandSet(PUBLIC_COMMANDS.replay, PUBLIC_COMMANDS.test), - snapshot: commandSet( - PUBLIC_COMMANDS.snapshot, - PUBLIC_COMMANDS.diff, - PUBLIC_COMMANDS.wait, - PUBLIC_COMMANDS.alert, - PUBLIC_COMMANDS.settings, - ), - replayScopedAction: commandSet( - PUBLIC_COMMANDS.alert, - PUBLIC_COMMANDS.back, - PUBLIC_COMMANDS.click, - PUBLIC_COMMANDS.clipboard, - PUBLIC_COMMANDS.diff, - PUBLIC_COMMANDS.fill, - PUBLIC_COMMANDS.find, - PUBLIC_COMMANDS.gesture, - PUBLIC_COMMANDS.get, - PUBLIC_COMMANDS.home, - PUBLIC_COMMANDS.is, - PUBLIC_COMMANDS.keyboard, - PUBLIC_COMMANDS.longPress, - 'pinch', - PUBLIC_COMMANDS.press, - PUBLIC_COMMANDS.record, - PUBLIC_COMMANDS.reactNative, - PUBLIC_COMMANDS.rotate, - PUBLIC_COMMANDS.screenshot, - PUBLIC_COMMANDS.scroll, - PUBLIC_COMMANDS.settings, - PUBLIC_COMMANDS.snapshot, - PUBLIC_COMMANDS.swipe, - PUBLIC_COMMANDS.type, - PUBLIC_COMMANDS.wait, - ), - androidBlockingDialogGuardedAction: commandSet( - PUBLIC_COMMANDS.back, - PUBLIC_COMMANDS.click, - PUBLIC_COMMANDS.fill, - PUBLIC_COMMANDS.focus, - PUBLIC_COMMANDS.gesture, - PUBLIC_COMMANDS.home, - PUBLIC_COMMANDS.keyboard, - PUBLIC_COMMANDS.longPress, - 'fling', - 'pan', - 'pinch', - PUBLIC_COMMANDS.press, - PUBLIC_COMMANDS.rotate, - 'rotate-gesture', - PUBLIC_COMMANDS.scroll, - PUBLIC_COMMANDS.swipe, - 'transform-gesture', - PUBLIC_COMMANDS.type, - ), - selectorValidationExempt: commandSet( - INTERNAL_COMMANDS.sessionList, - PUBLIC_COMMANDS.devices, - INTERNAL_COMMANDS.releaseMaterializedPaths, - ), - leaseAdmissionExempt: commandSet( - INTERNAL_COMMANDS.sessionList, - PUBLIC_COMMANDS.devices, - INTERNAL_COMMANDS.releaseMaterializedPaths, - INTERNAL_COMMANDS.leaseAllocate, - INTERNAL_COMMANDS.leaseHeartbeat, - INTERNAL_COMMANDS.leaseRelease, - ), - // Specialized daemon handler families. Commands absent from these sets fall through to - // request-generic-dispatch after request admission and provider scoping. - leaseHandler: commandSet( - INTERNAL_COMMANDS.leaseAllocate, - INTERNAL_COMMANDS.leaseHeartbeat, - INTERNAL_COMMANDS.leaseRelease, - ), - sessionHandler: commandSet( - INTERNAL_COMMANDS.installSource, - INTERNAL_COMMANDS.releaseMaterializedPaths, - INTERNAL_COMMANDS.sessionList, - PUBLIC_COMMANDS.appState, - PUBLIC_COMMANDS.apps, - PUBLIC_COMMANDS.batch, - PUBLIC_COMMANDS.boot, - PUBLIC_COMMANDS.clipboard, - PUBLIC_COMMANDS.close, - PUBLIC_COMMANDS.devices, - PUBLIC_COMMANDS.install, - PUBLIC_COMMANDS.keyboard, - PUBLIC_COMMANDS.logs, - PUBLIC_COMMANDS.network, - PUBLIC_COMMANDS.open, - PUBLIC_COMMANDS.perf, - PUBLIC_COMMANDS.prepare, - PUBLIC_COMMANDS.push, - PUBLIC_COMMANDS.reinstall, - PUBLIC_COMMANDS.replay, - PUBLIC_COMMANDS.test, - PUBLIC_COMMANDS.triggerAppEvent, - INTERNAL_COMMANDS.runtime, - ), - reactNativeHandler: commandSet(PUBLIC_COMMANDS.reactNative), - recordTraceHandler: commandSet(PUBLIC_COMMANDS.record, PUBLIC_COMMANDS.trace), - findHandler: commandSet(PUBLIC_COMMANDS.find), - interactionHandler: commandSet( - PUBLIC_COMMANDS.click, - PUBLIC_COMMANDS.fill, - PUBLIC_COMMANDS.get, - PUBLIC_COMMANDS.is, - PUBLIC_COMMANDS.longPress, - PUBLIC_COMMANDS.press, - PUBLIC_COMMANDS.type, - ), -} as const; - function commandSet(...commands: readonly string[]): ReadonlySet { return new Set(commands); } diff --git a/src/daemon/__tests__/daemon-command-registry.test.ts b/src/daemon/__tests__/daemon-command-registry.test.ts new file mode 100644 index 000000000..58d0e61a4 --- /dev/null +++ b/src/daemon/__tests__/daemon-command-registry.test.ts @@ -0,0 +1,168 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { INTERNAL_COMMANDS, PUBLIC_COMMANDS } from '../../command-catalog.ts'; +import { + canOverrideLockPolicySelector, + canRunReplayScopedAction, + getDaemonCommandRoute, + getSessionCommandKind, + isLeaseAdmissionExempt, + listDaemonHandlerCommands, + shouldBlockForInvalidRecording, + shouldGuardAndroidBlockingDialog, + shouldLockSessionExecution, + shouldPreferExplicitDeviceOverExistingSession, + shouldValidateSessionSelector, + usesSessionlessDefaultProviderDevice, +} from '../daemon-command-registry.ts'; +import type { DaemonRequest } from '../types.ts'; + +test('daemon command registry owns specialized handler routes', () => { + assert.deepEqual(listDaemonHandlerCommands('lease').sort(), [ + INTERNAL_COMMANDS.leaseAllocate, + INTERNAL_COMMANDS.leaseHeartbeat, + INTERNAL_COMMANDS.leaseRelease, + ]); + assert.deepEqual(listDaemonHandlerCommands('snapshot').sort(), [ + PUBLIC_COMMANDS.alert, + PUBLIC_COMMANDS.diff, + PUBLIC_COMMANDS.settings, + PUBLIC_COMMANDS.snapshot, + PUBLIC_COMMANDS.wait, + ]); + assert.equal(getDaemonCommandRoute(PUBLIC_COMMANDS.back), 'generic'); +}); + +test('daemon command registry owns session handler subroutes', () => { + assert.equal(getSessionCommandKind(INTERNAL_COMMANDS.sessionList), 'inventory'); + assert.equal(getSessionCommandKind(PUBLIC_COMMANDS.devices), 'inventory'); + assert.equal(getSessionCommandKind(PUBLIC_COMMANDS.apps), 'inventory'); + assert.equal(getSessionCommandKind(PUBLIC_COMMANDS.boot), 'state'); + assert.equal(getSessionCommandKind(PUBLIC_COMMANDS.appState), 'state'); + assert.equal(getSessionCommandKind(PUBLIC_COMMANDS.logs), 'observability'); + assert.equal(getSessionCommandKind(PUBLIC_COMMANDS.test), 'replay'); + assert.equal(getSessionCommandKind(PUBLIC_COMMANDS.open), undefined); +}); + +test('daemon command registry preserves request admission traits', () => { + for (const command of [ + INTERNAL_COMMANDS.sessionList, + PUBLIC_COMMANDS.devices, + INTERNAL_COMMANDS.releaseMaterializedPaths, + INTERNAL_COMMANDS.leaseAllocate, + INTERNAL_COMMANDS.leaseHeartbeat, + INTERNAL_COMMANDS.leaseRelease, + ]) { + assert.equal(isLeaseAdmissionExempt(command), true, `${command} lease admission`); + assert.equal(shouldLockSessionExecution(command), false, `${command} lock`); + } + + for (const command of [ + INTERNAL_COMMANDS.sessionList, + PUBLIC_COMMANDS.devices, + INTERNAL_COMMANDS.releaseMaterializedPaths, + ]) { + assert.equal(shouldValidateSessionSelector(command), false, `${command} selector`); + } + + assert.equal(shouldValidateSessionSelector(INTERNAL_COMMANDS.leaseAllocate), true); + assert.equal(isLeaseAdmissionExempt(PUBLIC_COMMANDS.open), false); + assert.equal(shouldLockSessionExecution(PUBLIC_COMMANDS.open), true); +}); + +test('daemon command registry preserves replay and recording traits', () => { + for (const command of [ + PUBLIC_COMMANDS.alert, + PUBLIC_COMMANDS.back, + PUBLIC_COMMANDS.click, + PUBLIC_COMMANDS.clipboard, + PUBLIC_COMMANDS.diff, + PUBLIC_COMMANDS.fill, + PUBLIC_COMMANDS.find, + PUBLIC_COMMANDS.gesture, + PUBLIC_COMMANDS.get, + PUBLIC_COMMANDS.home, + PUBLIC_COMMANDS.is, + PUBLIC_COMMANDS.keyboard, + PUBLIC_COMMANDS.longPress, + 'pinch', + PUBLIC_COMMANDS.press, + PUBLIC_COMMANDS.record, + PUBLIC_COMMANDS.reactNative, + PUBLIC_COMMANDS.rotate, + PUBLIC_COMMANDS.screenshot, + PUBLIC_COMMANDS.scroll, + PUBLIC_COMMANDS.settings, + PUBLIC_COMMANDS.snapshot, + PUBLIC_COMMANDS.swipe, + PUBLIC_COMMANDS.type, + PUBLIC_COMMANDS.wait, + ]) { + assert.equal(canRunReplayScopedAction(command), true, `${command} replay scope`); + } + + assert.equal(canRunReplayScopedAction(PUBLIC_COMMANDS.focus), false); + assert.equal(shouldBlockForInvalidRecording(PUBLIC_COMMANDS.record), false); + assert.equal(shouldBlockForInvalidRecording(PUBLIC_COMMANDS.close), false); + assert.equal(shouldBlockForInvalidRecording(PUBLIC_COMMANDS.snapshot), true); +}); + +test('daemon command registry preserves Android modal and lock-policy traits', () => { + for (const command of [ + PUBLIC_COMMANDS.back, + PUBLIC_COMMANDS.click, + PUBLIC_COMMANDS.fill, + PUBLIC_COMMANDS.focus, + PUBLIC_COMMANDS.gesture, + PUBLIC_COMMANDS.home, + PUBLIC_COMMANDS.keyboard, + PUBLIC_COMMANDS.longPress, + 'fling', + 'pan', + 'pinch', + PUBLIC_COMMANDS.press, + PUBLIC_COMMANDS.rotate, + 'rotate-gesture', + PUBLIC_COMMANDS.scroll, + PUBLIC_COMMANDS.swipe, + 'transform-gesture', + PUBLIC_COMMANDS.type, + ]) { + assert.equal(shouldGuardAndroidBlockingDialog(command), true, `${command} Android guard`); + } + + assert.equal(shouldGuardAndroidBlockingDialog(PUBLIC_COMMANDS.get), false); + assert.equal(canOverrideLockPolicySelector(PUBLIC_COMMANDS.apps), true); + assert.equal(canOverrideLockPolicySelector(PUBLIC_COMMANDS.devices), true); + assert.equal(canOverrideLockPolicySelector(PUBLIC_COMMANDS.open), false); +}); + +test('daemon command registry preserves provider device resolution traits', () => { + assert.equal( + shouldPreferExplicitDeviceOverExistingSession(makeRequest(PUBLIC_COMMANDS.apps)), + true, + ); + assert.equal( + shouldPreferExplicitDeviceOverExistingSession(makeRequest(PUBLIC_COMMANDS.snapshot)), + false, + ); + assert.equal(usesSessionlessDefaultProviderDevice(makeRequest(PUBLIC_COMMANDS.open)), true); + assert.equal( + usesSessionlessDefaultProviderDevice(makeRequest(PUBLIC_COMMANDS.record, ['start'])), + true, + ); + assert.equal( + usesSessionlessDefaultProviderDevice(makeRequest(PUBLIC_COMMANDS.record, ['stop'])), + false, + ); +}); + +function makeRequest(command: string, positionals: string[] = []): DaemonRequest { + return { + command, + token: 'test-token', + session: 'registry-test', + positionals, + flags: {}, + }; +} diff --git a/src/daemon/__tests__/request-handler-catalog.test.ts b/src/daemon/__tests__/request-handler-catalog.test.ts index 84441229e..3441c6222 100644 --- a/src/daemon/__tests__/request-handler-catalog.test.ts +++ b/src/daemon/__tests__/request-handler-catalog.test.ts @@ -1,68 +1,71 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; -import { DAEMON_COMMAND_GROUPS, INTERNAL_COMMANDS } from '../../command-catalog.ts'; -import { FIND_COMMAND_HANDLERS } from '../handlers/find.ts'; -import { INTERACTION_COMMAND_HANDLERS } from '../handlers/interaction.ts'; -import { handleLeaseCommands, LEASE_COMMAND_HANDLERS } from '../handlers/lease.ts'; -import { REACT_NATIVE_COMMAND_HANDLERS } from '../handlers/react-native.ts'; -import { RECORD_TRACE_COMMAND_HANDLERS } from '../handlers/record-trace.ts'; -import { SESSION_COMMAND_HANDLERS } from '../handlers/session.ts'; -import { SNAPSHOT_COMMAND_HANDLERS } from '../handlers/snapshot.ts'; +import { withTargetDeviceResolutionScope } from '../../core/dispatch-resolve.ts'; +import { INTERNAL_COMMANDS, PUBLIC_COMMANDS } from '../../command-catalog.ts'; +import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; +import { + getDaemonCommandRoute, + listDaemonHandlerCommands, + type DaemonCommandRoute, +} from '../daemon-command-registry.ts'; +import { contextFromFlags } from '../context.ts'; +import { handleLeaseCommands } from '../handlers/lease.ts'; import { LeaseRegistry } from '../lease-registry.ts'; +import { runRequestHandlerChain } from '../request-handler-chain.ts'; +import type { DaemonRequest, DaemonResponse } from '../types.ts'; -const handlerFamilies = [ - { - name: 'leaseHandler', - commands: DAEMON_COMMAND_GROUPS.leaseHandler, - handlers: LEASE_COMMAND_HANDLERS, - }, - { - name: 'sessionHandler', - commands: DAEMON_COMMAND_GROUPS.sessionHandler, - handlers: SESSION_COMMAND_HANDLERS, - }, - { - name: 'snapshot', - commands: DAEMON_COMMAND_GROUPS.snapshot, - handlers: SNAPSHOT_COMMAND_HANDLERS, - }, - { - name: 'reactNativeHandler', - commands: DAEMON_COMMAND_GROUPS.reactNativeHandler, - handlers: REACT_NATIVE_COMMAND_HANDLERS, - }, - { - name: 'recordTraceHandler', - commands: DAEMON_COMMAND_GROUPS.recordTraceHandler, - handlers: RECORD_TRACE_COMMAND_HANDLERS, - }, - { - name: 'findHandler', - commands: DAEMON_COMMAND_GROUPS.findHandler, - handlers: FIND_COMMAND_HANDLERS, - }, - { - name: 'interactionHandler', - commands: DAEMON_COMMAND_GROUPS.interactionHandler, - handlers: INTERACTION_COMMAND_HANDLERS, - }, -] as const; +const SPECIALIZED_ROUTES = [ + 'lease', + 'session', + 'snapshot', + 'reactNative', + 'recordTrace', + 'find', + 'interaction', +] as const satisfies readonly Exclude[]; -test('daemon handler routing groups match handler coverage', () => { - for (const { name, commands, handlers } of handlerFamilies) { - assert.deepEqual( - Object.keys(handlers).sort(), - [...commands].sort(), - `${name} catalog must match its handler module`, - ); +const ROUTING_MISMATCH_MESSAGE = 'Daemon handler routing mismatch'; + +test('specialized daemon routes are claimed by their handler chain', async () => { + for (const route of SPECIALIZED_ROUTES) { + for (const command of listDaemonHandlerCommands(route)) { + const response = await runCatalogCommandThroughHandlerChain(command); + assert.notEqual(response, null, `${route} route should claim ${command}`); + } + } +}); + +test('catalog commands use generic routing only when intentionally passthrough or projected', () => { + const intentionalGenericCatalogCommands = [ + PUBLIC_COMMANDS.appSwitcher, + PUBLIC_COMMANDS.back, + PUBLIC_COMMANDS.focus, + PUBLIC_COMMANDS.gesture, + PUBLIC_COMMANDS.home, + PUBLIC_COMMANDS.installFromSource, + PUBLIC_COMMANDS.rotate, + PUBLIC_COMMANDS.screenshot, + PUBLIC_COMMANDS.scroll, + PUBLIC_COMMANDS.swipe, + ].sort(); + const genericCatalogCommands = [ + ...Object.values(PUBLIC_COMMANDS), + ...Object.values(INTERNAL_COMMANDS), + ] + .filter((command) => getDaemonCommandRoute(command) === 'generic') + .sort(); + + assert.deepEqual(genericCatalogCommands, intentionalGenericCatalogCommands); + for (const command of ['fling', 'pan', 'pinch', 'rotate-gesture', 'transform-gesture']) { + assert.equal(getDaemonCommandRoute(command), 'generic', `${command} passthrough route`); } }); -test('lease handler coverage table points at executable commands', async () => { +test('lease handler executes commands owned by the lease route', async () => { const leaseRegistry = new LeaseRegistry(); const allocated = leaseRegistry.allocateLease({ tenantId: 'tenant-a', runId: 'run-a' }); - for (const command of Object.keys(LEASE_COMMAND_HANDLERS)) { + for (const command of listDaemonHandlerCommands('lease')) { const response = await handleLeaseCommands({ req: { command, @@ -82,17 +85,61 @@ test('lease handler coverage table points at executable commands', async () => { } }); -test('daemon handler routing groups are disjoint', () => { - const ownerByCommand = new Map(); - for (const { name, commands } of handlerFamilies) { - for (const command of commands) { - const previousOwner = ownerByCommand.get(command); - assert.equal( - previousOwner, - undefined, - `${command} is routed by both ${previousOwner} and ${name}`, - ); - ownerByCommand.set(command, name); - } +async function runCatalogCommandThroughHandlerChain( + command: string, +): Promise { + const sessionStore = makeSessionStore('agent-device-catalog-route-'); + const leaseRegistry = new LeaseRegistry(); + const req = catalogRouteRequest(command); + + try { + return await withTargetDeviceResolutionScope( + async () => [], + async () => + await runRequestHandlerChain({ + req, + sessionName: req.session, + logPath: '/tmp/agent-device-catalog-route.log', + sessionStore, + leaseRegistry, + invoke: async () => ({ ok: true, data: {} }), + androidAdbExecutor: async () => ({ stdout: '', stderr: '', exitCode: 0 }), + contextFromFlags: (flags, appBundleId, traceLogPath) => + contextFromFlags( + '/tmp/agent-device-catalog-route.log', + flags, + appBundleId, + traceLogPath, + ), + }), + ); + } catch (error) { + assertNoRoutingMismatch(error, command); + return { + ok: false, + error: { + code: 'ROUTE_CLAIMED', + message: error instanceof Error ? error.message : String(error), + }, + }; } -}); +} + +function catalogRouteRequest(command: string): DaemonRequest { + return { + command, + token: 'test-token', + session: 'catalog-test', + flags: { + tenant: 'tenant-a', + runId: 'run-a', + leaseId: '0'.repeat(32), + }, + positionals: [], + }; +} + +function assertNoRoutingMismatch(error: unknown, command: string): void { + assert.ok(error instanceof Error, `${command} threw a non-error value`); + assert.doesNotMatch(error.message, new RegExp(ROUTING_MISMATCH_MESSAGE), command); +} diff --git a/src/daemon/daemon-command-registry.ts b/src/daemon/daemon-command-registry.ts new file mode 100644 index 000000000..41ce3d0d8 --- /dev/null +++ b/src/daemon/daemon-command-registry.ts @@ -0,0 +1,254 @@ +import { INTERNAL_COMMANDS, PUBLIC_COMMANDS } from '../command-catalog.ts'; +import type { DaemonRequest } from './types.ts'; + +export type DaemonCommandRoute = + | 'lease' + | 'session' + | 'snapshot' + | 'reactNative' + | 'recordTrace' + | 'find' + | 'interaction' + | 'generic'; + +export type SessionCommandKind = 'inventory' | 'state' | 'observability' | 'replay'; +type DaemonHandlerRoute = Exclude; + +type DaemonCommandDescriptor = { + command: string; + route: DaemonCommandRoute; + sessionKind?: SessionCommandKind; + leaseAdmissionExempt?: boolean; + sessionExecutionLockExempt?: boolean; + selectorValidationExempt?: boolean; + replayScopedAction?: boolean; + allowInvalidRecording?: boolean; + lockPolicySelectorOverride?: boolean; + androidBlockingDialogGuard?: boolean; + preferExplicitDeviceOverExistingSession?: boolean; + allowSessionlessDefaultDevice?: (req: DaemonRequest) => boolean; +}; + +const REQUEST_EXECUTION_EXEMPT = { + leaseAdmissionExempt: true, + sessionExecutionLockExempt: true, + selectorValidationExempt: true, +} as const; + +const ADMISSION_AND_LOCK_EXEMPT = { + leaseAdmissionExempt: true, + sessionExecutionLockExempt: true, +} as const; + +const DAEMON_COMMAND_DESCRIPTORS = [ + ...descriptors( + 'lease', + ADMISSION_AND_LOCK_EXEMPT, + INTERNAL_COMMANDS.leaseAllocate, + INTERNAL_COMMANDS.leaseHeartbeat, + INTERNAL_COMMANDS.leaseRelease, + ), + + descriptor(INTERNAL_COMMANDS.sessionList, 'session', { + sessionKind: 'inventory', + ...REQUEST_EXECUTION_EXEMPT, + }), + descriptor(PUBLIC_COMMANDS.devices, 'session', { + sessionKind: 'inventory', + lockPolicySelectorOverride: true, + ...REQUEST_EXECUTION_EXEMPT, + }), + descriptor(PUBLIC_COMMANDS.apps, 'session', { + sessionKind: 'inventory', + lockPolicySelectorOverride: true, + preferExplicitDeviceOverExistingSession: true, + }), + ...descriptors( + 'session', + { sessionKind: 'state' }, + PUBLIC_COMMANDS.boot, + PUBLIC_COMMANDS.appState, + ), + ...descriptors( + 'session', + { sessionKind: 'observability' }, + PUBLIC_COMMANDS.perf, + PUBLIC_COMMANDS.logs, + PUBLIC_COMMANDS.network, + ), + ...descriptors( + 'session', + { sessionKind: 'replay' }, + PUBLIC_COMMANDS.replay, + PUBLIC_COMMANDS.test, + ), + descriptor(INTERNAL_COMMANDS.runtime, 'session'), + descriptor(PUBLIC_COMMANDS.clipboard, 'session', { replayScopedAction: true }), + descriptor(PUBLIC_COMMANDS.keyboard, 'session', { + replayScopedAction: true, + androidBlockingDialogGuard: true, + }), + ...descriptors( + 'session', + {}, + PUBLIC_COMMANDS.install, + PUBLIC_COMMANDS.reinstall, + INTERNAL_COMMANDS.installSource, + ), + descriptor(INTERNAL_COMMANDS.releaseMaterializedPaths, 'session', REQUEST_EXECUTION_EXEMPT), + ...descriptors('session', {}, PUBLIC_COMMANDS.push, PUBLIC_COMMANDS.triggerAppEvent), + descriptor(PUBLIC_COMMANDS.open, 'session', { + allowSessionlessDefaultDevice: () => true, + }), + ...descriptors('session', {}, PUBLIC_COMMANDS.prepare, PUBLIC_COMMANDS.batch), + descriptor(PUBLIC_COMMANDS.close, 'session', { allowInvalidRecording: true }), + + ...descriptors( + 'snapshot', + { replayScopedAction: true }, + PUBLIC_COMMANDS.snapshot, + PUBLIC_COMMANDS.diff, + PUBLIC_COMMANDS.wait, + PUBLIC_COMMANDS.alert, + PUBLIC_COMMANDS.settings, + ), + + descriptor(PUBLIC_COMMANDS.reactNative, 'reactNative', { replayScopedAction: true }), + descriptor(PUBLIC_COMMANDS.record, 'recordTrace', { + replayScopedAction: true, + allowInvalidRecording: true, + allowSessionlessDefaultDevice: isRecordStartRequest, + }), + descriptor(PUBLIC_COMMANDS.trace, 'recordTrace'), + descriptor(PUBLIC_COMMANDS.find, 'find', { replayScopedAction: true }), + + ...descriptors( + 'interaction', + { replayScopedAction: true, androidBlockingDialogGuard: true }, + PUBLIC_COMMANDS.click, + PUBLIC_COMMANDS.fill, + PUBLIC_COMMANDS.longPress, + PUBLIC_COMMANDS.press, + PUBLIC_COMMANDS.type, + ), + ...descriptors( + 'interaction', + { replayScopedAction: true }, + PUBLIC_COMMANDS.get, + PUBLIC_COMMANDS.is, + ), + + ...descriptors( + 'generic', + { replayScopedAction: true, androidBlockingDialogGuard: true }, + PUBLIC_COMMANDS.back, + PUBLIC_COMMANDS.gesture, + PUBLIC_COMMANDS.home, + PUBLIC_COMMANDS.rotate, + PUBLIC_COMMANDS.scroll, + PUBLIC_COMMANDS.swipe, + 'pinch', + ), + descriptor(PUBLIC_COMMANDS.focus, 'generic', { androidBlockingDialogGuard: true }), + descriptor(PUBLIC_COMMANDS.screenshot, 'generic', { replayScopedAction: true }), + ...descriptors( + 'generic', + { androidBlockingDialogGuard: true }, + 'pan', + 'fling', + 'rotate-gesture', + 'transform-gesture', + ), +] as const satisfies readonly DaemonCommandDescriptor[]; + +const DAEMON_COMMAND_REGISTRY = buildDaemonCommandRegistry(DAEMON_COMMAND_DESCRIPTORS); + +export function getDaemonCommandRoute(command: string): DaemonCommandRoute { + return getDaemonCommandDescriptor(command)?.route ?? 'generic'; +} + +export function getSessionCommandKind(command: string): SessionCommandKind | undefined { + return getDaemonCommandDescriptor(command)?.sessionKind; +} + +export function listDaemonHandlerCommands(route: DaemonHandlerRoute): string[] { + return [...(DAEMON_COMMAND_REGISTRY.handlerCommandsByRoute.get(route) ?? [])]; +} + +export function isLeaseAdmissionExempt(command: string): boolean { + return getDaemonCommandDescriptor(command)?.leaseAdmissionExempt === true; +} + +export function shouldValidateSessionSelector(command: string): boolean { + return getDaemonCommandDescriptor(command)?.selectorValidationExempt !== true; +} + +export function shouldLockSessionExecution(command: string): boolean { + return getDaemonCommandDescriptor(command)?.sessionExecutionLockExempt !== true; +} + +export function canRunReplayScopedAction(command: string): boolean { + return getDaemonCommandDescriptor(command)?.replayScopedAction === true; +} + +export function shouldBlockForInvalidRecording(command: string): boolean { + return getDaemonCommandDescriptor(command)?.allowInvalidRecording !== true; +} + +export function canOverrideLockPolicySelector(command: string): boolean { + return getDaemonCommandDescriptor(command)?.lockPolicySelectorOverride === true; +} + +export function shouldGuardAndroidBlockingDialog(command: string): boolean { + return getDaemonCommandDescriptor(command)?.androidBlockingDialogGuard === true; +} + +export function shouldPreferExplicitDeviceOverExistingSession(req: DaemonRequest): boolean { + return getDaemonCommandDescriptor(req.command)?.preferExplicitDeviceOverExistingSession === true; +} + +export function usesSessionlessDefaultProviderDevice(req: DaemonRequest): boolean { + const allow = getDaemonCommandDescriptor(req.command)?.allowSessionlessDefaultDevice; + return typeof allow === 'function' ? allow(req) : false; +} + +function descriptor( + command: string, + route: DaemonCommandRoute, + traits: Omit = {}, +): DaemonCommandDescriptor { + return { command, route, ...traits }; +} + +function descriptors( + route: DaemonCommandRoute, + traits: Omit, + ...commands: readonly string[] +): DaemonCommandDescriptor[] { + return commands.map((command) => descriptor(command, route, traits)); +} + +function getDaemonCommandDescriptor(command: string): DaemonCommandDescriptor | undefined { + return DAEMON_COMMAND_REGISTRY.descriptorsByCommand.get(command); +} + +function buildDaemonCommandRegistry(descriptors: readonly DaemonCommandDescriptor[]) { + const descriptorsByCommand = new Map(); + const handlerCommandsByRoute = new Map(); + for (const descriptor of descriptors) { + if (descriptorsByCommand.has(descriptor.command)) { + throw new Error(`Duplicate daemon command descriptor: ${descriptor.command}`); + } + descriptorsByCommand.set(descriptor.command, descriptor); + if (descriptor.route !== 'generic') { + const commands = handlerCommandsByRoute.get(descriptor.route) ?? []; + commands.push(descriptor.command); + handlerCommandsByRoute.set(descriptor.route, commands); + } + } + return { descriptorsByCommand, handlerCommandsByRoute }; +} + +function isRecordStartRequest(req: DaemonRequest): boolean { + return (req.positionals?.[0] ?? '').toLowerCase() === 'start'; +} diff --git a/src/daemon/handlers/__tests__/snapshot-routing.test.ts b/src/daemon/handlers/__tests__/snapshot-routing.test.ts deleted file mode 100644 index d5ba054ac..000000000 --- a/src/daemon/handlers/__tests__/snapshot-routing.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { test, expect } from 'vitest'; -import { DAEMON_COMMAND_GROUPS } from '../../../command-catalog.ts'; -import { SNAPSHOT_COMMAND_HANDLERS } from '../snapshot.ts'; - -test('snapshot command catalog has handler coverage', () => { - for (const command of DAEMON_COMMAND_GROUPS.snapshot) { - expect(SNAPSHOT_COMMAND_HANDLERS).toHaveProperty(command); - } -}); diff --git a/src/daemon/handlers/find.ts b/src/daemon/handlers/find.ts index b530d63c7..7c01c8685 100644 --- a/src/daemon/handlers/find.ts +++ b/src/daemon/handlers/find.ts @@ -18,14 +18,9 @@ import { errorResponse } from './response.ts'; import { getActiveAndroidSnapshotFreshness } from '../android-snapshot-freshness.ts'; import { stripInternalInteractionFlags } from '../interaction-outcome-policy.ts'; import { dispatchFindReadOnlyViaRuntime } from '../selector-runtime.ts'; -import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; export { parseFindArgs } from '../../utils/finders.ts'; -export const FIND_COMMAND_HANDLERS = { - [PUBLIC_COMMANDS.find]: true, -} as const satisfies Record; - type FindContext = { req: DaemonRequest; sessionName: string; diff --git a/src/daemon/handlers/interaction.ts b/src/daemon/handlers/interaction.ts index caab37671..2d5590a20 100644 --- a/src/daemon/handlers/interaction.ts +++ b/src/daemon/handlers/interaction.ts @@ -16,16 +16,6 @@ import { recoverAndroidBlockingSystemDialog, } from '../android-system-dialog.ts'; -export const INTERACTION_COMMAND_HANDLERS = { - [PUBLIC_COMMANDS.click]: true, - [PUBLIC_COMMANDS.fill]: true, - [PUBLIC_COMMANDS.get]: true, - [PUBLIC_COMMANDS.is]: true, - [PUBLIC_COMMANDS.longPress]: true, - [PUBLIC_COMMANDS.press]: true, - [PUBLIC_COMMANDS.type]: true, -} as const satisfies Record; - export async function handleInteractionCommands( params: InteractionHandlerParams, ): Promise { diff --git a/src/daemon/handlers/lease.ts b/src/daemon/handlers/lease.ts index 05e191085..ad36e01ac 100644 --- a/src/daemon/handlers/lease.ts +++ b/src/daemon/handlers/lease.ts @@ -1,19 +1,12 @@ import type { DaemonRequest, DaemonResponse } from '../types.ts'; import type { LeaseRegistry } from '../lease-registry.ts'; import { resolveLeaseScope } from '../lease-context.ts'; -import { INTERNAL_COMMANDS } from '../../command-catalog.ts'; type LeaseHandlerArgs = { req: DaemonRequest; leaseRegistry: LeaseRegistry; }; -export const LEASE_COMMAND_HANDLERS = { - [INTERNAL_COMMANDS.leaseAllocate]: true, - [INTERNAL_COMMANDS.leaseHeartbeat]: true, - [INTERNAL_COMMANDS.leaseRelease]: true, -} as const satisfies Record; - export async function handleLeaseCommands(args: LeaseHandlerArgs): Promise { const { req, leaseRegistry } = args; const leaseScope = resolveLeaseScope(req); diff --git a/src/daemon/handlers/react-native.ts b/src/daemon/handlers/react-native.ts index 26e300d36..77fdecafa 100644 --- a/src/daemon/handlers/react-native.ts +++ b/src/daemon/handlers/react-native.ts @@ -15,10 +15,6 @@ import { captureSnapshotForSession } from './interaction-snapshot.ts'; import { finalizeTouchInteraction, type InteractionHandlerParams } from './interaction-common.ts'; import { readSnapshotNodesReferenceFrame } from './interaction-touch-reference-frame.ts'; -export const REACT_NATIVE_COMMAND_HANDLERS = { - [PUBLIC_COMMANDS.reactNative]: true, -} as const satisfies Record; - export async function handleReactNativeCommands( params: InteractionHandlerParams, ): Promise { diff --git a/src/daemon/handlers/record-trace.ts b/src/daemon/handlers/record-trace.ts index ab54e1fd9..ccff718d2 100644 --- a/src/daemon/handlers/record-trace.ts +++ b/src/daemon/handlers/record-trace.ts @@ -5,12 +5,6 @@ import type { DaemonRequest, DaemonResponse } from '../types.ts'; import { SessionStore } from '../session-store.ts'; import { handleRecordCommand } from './record-trace-recording.ts'; import { errorResponse } from './response.ts'; -import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; - -export const RECORD_TRACE_COMMAND_HANDLERS = { - [PUBLIC_COMMANDS.record]: true, - [PUBLIC_COMMANDS.trace]: true, -} as const satisfies Record; export async function handleRecordTraceCommands(params: { req: DaemonRequest; diff --git a/src/daemon/handlers/session.ts b/src/daemon/handlers/session.ts index 6feb29bc4..c64e47cd8 100644 --- a/src/daemon/handlers/session.ts +++ b/src/daemon/handlers/session.ts @@ -1,10 +1,6 @@ import { dispatchCommand } from '../../core/dispatch.ts'; import { isCommandSupportedOnDevice } from '../../core/capabilities.ts'; -import { - DAEMON_COMMAND_GROUPS, - INTERNAL_COMMANDS, - PUBLIC_COMMANDS, -} from '../../command-catalog.ts'; +import { INTERNAL_COMMANDS, PUBLIC_COMMANDS } from '../../command-catalog.ts'; import { resolvePayloadInput } from '../../utils/payload-input.ts'; import type { AndroidAdbExecutor } from '../../platforms/android/adb-executor.ts'; import { @@ -39,35 +35,12 @@ import { handleSessionInventoryCommands } from './session-inventory.ts'; import { handleSessionStateCommands } from './session-state.ts'; import { handleSessionObservabilityCommands } from './session-observability.ts'; import { handleSessionReplayCommands } from './session-replay.ts'; +import { getSessionCommandKind } from '../daemon-command-registry.ts'; -const INVENTORY_COMMANDS = DAEMON_COMMAND_GROUPS.inventory; -const STATE_COMMANDS = DAEMON_COMMAND_GROUPS.state; -const OBSERVABILITY_COMMANDS = DAEMON_COMMAND_GROUPS.observability; -const REPLAY_COMMANDS = DAEMON_COMMAND_GROUPS.replay; const PREPARE_IOS_RUNNER_MIN_STARTUP_TIMEOUT_MS = 45_000; const PREPARE_IOS_RUNNER_DEFAULT_BUILD_TIMEOUT_MS = 5 * 60_000; const PREPARE_IOS_RUNNER_HEALTH_TIMEOUT_MS = 90_000; -export const SESSION_COMMAND_HANDLERS = { - ...Object.fromEntries([...INVENTORY_COMMANDS].map((command) => [command, true] as const)), - ...Object.fromEntries([...STATE_COMMANDS].map((command) => [command, true] as const)), - ...Object.fromEntries([...OBSERVABILITY_COMMANDS].map((command) => [command, true] as const)), - ...Object.fromEntries([...REPLAY_COMMANDS].map((command) => [command, true] as const)), - [INTERNAL_COMMANDS.runtime]: true, - [PUBLIC_COMMANDS.clipboard]: true, - [PUBLIC_COMMANDS.keyboard]: true, - [PUBLIC_COMMANDS.install]: true, - [PUBLIC_COMMANDS.reinstall]: true, - [INTERNAL_COMMANDS.installSource]: true, - [INTERNAL_COMMANDS.releaseMaterializedPaths]: true, - [PUBLIC_COMMANDS.push]: true, - [PUBLIC_COMMANDS.triggerAppEvent]: true, - [PUBLIC_COMMANDS.open]: true, - [PUBLIC_COMMANDS.prepare]: true, - [PUBLIC_COMMANDS.batch]: true, - [PUBLIC_COMMANDS.close]: true, -} as const satisfies Record; - async function handlePrepareCommand(params: { req: DaemonRequest; sessionName: string; @@ -285,7 +258,7 @@ export async function handleSessionCommands(params: { androidAdbExecutor, } = params; - if (INVENTORY_COMMANDS.has(req.command)) { + if (getSessionCommandKind(req.command) === 'inventory') { return await handleSessionInventoryCommands({ req, sessionName, @@ -301,7 +274,7 @@ export async function handleSessionCommands(params: { }); } - if (STATE_COMMANDS.has(req.command)) { + if (getSessionCommandKind(req.command) === 'state') { return await handleSessionStateCommands({ req, sessionName, @@ -343,7 +316,7 @@ export async function handleSessionCommands(params: { }); } - if (OBSERVABILITY_COMMANDS.has(req.command)) { + if (getSessionCommandKind(req.command) === 'observability') { return await handleSessionObservabilityCommands({ req, sessionName, @@ -439,7 +412,7 @@ export async function handleSessionCommands(params: { }); } - if (REPLAY_COMMANDS.has(req.command)) { + if (getSessionCommandKind(req.command) === 'replay') { return await handleSessionReplayCommands({ req, sessionName, diff --git a/src/daemon/handlers/snapshot.ts b/src/daemon/handlers/snapshot.ts index a521e61d4..1641be020 100644 --- a/src/daemon/handlers/snapshot.ts +++ b/src/daemon/handlers/snapshot.ts @@ -1,15 +1,12 @@ import type { DaemonRequest, DaemonResponse } from '../types.ts'; import { SessionStore } from '../session-store.ts'; import { errorResponse } from './response.ts'; -import { DAEMON_COMMAND_GROUPS } from '../../command-catalog.ts'; import { handleAlertCommand } from './snapshot-alert.ts'; import { handleSettingsCommand, parseSettingsArgs } from './snapshot-settings.ts'; import { dispatchSnapshotDiffViaRuntime, dispatchSnapshotViaRuntime } from '../snapshot-runtime.ts'; import { dispatchWaitViaRuntime } from '../selector-runtime.ts'; import { resolveSessionDevice, withSessionlessRunnerCleanup } from './snapshot-session.ts'; -const SNAPSHOT_COMMANDS = DAEMON_COMMAND_GROUPS.snapshot; - type SnapshotCommandParams = { req: DaemonRequest; sessionName: string; @@ -19,7 +16,7 @@ type SnapshotCommandParams = { type SnapshotCommandHandler = (params: SnapshotCommandParams) => Promise; -export const SNAPSHOT_COMMAND_HANDLERS = { +const SNAPSHOT_COMMAND_HANDLER_IMPLS = { snapshot: async ({ req, sessionName, logPath, sessionStore }) => await dispatchSnapshotViaRuntime({ req, @@ -69,13 +66,10 @@ export async function handleSnapshotCommands( ): Promise { const command = params.req.command; - if (!SNAPSHOT_COMMANDS.has(command)) { - return null; - } - - const handler = SNAPSHOT_COMMAND_HANDLERS[command as keyof typeof SNAPSHOT_COMMAND_HANDLERS]; + const handler = + SNAPSHOT_COMMAND_HANDLER_IMPLS[command as keyof typeof SNAPSHOT_COMMAND_HANDLER_IMPLS]; if (!handler) { - return errorResponse('COMMAND_FAILED', `Snapshot command has no handler: ${command}`); + return null; } return await handler(params); diff --git a/src/daemon/request-admission.ts b/src/daemon/request-admission.ts index 6b3932f50..1930a0784 100644 --- a/src/daemon/request-admission.ts +++ b/src/daemon/request-admission.ts @@ -1,14 +1,10 @@ -import { DAEMON_COMMAND_GROUPS } from '../command-catalog.ts'; import { AppError } from '../utils/errors.ts'; import { normalizeTenantId, resolveSessionIsolationMode } from './config.ts'; +import { isLeaseAdmissionExempt } from './daemon-command-registry.ts'; import { resolveLeaseScope } from './lease-context.ts'; import type { LeaseRegistry } from './lease-registry.ts'; import type { DaemonRequest } from './types.ts'; -const selectorValidationExemptCommands = DAEMON_COMMAND_GROUPS.selectorValidationExempt; -const leaseAdmissionExemptCommands = DAEMON_COMMAND_GROUPS.leaseAdmissionExempt; -const sessionExecutionExemptCommands = new Set(leaseAdmissionExemptCommands); - export function scopeRequestSession(req: DaemonRequest): DaemonRequest { const isolation = resolveSessionIsolationMode( req.meta?.sessionIsolation ?? req.flags?.sessionIsolation, @@ -57,7 +53,7 @@ export function assertRequestLeaseAdmission( req: DaemonRequest, leaseRegistry: LeaseRegistry, ): void { - if (leaseAdmissionExemptCommands.has(req.command) || req.meta?.sessionIsolation !== 'tenant') { + if (isLeaseAdmissionExempt(req.command) || req.meta?.sessionIsolation !== 'tenant') { return; } const leaseScope = resolveLeaseScope(req); @@ -68,11 +64,3 @@ export function assertRequestLeaseAdmission( backend: leaseScope.leaseBackend, }); } - -export function shouldValidateSessionSelector(command: string): boolean { - return !selectorValidationExemptCommands.has(command); -} - -export function shouldLockSessionExecution(command: string): boolean { - return !sessionExecutionExemptCommands.has(command); -} diff --git a/src/daemon/request-execution-scope.ts b/src/daemon/request-execution-scope.ts index fb9abd14e..5acc0012d 100644 --- a/src/daemon/request-execution-scope.ts +++ b/src/daemon/request-execution-scope.ts @@ -6,12 +6,7 @@ import type { DaemonCommandContext } from './context.ts'; import { contextFromFlags as contextFromFlagsWithLog } from './context.ts'; import { assertSessionSelectorMatches } from './session-selector.ts'; import { resolveEffectiveSessionName } from './session-routing.ts'; -import { - assertRequestLeaseAdmission, - scopeRequestSession, - shouldLockSessionExecution, - shouldValidateSessionSelector, -} from './request-admission.ts'; +import { assertRequestLeaseAdmission, scopeRequestSession } from './request-admission.ts'; import { prepareLockedRequestBinding, resolveRequestExecutionLockKeys, @@ -19,10 +14,12 @@ import { } from './request-binding.ts'; import { throwIfRequestCanceled } from './request-cancel.ts'; import { finalizeDaemonResponse } from './request-finalization.ts'; +import { refreshRecordingHealth } from './request-recording-health.ts'; import { - refreshRecordingHealth, shouldBlockForInvalidRecording, -} from './request-recording-health.ts'; + shouldLockSessionExecution, + shouldValidateSessionSelector, +} from './daemon-command-registry.ts'; import type { LeaseRegistry } from './lease-registry.ts'; import type { SessionStore } from './session-store.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from './types.ts'; @@ -121,7 +118,6 @@ async function withRequestExecutionLocks( function applyRequestCommandDefaults(req: DaemonRequest): DaemonRequest { const flags = { ...(req.flags ?? {}) }; const changed = applyCommandDefaults(req.command, flags); - if (!changed && req.flags) return req; if (!changed) return req; return { ...req, diff --git a/src/daemon/request-generic-dispatch.ts b/src/daemon/request-generic-dispatch.ts index ed7e08ab2..94118bca1 100644 --- a/src/daemon/request-generic-dispatch.ts +++ b/src/daemon/request-generic-dispatch.ts @@ -1,5 +1,5 @@ import { dispatchCommand, type CommandFlags } from '../core/dispatch.ts'; -import { DAEMON_COMMAND_GROUPS, GESTURE_SUBCOMMAND_ERROR } from '../command-catalog.ts'; +import { GESTURE_SUBCOMMAND_ERROR } from '../command-catalog.ts'; import { isCommandSupportedOnDevice, unsupportedHintForDevice } from '../core/capabilities.ts'; import { SessionStore } from './session-store.ts'; import type { DaemonCommandContext } from './context.ts'; @@ -25,6 +25,7 @@ import { } from './recording-gestures.ts'; import { markPostGestureStabilization } from './post-gesture-stabilization.ts'; import { normalizeError } from '../utils/errors.ts'; +import { shouldGuardAndroidBlockingDialog } from './daemon-command-registry.ts'; const GESTURE_PLATFORM_COMMANDS: Readonly> = { pan: 'pan', @@ -132,10 +133,7 @@ async function ensureNoAndroidBlockingDialogReady( ): Promise< { status: 'clear' } | { status: 'recovered'; warning: string } | { response: DaemonResponse } > { - if ( - session.device.platform !== 'android' || - !DAEMON_COMMAND_GROUPS.androidBlockingDialogGuardedAction.has(platformCommand) - ) { + if (session.device.platform !== 'android' || !shouldGuardAndroidBlockingDialog(platformCommand)) { return { status: 'clear' }; } try { diff --git a/src/daemon/request-handler-chain.ts b/src/daemon/request-handler-chain.ts index 0bacbb26c..c6cfc874c 100644 --- a/src/daemon/request-handler-chain.ts +++ b/src/daemon/request-handler-chain.ts @@ -1,7 +1,7 @@ import type { CommandFlags } from '../core/dispatch.ts'; -import { DAEMON_COMMAND_GROUPS } from '../command-catalog.ts'; import type { AndroidAdbExecutor } from '../platforms/android/adb-executor.ts'; import { AppError } from '../utils/errors.ts'; +import { getDaemonCommandRoute } from './daemon-command-registry.ts'; import type { DaemonCommandContext } from './context.ts'; import type { LeaseRegistry } from './lease-registry.ts'; import type { SessionStore } from './session-store.ts'; @@ -34,31 +34,24 @@ type RequestHandlerChainParams = { export async function runRequestHandlerChain( params: RequestHandlerChainParams, ): Promise { - const { command } = params.req; - if (DAEMON_COMMAND_GROUPS.leaseHandler.has(command)) { - return await runLeaseHandler(params); + switch (getDaemonCommandRoute(params.req.command)) { + case 'lease': + return await runLeaseHandler(params); + case 'session': + return await runSessionHandler(params); + case 'snapshot': + return await runSnapshotHandler(params); + case 'reactNative': + return await runReactNativeHandler(params); + case 'recordTrace': + return await runRecordTraceHandler(params); + case 'find': + return await runFindHandler(params); + case 'interaction': + return await runInteractionHandler(params); + case 'generic': + return null; } - if (DAEMON_COMMAND_GROUPS.sessionHandler.has(command)) { - return await runSessionHandler(params); - } - if (DAEMON_COMMAND_GROUPS.snapshot.has(command)) { - return await runSnapshotHandler(params); - } - if (DAEMON_COMMAND_GROUPS.reactNativeHandler.has(command)) { - return await runReactNativeHandler(params); - } - if (DAEMON_COMMAND_GROUPS.recordTraceHandler.has(command)) { - return await runRecordTraceHandler(params); - } - if (DAEMON_COMMAND_GROUPS.findHandler.has(command)) { - return await runFindHandler(params); - } - if (DAEMON_COMMAND_GROUPS.interactionHandler.has(command)) { - return await runInteractionHandler(params); - } - - // Commands not claimed by a specialized family continue to generic platform dispatch. - return null; } async function runLeaseHandler(params: RequestHandlerChainParams): Promise { diff --git a/src/daemon/request-lock-policy.ts b/src/daemon/request-lock-policy.ts index 05fccfb9c..1b03211e3 100644 --- a/src/daemon/request-lock-policy.ts +++ b/src/daemon/request-lock-policy.ts @@ -1,7 +1,6 @@ import { AppError } from '../utils/errors.ts'; import type { CommandFlags } from '../core/dispatch.ts'; import type { SessionState, DaemonRequest } from './types.ts'; -import { PUBLIC_COMMANDS } from '../command-catalog.ts'; import { formatSessionSelectorConflict, listSessionSelectorConflicts, @@ -12,15 +11,11 @@ import { isApplePlatform, normalizePlatformSelector } from '../utils/device.ts'; import { buildSessionRecoveryHint, describeSessionDevice } from './session-recovery-hints.ts'; import { shellQuoteIfNeeded } from '../utils/shell-quote.ts'; import { hasLockableDeviceSelector, hasSelectorValue } from './device-selector-intent.ts'; +import { canOverrideLockPolicySelector } from './daemon-command-registry.ts'; type LockPlatform = NonNullable['lockPlatform']; type NormalizedLockPlatform = NonNullable>; -const SELECTOR_OVERRIDE_LOCK_POLICY_COMMANDS: ReadonlySet = new Set([ - PUBLIC_COMMANDS.apps, - PUBLIC_COMMANDS.devices, -]); - export function applyRequestLockPolicy( req: DaemonRequest, existingSession?: SessionState, @@ -31,7 +26,7 @@ export function applyRequestLockPolicy( } const nextFlags: CommandFlags = { ...(req.flags ?? {}) }; - const canOverrideSelector = SELECTOR_OVERRIDE_LOCK_POLICY_COMMANDS.has(req.command); + const canOverrideSelector = canOverrideLockPolicySelector(req.command); const conflicts = canOverrideSelector ? [] : existingSession diff --git a/src/daemon/request-platform-providers.ts b/src/daemon/request-platform-providers.ts index 8b71d684b..5f9209726 100644 --- a/src/daemon/request-platform-providers.ts +++ b/src/daemon/request-platform-providers.ts @@ -14,7 +14,10 @@ import type { AppLogProvider } from './app-log.ts'; import { hasExplicitDeviceSelector } from './device-selector-intent.ts'; import type { RecordingProvider } from './recording-provider.ts'; import type { DaemonRequest, SessionState } from './types.ts'; -import { PUBLIC_COMMANDS } from '../command-catalog.ts'; +import { + shouldPreferExplicitDeviceOverExistingSession, + usesSessionlessDefaultProviderDevice, +} from './daemon-command-registry.ts'; export type PlatformProviderRequestSession = Pick< SessionState, @@ -277,26 +280,17 @@ async function resolveScopedProviderDevice( existingSession: SessionState | undefined, ): Promise { if (existingSession) { - return req.command === PUBLIC_COMMANDS.apps && hasExplicitDeviceSelector(req.flags) + return shouldPreferExplicitDeviceOverExistingSession(req) && + hasExplicitDeviceSelector(req.flags) ? await resolveTargetDevice(req.flags ?? {}) : existingSession.device; } - if ( - req.command !== PUBLIC_COMMANDS.open && - !hasExplicitDeviceSelector(req.flags) && - !usesSessionlessDefaultDevice(req) - ) { + if (!hasExplicitDeviceSelector(req.flags) && !usesSessionlessDefaultProviderDevice(req)) { return undefined; } return await resolveTargetDevice(req.flags ?? {}); } -function usesSessionlessDefaultDevice(req: DaemonRequest): boolean { - return ( - req.command === PUBLIC_COMMANDS.record && (req.positionals?.[0] ?? '').toLowerCase() === 'start' - ); -} - async function requestPlatformProviderScopeWrappers( scopedProviders: ResolvedRequestPlatformProviders, ): Promise { diff --git a/src/daemon/request-recording-health.ts b/src/daemon/request-recording-health.ts index 0dfa3ca51..2f1f4aee2 100644 --- a/src/daemon/request-recording-health.ts +++ b/src/daemon/request-recording-health.ts @@ -25,10 +25,6 @@ export function refreshRecordingHealth(session: SessionState): void { } } -export function shouldBlockForInvalidRecording(command: string): boolean { - return command !== 'record' && command !== 'close'; -} - function recordingRequiresRunnerHealth(session: SessionState): boolean { const recording = session.recording; if (!recording || session.device.platform !== 'ios') return false; diff --git a/src/daemon/request-router.ts b/src/daemon/request-router.ts index 1dcb10c59..be9c27748 100644 --- a/src/daemon/request-router.ts +++ b/src/daemon/request-router.ts @@ -28,8 +28,9 @@ import { createRequestExecutionScope, type LockedRequestScope, prepareLockedRequestScope, + type RequestExecutionScope, } from './request-execution-scope.ts'; -import { DAEMON_COMMAND_GROUPS } from '../command-catalog.ts'; +import { canRunReplayScopedAction } from './daemon-command-registry.ts'; // --------------------------------------------------------------------------- // Request handler API @@ -83,8 +84,7 @@ export function createRequestHandler( }, async () => { if (req.token !== token) { - const unauthorizedError = normalizeError(new AppError('UNAUTHORIZED', 'Invalid token')); - return { ok: false, error: unauthorizedError }; + return unauthorizedResponse(); } try { @@ -94,66 +94,7 @@ export function createRequestHandler( sessionStore, leaseRegistry, }); - - return await scope.runLocked(async () => { - const locked = prepareLockedRequestScope({ - scope, - logPath, - sessionStore, - trackDownloadableArtifact, - }); - if (locked.type === 'response') return locked.response; - const lockedScope = locked.scope; - - return await withRequestPlatformProviderScope( - { - req: lockedScope.req, - existingSession: lockedScope.existingSession, - providers: { - androidAdbProvider, - appleRunnerProvider, - appleToolProvider, - linuxToolProvider, - appLogProvider, - recordingProvider, - }, - }, - async (providerScope) => { - // Platform providers are scoped to this single locked request; handlers may - // re-read session state, but all device-scoped calls in this request share them. - // Phase 1: Try specialized handler chain - const handlerResponse = await runRequestHandlerChain({ - req: lockedScope.req, - sessionName: lockedScope.sessionName, - logPath, - sessionStore, - leaseRegistry, - invoke: handleRequest, - invokeReplayAction: createReplayScopedActionInvoker({ - parentScope: lockedScope, - providerScope, - handleRequest, - deps: { - logPath, - token, - sessionStore, - leaseRegistry, - trackDownloadableArtifact, - }, - }), - androidAdbExecutor: providerScope.androidAdbExecutor, - contextFromFlags: lockedScope.handlerContextFromFlags, - }); - if (handlerResponse) return lockedScope.finalize(handlerResponse); - - return await dispatchGenericForLockedScope({ - lockedScope, - logPath, - sessionStore, - }); - }, - ); - }); + return await executeRequestScope(scope); }); } catch (error) { return finalizeThrownRequestError(error); @@ -162,72 +103,100 @@ export function createRequestHandler( ); } - return handleRequest; -} + async function executeRequestScope( + scope: RequestExecutionScope, + inheritedProviderScope?: RequestPlatformProviderScope, + ): Promise { + const run = async (): Promise => { + const locked = prepareLockedRequestScope({ + scope, + logPath, + sessionStore, + trackDownloadableArtifact, + }); + if (locked.type === 'response') return locked.response; + const lockedScope = locked.scope; + const executeLocked = async (providerScope: RequestPlatformProviderScope) => + await executeLockedRequest({ + lockedScope, + providerScope, + allowReplayActions: inheritedProviderScope === undefined, + }); + + return inheritedProviderScope + ? await executeLocked(inheritedProviderScope) + : await withRequestPlatformProviderScope( + { + req: lockedScope.req, + existingSession: lockedScope.existingSession, + providers: { + androidAdbProvider, + appleRunnerProvider, + appleToolProvider, + linuxToolProvider, + appLogProvider, + recordingProvider, + }, + }, + executeLocked, + ); + }; + + return inheritedProviderScope ? await run() : await scope.runLocked(run); + } -type ReplayScopedActionInvokerDeps = { - logPath: string; - token: string; - sessionStore: SessionStore; - leaseRegistry: LeaseRegistry; - trackDownloadableArtifact: RequestRouterDeps['trackDownloadableArtifact']; -}; + async function executeLockedRequest(params: { + lockedScope: LockedRequestScope; + providerScope: RequestPlatformProviderScope; + allowReplayActions: boolean; + }): Promise { + const { lockedScope, providerScope, allowReplayActions } = params; + const handlerResponse = await runRequestHandlerChain({ + req: lockedScope.req, + sessionName: lockedScope.sessionName, + logPath, + sessionStore, + leaseRegistry, + invoke: handleRequest, + invokeReplayAction: allowReplayActions + ? createReplayScopedActionInvoker(lockedScope, providerScope) + : undefined, + androidAdbExecutor: providerScope.androidAdbExecutor, + contextFromFlags: lockedScope.handlerContextFromFlags, + }); + if (handlerResponse) return lockedScope.finalize(handlerResponse); -function createReplayScopedActionInvoker(params: { - parentScope: LockedRequestScope; - providerScope: RequestPlatformProviderScope; - handleRequest: (req: DaemonRequest) => Promise; - deps: ReplayScopedActionInvokerDeps; -}): (req: DaemonRequest) => Promise { - const { parentScope, providerScope, handleRequest, deps } = params; - return async (req) => { - if (!canRunReplayActionInCurrentScope(req, parentScope)) { - return await handleRequest(req); - } - if (req.token !== deps.token) { - const unauthorizedError = normalizeError(new AppError('UNAUTHORIZED', 'Invalid token')); - return { ok: false, error: unauthorizedError }; - } + return await dispatchGenericForLockedScope({ lockedScope, logPath, sessionStore }); + } - try { - const childScope = await createRequestExecutionScope({ - req, - sessionStore: deps.sessionStore, - leaseRegistry: deps.leaseRegistry, - }); - if (childScope.sessionName !== parentScope.sessionName) { - return await handleRequest(req); + function createReplayScopedActionInvoker( + parentScope: LockedRequestScope, + providerScope: RequestPlatformProviderScope, + ): (req: DaemonRequest) => Promise { + return async (req) => { + if (!canRunReplayActionInCurrentScope(req, parentScope)) return await handleRequest(req); + if (req.token !== token) { + return unauthorizedResponse(); } - const locked = prepareLockedRequestScope({ - scope: childScope, - logPath: deps.logPath, - sessionStore: deps.sessionStore, - trackDownloadableArtifact: deps.trackDownloadableArtifact, - }); - if (locked.type === 'response') return locked.response; - const lockedScope = locked.scope; + try { + const childScope = await createRequestExecutionScope({ req, sessionStore, leaseRegistry }); + return childScope.sessionName === parentScope.sessionName + ? await executeRequestScope(childScope, providerScope) + : await handleRequest(req); + } catch (error) { + return finalizeThrownRequestError(error); + } + }; + } - const handlerResponse = await runRequestHandlerChain({ - req: lockedScope.req, - sessionName: lockedScope.sessionName, - logPath: deps.logPath, - sessionStore: deps.sessionStore, - leaseRegistry: deps.leaseRegistry, - invoke: handleRequest, - androidAdbExecutor: providerScope.androidAdbExecutor, - contextFromFlags: lockedScope.handlerContextFromFlags, - }); - if (handlerResponse) return lockedScope.finalize(handlerResponse); + return handleRequest; +} - return await dispatchGenericForLockedScope({ - lockedScope, - logPath: deps.logPath, - sessionStore: deps.sessionStore, - }); - } catch (error) { - return finalizeThrownRequestError(error); - } +function unauthorizedResponse(): DaemonResponse { + return { + ok: false, + error: normalizeError(new AppError('UNAUTHORIZED', 'Invalid token')), }; } @@ -263,10 +232,7 @@ function canRunReplayActionInCurrentScope( req: DaemonRequest, parentScope: LockedRequestScope, ): boolean { - return ( - req.session === parentScope.sessionName && - DAEMON_COMMAND_GROUPS.replayScopedAction.has(req.command) - ); + return req.session === parentScope.sessionName && canRunReplayScopedAction(req.command); } function finalizeThrownRequestError(error: unknown): DaemonResponse { diff --git a/test/integration/provider-scenarios/daemon-command-policy.test.ts b/test/integration/provider-scenarios/daemon-command-policy.test.ts new file mode 100644 index 000000000..f5e956aaa --- /dev/null +++ b/test/integration/provider-scenarios/daemon-command-policy.test.ts @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import type { AndroidAdbProvider } from '../../../src/platforms/android/adb-executor.ts'; +import { assertRpcError, assertRpcOk } from './assertions.ts'; +import { PROVIDER_SCENARIO_ANDROID } from './fixtures.ts'; +import { createProviderScenarioHarness, withProviderScenarioResource } from './harness.ts'; + +test('Provider-backed integration daemon command policies gate admission and provider scoping', async () => { + const adbCalls: string[][] = []; + const inactiveLeaseMeta = { + tenantId: 'tenant-a', + runId: 'run-a', + leaseId: '0'.repeat(32), + sessionIsolation: 'tenant' as const, + }; + const adbProvider: AndroidAdbProvider = { + exec: async (args) => { + adbCalls.push([...args]); + return androidAdbResult(args); + }, + }; + + await withProviderScenarioResource( + async () => + await createProviderScenarioHarness({ + androidAdbProvider: () => adbProvider, + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], + }), + async (daemon) => { + const devices = await daemon.callCommand( + 'devices', + [], + { platform: 'android' }, + { meta: inactiveLeaseMeta }, + ); + assertRpcOk(devices); + + const blockedSnapshot = await daemon.callCommand( + 'snapshot', + [], + { platform: 'android' }, + { meta: inactiveLeaseMeta }, + ); + assertRpcError(blockedSnapshot, 'UNAUTHORIZED', /Lease is not active/); + + const recordStart = await daemon.callCommand('record', ['start', '/tmp/policy-record.mp4']); + assertRpcOk(recordStart); + assert.ok( + adbCalls.some((args) => isAndroidScreenrecordStartCommand(args.join(' '))), + JSON.stringify(adbCalls), + ); + }, + ); +}); + +function androidAdbResult(args: string[]): { + stdout: string; + stderr: string; + exitCode: number; +} { + const command = args.join(' '); + if (command === 'shell getprop sys.boot_completed') { + return { stdout: '1\n', stderr: '', exitCode: 0 }; + } + if (command === 'shell wm size') { + return { stdout: 'Physical size: 1080x1920\n', stderr: '', exitCode: 0 }; + } + if (isAndroidScreenrecordStartCommand(command)) { + return { stdout: '4321\n', stderr: '', exitCode: 0 }; + } + if (/^shell stat -c %s \/sdcard\/agent-device-recording-\d+\.mp4$/.test(command)) { + return { stdout: '2048\n', stderr: '', exitCode: 0 }; + } + if (command === 'shell ps -o pid= -p 4321') { + return { stdout: '4321\n', stderr: '', exitCode: 0 }; + } + return { stdout: '', stderr: '', exitCode: 0 }; +} + +function isAndroidScreenrecordStartCommand(command: string): boolean { + return command.startsWith('shell screenrecord ') && command.endsWith(' & echo $!'); +}