From 78e54e4e0a7d53691c671951fdc74bc169e3ba51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 11 Jun 2026 16:57:19 +0200 Subject: [PATCH 01/11] refactor: localize command surface modules --- src/commands/batch-command.ts | 2 +- src/commands/capture/index.test.ts | 115 +++++ src/commands/capture/index.ts | 388 +++++++++++++++ src/commands/capture/settings.ts | 162 +++++++ src/commands/cli-grammar/apps.ts | 205 -------- src/commands/cli-grammar/capture.ts | 282 ----------- src/commands/cli-grammar/metro.ts | 60 --- src/commands/cli-grammar/observability.ts | 242 ---------- src/commands/cli-grammar/registry.ts | 26 +- src/commands/cli-grammar/replay.ts | 61 --- src/commands/cli-grammar/system.test.ts | 176 ------- src/commands/cli-grammar/system.ts | 115 ----- src/commands/client-command-contracts.ts | 163 +------ src/commands/client-command-metadata.ts | 281 +---------- src/commands/command-descriptions.ts | 77 +-- src/commands/command-metadata.ts | 2 +- src/commands/command-projection.ts | 22 +- src/commands/command-surface.ts | 2 +- .../gesture.test.ts | 2 +- .../{cli-grammar => interaction}/gesture.ts | 4 +- .../index.ts} | 89 +++- .../interactions.ts | 4 +- .../metadata.ts} | 46 +- .../{cli-grammar => interaction}/selectors.ts | 4 +- src/commands/management/index.ts | 441 ++++++++++++++++++ src/commands/metro/index.test.ts | 101 ++++ src/commands/metro/index.ts | 183 ++++++++ src/commands/observability/index.test.ts | 107 +++++ src/commands/observability/index.ts | 381 +++++++++++++++ src/commands/react-native/index.test.ts | 48 ++ src/commands/react-native/index.ts | 63 +++ src/commands/recording/index.test.ts | 71 +++ src/commands/recording/index.ts | 133 ++++++ src/commands/replay/index.test.ts | 129 +++++ src/commands/replay/index.ts | 173 +++++++ src/commands/system/index.test.ts | 120 +++++ src/commands/system/index.ts | 305 ++++++++++++ .../handlers/__tests__/snapshot.test.ts | 2 +- src/utils/cli-command-overrides.ts | 338 +------------- 39 files changed, 3147 insertions(+), 1978 deletions(-) create mode 100644 src/commands/capture/index.test.ts create mode 100644 src/commands/capture/index.ts create mode 100644 src/commands/capture/settings.ts delete mode 100644 src/commands/cli-grammar/apps.ts delete mode 100644 src/commands/cli-grammar/capture.ts delete mode 100644 src/commands/cli-grammar/metro.ts delete mode 100644 src/commands/cli-grammar/observability.ts delete mode 100644 src/commands/cli-grammar/replay.ts delete mode 100644 src/commands/cli-grammar/system.test.ts delete mode 100644 src/commands/cli-grammar/system.ts rename src/commands/{cli-grammar => interaction}/gesture.test.ts (99%) rename src/commands/{cli-grammar => interaction}/gesture.ts (98%) rename src/commands/{interaction-command-contracts.ts => interaction/index.ts} (61%) rename src/commands/{cli-grammar => interaction}/interactions.ts (98%) rename src/commands/{interaction-command-metadata.ts => interaction/metadata.ts} (83%) rename src/commands/{cli-grammar => interaction}/selectors.ts (97%) create mode 100644 src/commands/management/index.ts create mode 100644 src/commands/metro/index.test.ts create mode 100644 src/commands/metro/index.ts create mode 100644 src/commands/observability/index.test.ts create mode 100644 src/commands/observability/index.ts create mode 100644 src/commands/react-native/index.test.ts create mode 100644 src/commands/react-native/index.ts create mode 100644 src/commands/recording/index.test.ts create mode 100644 src/commands/recording/index.ts create mode 100644 src/commands/replay/index.test.ts create mode 100644 src/commands/replay/index.ts create mode 100644 src/commands/system/index.test.ts create mode 100644 src/commands/system/index.ts diff --git a/src/commands/batch-command.ts b/src/commands/batch-command.ts index 4c9029a9d..b43fb23bb 100644 --- a/src/commands/batch-command.ts +++ b/src/commands/batch-command.ts @@ -4,7 +4,7 @@ import { type DaemonCommandName } from './command-projection.ts'; import { commonToClientOptions } from './command-input.ts'; import { createBatchCommandMetadata, type BatchInput } from './batch-command-metadata.ts'; -export function createBatchCommand( +export function createBatchCommand( nestedCommands: readonly TCommand[], ) { return defineExecutableCommand(createBatchCommandMetadata(nestedCommands), (client, input) => diff --git a/src/commands/capture/index.test.ts b/src/commands/capture/index.test.ts new file mode 100644 index 000000000..d56f3417b --- /dev/null +++ b/src/commands/capture/index.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, test } from 'vitest'; +import type { CliFlags } from '../../utils/cli-flags.ts'; +import { + alertCliReader, + alertDaemonWriter, + diffCliReader, + screenshotCliReader, + screenshotDaemonWriter, + settingsCliReader, + settingsDaemonWriter, + snapshotCliReader, + waitCliReader, + waitDaemonWriter, +} from './index.ts'; + +function flags(overrides: Partial = {}): CliFlags { + return overrides as CliFlags; +} + +function expectInvalidArgs(fn: () => unknown, messageFragment: string) { + expect(fn).toThrow( + expect.objectContaining({ + code: 'INVALID_ARGS', + message: expect.stringContaining(messageFragment), + }), + ); +} + +describe('capture command interface', () => { + test('reads snapshot flags', () => { + expect( + snapshotCliReader( + [], + flags({ + snapshotInteractiveOnly: true, + snapshotCompact: true, + snapshotDepth: 3, + snapshotScope: 'Login', + snapshotRaw: true, + snapshotForceFull: true, + timeoutMs: 10_000, + }), + ), + ).toMatchObject({ + interactiveOnly: true, + compact: true, + depth: 3, + scope: 'Login', + raw: true, + forceFull: true, + timeoutMs: 10_000, + }); + }); + + test('reads screenshot path and writes screenshot flags', () => { + const input = screenshotCliReader( + ['page.png'], + flags({ screenshotFullscreen: true, screenshotMaxSize: 1024 }), + ); + expect(input).toMatchObject({ path: 'page.png', fullscreen: true, maxSize: 1024 }); + expect(screenshotDaemonWriter(input)).toMatchObject({ + command: 'screenshot', + positionals: ['page.png'], + options: { screenshotFullscreen: true, screenshotMaxSize: 1024 }, + }); + }); + + test('reads diff snapshot input only', () => { + expect( + diffCliReader(['snapshot'], flags({ snapshotDepth: 4, out: './diff.json' })), + ).toMatchObject({ + kind: 'snapshot', + depth: 4, + out: './diff.json', + }); + expectInvalidArgs(() => diffCliReader(['screenshot'], flags()), 'Only diff snapshot'); + }); + + test('reads and writes wait targets', () => { + expect(waitCliReader(['text', 'Ready', '5000'], flags())).toMatchObject({ + text: 'Ready', + timeoutMs: 5000, + }); + expect(waitDaemonWriter({ text: 'Ready', timeoutMs: 5000 })).toMatchObject({ + command: 'wait', + positionals: ['text', 'Ready', '5000'], + }); + expectInvalidArgs(() => waitDaemonWriter({ text: 'Ready', ref: '@e1' }), 'exactly one'); + }); + + test('reads and writes alert action and timeout', () => { + expect(alertCliReader(['wait', '3000'], flags())).toMatchObject({ + action: 'wait', + timeoutMs: 3000, + }); + expect(alertDaemonWriter({ action: 'dismiss', timeoutMs: 1000 })).toMatchObject({ + command: 'alert', + positionals: ['dismiss', '1000'], + }); + }); + + test('reads and writes settings input', () => { + const input = settingsCliReader(['permission', 'grant', 'camera', 'limited'], flags()); + expect(input).toMatchObject({ + setting: 'permission', + state: 'grant', + permission: 'camera', + mode: 'limited', + }); + expect(settingsDaemonWriter(input)).toMatchObject({ + command: 'settings', + positionals: ['permission', 'grant', 'camera', 'limited'], + }); + }); +}); diff --git a/src/commands/capture/index.ts b/src/commands/capture/index.ts new file mode 100644 index 000000000..b531361c8 --- /dev/null +++ b/src/commands/capture/index.ts @@ -0,0 +1,388 @@ +import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; +import type { + AlertCommandOptions, + CaptureScreenshotOptions, + WaitCommandOptions, +} from '../../client-types.ts'; +import type { AlertAction } from '../../alert-contract.ts'; +import { ALERT_ACTIONS } from '../../alert-contract.ts'; +import { parseWaitPositionals } from '../../core/wait-positionals.ts'; +import { SESSION_SURFACES } from '../../core/session-surface.ts'; +import { SCREENSHOT_COMMAND_FLAG_KEYS } from '../../contracts/screenshot.ts'; +import type { CommandSchemaOverride } from '../../utils/cli-command-schema-types.ts'; +import { SELECTOR_SNAPSHOT_FLAGS, SNAPSHOT_FLAGS, type CliFlags } from '../../utils/cli-flags.ts'; +import { AppError } from '../../utils/errors.ts'; +import { tryParseSelectorChain } from '../../utils/selectors-parse.ts'; +import { + screenshotFlagsFromOptions, + screenshotOptionsFromFlags, +} from '../capture-screenshot-options.ts'; +import { + booleanField, + compactRecord, + enumField, + integerField, + jsonSchemaField, + optionalEnum, + requiredField, + stringField, +} from '../command-input.ts'; +import { defineExecutableCommand } from '../command-contract.ts'; +import { defineFieldCommandMetadata } from '../field-command-contract.ts'; +import { WAIT_KIND_VALUES } from '../wait-command-contract.ts'; +import { + commonInputFromFlags, + direct, + optionalNumber, + optionalString, + readFiniteNumber, + request, + requiredDaemonString, + selectionOptionsFromFlags, + selectorSnapshotOptionsFromFlags, +} from '../cli-grammar/common.ts'; +import type { CliReader, DaemonWriter } from '../cli-grammar/types.ts'; +import { + SETTINGS_COMMAND_NAME, + settingsCliReader as settingsCliReaderImpl, + settingsCliSchema, + settingsCommandDefinition, + settingsCommandDescription, + settingsCommandMetadata, + settingsDaemonWriter as settingsDaemonWriterImpl, +} from './settings.ts'; + +export const SNAPSHOT_COMMAND_NAME = 'snapshot'; +export const SCREENSHOT_COMMAND_NAME = 'screenshot'; +export const DIFF_COMMAND_NAME = 'diff'; +export const WAIT_COMMAND_NAME = 'wait'; +export const ALERT_COMMAND_NAME = 'alert'; + +export const snapshotCommandDescription = 'Capture an accessibility snapshot.'; +export const screenshotCommandDescription = 'Capture a screenshot.'; +export const diffCommandDescription = 'Diff accessibility snapshots.'; +export const waitCommandDescription = 'Wait for duration, text, ref, or selector.'; +export const alertCommandDescription = 'Inspect or handle platform alerts.'; + +export const captureCommandDescriptions = { + [SNAPSHOT_COMMAND_NAME]: snapshotCommandDescription, + [SCREENSHOT_COMMAND_NAME]: screenshotCommandDescription, + [DIFF_COMMAND_NAME]: diffCommandDescription, + [WAIT_COMMAND_NAME]: waitCommandDescription, + [ALERT_COMMAND_NAME]: alertCommandDescription, + [SETTINGS_COMMAND_NAME]: settingsCommandDescription, +} as const; + +export const snapshotCommandMetadata = defineFieldCommandMetadata( + SNAPSHOT_COMMAND_NAME, + snapshotCommandDescription, + { + interactiveOnly: booleanField(), + compact: booleanField(), + depth: integerField(), + scope: stringField(), + raw: booleanField(), + forceFull: booleanField(), + timeoutMs: integerField('Maximum wall-clock time for the snapshot command.'), + }, +); + +export const screenshotCommandMetadata = defineFieldCommandMetadata( + SCREENSHOT_COMMAND_NAME, + screenshotCommandDescription, + { + path: stringField('Output path.'), + overlayRefs: booleanField(), + fullscreen: booleanField(), + maxSize: integerField(), + stabilize: booleanField(), + surface: enumField(SESSION_SURFACES), + }, +); + +export const diffCommandMetadata = defineFieldCommandMetadata( + DIFF_COMMAND_NAME, + diffCommandDescription, + { + kind: requiredField(jsonSchemaField<'snapshot'>({ type: 'string', const: 'snapshot' })), + out: stringField(), + interactiveOnly: booleanField(), + compact: booleanField(), + depth: integerField(), + scope: stringField(), + raw: booleanField(), + }, +); + +export const waitCommandMetadata = defineFieldCommandMetadata( + WAIT_COMMAND_NAME, + waitCommandDescription, + { + kind: enumField(WAIT_KIND_VALUES), + durationMs: integerField(), + text: stringField(), + ref: stringField(), + selector: stringField(), + timeoutMs: integerField(), + depth: integerField(), + scope: stringField(), + raw: booleanField(), + }, +); + +export const alertCommandMetadata = defineFieldCommandMetadata( + ALERT_COMMAND_NAME, + alertCommandDescription, + { + action: enumField(ALERT_ACTIONS), + timeoutMs: integerField(), + }, +); + +export const captureCommandMetadata = [ + snapshotCommandMetadata, + screenshotCommandMetadata, + diffCommandMetadata, + waitCommandMetadata, + alertCommandMetadata, + settingsCommandMetadata, +] as const; + +export const snapshotCommandDefinition = defineExecutableCommand( + snapshotCommandMetadata, + (client, input) => client.capture.snapshot(input), +); + +export const screenshotCommandDefinition = defineExecutableCommand( + screenshotCommandMetadata, + (client, input) => client.capture.screenshot(input), +); + +export const diffCommandDefinition = defineExecutableCommand(diffCommandMetadata, (client, input) => + client.capture.diff(input), +); + +export const waitCommandDefinition = defineExecutableCommand(waitCommandMetadata, (client, input) => + client.command.wait(waitInputToOptions(input)), +); + +export const alertCommandDefinition = defineExecutableCommand( + alertCommandMetadata, + (client, input) => client.command.alert(input), +); + +export const captureCommandDefinitions = [ + snapshotCommandDefinition, + screenshotCommandDefinition, + diffCommandDefinition, + waitCommandDefinition, + alertCommandDefinition, + settingsCommandDefinition, +] as const; + +export const snapshotCliSchema = { + usageOverride: + 'snapshot [--diff] [-i] [-c] [-d ] [-s ] [--raw] [--force-full] [--timeout ]', + helpDescription: 'Capture accessibility tree or diff against the previous session baseline', + allowedFlags: ['snapshotDiff', ...SNAPSHOT_FLAGS, 'snapshotForceFull', 'timeoutMs'], +} as const satisfies CommandSchemaOverride; + +export const diffCliSchema = { + usageOverride: + 'diff snapshot | diff screenshot --baseline [current.png] [--out ] [--threshold <0-1>] [--overlay-refs]', + helpDescription: 'Diff accessibility snapshot or compare screenshots pixel-by-pixel', + summary: 'Diff snapshot or screenshot', + positionalArgs: ['kind', 'current?'], + allowedFlags: [...SNAPSHOT_FLAGS, 'baseline', 'threshold', 'out', 'overlayRefs'], +} as const satisfies CommandSchemaOverride; + +export const screenshotCliSchema = { + helpDescription: + 'Capture screenshot (macOS app sessions default to the app window; use --fullscreen for full desktop, --max-size to downscale, --overlay-refs to annotate current refs, or --no-stabilize for low-latency Android capture loops)', + positionalArgs: ['path?'], + allowedFlags: SCREENSHOT_COMMAND_FLAG_KEYS, +} as const satisfies CommandSchemaOverride; + +export const waitCliSchema = { + usageOverride: 'wait |text |@ref| [timeoutMs]', + positionalArgs: ['durationOrSelector', 'timeoutMs?'], + allowsExtraPositionals: true, + allowedFlags: [...SELECTOR_SNAPSHOT_FLAGS], +} as const satisfies CommandSchemaOverride; + +export const alertCliSchema = { + usageOverride: 'alert [get|accept|dismiss|wait] [timeout]', + positionalArgs: ['action?', 'timeout?'], +} as const satisfies CommandSchemaOverride; + +export const captureCliSchemas = { + [SNAPSHOT_COMMAND_NAME]: snapshotCliSchema, + [SCREENSHOT_COMMAND_NAME]: screenshotCliSchema, + [DIFF_COMMAND_NAME]: diffCliSchema, + [WAIT_COMMAND_NAME]: waitCliSchema, + [ALERT_COMMAND_NAME]: alertCliSchema, + [SETTINGS_COMMAND_NAME]: settingsCliSchema, +} as const satisfies Record; + +function waitInputToOptions(input: Record): WaitCommandOptions { + optionalEnum(input, 'kind', WAIT_KIND_VALUES); + const options = { ...input }; + delete options.kind; + return options as WaitCommandOptions & { kind?: never }; +} + +export const captureCliReaders = { + snapshot: (_positionals, flags) => ({ + ...commonInputFromFlags(flags), + interactiveOnly: flags.snapshotInteractiveOnly, + compact: flags.snapshotCompact, + depth: flags.snapshotDepth, + scope: flags.snapshotScope, + raw: flags.snapshotRaw, + forceFull: flags.snapshotForceFull, + timeoutMs: flags.timeoutMs, + }), + screenshot: (positionals, flags) => ({ + ...commonInputFromFlags(flags), + path: positionals[0] ?? flags.out, + ...screenshotOptionsFromFlags(flags), + }), + diff: (positionals, flags) => { + if (positionals[0] !== 'snapshot') { + throw new AppError('INVALID_ARGS', 'Only diff snapshot is available through this parser.'); + } + return { + ...commonInputFromFlags(flags), + kind: 'snapshot', + out: flags.out, + interactiveOnly: flags.snapshotInteractiveOnly, + compact: flags.snapshotCompact, + depth: flags.snapshotDepth, + scope: flags.snapshotScope, + raw: flags.snapshotRaw, + }; + }, + wait: (positionals, flags) => readWaitOptionsFromPositionals(positionals, flags), + alert: (positionals, flags) => ({ + ...commonInputFromFlags(flags), + ...readAlertInput(positionals), + }), + settings: settingsCliReaderImpl, +} satisfies Record; + +export const snapshotCliReader = captureCliReaders.snapshot; +export const screenshotCliReader = captureCliReaders.screenshot; +export const diffCliReader = captureCliReaders.diff; +export const waitCliReader = captureCliReaders.wait; +export const alertCliReader = captureCliReaders.alert; +export const settingsCliReader = captureCliReaders.settings; + +export const captureDaemonWriters = { + snapshot: direct(PUBLIC_COMMANDS.snapshot), + screenshot: (input) => + request(PUBLIC_COMMANDS.screenshot, optionalString(input.path), { + ...input, + ...screenshotFlagsFromOptions(input as CaptureScreenshotOptions), + }), + diff: direct(PUBLIC_COMMANDS.diff, (input) => [ + requiredDaemonString(input.kind, 'diff requires kind'), + ]), + wait: direct(PUBLIC_COMMANDS.wait, (input) => waitPositionals(input as WaitCommandOptions)), + alert: direct(PUBLIC_COMMANDS.alert, (input) => alertPositionals(input as AlertCommandOptions)), + settings: settingsDaemonWriterImpl, +} satisfies Record; + +export const snapshotDaemonWriter = captureDaemonWriters.snapshot; +export const screenshotDaemonWriter = captureDaemonWriters.screenshot; +export const diffDaemonWriter = captureDaemonWriters.diff; +export const waitDaemonWriter = captureDaemonWriters.wait; +export const alertDaemonWriter = captureDaemonWriters.alert; +export const settingsDaemonWriter = captureDaemonWriters.settings; + +function readWaitOptionsFromPositionals( + positionals: string[], + flags: CliFlags, +): WaitCommandOptions { + const parsed = parseWaitPositionals(positionals); + if (!parsed) { + throw new AppError( + 'INVALID_ARGS', + 'wait requires , text , @ref, or [timeoutMs].', + ); + } + const base = { + ...selectionOptionsFromFlags(flags), + ...selectorSnapshotOptionsFromFlags(flags), + }; + if (parsed.kind === 'sleep') return { ...base, durationMs: parsed.durationMs }; + if (parsed.kind === 'text') { + if (!parsed.text) throw new AppError('INVALID_ARGS', 'wait requires text.'); + return { ...base, text: parsed.text, ...readTimeoutOption(parsed.timeoutMs) }; + } + if (parsed.kind === 'ref') { + return { ...base, ref: parsed.rawRef, ...readTimeoutOption(parsed.timeoutMs) }; + } + return { + ...base, + selector: parsed.selectorExpression, + ...readTimeoutOption(parsed.timeoutMs), + }; +} + +export { parseWaitPositionals }; + +// fallow-ignore-next-line complexity +function waitPositionals(options: WaitCommandOptions): string[] { + const targets = [ + options.durationMs !== undefined ? 'durationMs' : undefined, + options.text !== undefined ? 'text' : undefined, + options.ref !== undefined ? 'ref' : undefined, + options.selector !== undefined ? 'selector' : undefined, + ].filter(Boolean); + if (targets.length !== 1) { + throw new AppError( + 'INVALID_ARGS', + 'wait command requires exactly one of durationMs, text, ref, or selector.', + ); + } + if (options.durationMs !== undefined) return [String(options.durationMs)]; + const timeout = optionalNumber(options.timeoutMs); + if (options.text !== undefined) return ['text', options.text, ...timeout]; + if (options.ref !== undefined) return [options.ref, ...timeout]; + const selector = options.selector!; + if (!tryParseSelectorChain(selector)) { + throw new AppError('INVALID_ARGS', `Invalid wait selector: ${selector}`); + } + return [selector, ...timeout]; +} + +function alertPositionals(input: AlertCommandOptions): string[] { + return [input.action ?? 'get', ...optionalNumber(input.timeoutMs)]; +} + +function readAlertInput(positionals: string[]): Record { + if (positionals.length > 2) { + throw new AppError('INVALID_ARGS', 'alert accepts at most action and timeout arguments.'); + } + const action = readAlertAction(positionals[0]); + const timeoutMs = readFiniteNumber(positionals[1], 'alert timeout'); + return compactRecord({ action, timeoutMs }); +} + +function readAlertAction(value: string | undefined): AlertAction | undefined { + const action = value?.toLowerCase(); + if ( + action === undefined || + action === 'get' || + action === 'accept' || + action === 'dismiss' || + action === 'wait' + ) { + return action; + } + throw new AppError('INVALID_ARGS', 'alert action must be get, accept, dismiss, or wait.'); +} + +function readTimeoutOption(timeoutMs: number | null): { timeoutMs?: number } { + return timeoutMs === null ? {} : { timeoutMs }; +} diff --git a/src/commands/capture/settings.ts b/src/commands/capture/settings.ts new file mode 100644 index 000000000..72f718c47 --- /dev/null +++ b/src/commands/capture/settings.ts @@ -0,0 +1,162 @@ +import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; +import type { SettingsUpdateOptions } from '../../client-types.ts'; +import { SETTINGS_USAGE_OVERRIDE } from '../../core/settings-contract.ts'; +import type { CommandSchemaOverride } from '../../utils/cli-command-schema-types.ts'; +import type { CliFlags } from '../../utils/cli-flags.ts'; +import { AppError } from '../../utils/errors.ts'; +import { readLocationCoordinate } from '../../utils/location-coordinates.ts'; +import { defineExecutableCommand } from '../command-contract.ts'; +import { enumField, numberField, requiredField, stringField } from '../command-input.ts'; +import { + direct, + isOneOf, + optionalString, + selectionOptionsFromFlags, + setOf, +} from '../cli-grammar/common.ts'; +import type { CliReader, DaemonWriter } from '../cli-grammar/types.ts'; +import { defineFieldCommandMetadata } from '../field-command-contract.ts'; + +export const SETTINGS_COMMAND_NAME = 'settings'; +export const settingsCommandDescription = 'Change OS settings and app permissions.'; + +export const settingsCommandMetadata = defineFieldCommandMetadata( + SETTINGS_COMMAND_NAME, + settingsCommandDescription, + { + setting: requiredField(stringField()), + state: requiredField(stringField()), + app: stringField(), + latitude: numberField(), + longitude: numberField(), + permission: stringField(), + mode: enumField(['full', 'limited']), + }, +); + +export const settingsCommandDefinition = defineExecutableCommand( + settingsCommandMetadata, + (client, input) => client.settings.update(input as SettingsUpdateOptions), +); + +export const settingsCliSchema = { + usageOverride: SETTINGS_USAGE_OVERRIDE, + listUsageOverride: 'settings [area] [options]', + helpDescription: + 'Toggle OS settings, animation scales, appearance, and app permissions (macOS supports only settings appearance and settings permission ; wifi|airplane|location|animations remain unsupported on macOS; mobile permission actions use the active session app)', + summary: 'Change OS settings and app permissions', + positionalArgs: ['setting', 'state', 'target?', 'mode?'], +} as const satisfies CommandSchemaOverride; + +export const settingsCliReader: CliReader = (positionals, flags) => + readSettingsOptionsFromPositionals(positionals, flags); + +export const settingsDaemonWriter: DaemonWriter = direct(PUBLIC_COMMANDS.settings, (input) => + settingsPositionals(input as SettingsUpdateOptions), +); + +// fallow-ignore-next-line complexity +function readSettingsOptionsFromPositionals( + positionals: string[], + flags: CliFlags, +): SettingsUpdateOptions { + const base = selectionOptionsFromFlags(flags); + const setting = positionals[0]; + const state = positionals[1]; + if (isOneOf(setting, ON_OFF_SETTINGS) && isOneOf(state, ON_OFF_STATES)) { + return { ...base, setting, state }; + } + if (setting === 'location' && state === 'set') { + return { + ...base, + setting, + state, + latitude: readLocationCoordinate(positionals[2], 'latitude'), + longitude: readLocationCoordinate(positionals[3], 'longitude'), + }; + } + if (setting === 'appearance' && isOneOf(state, APPEARANCE_STATES)) { + return { ...base, setting, state }; + } + if (isOneOf(setting, BIOMETRIC_SETTINGS) && isOneOf(state, BIOMETRIC_STATES)) { + return { ...base, setting, state }; + } + if (setting === 'fingerprint' && isOneOf(state, FINGERPRINT_STATES)) { + return { ...base, setting, state }; + } + if (setting === 'permission' && isOneOf(state, PERMISSION_STATES)) { + return { + ...base, + setting, + state, + permission: readPermission(positionals[2]), + mode: readPermissionMode(positionals[3]), + }; + } + if (setting === 'clear-app-state') { + const app = state === 'clear' ? positionals[2] : state; + return { ...base, setting, state: 'clear', app }; + } + throw new AppError('INVALID_ARGS', 'Invalid settings arguments.'); +} + +function settingsPositionals(input: SettingsUpdateOptions): string[] { + if (input.setting === 'clear-app-state') { + return [input.setting, ...optionalString(input.app)]; + } + if (input.setting === 'location' && input.state === 'set') { + return [input.setting, input.state, String(input.latitude), String(input.longitude)]; + } + if (input.setting === 'permission') { + return [input.setting, input.state, input.permission, ...optionalString(input.mode)]; + } + return [input.setting, input.state]; +} + +function readPermission(value: string | undefined): PermissionTarget { + if (isOneOf(value, PERMISSION_TARGETS)) return value; + throw new AppError('INVALID_ARGS', 'settings permission requires a permission target.'); +} + +function readPermissionMode(value: string | undefined): 'full' | 'limited' | undefined { + if (value === undefined || value === 'full' || value === 'limited') return value; + throw new AppError('INVALID_ARGS', 'settings permission mode must be full or limited.'); +} + +type PermissionTarget = Extract['permission']; +type OnOffSetting = Extract['setting']; +type OnOffState = Extract['state']; +type BiometricSetting = Extract< + SettingsUpdateOptions, + { setting: 'faceid' | 'touchid' } +>['setting']; +type BiometricState = Extract['state']; +type FingerprintState = Extract['state']; +type AppearanceState = Extract['state']; +type PermissionState = Extract['state']; + +const ON_OFF_SETTINGS = setOf('wifi', 'airplane', 'location', 'animations'); +const ON_OFF_STATES = setOf('on', 'off'); +const APPEARANCE_STATES = setOf('light', 'dark', 'toggle'); +const BIOMETRIC_SETTINGS = setOf('faceid', 'touchid'); +const BIOMETRIC_STATES = setOf('match', 'nonmatch', 'enroll', 'unenroll'); +const FINGERPRINT_STATES = setOf('match', 'nonmatch'); +const PERMISSION_STATES = setOf('grant', 'deny', 'reset'); +const PERMISSION_TARGETS = setOf( + 'camera', + 'microphone', + 'photos', + 'contacts', + 'contacts-limited', + 'notifications', + 'calendar', + 'location', + 'location-always', + 'media-library', + 'motion', + 'reminders', + 'siri', + 'accessibility', + 'screen-recording', + 'input-monitoring', +); diff --git a/src/commands/cli-grammar/apps.ts b/src/commands/cli-grammar/apps.ts deleted file mode 100644 index 55e97cea1..000000000 --- a/src/commands/cli-grammar/apps.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { INTERNAL_COMMANDS, PUBLIC_COMMANDS } from '../../command-catalog.ts'; -import type { AppPushOptions, AppTriggerEventOptions } from '../../client-types.ts'; -import type { CliFlags } from '../../utils/cli-flags.ts'; -import { AppError } from '../../utils/errors.ts'; -import { parseGitHubActionsArtifactInstallSourceSpec } from '../../utils/install-source-config.ts'; -import { assertResolvedAppsFilter } from '../app-inventory-contract.ts'; -import { - commonInputFromFlags, - direct, - optionalString, - readJsonObject, - request, - requiredDaemonString, - requiredString, -} from './common.ts'; -import type { CliReader, DaemonWriter, CommandInput } from './types.ts'; - -export const appCliReaders = { - devices: (_positionals, flags) => commonInputFromFlags(flags), - apps: (_positionals, flags) => ({ - ...commonInputFromFlags(flags), - appsFilter: assertResolvedAppsFilter(flags.appsFilter), - }), - session: (positionals, flags) => ({ - ...commonInputFromFlags(flags), - action: readSessionAction(positionals[0]), - }), - boot: (_positionals, flags) => ({ - ...commonInputFromFlags(flags), - headless: flags.headless, - }), - shutdown: (_positionals, flags) => commonInputFromFlags(flags), - prepare: (positionals, flags) => ({ - ...commonInputFromFlags(flags), - action: requiredString(positionals[0], 'prepare requires subcommand'), - timeoutMs: flags.timeoutMs, - }), - open: (positionals, flags) => ({ - ...commonInputFromFlags(flags), - app: positionals[0], - url: positionals[1], - surface: flags.surface, - activity: flags.activity, - launchConsole: flags.launchConsole, - launchArgs: flags.launchArgs, - relaunch: flags.relaunch, - saveScript: flags.saveScript, - deviceHub: flags.deviceHub, - noRecord: flags.noRecord, - }), - close: (positionals, flags) => ({ - ...commonInputFromFlags(flags), - app: positionals[0], - shutdown: flags.shutdown, - saveScript: flags.saveScript, - }), - install: installInputFromCli, - reinstall: installInputFromCli, - 'install-from-source': (positionals, flags) => ({ - ...commonInputFromFlags(flags), - source: resolveInstallSource(positionals, flags), - retainPaths: flags.retainPaths, - retentionMs: flags.retentionMs, - }), - push: (positionals, flags) => ({ - ...commonInputFromFlags(flags), - app: requiredString(positionals[0], 'push requires bundleOrPackage'), - payload: requiredString(positionals[1], 'push requires payloadOrJson'), - }), - 'trigger-app-event': (positionals, flags) => ({ - ...commonInputFromFlags(flags), - event: requiredString(positionals[0], 'trigger-app-event requires event'), - payload: positionals[1] - ? readJsonObject(positionals[1], 'trigger-app-event payload') - : undefined, - }), -} satisfies Record; - -export const appDaemonWriters = { - devices: direct(PUBLIC_COMMANDS.devices), - boot: direct(PUBLIC_COMMANDS.boot), - shutdown: direct(PUBLIC_COMMANDS.shutdown), - prepare: direct(PUBLIC_COMMANDS.prepare, (input) => [ - requiredDaemonString(input.action, 'prepare requires subcommand'), - ]), - apps: direct(PUBLIC_COMMANDS.apps), - open: direct(PUBLIC_COMMANDS.open, openPositionals), - close: direct(PUBLIC_COMMANDS.close, (input) => optionalString(input.app)), - install: direct(PUBLIC_COMMANDS.install, (input) => requiredPair(input.app, input.appPath)), - reinstall: direct(PUBLIC_COMMANDS.reinstall, (input) => requiredPair(input.app, input.appPath)), - 'install-from-source': (input) => - request(INTERNAL_COMMANDS.installSource, [], { - ...input, - installSource: input.source, - retainMaterializedPaths: input.retainPaths, - materializedPathRetentionMs: input.retentionMs, - }), - push: direct(PUBLIC_COMMANDS.push, (input) => pushPositionals(input as AppPushOptions)), - 'trigger-app-event': direct(PUBLIC_COMMANDS.triggerAppEvent, (input) => - triggerEventPositionals(input as AppTriggerEventOptions), - ), -} satisfies Record; - -function installInputFromCli( - positionals: string[], - flags: CliFlags, - command = 'install', -): Record { - return { - ...commonInputFromFlags(flags), - app: requiredString(positionals[0], `${command} requires app`), - appPath: requiredString(positionals[1], `${command} requires path`), - }; -} - -function readSessionAction(value: string | undefined): 'list' | 'state-dir' { - const action = value ?? 'list'; - if (action === 'list') return action; - if (action === 'state-dir') return action; - throw new AppError('INVALID_ARGS', 'session only supports list or state-dir'); -} - -function openPositionals(input: CommandInput): string[] { - if (!input.app) return []; - return input.url ? [input.app, input.url] : [input.app]; -} - -function requiredPair(first: unknown, second: unknown): string[] { - return [ - requiredDaemonString(first, 'missing first positional'), - requiredDaemonString(second, 'missing second positional'), - ]; -} - -function pushPositionals(input: AppPushOptions): string[] { - return [ - input.app, - typeof input.payload === 'string' ? input.payload : JSON.stringify(input.payload), - ]; -} - -function triggerEventPositionals(input: AppTriggerEventOptions): string[] { - return [input.event, ...(input.payload ? [JSON.stringify(input.payload)] : [])]; -} - -// fallow-ignore-next-line complexity -function resolveInstallSource(positionals: string[], flags: CliFlags) { - const url = positionals[0]?.trim(); - if (positionals.length > 1) { - throw new AppError( - 'INVALID_ARGS', - 'install-from-source accepts either one positional or --github-actions-artifact', - ); - } - const githubArtifactSource = flags.githubActionsArtifact - ? parseGitHubActionsArtifactInstallSourceSpec(flags.githubActionsArtifact) - : undefined; - const configuredSource = flags.installSource; - const sourceCount = (url ? 1 : 0) + (githubArtifactSource ? 1 : 0) + (configuredSource ? 1 : 0); - if (sourceCount !== 1) { - throw new AppError( - 'INVALID_ARGS', - 'install-from-source requires exactly one source: , --github-actions-artifact, or config installSource', - ); - } - if (!url && flags.header && flags.header.length > 0) { - throw new AppError( - 'INVALID_ARGS', - 'install-from-source --header is only supported for URL sources', - ); - } - if (githubArtifactSource) return githubArtifactSource; - if (configuredSource) return configuredSource; - return { - kind: 'url' as const, - url: url!, - headers: parseInstallSourceHeaders(flags.header), - }; -} - -function parseInstallSourceHeaders( - headerFlags: CliFlags['header'], -): Record | undefined { - if (!headerFlags || headerFlags.length === 0) return undefined; - const headers: Record = {}; - for (const rawHeader of headerFlags) { - const separator = rawHeader.indexOf(':'); - if (separator <= 0) { - throw new AppError( - 'INVALID_ARGS', - `Invalid --header value "${rawHeader}". Expected "name:value".`, - ); - } - const name = rawHeader.slice(0, separator).trim(); - const value = rawHeader.slice(separator + 1).trim(); - if (!name) { - throw new AppError( - 'INVALID_ARGS', - `Invalid --header value "${rawHeader}". Header name cannot be empty.`, - ); - } - headers[name] = value; - } - return headers; -} diff --git a/src/commands/cli-grammar/capture.ts b/src/commands/cli-grammar/capture.ts deleted file mode 100644 index 7812f0a05..000000000 --- a/src/commands/cli-grammar/capture.ts +++ /dev/null @@ -1,282 +0,0 @@ -import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; -import type { - AlertCommandOptions, - CaptureScreenshotOptions, - SettingsUpdateOptions, - WaitCommandOptions, -} from '../../client-types.ts'; -import type { AlertAction } from '../../alert-contract.ts'; -import { parseWaitPositionals } from '../../core/wait-positionals.ts'; -import type { CliFlags } from '../../utils/cli-flags.ts'; -import { AppError } from '../../utils/errors.ts'; -import { readLocationCoordinate } from '../../utils/location-coordinates.ts'; -import { tryParseSelectorChain } from '../../utils/selectors-parse.ts'; -import { - screenshotFlagsFromOptions, - screenshotOptionsFromFlags, -} from '../capture-screenshot-options.ts'; -import { compactRecord } from '../command-input.ts'; -import { - commonInputFromFlags, - direct, - isOneOf, - optionalNumber, - optionalString, - readFiniteNumber, - request, - requiredDaemonString, - selectionOptionsFromFlags, - selectorSnapshotOptionsFromFlags, - setOf, -} from './common.ts'; -import type { CliReader, DaemonWriter } from './types.ts'; - -export const captureCliReaders = { - snapshot: (_positionals, flags) => ({ - ...commonInputFromFlags(flags), - interactiveOnly: flags.snapshotInteractiveOnly, - compact: flags.snapshotCompact, - depth: flags.snapshotDepth, - scope: flags.snapshotScope, - raw: flags.snapshotRaw, - forceFull: flags.snapshotForceFull, - timeoutMs: flags.timeoutMs, - }), - screenshot: (positionals, flags) => ({ - ...commonInputFromFlags(flags), - path: positionals[0] ?? flags.out, - ...screenshotOptionsFromFlags(flags), - }), - diff: (positionals, flags) => { - if (positionals[0] !== 'snapshot') { - throw new AppError('INVALID_ARGS', 'Only diff snapshot is available through this parser.'); - } - return { - ...commonInputFromFlags(flags), - kind: 'snapshot', - out: flags.out, - interactiveOnly: flags.snapshotInteractiveOnly, - compact: flags.snapshotCompact, - depth: flags.snapshotDepth, - scope: flags.snapshotScope, - raw: flags.snapshotRaw, - }; - }, - wait: (positionals, flags) => readWaitOptionsFromPositionals(positionals, flags), - alert: (positionals, flags) => ({ - ...commonInputFromFlags(flags), - ...readAlertInput(positionals), - }), - settings: (positionals, flags) => readSettingsOptionsFromPositionals(positionals, flags), -} satisfies Record; - -export const captureDaemonWriters = { - snapshot: direct(PUBLIC_COMMANDS.snapshot), - screenshot: (input) => - request(PUBLIC_COMMANDS.screenshot, optionalString(input.path), { - ...input, - ...screenshotFlagsFromOptions(input as CaptureScreenshotOptions), - }), - diff: direct(PUBLIC_COMMANDS.diff, (input) => [ - requiredDaemonString(input.kind, 'diff requires kind'), - ]), - wait: direct(PUBLIC_COMMANDS.wait, (input) => waitPositionals(input as WaitCommandOptions)), - alert: direct(PUBLIC_COMMANDS.alert, (input) => alertPositionals(input as AlertCommandOptions)), - settings: direct(PUBLIC_COMMANDS.settings, (input) => - settingsPositionals(input as SettingsUpdateOptions), - ), -} satisfies Record; - -function readWaitOptionsFromPositionals( - positionals: string[], - flags: CliFlags, -): WaitCommandOptions { - const parsed = parseWaitPositionals(positionals); - if (!parsed) { - throw new AppError( - 'INVALID_ARGS', - 'wait requires , text , @ref, or [timeoutMs].', - ); - } - const base = { - ...selectionOptionsFromFlags(flags), - ...selectorSnapshotOptionsFromFlags(flags), - }; - if (parsed.kind === 'sleep') return { ...base, durationMs: parsed.durationMs }; - if (parsed.kind === 'text') { - if (!parsed.text) throw new AppError('INVALID_ARGS', 'wait requires text.'); - return { ...base, text: parsed.text, ...readTimeoutOption(parsed.timeoutMs) }; - } - if (parsed.kind === 'ref') { - return { ...base, ref: parsed.rawRef, ...readTimeoutOption(parsed.timeoutMs) }; - } - return { - ...base, - selector: parsed.selectorExpression, - ...readTimeoutOption(parsed.timeoutMs), - }; -} - -export { parseWaitPositionals }; - -// fallow-ignore-next-line complexity -function waitPositionals(options: WaitCommandOptions): string[] { - const targets = [ - options.durationMs !== undefined ? 'durationMs' : undefined, - options.text !== undefined ? 'text' : undefined, - options.ref !== undefined ? 'ref' : undefined, - options.selector !== undefined ? 'selector' : undefined, - ].filter(Boolean); - if (targets.length !== 1) { - throw new AppError( - 'INVALID_ARGS', - 'wait command requires exactly one of durationMs, text, ref, or selector.', - ); - } - if (options.durationMs !== undefined) return [String(options.durationMs)]; - const timeout = optionalNumber(options.timeoutMs); - if (options.text !== undefined) return ['text', options.text, ...timeout]; - if (options.ref !== undefined) return [options.ref, ...timeout]; - const selector = options.selector!; - if (!tryParseSelectorChain(selector)) { - throw new AppError('INVALID_ARGS', `Invalid wait selector: ${selector}`); - } - return [selector, ...timeout]; -} - -function alertPositionals(input: AlertCommandOptions): string[] { - return [input.action ?? 'get', ...optionalNumber(input.timeoutMs)]; -} - -function readAlertInput(positionals: string[]): Record { - if (positionals.length > 2) { - throw new AppError('INVALID_ARGS', 'alert accepts at most action and timeout arguments.'); - } - const action = readAlertAction(positionals[0]); - const timeoutMs = readFiniteNumber(positionals[1], 'alert timeout'); - return compactRecord({ action, timeoutMs }); -} - -function readAlertAction(value: string | undefined): AlertAction | undefined { - const action = value?.toLowerCase(); - if ( - action === undefined || - action === 'get' || - action === 'accept' || - action === 'dismiss' || - action === 'wait' - ) { - return action; - } - throw new AppError('INVALID_ARGS', 'alert action must be get, accept, dismiss, or wait.'); -} - -function readTimeoutOption(timeoutMs: number | null): { timeoutMs?: number } { - return timeoutMs === null ? {} : { timeoutMs }; -} - -// fallow-ignore-next-line complexity -function readSettingsOptionsFromPositionals( - positionals: string[], - flags: CliFlags, -): SettingsUpdateOptions { - const base = selectionOptionsFromFlags(flags); - const setting = positionals[0]; - const state = positionals[1]; - if (isOneOf(setting, ON_OFF_SETTINGS) && isOneOf(state, ON_OFF_STATES)) { - return { ...base, setting, state }; - } - if (setting === 'location' && state === 'set') { - return { - ...base, - setting, - state, - latitude: readLocationCoordinate(positionals[2], 'latitude'), - longitude: readLocationCoordinate(positionals[3], 'longitude'), - }; - } - if (setting === 'appearance' && isOneOf(state, APPEARANCE_STATES)) { - return { ...base, setting, state }; - } - if (isOneOf(setting, BIOMETRIC_SETTINGS) && isOneOf(state, BIOMETRIC_STATES)) { - return { ...base, setting, state }; - } - if (setting === 'fingerprint' && isOneOf(state, FINGERPRINT_STATES)) { - return { ...base, setting, state }; - } - if (setting === 'permission' && isOneOf(state, PERMISSION_STATES)) { - return { - ...base, - setting, - state, - permission: readPermission(positionals[2]), - mode: readPermissionMode(positionals[3]), - }; - } - if (setting === 'clear-app-state') { - const app = state === 'clear' ? positionals[2] : state; - return { ...base, setting, state: 'clear', app }; - } - throw new AppError('INVALID_ARGS', 'Invalid settings arguments.'); -} - -function settingsPositionals(input: SettingsUpdateOptions): string[] { - if (input.setting === 'clear-app-state') { - return [input.setting, ...optionalString(input.app)]; - } - if (input.setting === 'location' && input.state === 'set') { - return [input.setting, input.state, String(input.latitude), String(input.longitude)]; - } - if (input.setting === 'permission') { - return [input.setting, input.state, input.permission, ...optionalString(input.mode)]; - } - return [input.setting, input.state]; -} - -function readPermission(value: string | undefined): PermissionTarget { - if (isOneOf(value, PERMISSION_TARGETS)) return value; - throw new AppError('INVALID_ARGS', 'settings permission requires a permission target.'); -} - -function readPermissionMode(value: string | undefined): 'full' | 'limited' | undefined { - if (value === undefined || value === 'full' || value === 'limited') return value; - throw new AppError('INVALID_ARGS', 'settings permission mode must be full or limited.'); -} - -type PermissionTarget = Extract['permission']; -type OnOffSetting = Extract['setting']; -type OnOffState = Extract['state']; -type BiometricSetting = Extract< - SettingsUpdateOptions, - { setting: 'faceid' | 'touchid' } ->['setting']; -type BiometricState = Extract['state']; -type FingerprintState = Extract['state']; -type AppearanceState = Extract['state']; -type PermissionState = Extract['state']; - -const ON_OFF_SETTINGS = setOf('wifi', 'airplane', 'location', 'animations'); -const ON_OFF_STATES = setOf('on', 'off'); -const APPEARANCE_STATES = setOf('light', 'dark', 'toggle'); -const BIOMETRIC_SETTINGS = setOf('faceid', 'touchid'); -const BIOMETRIC_STATES = setOf('match', 'nonmatch', 'enroll', 'unenroll'); -const FINGERPRINT_STATES = setOf('match', 'nonmatch'); -const PERMISSION_STATES = setOf('grant', 'deny', 'reset'); -const PERMISSION_TARGETS = setOf( - 'camera', - 'microphone', - 'photos', - 'contacts', - 'contacts-limited', - 'notifications', - 'calendar', - 'location', - 'location-always', - 'media-library', - 'motion', - 'reminders', - 'siri', - 'accessibility', - 'screen-recording', - 'input-monitoring', -); diff --git a/src/commands/cli-grammar/metro.ts b/src/commands/cli-grammar/metro.ts deleted file mode 100644 index cd2c6f740..000000000 --- a/src/commands/cli-grammar/metro.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { AppError } from '../../utils/errors.ts'; -import type { MetroPrepareKind } from '../../client-metro.ts'; -import type { CliReader } from './types.ts'; - -export const metroCliReaders = { - metro: metroInputFromCli, -} satisfies Record; - -// fallow-ignore-next-line complexity -function metroInputFromCli(positionals: string[], flags: Parameters[1]) { - const action = (positionals[0] ?? '').toLowerCase(); - if (action !== 'prepare' && action !== 'reload') { - throw new AppError('INVALID_ARGS', 'metro requires a subcommand: prepare or reload'); - } - if (action === 'reload') { - return { - action, - metroHost: flags.metroHost, - metroPort: flags.metroPort, - bundleUrl: flags.bundleUrl, - timeoutMs: flags.metroProbeTimeoutMs, - }; - } - if (!flags.metroPublicBaseUrl && !flags.metroProxyBaseUrl) { - throw new AppError( - 'INVALID_ARGS', - 'metro prepare requires --public-base-url or --proxy-base-url .', - ); - } - return { - action, - projectRoot: flags.metroProjectRoot, - kind: readMetroPrepareKind(flags.kind ?? flags.metroKind), - port: flags.metroPreparePort, - listenHost: flags.metroListenHost, - statusHost: flags.metroStatusHost, - publicBaseUrl: flags.metroPublicBaseUrl, - proxyBaseUrl: flags.metroProxyBaseUrl, - bearerToken: flags.metroBearerToken, - bridgeScope: - flags.tenant && flags.runId && flags.leaseId - ? { - tenantId: flags.tenant, - runId: flags.runId, - leaseId: flags.leaseId, - } - : undefined, - startupTimeoutMs: flags.metroStartupTimeoutMs, - probeTimeoutMs: flags.metroProbeTimeoutMs, - reuseExisting: flags.metroNoReuseExisting ? false : undefined, - installDependenciesIfNeeded: flags.metroNoInstallDeps ? false : undefined, - runtimeFilePath: flags.metroRuntimeFile, - }; -} - -function readMetroPrepareKind(value: string | undefined): MetroPrepareKind | undefined { - if (value === undefined) return undefined; - if (value === 'auto' || value === 'react-native' || value === 'expo') return value; - throw new AppError('INVALID_ARGS', 'metro prepare --kind must be auto, react-native, or expo'); -} diff --git a/src/commands/cli-grammar/observability.ts b/src/commands/cli-grammar/observability.ts deleted file mode 100644 index 02f77f17b..000000000 --- a/src/commands/cli-grammar/observability.ts +++ /dev/null @@ -1,242 +0,0 @@ -import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; -import type { - LogsOptions, - NetworkOptions, - PerfOptions, - RecordOptions, -} from '../../client-types.ts'; -import { AppError } from '../../utils/errors.ts'; -import { NETWORK_INCLUDE_MODES, type NetworkIncludeMode } from '../../contracts.ts'; -import { parseStringMember } from '../../utils/string-enum.ts'; -import { LOG_ACTION_VALUES, type LogAction } from '../log-command-contract.ts'; -import { - isPerfAction, - isPerfArea, - isPerfKind, - isPerfSubject, - PERF_ACTION_ERROR_MESSAGE, - PERF_AREA_ERROR_MESSAGE, - PERF_KIND_ERROR_MESSAGE, - PERF_SUBJECT_ERROR_MESSAGE, - type PerfAction, - type PerfArea, - type PerfKind, - type PerfSubject, -} from '../perf-command-contract.ts'; -import { - commonInputFromFlags, - direct, - optionalCliNumber, - optionalNumber, - optionalString, - request, -} from './common.ts'; -import type { CliReader, DaemonWriter } from './types.ts'; - -export const observabilityCliReaders = { - debug: (positionals, flags) => ({ - ...commonInputFromFlags(flags), - action: readDebugAction(positionals[0]), - artifact: flags.artifact, - dsym: flags.dsym, - searchPath: flags.searchPath, - out: flags.out, - }), - perf: (positionals, flags) => ({ - ...commonInputFromFlags(flags), - ...readPerfPositionals(positionals, { - kind: readPerfKindFlag(flags.kind), - template: flags.perfTemplate, - out: flags.out, - }), - }), - logs: (positionals, flags) => ({ - ...commonInputFromFlags(flags), - action: readLogsAction(positionals[0]), - message: positionals.slice(1).join(' ') || undefined, - restart: flags.restart, - }), - network: (positionals, flags) => ({ - ...commonInputFromFlags(flags), - action: readNetworkAction(positionals[0]), - limit: optionalCliNumber(positionals[1]), - include: flags.networkInclude ?? readNetworkInclude(positionals[2]), - }), - record: (positionals, flags) => ({ - ...commonInputFromFlags(flags), - action: readStartStop(positionals[0], 'record'), - path: positionals[1], - fps: flags.fps, - quality: flags.quality as RecordOptions['quality'], - hideTouches: flags.hideTouches, - }), - trace: (positionals, flags) => ({ - ...commonInputFromFlags(flags), - action: readStartStop(positionals[0], 'trace'), - path: positionals[1], - }), -} satisfies Record; - -export const observabilityDaemonWriters = { - perf: direct(PUBLIC_COMMANDS.perf, (input) => perfPositionals(input as PerfOptions)), - logs: direct(PUBLIC_COMMANDS.logs, (input) => logsPositionals(input as LogsOptions)), - network: (input) => - request(PUBLIC_COMMANDS.network, networkPositionals(input as NetworkOptions), { - ...input, - networkInclude: input.include, - }), - record: direct(PUBLIC_COMMANDS.record, (input) => recordingPositionals(input as RecordOptions)), - trace: direct(PUBLIC_COMMANDS.trace, (input) => recordingPositionals(input as RecordOptions)), -} satisfies Record; - -function perfPositionals(input: PerfOptions): string[] { - const area = input.area ?? (input.action ? 'metrics' : undefined); - if (area === 'cpu') { - return nativePerfPositionals( - [ - ...optionalString(area), - ...optionalString(input.subject), - ...optionalString(input.action), - ...optionalString(input.kind), - ], - input, - ); - } - if (area === 'trace') { - return nativePerfPositionals( - [...optionalString(area), ...optionalString(input.action), ...optionalString(input.kind)], - input, - ); - } - return [...optionalString(area), ...optionalString(input.action)]; -} - -function nativePerfPositionals(base: string[], input: PerfOptions): string[] { - const positionals = [...base]; - if (input.template || input.out || input.tracePath) { - positionals.push(input.template ?? ''); - } - if (input.out || input.tracePath) { - positionals.push(input.out ?? ''); - } - if (input.tracePath) { - positionals.push(input.tracePath); - } - return positionals; -} - -function readPerfPositionals( - positionals: string[], - flags: Pick = {}, -): Pick { - if (positionals[0] !== undefined && positionals[1] === undefined) { - const action = readPerfAction(positionals[0], { allowUndefined: true }); - if (action) return { action, kind: readPerfKind(flags.kind), out: flags.out }; - } - const area = readPerfArea(positionals[0]); - if (area === 'cpu') { - return { - area, - subject: readPerfSubject(positionals[1]), - action: readPerfAction(positionals[2]), - kind: readPerfKind(flags.kind), - template: flags.template, - out: flags.out, - }; - } - if (area === 'trace') { - return { - area, - action: readPerfAction(positionals[1]), - kind: readPerfKind(flags.kind), - template: flags.template, - out: flags.out, - }; - } - return { - area, - action: readPerfAction(positionals[1]), - kind: readPerfKind(flags.kind), - out: flags.out, - }; -} - -function logsPositionals(input: { action?: string; message?: string }): string[] { - return [input.action ?? 'path', ...optionalString(input.message)]; -} - -function networkPositionals(input: NetworkOptions): string[] { - return [...(input.action ? [input.action] : []), ...optionalNumber(input.limit)]; -} - -function recordingPositionals(input: RecordOptions): string[] { - return [input.action, ...optionalString(input.path)]; -} - -function readStartStop(value: string | undefined, command: string): 'start' | 'stop' { - if (value === 'start' || value === 'stop') return value; - throw new AppError('INVALID_ARGS', `${command} requires start|stop`); -} - -function readDebugAction(value: string | undefined): 'symbols' { - if (value === 'symbols') return value; - throw new AppError( - 'INVALID_ARGS', - 'debug supports only symbols; use logs, network, perf, record, trace, or react-devtools for other diagnostics.', - ); -} - -function readPerfArea(value: string | undefined): PerfArea | undefined { - if (value === undefined) return undefined; - const normalized = value.toLowerCase(); - if (isPerfArea(normalized)) return normalized; - throw new AppError('INVALID_ARGS', PERF_AREA_ERROR_MESSAGE); -} - -function readPerfAction( - value: string | undefined, - options: { allowUndefined?: boolean } = {}, -): PerfAction | undefined { - if (value === undefined) return undefined; - const normalized = value.toLowerCase(); - if (isPerfAction(normalized)) return normalized; - if (options.allowUndefined) return undefined; - throw new AppError('INVALID_ARGS', PERF_ACTION_ERROR_MESSAGE); -} - -function readPerfSubject(value: string | undefined): PerfSubject { - const normalized = value?.toLowerCase(); - if (normalized !== undefined && isPerfSubject(normalized)) return normalized; - throw new AppError('INVALID_ARGS', PERF_SUBJECT_ERROR_MESSAGE); -} - -function readPerfKind(value: string | undefined): PerfKind | undefined { - if (value === undefined) return undefined; - const normalized = value.toLowerCase(); - if (isPerfKind(normalized)) return normalized; - throw new AppError('INVALID_ARGS', PERF_KIND_ERROR_MESSAGE); -} - -function readPerfKindFlag(value: unknown): PerfKind | undefined { - return typeof value === 'string' ? readPerfKind(value) : undefined; -} - -function readLogsAction(value: string | undefined): LogAction | undefined { - if (value === undefined) return undefined; - return parseStringMember(LOG_ACTION_VALUES, value, { - message: 'logs requires path, start, stop, doctor, mark, or clear', - }); -} - -function readNetworkAction(value: string | undefined): 'dump' | 'log' | undefined { - if (value === undefined) return undefined; - if (value === 'dump' || value === 'log') return value; - throw new AppError('INVALID_ARGS', 'network requires dump or log'); -} - -function readNetworkInclude(value: string | undefined): NetworkIncludeMode | undefined { - if (value === undefined) return undefined; - return parseStringMember(NETWORK_INCLUDE_MODES, value, { - message: 'network include mode must be summary, headers, body, or all', - }); -} diff --git a/src/commands/cli-grammar/registry.ts b/src/commands/cli-grammar/registry.ts index 99ad268ba..fb7e8bab1 100644 --- a/src/commands/cli-grammar/registry.ts +++ b/src/commands/cli-grammar/registry.ts @@ -1,24 +1,30 @@ import type { CliFlags } from '../../utils/cli-flags.ts'; -import { appCliReaders } from './apps.ts'; -import { captureCliReaders } from './capture.ts'; +import { captureCliReaders } from '../capture/index.ts'; import { commonInputFromFlags } from './common.ts'; -import { gestureCliReaders } from './gesture.ts'; -import { interactionCliReaders } from './interactions.ts'; -import { metroCliReaders } from './metro.ts'; -import { observabilityCliReaders } from './observability.ts'; -import { replayCliReaders } from './replay.ts'; -import { selectorCliReaders } from './selectors.ts'; -import { systemCliReaders } from './system.ts'; import type { CliReader } from './types.ts'; import type { CommandName } from '../command-metadata.ts'; +import { + gestureCliReaders, + interactionCliReaders, + selectorCliReaders as interactionSelectorCliReaders, +} from '../interaction/index.ts'; +import { appCliReaders } from '../management/index.ts'; +import { metroCliReaders } from '../metro/index.ts'; +import { observabilityCliReaders } from '../observability/index.ts'; +import { reactNativeCliReaders } from '../react-native/index.ts'; +import { recordingCliReaders } from '../recording/index.ts'; +import { replayCliReaders } from '../replay/index.ts'; +import { systemCliReaders } from '../system/index.ts'; const cliReaders = { ...appCliReaders, ...captureCliReaders, ...interactionCliReaders, ...gestureCliReaders, - ...selectorCliReaders, + ...interactionSelectorCliReaders, ...observabilityCliReaders, + ...reactNativeCliReaders, + ...recordingCliReaders, ...replayCliReaders, ...systemCliReaders, ...metroCliReaders, diff --git a/src/commands/cli-grammar/replay.ts b/src/commands/cli-grammar/replay.ts deleted file mode 100644 index bc963f3a7..000000000 --- a/src/commands/cli-grammar/replay.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; -import { commonInputFromFlags, request, requiredDaemonString, requiredString } from './common.ts'; -import type { CliReader, CommandInput, DaemonWriter } from './types.ts'; - -export const replayCliReaders = { - replay: (positionals, flags) => ({ - ...commonInputFromFlags(flags), - path: requiredString(positionals[0], 'replay requires path'), - update: flags.replayUpdate, - backend: flags.replayMaestro ? 'maestro' : undefined, - env: flags.replayEnv, - }), - test: (positionals, flags) => ({ - ...commonInputFromFlags(flags), - paths: positionals, - update: flags.replayUpdate, - backend: flags.replayMaestro ? 'maestro' : undefined, - env: flags.replayEnv, - failFast: flags.failFast, - timeoutMs: flags.timeoutMs, - retries: flags.retries, - recordVideo: flags.recordVideo, - artifactsDir: flags.artifactsDir, - reportJunit: flags.reportJunit, - shardAll: flags.shardAll, - shardSplit: flags.shardSplit, - }), -} satisfies Record; - -export const replayDaemonWriters = { - replay: (input) => - request(PUBLIC_COMMANDS.replay, [requiredDaemonString(input.path, 'replay requires path')], { - ...input, - replayUpdate: input.update, - replayBackend: readReplayBackend(input), - replayEnv: input.env, - replayShellEnv: collectReplayClientShellEnv(process.env), - }), - test: (input) => - request(PUBLIC_COMMANDS.test, input.paths ?? [], { - ...input, - replayUpdate: input.update, - replayBackend: readReplayBackend(input), - replayEnv: input.env, - replayShellEnv: collectReplayClientShellEnv(process.env), - }), -} satisfies Record; - -const REPLAY_SHELL_ENV_PREFIX = 'AD_VAR_'; - -function readReplayBackend(input: CommandInput): string | undefined { - return input.backend ?? (input.maestro === true ? 'maestro' : undefined); -} - -function collectReplayClientShellEnv(env: NodeJS.ProcessEnv): Record { - const result: Record = {}; - for (const [key, value] of Object.entries(env)) { - if (typeof value === 'string' && key.startsWith(REPLAY_SHELL_ENV_PREFIX)) result[key] = value; - } - return result; -} diff --git a/src/commands/cli-grammar/system.test.ts b/src/commands/cli-grammar/system.test.ts deleted file mode 100644 index 4d7508974..000000000 --- a/src/commands/cli-grammar/system.test.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { describe, expect, test } from 'vitest'; -import type { CliFlags } from '../../utils/cli-flags.ts'; -import type { CommandInput } from './types.ts'; -import { systemCliReaders, systemDaemonWriters } from './system.ts'; - -function flags(overrides: Partial = {}): CliFlags { - return overrides as CliFlags; -} - -function expectInvalidArgs(fn: () => unknown, messageFragment: string) { - expect(fn).toThrow( - expect.objectContaining({ - code: 'INVALID_ARGS', - message: expect.stringContaining(messageFragment), - }), - ); -} - -describe('system CLI readers', () => { - test('the parameterless readers project the common selection flags through', () => { - for (const command of ['appstate', 'home', 'app-switcher'] as const) { - expect(systemCliReaders[command]([], flags({ platform: 'ios' }))).toEqual({ - platform: 'ios', - }); - } - }); - - test('back reader forwards the configured back mode', () => { - expect(systemCliReaders.back([], flags({ backMode: 'system' }))).toMatchObject({ - mode: 'system', - }); - }); - - test('rotate reader normalizes the orientation argument', () => { - expect(systemCliReaders.rotate(['left'], flags())).toMatchObject({ - orientation: 'landscape-left', - }); - }); - - test('rotate reader rejects a missing orientation', () => { - expectInvalidArgs(() => systemCliReaders.rotate([], flags()), 'rotate requires an orientation'); - }); - - describe('keyboard reader', () => { - test('maps the "get" alias to the status action', () => { - expect(systemCliReaders.keyboard(['get'], flags())).toMatchObject({ action: 'status' }); - }); - - test('omits the action entirely when no argument is given', () => { - expect(systemCliReaders.keyboard([], flags())).not.toHaveProperty('action'); - }); - - test('rejects more than one keyboard argument', () => { - expectInvalidArgs( - () => systemCliReaders.keyboard(['dismiss', 'extra'], flags()), - 'at most one action argument', - ); - }); - - test('rejects an unknown keyboard action', () => { - expectInvalidArgs( - () => systemCliReaders.keyboard(['wiggle'], flags()), - 'keyboard action must be', - ); - }); - }); - - describe('clipboard reader', () => { - test('parses a read subcommand', () => { - expect(systemCliReaders.clipboard(['read'], flags())).toMatchObject({ action: 'read' }); - }); - - test('joins multi-word text for a write subcommand', () => { - expect(systemCliReaders.clipboard(['write', 'hello', 'world'], flags())).toMatchObject({ - action: 'write', - text: 'hello world', - }); - }); - - test('rejects a missing subcommand', () => { - expectInvalidArgs(() => systemCliReaders.clipboard([], flags()), 'read or write'); - }); - - test('rejects extra arguments after read', () => { - expectInvalidArgs( - () => systemCliReaders.clipboard(['read', 'oops'], flags()), - 'does not accept additional arguments', - ); - }); - - test('rejects a write without any text', () => { - expectInvalidArgs( - () => systemCliReaders.clipboard(['write'], flags()), - 'clipboard write requires text', - ); - }); - }); - - describe('react-native reader', () => { - test('accepts the dismiss-overlay action', () => { - expect(systemCliReaders['react-native'](['dismiss-overlay'], flags())).toMatchObject({ - action: 'dismiss-overlay', - }); - }); - - test('rejects any other react-native action', () => { - expectInvalidArgs( - () => systemCliReaders['react-native'](['reload'], flags()), - 'react-native supports only', - ); - }); - }); -}); - -describe('system daemon writers', () => { - test('the direct writers emit their command with no positionals', () => { - for (const command of ['appstate', 'home', 'app-switcher'] as const) { - const request = systemDaemonWriters[command]({} as CommandInput); - expect(request.command).toBe(command); - expect(request.positionals).toEqual([]); - } - }); - - test('back writer keeps recognized back modes', () => { - expect(systemDaemonWriters.back({ mode: 'in-app' } as CommandInput).options).toMatchObject({ - backMode: 'in-app', - }); - }); - - test('back writer drops an unrecognized back mode', () => { - const options = systemDaemonWriters.back({ mode: 'teleport' } as unknown as CommandInput) - .options as Record; - expect(options.backMode).toBeUndefined(); - }); - - test('rotate writer serializes the orientation positional', () => { - expect( - systemDaemonWriters.rotate({ orientation: 'portrait' } as CommandInput).positionals, - ).toEqual(['portrait']); - }); - - test('rotate writer requires an orientation', () => { - expectInvalidArgs( - () => systemDaemonWriters.rotate({} as CommandInput), - 'rotate requires orientation', - ); - }); - - test('keyboard writer forwards the action when present and is empty otherwise', () => { - expect(systemDaemonWriters.keyboard({ action: 'dismiss' } as CommandInput).positionals).toEqual( - ['dismiss'], - ); - expect(systemDaemonWriters.keyboard({} as CommandInput).positionals).toEqual([]); - }); - - test('clipboard writer serializes read and write subcommands', () => { - expect(systemDaemonWriters.clipboard({ action: 'read' } as CommandInput).positionals).toEqual([ - 'read', - ]); - expect( - systemDaemonWriters.clipboard({ action: 'write', text: 'copied' } as CommandInput) - .positionals, - ).toEqual(['write', 'copied']); - }); - - test('react-native writer requires an action', () => { - expect( - systemDaemonWriters['react-native']({ action: 'dismiss-overlay' } as CommandInput) - .positionals, - ).toEqual(['dismiss-overlay']); - expectInvalidArgs( - () => systemDaemonWriters['react-native']({} as CommandInput), - 'react-native requires action', - ); - }); -}); diff --git a/src/commands/cli-grammar/system.ts b/src/commands/cli-grammar/system.ts deleted file mode 100644 index f77c6a089..000000000 --- a/src/commands/cli-grammar/system.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; -import type { ClipboardCommandOptions } from '../../client-types.ts'; -import { parseDeviceRotation } from '../../core/device-rotation.ts'; -import type { BackMode } from '../../core/back-mode.ts'; -import { AppError } from '../../utils/errors.ts'; -import { compactRecord } from '../command-input.ts'; -import { - commonInputFromFlags, - direct, - optionalString, - request, - requiredDaemonString, -} from './common.ts'; -import type { CliReader, DaemonWriter } from './types.ts'; - -export const systemCliReaders = { - appstate: (_positionals, flags) => commonInputFromFlags(flags), - home: (_positionals, flags) => commonInputFromFlags(flags), - 'app-switcher': (_positionals, flags) => commonInputFromFlags(flags), - back: (_positionals, flags) => ({ - ...commonInputFromFlags(flags), - mode: flags.backMode, - }), - rotate: (positionals, flags) => ({ - ...commonInputFromFlags(flags), - orientation: parseDeviceRotation(positionals[0]), - }), - keyboard: (positionals, flags) => ({ - ...commonInputFromFlags(flags), - ...readKeyboardInput(positionals), - }), - clipboard: (positionals, flags) => ({ - ...commonInputFromFlags(flags), - ...readClipboardInput(positionals), - }), - 'react-native': (positionals, flags) => ({ - ...commonInputFromFlags(flags), - action: readReactNativeAction(positionals[0]), - }), -} satisfies Record; - -export const systemDaemonWriters = { - appstate: direct(PUBLIC_COMMANDS.appState), - back: (input) => - request(PUBLIC_COMMANDS.back, [], { ...input, backMode: readBackMode(input.mode) }), - home: direct(PUBLIC_COMMANDS.home), - rotate: direct(PUBLIC_COMMANDS.rotate, (input) => [ - requiredDaemonString(input.orientation, 'rotate requires orientation'), - ]), - 'app-switcher': direct(PUBLIC_COMMANDS.appSwitcher), - keyboard: direct(PUBLIC_COMMANDS.keyboard, (input) => optionalString(input.action)), - clipboard: direct(PUBLIC_COMMANDS.clipboard, (input) => - clipboardPositionals(input as ClipboardCommandOptions), - ), - 'react-native': direct(PUBLIC_COMMANDS.reactNative, (input) => [ - requiredDaemonString(input.action, 'react-native requires action'), - ]), -} satisfies Record; - -function readBackMode(value: unknown): BackMode | undefined { - return value === 'in-app' || value === 'system' ? value : undefined; -} - -function clipboardPositionals(input: ClipboardCommandOptions): string[] { - return input.action === 'read' ? ['read'] : ['write', input.text]; -} - -function readKeyboardInput(positionals: string[]): Record { - if (positionals.length > 1) { - throw new AppError('INVALID_ARGS', 'keyboard accepts at most one action argument.'); - } - return compactRecord({ action: readKeyboardAction(positionals[0]) }); -} - -function readClipboardInput(positionals: string[]): Record { - const action = positionals[0]?.toLowerCase(); - if (action !== 'read' && action !== 'write') { - throw new AppError('INVALID_ARGS', 'clipboard requires a subcommand: read or write.'); - } - if (action === 'read') { - if (positionals.length !== 1) { - throw new AppError('INVALID_ARGS', 'clipboard read does not accept additional arguments.'); - } - return { action }; - } - if (positionals.length < 2) { - throw new AppError('INVALID_ARGS', 'clipboard write requires text.'); - } - return { action, text: positionals.slice(1).join(' ') }; -} - -function readKeyboardAction( - value: string | undefined, -): 'status' | 'dismiss' | 'enter' | 'return' | undefined { - const action = value?.toLowerCase(); - if (action === 'get') return 'status'; - if ( - action === undefined || - action === 'status' || - action === 'dismiss' || - action === 'enter' || - action === 'return' - ) { - return action; - } - throw new AppError( - 'INVALID_ARGS', - 'keyboard action must be status, get, dismiss, enter, or return.', - ); -} - -function readReactNativeAction(value: string | undefined): 'dismiss-overlay' { - if (value === 'dismiss-overlay') return value; - throw new AppError('INVALID_ARGS', 'react-native supports only: dismiss-overlay'); -} diff --git a/src/commands/client-command-contracts.ts b/src/commands/client-command-contracts.ts index 6adefe929..b91c0f5d5 100644 --- a/src/commands/client-command-contracts.ts +++ b/src/commands/client-command-contracts.ts @@ -1,150 +1,19 @@ -import type { - AppCloseOptions, - ClipboardCommandOptions, - MetroPrepareOptions, - MetroPrepareResult, - MetroReloadOptions, - MetroReloadResult, - RecordOptions, - SettingsUpdateOptions, - WaitCommandOptions, -} from '../client-types.ts'; -import { defineExecutableCommand } from './command-contract.ts'; -import { optionalEnum } from './command-input.ts'; -import { clientCommandMetadata } from './client-command-metadata.ts'; -import { WAIT_KIND_VALUES } from './wait-command-contract.ts'; - -type ClientCommandMetadata = (typeof clientCommandMetadata)[number]; -type ClientCommandName = ClientCommandMetadata['name']; -type MetroInput = { action: 'prepare' | 'reload' } & MetroPrepareOptions & MetroReloadOptions; +import { captureCommandDefinitions } from './capture/index.ts'; +import { managementCommandDefinitions } from './management/index.ts'; +import { metroCommandDefinition } from './metro/index.ts'; +import { observabilityCommandDefinitions } from './observability/index.ts'; +import { reactNativeCommandDefinition } from './react-native/index.ts'; +import { recordingCommandDefinitions } from './recording/index.ts'; +import { replayCommandDefinitions } from './replay/index.ts'; +import { systemCommandDefinitions } from './system/index.ts'; export const clientCommandDefinitions = [ - defineExecutableCommand(metadata('devices'), (client, input) => client.devices.list(input)), - defineExecutableCommand(metadata('boot'), (client, input) => client.devices.boot(input)), - defineExecutableCommand(metadata('shutdown'), (client, input) => client.devices.shutdown(input)), - defineExecutableCommand(metadata('apps'), (client, input) => client.apps.list(input)), - defineExecutableCommand(metadata('session'), async (client, { action, ...input }) => - action === 'state-dir' - ? { stateDir: await client.sessions.stateDir(input) } - : { sessions: await client.sessions.list(input) }, - ), - defineExecutableCommand(metadata('open'), (client, input) => client.apps.open(input)), - defineExecutableCommand(metadata('close'), (client, input) => - input.app ? client.apps.close(input) : client.sessions.close(withoutApp(input)), - ), - defineExecutableCommand(metadata('install'), (client, input) => client.apps.install(input)), - defineExecutableCommand(metadata('reinstall'), (client, input) => client.apps.reinstall(input)), - defineExecutableCommand(metadata('install-from-source'), (client, input) => - client.apps.installFromSource(input), - ), - defineExecutableCommand(metadata('push'), (client, input) => client.apps.push(input)), - defineExecutableCommand(metadata('trigger-app-event'), (client, input) => - client.apps.triggerEvent(input), - ), - defineExecutableCommand(metadata('snapshot'), (client, input) => client.capture.snapshot(input)), - defineExecutableCommand(metadata('screenshot'), (client, input) => - client.capture.screenshot(input), - ), - defineExecutableCommand(metadata('diff'), (client, input) => client.capture.diff(input)), - defineExecutableCommand(metadata('wait'), (client, input) => - client.command.wait(waitInputToOptions(input)), - ), - defineExecutableCommand(metadata('alert'), (client, input) => client.command.alert(input)), - defineExecutableCommand(metadata('appstate'), (client, input) => client.command.appState(input)), - defineExecutableCommand(metadata('back'), (client, input) => client.command.back(input)), - defineExecutableCommand(metadata('home'), (client, input) => client.command.home(input)), - defineExecutableCommand(metadata('rotate'), (client, input) => client.command.rotate(input)), - defineExecutableCommand(metadata('app-switcher'), (client, input) => - client.command.appSwitcher(input), - ), - defineExecutableCommand(metadata('keyboard'), (client, input) => client.command.keyboard(input)), - defineExecutableCommand(metadata('clipboard'), (client, input) => - client.command.clipboard(input as ClipboardCommandOptions), - ), - defineExecutableCommand(metadata('react-native'), (client, input) => - client.command.reactNative(input), - ), - defineExecutableCommand(metadata('prepare'), (client, input) => client.command.prepare(input)), - defineExecutableCommand(metadata('debug'), (client, input) => client.debug.symbols(input)), - defineExecutableCommand(metadata('replay'), (client, input) => client.replay.run(input)), - defineExecutableCommand(metadata('test'), (client, input) => client.replay.test(input)), - defineExecutableCommand(metadata('perf'), (client, input) => client.observability.perf(input)), - defineExecutableCommand(metadata('logs'), (client, input) => client.observability.logs(input)), - defineExecutableCommand(metadata('network'), (client, input) => - client.observability.network(input), - ), - defineExecutableCommand(metadata('record'), (client, input) => - client.recording.record(input as RecordOptions), - ), - defineExecutableCommand(metadata('trace'), (client, input) => client.recording.trace(input)), - defineExecutableCommand(metadata('settings'), (client, input) => - client.settings.update(input as SettingsUpdateOptions), - ), - defineExecutableCommand( - metadata('metro'), - async (client, input): Promise => - input.action === 'prepare' - ? await client.metro.prepare(toMetroPrepareOptions(input)) - : await client.metro.reload(toMetroReloadOptions(input)), - ), + ...managementCommandDefinitions, + ...captureCommandDefinitions, + ...systemCommandDefinitions, + reactNativeCommandDefinition, + ...replayCommandDefinitions, + ...observabilityCommandDefinitions, + ...recordingCommandDefinitions, + metroCommandDefinition, ] as const; - -function metadata( - name: TName, -): Extract { - const definition = clientCommandMetadata.find((item) => item.name === name); - if (!definition) throw new Error(`Missing client command metadata for ${name}`); - return definition as Extract; -} - -function withoutApp(input: AppCloseOptions & { shutdown?: boolean }): { shutdown?: boolean } { - const { app: _app, ...rest } = input; - return rest; -} - -function toMetroPrepareOptions(input: MetroInput): MetroPrepareOptions { - return { - projectRoot: input.projectRoot, - kind: input.kind, - publicBaseUrl: input.publicBaseUrl, - proxyBaseUrl: input.proxyBaseUrl, - bearerToken: input.bearerToken, - bridgeScope: input.bridgeScope ?? metroBridgeScopeFromInput(input), - port: input.port, - listenHost: input.listenHost, - statusHost: input.statusHost, - startupTimeoutMs: input.startupTimeoutMs, - probeTimeoutMs: input.probeTimeoutMs, - reuseExisting: input.reuseExisting, - installDependenciesIfNeeded: input.installDependenciesIfNeeded, - runtimeFilePath: input.runtimeFilePath, - }; -} - -function metroBridgeScopeFromInput( - input: MetroInput & { - tenant?: string; - runId?: string; - leaseId?: string; - }, -): MetroPrepareOptions['bridgeScope'] { - return input.tenant && input.runId && input.leaseId - ? { tenantId: input.tenant, runId: input.runId, leaseId: input.leaseId } - : undefined; -} - -function toMetroReloadOptions(input: MetroInput): MetroReloadOptions { - return { - metroHost: input.metroHost, - metroPort: input.metroPort, - bundleUrl: input.bundleUrl, - timeoutMs: input.timeoutMs, - }; -} - -function waitInputToOptions(input: Record): WaitCommandOptions { - optionalEnum(input, 'kind', WAIT_KIND_VALUES); - const options = { ...input }; - delete options.kind; - return options as WaitCommandOptions & { kind?: never }; -} diff --git a/src/commands/client-command-metadata.ts b/src/commands/client-command-metadata.ts index 99a6bc9b9..accf4bd70 100644 --- a/src/commands/client-command-metadata.ts +++ b/src/commands/client-command-metadata.ts @@ -1,268 +1,19 @@ -import type { MetroPrepareOptions, RecordOptions } from '../client-types.ts'; -import { NETWORK_INCLUDE_MODES, type DaemonInstallSource } from '../contracts.ts'; -import { ALERT_ACTIONS } from '../alert-contract.ts'; -import { BACK_MODES } from '../core/back-mode.ts'; -import { DEVICE_ROTATIONS } from '../core/device-rotation.ts'; -import { SESSION_SURFACES } from '../core/session-surface.ts'; -import { LOG_ACTION_VALUES } from './log-command-contract.ts'; -import { requireCommandDescription } from './command-descriptions.ts'; -import { - booleanField, - booleanSchema, - enumField, - integerField, - integerSchema, - jsonSchemaField, - looseObjectField, - looseObjectSchema, - numberField, - requiredField, - stringArrayField, - stringField, - stringSchema, - type CommandFieldMap, -} from './command-input.ts'; -import { defineFieldCommandMetadata } from './field-command-contract.ts'; -import { - PERF_ACTION_VALUES, - PERF_AREA_VALUES, - PERF_KIND_VALUES, - PERF_SUBJECT_VALUES, -} from './perf-command-contract.ts'; -import { WAIT_KIND_VALUES } from './wait-command-contract.ts'; - -const CLIPBOARD_ACTION_VALUES = ['read', 'write'] as const; -const NETWORK_ACTION_VALUES = ['dump', 'log'] as const; -const START_STOP_VALUES = ['start', 'stop'] as const; -const DEBUG_ACTION_VALUES = ['symbols'] as const; -const REACT_NATIVE_ACTION_VALUES = ['dismiss-overlay'] as const; -const METRO_ACTION_VALUES = ['prepare', 'reload'] as const; -const PREPARE_ACTION_VALUES = ['ios-runner'] as const; +import { captureCommandMetadata } from './capture/index.ts'; +import { managementCommandMetadata } from './management/index.ts'; +import { metroCommandMetadata } from './metro/index.ts'; +import { observabilityCommandMetadata } from './observability/index.ts'; +import { reactNativeCommandMetadata } from './react-native/index.ts'; +import { recordingCommandMetadata } from './recording/index.ts'; +import { replayCommandMetadataList } from './replay/index.ts'; +import { systemCommandMetadata } from './system/index.ts'; export const clientCommandMetadata = [ - defineClientCommandMetadata('devices', {}), - defineClientCommandMetadata('boot', { - headless: booleanField('Boot without showing simulator UI when supported.'), - }), - defineClientCommandMetadata('shutdown', {}), - defineClientCommandMetadata('prepare', { - action: requiredField(enumField(PREPARE_ACTION_VALUES)), - timeoutMs: integerField('Maximum wall-clock time for the prepare command.'), - }), - defineClientCommandMetadata('debug', { - action: requiredField(enumField(DEBUG_ACTION_VALUES)), - artifact: requiredField(stringField('Apple crash artifact path (.ips, .crash, or .log).')), - dsym: stringField('Path to a matching .dSYM bundle.'), - searchPath: stringField('Directory to scan for matching .dSYM bundles.'), - out: stringField('Output path for the symbolicated artifact.'), - }), - defineClientCommandMetadata('apps', { - appsFilter: enumField(['user-installed', 'all']), - }), - defineClientCommandMetadata('session', { - action: enumField( - ['list', 'state-dir'], - 'list shows active sessions; state-dir prints the resolved daemon state directory without contacting the daemon.', - ), - }), - defineClientCommandMetadata('open', { - app: stringField('App name, bundle id, package, or URL.'), - url: stringField('Optional URL passed with an app shell.'), - surface: enumField(SESSION_SURFACES), - activity: stringField('Android activity name.'), - launchConsole: stringField('Launch console mode.'), - launchArgs: stringArrayField( - 'Launch arguments forwarded verbatim to the platform launch command.', - ), - relaunch: booleanField('Force relaunch.'), - saveScript: jsonSchemaField({ oneOf: [booleanSchema(), stringSchema()] }), - deviceHub: booleanField('Use Xcode Device Hub when surfacing Apple simulators.'), - noRecord: booleanField('Do not record this action.'), - }), - defineClientCommandMetadata('close', { - app: stringField('Optional app to close.'), - shutdown: booleanField('Shutdown the session/device where supported.'), - saveScript: jsonSchemaField({ oneOf: [booleanSchema(), stringSchema()] }), - }), - defineClientCommandMetadata('install', { - app: requiredField(stringField()), - appPath: requiredField(stringField('Path to app binary.')), - }), - defineClientCommandMetadata('reinstall', { - app: requiredField(stringField()), - appPath: requiredField(stringField('Path to app binary.')), - }), - defineClientCommandMetadata('install-from-source', { - source: requiredField( - jsonSchemaField(looseObjectSchema('Install source object.')), - ), - retainPaths: booleanField(), - retentionMs: integerField(), - }), - defineClientCommandMetadata('push', { - app: requiredField(stringField()), - payload: requiredField( - jsonSchemaField>({ - oneOf: [stringSchema(), looseObjectSchema()], - }), - ), - }), - defineClientCommandMetadata('trigger-app-event', { - event: requiredField(stringField()), - payload: looseObjectField(), - }), - defineClientCommandMetadata('snapshot', { - interactiveOnly: booleanField(), - compact: booleanField(), - depth: integerField(), - scope: stringField(), - raw: booleanField(), - forceFull: booleanField(), - timeoutMs: integerField('Maximum wall-clock time for the snapshot command.'), - }), - defineClientCommandMetadata('screenshot', { - path: stringField('Output path.'), - overlayRefs: booleanField(), - fullscreen: booleanField(), - maxSize: integerField(), - stabilize: booleanField(), - surface: enumField(SESSION_SURFACES), - }), - defineClientCommandMetadata('diff', { - kind: requiredField(jsonSchemaField<'snapshot'>({ type: 'string', const: 'snapshot' })), - out: stringField(), - interactiveOnly: booleanField(), - compact: booleanField(), - depth: integerField(), - scope: stringField(), - raw: booleanField(), - }), - defineClientCommandMetadata('wait', { - kind: enumField(WAIT_KIND_VALUES), - durationMs: integerField(), - text: stringField(), - ref: stringField(), - selector: stringField(), - timeoutMs: integerField(), - depth: integerField(), - scope: stringField(), - raw: booleanField(), - }), - defineClientCommandMetadata('alert', { - action: enumField(ALERT_ACTIONS), - timeoutMs: integerField(), - }), - defineClientCommandMetadata('appstate', {}), - defineClientCommandMetadata('back', { - mode: enumField(BACK_MODES), - }), - defineClientCommandMetadata('home', {}), - defineClientCommandMetadata('rotate', { - orientation: requiredField(enumField(DEVICE_ROTATIONS)), - }), - defineClientCommandMetadata('app-switcher', {}), - defineClientCommandMetadata('keyboard', { - action: enumField(['status', 'dismiss']), - }), - defineClientCommandMetadata('clipboard', { - action: requiredField(enumField(CLIPBOARD_ACTION_VALUES)), - text: stringField(), - }), - defineClientCommandMetadata('react-native', { - action: requiredField(enumField(REACT_NATIVE_ACTION_VALUES)), - }), - defineClientCommandMetadata('replay', { - path: requiredField(stringField()), - update: booleanField(), - backend: stringField(), - maestro: booleanField(), - env: stringArrayField(), - }), - defineClientCommandMetadata('test', { - paths: requiredField(stringArrayField()), - update: booleanField(), - backend: stringField(), - maestro: booleanField(), - env: stringArrayField(), - failFast: booleanField(), - timeoutMs: integerField(), - retries: integerField(), - recordVideo: booleanField(), - artifactsDir: stringField(), - reportJunit: stringField(), - shardAll: integerField(), - shardSplit: integerField(), - }), - defineClientCommandMetadata('perf', { - area: enumField(PERF_AREA_VALUES), - subject: enumField(PERF_SUBJECT_VALUES), - action: enumField(PERF_ACTION_VALUES), - kind: enumField(PERF_KIND_VALUES), - template: stringField('xctrace template name, for example Time Profiler.'), - out: stringField('Output artifact path.'), - tracePath: stringField('Existing .trace path to report, defaults to the latest session trace.'), - }), - defineClientCommandMetadata('logs', { - action: enumField(LOG_ACTION_VALUES), - message: stringField(), - restart: booleanField(), - }), - defineClientCommandMetadata('network', { - action: enumField(NETWORK_ACTION_VALUES), - limit: integerField(), - include: enumField(NETWORK_INCLUDE_MODES), - }), - defineClientCommandMetadata('record', { - action: requiredField(enumField(START_STOP_VALUES)), - path: stringField(), - fps: integerField(), - quality: jsonSchemaField(integerSchema()), - hideTouches: booleanField(), - }), - defineClientCommandMetadata('trace', { - action: requiredField(enumField(START_STOP_VALUES)), - path: stringField(), - }), - defineClientCommandMetadata('settings', { - setting: requiredField(stringField()), - state: requiredField(stringField()), - app: stringField(), - latitude: numberField(), - longitude: numberField(), - permission: stringField(), - mode: enumField(['full', 'limited']), - }), - defineClientCommandMetadata('metro', { - action: requiredField(enumField(METRO_ACTION_VALUES)), - projectRoot: stringField(), - kind: jsonSchemaField(stringSchema()), - publicBaseUrl: stringField(), - proxyBaseUrl: stringField(), - bearerToken: stringField(), - bridgeScope: jsonSchemaField({ - type: 'object', - additionalProperties: true, - }), - launchUrl: stringField(), - port: integerField(), - listenHost: stringField(), - statusHost: stringField(), - startupTimeoutMs: integerField(), - probeTimeoutMs: integerField(), - reuseExisting: booleanField(), - installDependenciesIfNeeded: booleanField(), - runtimeFilePath: stringField(), - logPath: stringField(), - metroHost: stringField(), - metroPort: integerField(), - bundleUrl: stringField(), - timeoutMs: integerField(), - }), + ...managementCommandMetadata, + ...captureCommandMetadata, + ...systemCommandMetadata, + reactNativeCommandMetadata, + ...replayCommandMetadataList, + ...observabilityCommandMetadata, + ...recordingCommandMetadata, + metroCommandMetadata, ] as const; - -function defineClientCommandMetadata< - const TName extends string, - const TFields extends CommandFieldMap, ->(name: TName, fields: TFields) { - return defineFieldCommandMetadata(name, requireCommandDescription(name), fields); -} diff --git a/src/commands/command-descriptions.ts b/src/commands/command-descriptions.ts index 78ac8a607..3fd3d4ed6 100644 --- a/src/commands/command-descriptions.ts +++ b/src/commands/command-descriptions.ts @@ -1,67 +1,28 @@ +import { captureCommandDescriptions } from './capture/index.ts'; +import { interactionCommandDescriptions } from './interaction/index.ts'; +import { managementCommandDescriptions } from './management/index.ts'; +import { metroCommandDescriptions } from './metro/index.ts'; +import { observabilityCommandDescriptions } from './observability/index.ts'; +import { reactNativeCommandDescriptions } from './react-native/index.ts'; +import { recordingCommandDescriptions } from './recording/index.ts'; +import { replayCommandDescriptions } from './replay/index.ts'; +import { systemCommandDescriptions } from './system/index.ts'; + const COMMAND_DESCRIPTIONS = { - devices: 'List available devices.', - debug: 'Symbolicate crash artifacts with matching debug symbols.', - boot: 'Boot or prepare a selected device without using CLI positional arguments.', - shutdown: 'Shutdown a selected simulator or emulator.', - apps: 'List installed apps.', - session: 'List active sessions or print daemon state directory.', - open: 'Open an app, deep link, URL, or platform surface.', - prepare: 'Prepare platform helper infrastructure.', - close: 'Close an app or end the active session.', - install: 'Install an app binary.', - reinstall: 'Reinstall an app binary.', - 'install-from-source': 'Install an app from a structured source.', - push: 'Deliver a push payload.', - 'trigger-app-event': 'Trigger an app-defined event.', - snapshot: 'Capture an accessibility snapshot.', - screenshot: 'Capture a screenshot.', - diff: 'Diff accessibility snapshots.', - wait: 'Wait for duration, text, ref, or selector.', - alert: 'Inspect or handle platform alerts.', - appstate: 'Show foreground app or activity.', - back: 'Navigate back.', - home: 'Go to the home screen.', - rotate: 'Rotate device orientation.', - 'app-switcher': 'Open the app switcher.', - keyboard: 'Inspect or dismiss the keyboard.', - clipboard: 'Read or write clipboard text.', - 'react-native': 'Run supported React Native app automation helpers.', - replay: 'Replay a recorded session.', - test: 'Run one or more replay scripts.', - perf: 'Show session performance, frame health, and memory diagnostics.', - logs: 'Manage session app logs.', - network: 'Show recent HTTP traffic.', - record: 'Start or stop screen recording.', - trace: 'Start or stop trace capture.', - settings: 'Change OS settings and app permissions.', - metro: 'Prepare Metro runtime or reload React Native apps.', - click: 'Click or tap a semantic UI target by ref, selector, or point.', - press: 'Press a semantic UI target by ref, selector, or point.', - fill: 'Fill text into a semantic UI target by ref, selector, or point.', - longpress: 'Long press by ref, selector, or point.', - swipe: 'Swipe between two points.', - focus: 'Focus input at coordinates.', - type: 'Type text in the focused field.', - scroll: 'Scroll in a direction or to an edge.', - get: 'Get element text or attributes.', - is: 'Assert UI state.', - find: 'Find an element and optionally act on it.', - gesture: 'Run a structured gesture.', + ...managementCommandDescriptions, + ...captureCommandDescriptions, + ...interactionCommandDescriptions, + ...systemCommandDescriptions, + ...reactNativeCommandDescriptions, + ...replayCommandDescriptions, + ...observabilityCommandDescriptions, + ...recordingCommandDescriptions, + ...metroCommandDescriptions, batch: 'Run multiple structured command steps in one daemon request.', } as const; export type DescribedCommandName = keyof typeof COMMAND_DESCRIPTIONS; -function getCommandDescription(command: string): string | undefined { - return COMMAND_DESCRIPTIONS[command as DescribedCommandName]; -} - -export function requireCommandDescription(command: string): string { - const description = getCommandDescription(command); - if (!description) throw new Error(`Missing command description for ${command}`); - return description; -} - export function listCommandDescriptionMetadata(): Array<{ name: DescribedCommandName; description: string; diff --git a/src/commands/command-metadata.ts b/src/commands/command-metadata.ts index e0679117b..442c432be 100644 --- a/src/commands/command-metadata.ts +++ b/src/commands/command-metadata.ts @@ -2,7 +2,7 @@ import { BATCH_COMMAND_NAMES, listMcpExposedCommandNames } from '../command-cata import { createBatchCommandMetadata } from './batch-command-metadata.ts'; import { clientCommandMetadata } from './client-command-metadata.ts'; import type { CommandMetadata } from './command-contract.ts'; -import { interactionCommandMetadata } from './interaction-command-metadata.ts'; +import { interactionCommandMetadata } from './interaction/index.ts'; const batchCommandMetadata = createBatchCommandMetadata(BATCH_COMMAND_NAMES); diff --git a/src/commands/command-projection.ts b/src/commands/command-projection.ts index 19fc947a4..8f4ebc834 100644 --- a/src/commands/command-projection.ts +++ b/src/commands/command-projection.ts @@ -2,16 +2,20 @@ import { BATCH_COMMAND_NAMES, PUBLIC_COMMANDS } from '../command-catalog.ts'; import { buildFlags } from '../client-normalizers.ts'; import type { DaemonBatchStep } from '../core/batch.ts'; import { AppError } from '../utils/errors.ts'; -import { appDaemonWriters } from './cli-grammar/apps.ts'; -import { captureDaemonWriters } from './cli-grammar/capture.ts'; +import { captureDaemonWriters } from './capture/index.ts'; import { commandNameSet, request } from './cli-grammar/common.ts'; -import { gestureDaemonWriters } from './cli-grammar/gesture.ts'; -import { interactionDaemonWriters } from './cli-grammar/interactions.ts'; -import { observabilityDaemonWriters } from './cli-grammar/observability.ts'; -import { replayDaemonWriters } from './cli-grammar/replay.ts'; -import { selectorDaemonWriters } from './cli-grammar/selectors.ts'; -import { systemDaemonWriters } from './cli-grammar/system.ts'; import type { CommandInput, DaemonCommandRequest, DaemonWriter } from './cli-grammar/types.ts'; +import { + gestureDaemonWriters, + interactionDaemonWriters, + selectorDaemonWriters, +} from './interaction/index.ts'; +import { appDaemonWriters } from './management/index.ts'; +import { observabilityDaemonWriters } from './observability/index.ts'; +import { reactNativeDaemonWriters } from './react-native/index.ts'; +import { recordingDaemonWriters } from './recording/index.ts'; +import { replayDaemonWriters } from './replay/index.ts'; +import { systemDaemonWriters } from './system/index.ts'; const daemonWriters = { ...appDaemonWriters, @@ -20,6 +24,8 @@ const daemonWriters = { ...gestureDaemonWriters, ...selectorDaemonWriters, ...observabilityDaemonWriters, + ...reactNativeDaemonWriters, + ...recordingDaemonWriters, ...replayDaemonWriters, ...systemDaemonWriters, batch: (input) => diff --git a/src/commands/command-surface.ts b/src/commands/command-surface.ts index ed38a3ef4..078bed84f 100644 --- a/src/commands/command-surface.ts +++ b/src/commands/command-surface.ts @@ -2,7 +2,7 @@ import type { AgentDeviceClient } from '../client-types.ts'; import { createBatchCommand } from './batch-command.ts'; import { clientCommandDefinitions } from './client-command-contracts.ts'; import type { JsonSchema } from './command-contract.ts'; -import { interactionCommandDefinitions } from './interaction-command-contracts.ts'; +import { interactionCommandDefinitions } from './interaction/index.ts'; import { batchCommandNames, type BatchCommandName } from './command-projection.ts'; import type { CommandName } from './command-metadata.ts'; diff --git a/src/commands/cli-grammar/gesture.test.ts b/src/commands/interaction/gesture.test.ts similarity index 99% rename from src/commands/cli-grammar/gesture.test.ts rename to src/commands/interaction/gesture.test.ts index 0eb8ce6ae..60de74f74 100644 --- a/src/commands/cli-grammar/gesture.test.ts +++ b/src/commands/interaction/gesture.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'vitest'; import type { CliFlags } from '../../utils/cli-flags.ts'; -import type { CommandInput } from './types.ts'; +import type { CommandInput } from '../cli-grammar/types.ts'; import { gestureCliReaders, gestureDaemonWriters } from './gesture.ts'; const NO_FLAGS = {} as CliFlags; diff --git a/src/commands/cli-grammar/gesture.ts b/src/commands/interaction/gesture.ts similarity index 98% rename from src/commands/cli-grammar/gesture.ts rename to src/commands/interaction/gesture.ts index b8f67f8c0..0bb640483 100644 --- a/src/commands/cli-grammar/gesture.ts +++ b/src/commands/interaction/gesture.ts @@ -8,8 +8,8 @@ import { optionalCliNumber, optionalNumber, requiredDaemonString, -} from './common.ts'; -import type { CliReader, DaemonWriter, CommandInput } from './types.ts'; +} from '../cli-grammar/common.ts'; +import type { CliReader, DaemonWriter, CommandInput } from '../cli-grammar/types.ts'; export const gestureCliReaders = { gesture: gestureInputFromCli, diff --git a/src/commands/interaction-command-contracts.ts b/src/commands/interaction/index.ts similarity index 61% rename from src/commands/interaction-command-contracts.ts rename to src/commands/interaction/index.ts index 44b8ce235..d22cff23d 100644 --- a/src/commands/interaction-command-contracts.ts +++ b/src/commands/interaction/index.ts @@ -16,15 +16,17 @@ import type { SwipeOptions, TransformGestureOptions, TypeTextOptions, -} from '../client-types.ts'; -import { defineExecutableCommand } from './command-contract.ts'; +} from '../../client-types.ts'; +import type { CommandSchemaOverride } from '../../utils/cli-command-schema-types.ts'; +import { REPEATED_TOUCH_FLAGS, SELECTOR_SNAPSHOT_FLAGS } from '../../utils/cli-flags.ts'; +import { defineExecutableCommand } from '../command-contract.ts'; import { commonToClientOptions, toClientElementTarget, toClientInteractionTarget, toRepeatedOptions, toSelectorSnapshotOptions, -} from './command-input.ts'; +} from '../command-input.ts'; import { interactionCommandMetadata, type ClickInput, @@ -38,7 +40,86 @@ import { type RotateInput, type SwipeGestureInput, type TransformInput, -} from './interaction-command-metadata.ts'; +} from './metadata.ts'; + +export { gestureCliReaders, gestureDaemonWriters } from './gesture.ts'; +export { interactionCliReaders, interactionDaemonWriters } from './interactions.ts'; +export { interactionCommandDescriptions, interactionCommandMetadata } from './metadata.ts'; +export { selectorCliReaders, selectorDaemonWriters } from './selectors.ts'; + +export const interactionCliSchemas = { + get: { + usageOverride: 'get text|attrs <@ref|selector>', + positionalArgs: ['subcommand', 'target'], + allowedFlags: [...SELECTOR_SNAPSHOT_FLAGS], + }, + find: { + usageOverride: 'find [value] [--first|--last]', + helpDescription: 'Find by text/label/value/role/id and run action', + summary: 'Find an element and act', + positionalArgs: ['query', 'action', 'value?'], + allowsExtraPositionals: true, + allowedFlags: ['snapshotDepth', 'snapshotRaw', 'findFirst', 'findLast'], + }, + is: { + positionalArgs: ['predicate', 'selector', 'value?'], + allowsExtraPositionals: true, + allowedFlags: [...SELECTOR_SNAPSHOT_FLAGS], + }, + click: { + usageOverride: 'click ', + positionalArgs: ['target'], + allowsExtraPositionals: true, + allowedFlags: [...REPEATED_TOUCH_FLAGS, 'clickButton', ...SELECTOR_SNAPSHOT_FLAGS], + }, + press: { + usageOverride: 'press ', + positionalArgs: ['targetOrX', 'y?'], + allowsExtraPositionals: true, + allowedFlags: [...REPEATED_TOUCH_FLAGS, ...SELECTOR_SNAPSHOT_FLAGS], + }, + longpress: { + usageOverride: 'longpress [durationMs]', + positionalArgs: ['targetOrX', 'yOrDurationMs?', 'durationMs?'], + allowsExtraPositionals: true, + allowedFlags: [...SELECTOR_SNAPSHOT_FLAGS], + }, + swipe: { + helpDescription: 'Swipe coordinates with optional repeat pattern', + positionalArgs: ['x1', 'y1', 'x2', 'y2', 'durationMs?'], + allowedFlags: ['count', 'pauseMs', 'pattern'], + }, + gesture: { + usageOverride: 'gesture ...', + listUsageOverride: 'gesture ...', + helpDescription: + 'Run touch gestures: pan [durationMs], fling [distance] [durationMs], swipe [durationMs], pinch [x] [y], rotate [x] [y] [velocity], or transform [durationMs]', + summary: 'Run pan, fling, swipe, pinch, rotate, or transform gestures', + positionalArgs: ['pan|fling|swipe|pinch|rotate|transform', 'args?'], + allowsExtraPositionals: true, + }, + focus: { + positionalArgs: ['x', 'y'], + }, + type: { + positionalArgs: ['text'], + allowsExtraPositionals: true, + allowedFlags: ['delayMs'], + }, + fill: { + usageOverride: 'fill | fill <@ref|selector> ', + positionalArgs: ['targetOrX', 'yOrText', 'text?'], + allowsExtraPositionals: true, + allowedFlags: [...SELECTOR_SNAPSHOT_FLAGS, 'delayMs'], + }, + scroll: { + usageOverride: 'scroll [amount] [--pixels ]', + helpDescription: 'Scroll in direction, or verify hidden content and scroll toward top/bottom', + summary: 'Scroll in a direction or to an edge', + positionalArgs: ['directionOrEdge', 'amount?'], + allowedFlags: ['pixels'], + }, +} as const satisfies Record; type InteractionCommandMetadata = (typeof interactionCommandMetadata)[number]; type InteractionCommandName = InteractionCommandMetadata['name']; diff --git a/src/commands/cli-grammar/interactions.ts b/src/commands/interaction/interactions.ts similarity index 98% rename from src/commands/cli-grammar/interactions.ts rename to src/commands/interaction/interactions.ts index 34480eb1c..9ef663da1 100644 --- a/src/commands/cli-grammar/interactions.ts +++ b/src/commands/interaction/interactions.ts @@ -27,8 +27,8 @@ import { repeatedInputFromFlags, selectorSnapshotInputFromFlags, targetInputFromClientTarget, -} from './common.ts'; -import type { CliReader, DaemonWriter, CommandInput } from './types.ts'; +} from '../cli-grammar/common.ts'; +import type { CliReader, DaemonWriter, CommandInput } from '../cli-grammar/types.ts'; export const interactionCliReaders = { click: (positionals, flags) => ({ diff --git a/src/commands/interaction-command-metadata.ts b/src/commands/interaction/metadata.ts similarity index 83% rename from src/commands/interaction-command-metadata.ts rename to src/commands/interaction/metadata.ts index 9e83784c7..d540a9c80 100644 --- a/src/commands/interaction-command-metadata.ts +++ b/src/commands/interaction/metadata.ts @@ -1,6 +1,5 @@ -import { requireCommandDescription } from './command-descriptions.ts'; -import { defineCommandMetadata } from './command-contract.ts'; -import { GESTURE_KINDS } from '../command-catalog.ts'; +import { defineCommandMetadata } from '../command-contract.ts'; +import { GESTURE_KINDS } from '../../command-catalog.ts'; import { booleanField, elementTargetField, @@ -25,18 +24,18 @@ import { type CommonCommandInput, type InferCommandInput, type PointInput, -} from './command-input.ts'; -import { defineFieldCommandMetadata } from './field-command-contract.ts'; -import { CLICK_BUTTONS } from '../core/click-button.ts'; +} from '../command-input.ts'; +import { defineFieldCommandMetadata } from '../field-command-contract.ts'; +import { CLICK_BUTTONS } from '../../core/click-button.ts'; import { SCROLL_DIRECTIONS, SWIPE_PATTERNS, SWIPE_PRESETS, type ScrollDirection, type SwipePreset, -} from '../core/scroll-gesture.ts'; -import { SCROLL_INPUT_DIRECTIONS } from './interaction-gestures.ts'; -import { FIND_LOCATORS } from '../utils/finders.ts'; +} from '../../core/scroll-gesture.ts'; +import { SCROLL_INPUT_DIRECTIONS } from '../interaction-gestures.ts'; +import { FIND_LOCATORS } from '../../utils/finders.ts'; const FIND_ACTION_VALUES = [ 'click', @@ -49,6 +48,23 @@ const FIND_ACTION_VALUES = [ 'type', ] as const; +export const interactionCommandDescriptions = { + click: 'Click or tap a semantic UI target by ref, selector, or point.', + press: 'Press a semantic UI target by ref, selector, or point.', + fill: 'Fill text into a semantic UI target by ref, selector, or point.', + longpress: 'Long press by ref, selector, or point.', + swipe: 'Swipe between two points.', + focus: 'Focus input at coordinates.', + type: 'Type text in the focused field.', + scroll: 'Scroll in a direction or to an edge.', + get: 'Get element text or attributes.', + is: 'Assert UI state.', + find: 'Find an element and optionally act on it.', + gesture: 'Run a structured gesture.', +} as const; + +type InteractionCommandName = keyof typeof interactionCommandDescriptions; + const clickFields = { target: requiredField(interactionTargetField()), button: enumField(CLICK_BUTTONS, 'Pointer button for platforms that support mouse buttons.'), @@ -200,19 +216,19 @@ export type GestureInput = export const interactionCommandMetadata = [ defineCommandMetadata({ name: 'click', - description: requireCommandDescription('click'), + description: interactionCommandDescriptions.click, inputSchema: fieldsInputSchema(clickFields), readInput: (input) => readFieldInput(input, clickFields), }), defineCommandMetadata({ name: 'press', - description: requireCommandDescription('press'), + description: interactionCommandDescriptions.press, inputSchema: fieldsInputSchema(pressFields), readInput: (input) => readFieldInput(input, pressFields), }), defineCommandMetadata({ name: 'fill', - description: requireCommandDescription('fill'), + description: interactionCommandDescriptions.fill, inputSchema: fieldsInputSchema(fillFields), readInput: (input) => readFieldInput(input, fillFields), }), @@ -226,7 +242,7 @@ export const interactionCommandMetadata = [ defineInteractionCommandMetadata('find', findFields), defineCommandMetadata({ name: 'gesture', - description: requireCommandDescription('gesture'), + description: interactionCommandDescriptions.gesture, inputSchema: fieldsInputSchema(gestureFields), readInput: readGestureInput, }), @@ -292,10 +308,10 @@ function readGestureInput(input: unknown): GestureInput { } function defineInteractionCommandMetadata< - const TName extends string, + const TName extends InteractionCommandName, const TFields extends CommandFieldMap, >(name: TName, fields: TFields) { - return defineFieldCommandMetadata(name, requireCommandDescription(name), fields); + return defineFieldCommandMetadata(name, interactionCommandDescriptions[name], fields); } function optionalPoint(record: Record, key: string): PointInput | undefined { diff --git a/src/commands/cli-grammar/selectors.ts b/src/commands/interaction/selectors.ts similarity index 97% rename from src/commands/cli-grammar/selectors.ts rename to src/commands/interaction/selectors.ts index cbf0dbba8..1fefa159b 100644 --- a/src/commands/cli-grammar/selectors.ts +++ b/src/commands/interaction/selectors.ts @@ -10,8 +10,8 @@ import { selectionOptionsFromFlags, selectorSnapshotOptionsFromFlags, splitRequiredSelector, -} from './common.ts'; -import type { CliReader, DaemonWriter } from './types.ts'; +} from '../cli-grammar/common.ts'; +import type { CliReader, DaemonWriter } from '../cli-grammar/types.ts'; export const selectorCliReaders = { find: (positionals, flags) => readFindOptionsFromPositionals(positionals, flags), diff --git a/src/commands/management/index.ts b/src/commands/management/index.ts new file mode 100644 index 000000000..731f4a07e --- /dev/null +++ b/src/commands/management/index.ts @@ -0,0 +1,441 @@ +import { INTERNAL_COMMANDS, PUBLIC_COMMANDS } from '../../command-catalog.ts'; +import type { + AppCloseOptions, + AppPushOptions, + AppTriggerEventOptions, +} from '../../client-types.ts'; +import type { DaemonInstallSource } from '../../contracts.ts'; +import { SESSION_SURFACES } from '../../core/session-surface.ts'; +import type { CliFlags } from '../../utils/cli-flags.ts'; +import type { CommandSchemaOverride } from '../../utils/cli-command-schema-types.ts'; +import { AppError } from '../../utils/errors.ts'; +import { parseGitHubActionsArtifactInstallSourceSpec } from '../../utils/install-source-config.ts'; +import { assertResolvedAppsFilter } from '../app-inventory-contract.ts'; +import { defineExecutableCommand } from '../command-contract.ts'; +import { + booleanField, + booleanSchema, + enumField, + integerField, + jsonSchemaField, + looseObjectField, + looseObjectSchema, + requiredField, + stringArrayField, + stringField, + stringSchema, +} from '../command-input.ts'; +import { + commonInputFromFlags, + direct, + optionalString, + readJsonObject, + request, + requiredDaemonString, + requiredString, +} from '../cli-grammar/common.ts'; +import type { CliReader, DaemonWriter, CommandInput } from '../cli-grammar/types.ts'; +import { defineFieldCommandMetadata } from '../field-command-contract.ts'; +import { DEFAULT_APPS_FILTER } from '../../contracts/app-inventory.ts'; + +const PREPARE_ACTION_VALUES = ['ios-runner'] as const; + +export const managementCommandDescriptions = { + devices: 'List available devices.', + boot: 'Boot or prepare a selected device without using CLI positional arguments.', + shutdown: 'Shutdown a selected simulator or emulator.', + apps: 'List installed apps.', + session: 'List active sessions or print daemon state directory.', + open: 'Open an app, deep link, URL, or platform surface.', + prepare: 'Prepare platform helper infrastructure.', + close: 'Close an app or end the active session.', + install: 'Install an app binary.', + reinstall: 'Reinstall an app binary.', + 'install-from-source': 'Install an app from a structured source.', + push: 'Deliver a push payload.', + 'trigger-app-event': 'Trigger an app-defined event.', +} as const; + +export const managementCommandMetadata = [ + defineFieldCommandMetadata('devices', managementCommandDescriptions.devices, {}), + defineFieldCommandMetadata('boot', managementCommandDescriptions.boot, { + headless: booleanField('Boot without showing simulator UI when supported.'), + }), + defineFieldCommandMetadata('shutdown', managementCommandDescriptions.shutdown, {}), + defineFieldCommandMetadata('prepare', managementCommandDescriptions.prepare, { + action: requiredField(enumField(PREPARE_ACTION_VALUES)), + timeoutMs: integerField('Maximum wall-clock time for the prepare command.'), + }), + defineFieldCommandMetadata('apps', managementCommandDescriptions.apps, { + appsFilter: enumField(['user-installed', 'all']), + }), + defineFieldCommandMetadata('session', managementCommandDescriptions.session, { + action: enumField( + ['list', 'state-dir'], + 'list shows active sessions; state-dir prints the resolved daemon state directory without contacting the daemon.', + ), + }), + defineFieldCommandMetadata('open', managementCommandDescriptions.open, { + app: stringField('App name, bundle id, package, or URL.'), + url: stringField('Optional URL passed with an app shell.'), + surface: enumField(SESSION_SURFACES), + activity: stringField('Android activity name.'), + launchConsole: stringField('Launch console mode.'), + launchArgs: stringArrayField( + 'Launch arguments forwarded verbatim to the platform launch command.', + ), + relaunch: booleanField('Force relaunch.'), + saveScript: jsonSchemaField({ oneOf: [booleanSchema(), stringSchema()] }), + deviceHub: booleanField('Use Xcode Device Hub when surfacing Apple simulators.'), + noRecord: booleanField('Do not record this action.'), + }), + defineFieldCommandMetadata('close', managementCommandDescriptions.close, { + app: stringField('Optional app to close.'), + shutdown: booleanField('Shutdown the session/device where supported.'), + saveScript: jsonSchemaField({ oneOf: [booleanSchema(), stringSchema()] }), + }), + defineFieldCommandMetadata('install', managementCommandDescriptions.install, { + app: requiredField(stringField()), + appPath: requiredField(stringField('Path to app binary.')), + }), + defineFieldCommandMetadata('reinstall', managementCommandDescriptions.reinstall, { + app: requiredField(stringField()), + appPath: requiredField(stringField('Path to app binary.')), + }), + defineFieldCommandMetadata( + 'install-from-source', + managementCommandDescriptions['install-from-source'], + { + source: requiredField( + jsonSchemaField(looseObjectSchema('Install source object.')), + ), + retainPaths: booleanField(), + retentionMs: integerField(), + }, + ), + defineFieldCommandMetadata('push', managementCommandDescriptions.push, { + app: requiredField(stringField()), + payload: requiredField( + jsonSchemaField>({ + oneOf: [stringSchema(), looseObjectSchema()], + }), + ), + }), + defineFieldCommandMetadata( + 'trigger-app-event', + managementCommandDescriptions['trigger-app-event'], + { + event: requiredField(stringField()), + payload: looseObjectField(), + }, + ), +] as const; + +type ManagementCommandMetadata = (typeof managementCommandMetadata)[number]; +type ManagementCommandName = ManagementCommandMetadata['name']; + +export const managementCommandDefinitions = [ + defineExecutableCommand(metadata('devices'), (client, input) => client.devices.list(input)), + defineExecutableCommand(metadata('boot'), (client, input) => client.devices.boot(input)), + defineExecutableCommand(metadata('shutdown'), (client, input) => client.devices.shutdown(input)), + defineExecutableCommand(metadata('apps'), (client, input) => client.apps.list(input)), + defineExecutableCommand(metadata('session'), async (client, { action, ...input }) => + action === 'state-dir' + ? { stateDir: await client.sessions.stateDir(input) } + : { sessions: await client.sessions.list(input) }, + ), + defineExecutableCommand(metadata('open'), (client, input) => client.apps.open(input)), + defineExecutableCommand(metadata('close'), (client, input) => + input.app ? client.apps.close(input) : client.sessions.close(withoutApp(input)), + ), + defineExecutableCommand(metadata('install'), (client, input) => client.apps.install(input)), + defineExecutableCommand(metadata('reinstall'), (client, input) => client.apps.reinstall(input)), + defineExecutableCommand(metadata('install-from-source'), (client, input) => + client.apps.installFromSource(input), + ), + defineExecutableCommand(metadata('push'), (client, input) => client.apps.push(input)), + defineExecutableCommand(metadata('trigger-app-event'), (client, input) => + client.apps.triggerEvent(input), + ), + defineExecutableCommand(metadata('prepare'), (client, input) => client.command.prepare(input)), +] as const; + +export const managementCliSchemas = { + boot: { + summary: 'Boot target device/simulator', + allowedFlags: ['headless'], + }, + shutdown: { + summary: 'Shutdown target simulator/emulator', + }, + prepare: { + usageOverride: 'prepare ios-runner --platform ios|macos [--timeout ]', + listUsageOverride: 'prepare ios-runner --platform ios|macos', + helpDescription: + 'Prepare platform helper infrastructure. ios-runner builds/reuses, starts, and health-checks the XCTest runner so later Apple snapshots and interactions do not pay first-use startup cost. In CI, run it after boot/install and before replay/test; if replay/test starts a separate daemon, run clean:daemon after prepare to release the prepared runner lease. Runner build/start output is written to the session runner.log; daemon.log is for daemon lifecycle/startup issues.', + summary: 'Prepare platform helpers', + positionalArgs: ['ios-runner'], + allowedFlags: ['timeoutMs'], + }, + open: { + helpDescription: + 'Boot device/simulator; optionally launch app or deep link URL (macOS also supports --surface app|frontmost-app|desktop|menubar)', + summary: 'Open an app, deep link or URL, save replays', + positionalArgs: ['appOrUrl?', 'url?'], + allowedFlags: [ + 'activity', + 'launchConsole', + 'launchArgs', + 'deviceHub', + 'saveScript', + 'relaunch', + 'surface', + ], + }, + close: { + positionalArgs: ['app?'], + allowedFlags: ['saveScript', 'shutdown'], + }, + reinstall: { + positionalArgs: ['app', 'path'], + }, + install: { + positionalArgs: ['app', 'path'], + }, + 'install-from-source': { + usageOverride: + 'install-from-source | install-from-source --github-actions-artifact ', + listUsageOverride: 'install-from-source | install-from-source --github-actions-artifact', + helpDescription: 'Install app from a URL or remote-resolved source', + summary: 'Install app from a source', + positionalArgs: ['url?'], + allowedFlags: [ + 'header', + 'githubActionsArtifact', + 'installSource', + 'retainPaths', + 'retentionMs', + ], + }, + apps: { + helpDescription: 'List user-installed apps; use --all to include system/OEM apps', + summary: 'List installed apps', + allowedFlags: ['appsFilter'], + defaults: { appsFilter: DEFAULT_APPS_FILTER }, + }, + push: { + positionalArgs: ['bundleOrPackage', 'payloadOrJson'], + }, + 'trigger-app-event': { + usageOverride: 'trigger-app-event [payloadJson]', + positionalArgs: ['event', 'payloadJson?'], + }, + session: { + usageOverride: 'session list | session state-dir', + listUsageOverride: 'session list', + helpDescription: 'List active sessions or print the effective daemon state directory', + positionalArgs: ['list|state-dir?'], + }, +} as const satisfies Record; + +function metadata( + name: TName, +): Extract { + const definition = managementCommandMetadata.find((item) => item.name === name); + if (!definition) throw new Error(`Missing management command metadata for ${name}`); + return definition as Extract; +} + +function withoutApp(input: AppCloseOptions & { shutdown?: boolean }): { shutdown?: boolean } { + const { app: _app, ...rest } = input; + return rest; +} + +export const appCliReaders = { + devices: (_positionals, flags) => commonInputFromFlags(flags), + apps: (_positionals, flags) => ({ + ...commonInputFromFlags(flags), + appsFilter: assertResolvedAppsFilter(flags.appsFilter), + }), + session: (positionals, flags) => ({ + ...commonInputFromFlags(flags), + action: readSessionAction(positionals[0]), + }), + boot: (_positionals, flags) => ({ + ...commonInputFromFlags(flags), + headless: flags.headless, + }), + shutdown: (_positionals, flags) => commonInputFromFlags(flags), + prepare: (positionals, flags) => ({ + ...commonInputFromFlags(flags), + action: requiredString(positionals[0], 'prepare requires subcommand'), + timeoutMs: flags.timeoutMs, + }), + open: (positionals, flags) => ({ + ...commonInputFromFlags(flags), + app: positionals[0], + url: positionals[1], + surface: flags.surface, + activity: flags.activity, + launchConsole: flags.launchConsole, + launchArgs: flags.launchArgs, + relaunch: flags.relaunch, + saveScript: flags.saveScript, + deviceHub: flags.deviceHub, + noRecord: flags.noRecord, + }), + close: (positionals, flags) => ({ + ...commonInputFromFlags(flags), + app: positionals[0], + shutdown: flags.shutdown, + saveScript: flags.saveScript, + }), + install: installInputFromCli, + reinstall: installInputFromCli, + 'install-from-source': (positionals, flags) => ({ + ...commonInputFromFlags(flags), + source: resolveInstallSource(positionals, flags), + retainPaths: flags.retainPaths, + retentionMs: flags.retentionMs, + }), + push: (positionals, flags) => ({ + ...commonInputFromFlags(flags), + app: requiredString(positionals[0], 'push requires bundleOrPackage'), + payload: requiredString(positionals[1], 'push requires payloadOrJson'), + }), + 'trigger-app-event': (positionals, flags) => ({ + ...commonInputFromFlags(flags), + event: requiredString(positionals[0], 'trigger-app-event requires event'), + payload: positionals[1] + ? readJsonObject(positionals[1], 'trigger-app-event payload') + : undefined, + }), +} satisfies Record; + +export const appDaemonWriters = { + devices: direct(PUBLIC_COMMANDS.devices), + boot: direct(PUBLIC_COMMANDS.boot), + shutdown: direct(PUBLIC_COMMANDS.shutdown), + prepare: direct(PUBLIC_COMMANDS.prepare, (input) => [ + requiredDaemonString(input.action, 'prepare requires subcommand'), + ]), + apps: direct(PUBLIC_COMMANDS.apps), + open: direct(PUBLIC_COMMANDS.open, openPositionals), + close: direct(PUBLIC_COMMANDS.close, (input) => optionalString(input.app)), + install: direct(PUBLIC_COMMANDS.install, (input) => requiredPair(input.app, input.appPath)), + reinstall: direct(PUBLIC_COMMANDS.reinstall, (input) => requiredPair(input.app, input.appPath)), + 'install-from-source': (input) => + request(INTERNAL_COMMANDS.installSource, [], { + ...input, + installSource: input.source, + retainMaterializedPaths: input.retainPaths, + materializedPathRetentionMs: input.retentionMs, + }), + push: direct(PUBLIC_COMMANDS.push, (input) => pushPositionals(input as AppPushOptions)), + 'trigger-app-event': direct(PUBLIC_COMMANDS.triggerAppEvent, (input) => + triggerEventPositionals(input as AppTriggerEventOptions), + ), +} satisfies Record; + +function installInputFromCli( + positionals: string[], + flags: CliFlags, + command = 'install', +): Record { + return { + ...commonInputFromFlags(flags), + app: requiredString(positionals[0], `${command} requires app`), + appPath: requiredString(positionals[1], `${command} requires path`), + }; +} + +function readSessionAction(value: string | undefined): 'list' | 'state-dir' { + const action = value ?? 'list'; + if (action === 'list') return action; + if (action === 'state-dir') return action; + throw new AppError('INVALID_ARGS', 'session only supports list or state-dir'); +} + +function openPositionals(input: CommandInput): string[] { + if (!input.app) return []; + return input.url ? [input.app, input.url] : [input.app]; +} + +function requiredPair(first: unknown, second: unknown): string[] { + return [ + requiredDaemonString(first, 'missing first positional'), + requiredDaemonString(second, 'missing second positional'), + ]; +} + +function pushPositionals(input: AppPushOptions): string[] { + return [ + input.app, + typeof input.payload === 'string' ? input.payload : JSON.stringify(input.payload), + ]; +} + +function triggerEventPositionals(input: AppTriggerEventOptions): string[] { + return [input.event, ...(input.payload ? [JSON.stringify(input.payload)] : [])]; +} + +// fallow-ignore-next-line complexity +function resolveInstallSource(positionals: string[], flags: CliFlags) { + const url = positionals[0]?.trim(); + if (positionals.length > 1) { + throw new AppError( + 'INVALID_ARGS', + 'install-from-source accepts either one positional or --github-actions-artifact', + ); + } + const githubArtifactSource = flags.githubActionsArtifact + ? parseGitHubActionsArtifactInstallSourceSpec(flags.githubActionsArtifact) + : undefined; + const configuredSource = flags.installSource; + const sourceCount = (url ? 1 : 0) + (githubArtifactSource ? 1 : 0) + (configuredSource ? 1 : 0); + if (sourceCount !== 1) { + throw new AppError( + 'INVALID_ARGS', + 'install-from-source requires exactly one source: , --github-actions-artifact, or config installSource', + ); + } + if (!url && flags.header && flags.header.length > 0) { + throw new AppError( + 'INVALID_ARGS', + 'install-from-source --header is only supported for URL sources', + ); + } + if (githubArtifactSource) return githubArtifactSource; + if (configuredSource) return configuredSource; + return { + kind: 'url' as const, + url: url!, + headers: parseInstallSourceHeaders(flags.header), + }; +} + +function parseInstallSourceHeaders( + headerFlags: CliFlags['header'], +): Record | undefined { + if (!headerFlags || headerFlags.length === 0) return undefined; + const headers: Record = {}; + for (const rawHeader of headerFlags) { + const separator = rawHeader.indexOf(':'); + if (separator <= 0) { + throw new AppError( + 'INVALID_ARGS', + `Invalid --header value "${rawHeader}". Expected "name:value".`, + ); + } + const name = rawHeader.slice(0, separator).trim(); + const value = rawHeader.slice(separator + 1).trim(); + if (!name) { + throw new AppError( + 'INVALID_ARGS', + `Invalid --header value "${rawHeader}". Header name cannot be empty.`, + ); + } + headers[name] = value; + } + return headers; +} diff --git a/src/commands/metro/index.test.ts b/src/commands/metro/index.test.ts new file mode 100644 index 000000000..53bac71db --- /dev/null +++ b/src/commands/metro/index.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, test } from 'vitest'; +import type { CliFlags } from '../../utils/cli-flags.ts'; +import { metroCliReader, metroCommandDefinition, metroCommandMetadata } from './index.ts'; + +function flags(overrides: Partial = {}): CliFlags { + return overrides as CliFlags; +} + +function expectInvalidArgs(fn: () => unknown, messageFragment: string) { + expect(fn).toThrow( + expect.objectContaining({ + code: 'INVALID_ARGS', + message: expect.stringContaining(messageFragment), + }), + ); +} + +describe('metro command interface', () => { + test('owns public metadata', () => { + expect(metroCommandMetadata.name).toBe('metro'); + expect(metroCommandDefinition.name).toBe('metro'); + }); + + test('reads prepare input from flags', () => { + expect( + metroCliReader( + ['prepare'], + flags({ + metroProjectRoot: './apps/demo', + metroPublicBaseUrl: 'https://public.example.test', + metroProxyBaseUrl: 'https://proxy.example.test', + metroBearerToken: 'secret', + metroPreparePort: 9090, + metroListenHost: '127.0.0.1', + metroStatusHost: 'localhost', + metroStartupTimeoutMs: 30_000, + metroProbeTimeoutMs: 1_500, + metroRuntimeFile: './runtime.json', + metroNoReuseExisting: true, + metroNoInstallDeps: true, + kind: 'expo', + tenant: 'tenant-a', + runId: 'run-a', + leaseId: 'lease-a', + }), + ), + ).toMatchObject({ + action: 'prepare', + projectRoot: './apps/demo', + publicBaseUrl: 'https://public.example.test', + proxyBaseUrl: 'https://proxy.example.test', + bearerToken: 'secret', + port: 9090, + listenHost: '127.0.0.1', + statusHost: 'localhost', + startupTimeoutMs: 30_000, + probeTimeoutMs: 1_500, + runtimeFilePath: './runtime.json', + reuseExisting: false, + installDependenciesIfNeeded: false, + kind: 'expo', + bridgeScope: { + tenantId: 'tenant-a', + runId: 'run-a', + leaseId: 'lease-a', + }, + }); + }); + + test('reads reload input from flags', () => { + expect( + metroCliReader( + ['reload'], + flags({ + metroHost: '127.0.0.1', + metroPort: 9090, + bundleUrl: 'http://localhost:9090/index.bundle', + metroProbeTimeoutMs: 1_500, + }), + ), + ).toEqual({ + action: 'reload', + metroHost: '127.0.0.1', + metroPort: 9090, + bundleUrl: 'http://localhost:9090/index.bundle', + timeoutMs: 1_500, + }); + }); + + test('rejects invalid metro input', () => { + expectInvalidArgs(() => metroCliReader(['start'], flags()), 'metro requires a subcommand'); + expectInvalidArgs( + () => metroCliReader(['prepare'], flags()), + 'metro prepare requires --public-base-url', + ); + expectInvalidArgs( + () => metroCliReader(['prepare'], flags({ metroPublicBaseUrl: 'https://x', kind: 'web' })), + 'metro prepare --kind must be', + ); + }); +}); diff --git a/src/commands/metro/index.ts b/src/commands/metro/index.ts new file mode 100644 index 000000000..ba1dc5a96 --- /dev/null +++ b/src/commands/metro/index.ts @@ -0,0 +1,183 @@ +import type { MetroPrepareKind } from '../../client-metro.ts'; +import type { + MetroPrepareOptions, + MetroPrepareResult, + MetroReloadOptions, + MetroReloadResult, +} from '../../client-types.ts'; +import { AppError } from '../../utils/errors.ts'; +import type { CommandSchemaOverride } from '../../utils/cli-command-schema-types.ts'; +import { + booleanField, + enumField, + integerField, + jsonSchemaField, + requiredField, + stringField, + stringSchema, +} from '../command-input.ts'; +import { defineExecutableCommand } from '../command-contract.ts'; +import { defineFieldCommandMetadata } from '../field-command-contract.ts'; +import type { CliReader } from '../cli-grammar/types.ts'; +import { METRO_PREPARE_FLAGS, METRO_RELOAD_FLAGS } from '../../utils/cli-flags.ts'; + +export const METRO_COMMAND_NAME = 'metro'; +const METRO_ACTION_VALUES = ['prepare', 'reload'] as const; + +export const metroCommandDescription = 'Prepare Metro runtime or reload React Native apps.'; + +export const metroCommandDescriptions = { + [METRO_COMMAND_NAME]: metroCommandDescription, +} as const; + +export const metroCommandMetadata = defineFieldCommandMetadata( + METRO_COMMAND_NAME, + metroCommandDescription, + { + action: requiredField(enumField(METRO_ACTION_VALUES)), + projectRoot: stringField(), + kind: jsonSchemaField(stringSchema()), + publicBaseUrl: stringField(), + proxyBaseUrl: stringField(), + bearerToken: stringField(), + bridgeScope: jsonSchemaField({ + type: 'object', + additionalProperties: true, + }), + launchUrl: stringField(), + port: integerField(), + listenHost: stringField(), + statusHost: stringField(), + startupTimeoutMs: integerField(), + probeTimeoutMs: integerField(), + reuseExisting: booleanField(), + installDependenciesIfNeeded: booleanField(), + runtimeFilePath: stringField(), + logPath: stringField(), + metroHost: stringField(), + metroPort: integerField(), + bundleUrl: stringField(), + timeoutMs: integerField(), + }, +); + +type MetroInput = { action: 'prepare' | 'reload' } & MetroPrepareOptions & MetroReloadOptions; + +export const metroCommandDefinition = defineExecutableCommand( + metroCommandMetadata, + async (client, input): Promise => + input.action === 'prepare' + ? await client.metro.prepare(toMetroPrepareOptions(input)) + : await client.metro.reload(toMetroReloadOptions(input)), +); + +export const metroCliSchema = { + usageOverride: + 'metro prepare (--public-base-url | --proxy-base-url ) [--project-root ] [--port ] [--kind auto|react-native|expo]\n agent-device metro reload [--metro-host ] [--metro-port ] [--bundle-url ]', + listUsageOverride: 'metro prepare --public-base-url | --proxy-base-url ; metro reload', + helpDescription: + 'Prepare a local Metro runtime or ask Metro to reload connected React Native apps', + summary: 'Prepare Metro or reload apps', + positionalArgs: ['prepare|reload'], + allowedFlags: [...METRO_RELOAD_FLAGS, ...METRO_PREPARE_FLAGS], +} as const satisfies CommandSchemaOverride; + +export const metroCliSchemas = { + [METRO_COMMAND_NAME]: metroCliSchema, +} as const satisfies Record; + +export const metroCliReader: CliReader = (positionals, flags) => { + const action = (positionals[0] ?? '').toLowerCase(); + if (action !== 'prepare' && action !== 'reload') { + throw new AppError('INVALID_ARGS', 'metro requires a subcommand: prepare or reload'); + } + if (action === 'reload') { + return { + action, + metroHost: flags.metroHost, + metroPort: flags.metroPort, + bundleUrl: flags.bundleUrl, + timeoutMs: flags.metroProbeTimeoutMs, + }; + } + if (!flags.metroPublicBaseUrl && !flags.metroProxyBaseUrl) { + throw new AppError( + 'INVALID_ARGS', + 'metro prepare requires --public-base-url or --proxy-base-url .', + ); + } + return { + action, + projectRoot: flags.metroProjectRoot, + kind: readMetroPrepareKind(flags.kind ?? flags.metroKind), + port: flags.metroPreparePort, + listenHost: flags.metroListenHost, + statusHost: flags.metroStatusHost, + publicBaseUrl: flags.metroPublicBaseUrl, + proxyBaseUrl: flags.metroProxyBaseUrl, + bearerToken: flags.metroBearerToken, + bridgeScope: + flags.tenant && flags.runId && flags.leaseId + ? { + tenantId: flags.tenant, + runId: flags.runId, + leaseId: flags.leaseId, + } + : undefined, + startupTimeoutMs: flags.metroStartupTimeoutMs, + probeTimeoutMs: flags.metroProbeTimeoutMs, + reuseExisting: flags.metroNoReuseExisting ? false : undefined, + installDependenciesIfNeeded: flags.metroNoInstallDeps ? false : undefined, + runtimeFilePath: flags.metroRuntimeFile, + }; +}; + +export const metroCliReaders = { + metro: metroCliReader, +} satisfies Record; + +function toMetroPrepareOptions(input: MetroInput): MetroPrepareOptions { + return { + projectRoot: input.projectRoot, + kind: input.kind, + publicBaseUrl: input.publicBaseUrl, + proxyBaseUrl: input.proxyBaseUrl, + bearerToken: input.bearerToken, + bridgeScope: input.bridgeScope ?? metroBridgeScopeFromInput(input), + port: input.port, + listenHost: input.listenHost, + statusHost: input.statusHost, + startupTimeoutMs: input.startupTimeoutMs, + probeTimeoutMs: input.probeTimeoutMs, + reuseExisting: input.reuseExisting, + installDependenciesIfNeeded: input.installDependenciesIfNeeded, + runtimeFilePath: input.runtimeFilePath, + }; +} + +function metroBridgeScopeFromInput( + input: MetroInput & { + tenant?: string; + runId?: string; + leaseId?: string; + }, +): MetroPrepareOptions['bridgeScope'] { + return input.tenant && input.runId && input.leaseId + ? { tenantId: input.tenant, runId: input.runId, leaseId: input.leaseId } + : undefined; +} + +function toMetroReloadOptions(input: MetroInput): MetroReloadOptions { + return { + metroHost: input.metroHost, + metroPort: input.metroPort, + bundleUrl: input.bundleUrl, + timeoutMs: input.timeoutMs, + }; +} + +function readMetroPrepareKind(value: string | undefined): MetroPrepareKind | undefined { + if (value === undefined) return undefined; + if (value === 'auto' || value === 'react-native' || value === 'expo') return value; + throw new AppError('INVALID_ARGS', 'metro prepare --kind must be auto, react-native, or expo'); +} diff --git a/src/commands/observability/index.test.ts b/src/commands/observability/index.test.ts new file mode 100644 index 000000000..c01479431 --- /dev/null +++ b/src/commands/observability/index.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, test } from 'vitest'; +import type { CliFlags } from '../../utils/cli-flags.ts'; +import { + logsCliReader, + logsCommandDefinition, + logsCommandMetadata, + logsDaemonWriter, + networkCliReader, + networkCommandDefinition, + networkCommandMetadata, + networkDaemonWriter, + perfCliReader, + perfCommandDefinition, + perfCommandMetadata, + perfDaemonWriter, +} from './index.ts'; + +const NO_FLAGS = {} as CliFlags; + +function expectInvalidArgs(fn: () => unknown, messageFragment: string) { + expect(fn).toThrow( + expect.objectContaining({ + code: 'INVALID_ARGS', + message: expect.stringContaining(messageFragment), + }), + ); +} + +describe('observability command interface', () => { + test('owns perf, logs, and network public metadata', () => { + expect(perfCommandMetadata.name).toBe('perf'); + expect(perfCommandDefinition.name).toBe('perf'); + expect(logsCommandMetadata.name).toBe('logs'); + expect(logsCommandDefinition.name).toBe('logs'); + expect(networkCommandMetadata.name).toBe('network'); + expect(networkCommandDefinition.name).toBe('network'); + }); + + test('reads perf area, action, kind, and out flags', () => { + expect( + perfCliReader(['memory', 'snapshot'], { + kind: 'android-hprof', + out: './heap.hprof', + } as CliFlags), + ).toEqual({ + area: 'memory', + action: 'snapshot', + kind: 'android-hprof', + out: './heap.hprof', + }); + }); + + test('treats a single perf action as metrics action', () => { + expect(perfCliReader(['sample'], NO_FLAGS)).toEqual({ + action: 'sample', + kind: undefined, + out: undefined, + }); + expect(perfDaemonWriter({ action: 'sample' })).toMatchObject({ + command: 'perf', + positionals: ['metrics', 'sample'], + }); + }); + + test('reads logs action and message', () => { + expect(logsCliReader(['mark', 'checkout', 'started'], NO_FLAGS)).toEqual({ + action: 'mark', + message: 'checkout started', + restart: undefined, + }); + expect(logsDaemonWriter({ action: 'mark', message: 'checkout started' })).toMatchObject({ + command: 'logs', + positionals: ['mark', 'checkout started'], + }); + }); + + test('reads network include from flag or positional', () => { + expect(networkCliReader(['dump', '25', 'headers'], NO_FLAGS)).toEqual({ + action: 'dump', + limit: 25, + include: 'headers', + }); + expect( + networkCliReader(['dump', '25', 'headers'], { networkInclude: 'all' } as CliFlags), + ).toMatchObject({ + include: 'all', + }); + }); + + test('writes network include as daemon flag', () => { + expect(networkDaemonWriter({ action: 'dump', limit: 25, include: 'body' })).toMatchObject({ + command: 'network', + positionals: ['dump', '25'], + options: { networkInclude: 'body' }, + }); + }); + + test('rejects invalid observability positionals', () => { + expectInvalidArgs(() => perfCliReader(['memory', 'explode'], NO_FLAGS), 'perf action'); + expectInvalidArgs(() => logsCliReader(['explode'], NO_FLAGS), 'logs requires'); + expectInvalidArgs(() => networkCliReader(['explode'], NO_FLAGS), 'network requires'); + expectInvalidArgs( + () => networkCliReader(['dump', '25', 'explode'], NO_FLAGS), + 'network include', + ); + }); +}); diff --git a/src/commands/observability/index.ts b/src/commands/observability/index.ts new file mode 100644 index 000000000..baa1247cb --- /dev/null +++ b/src/commands/observability/index.ts @@ -0,0 +1,381 @@ +import type { LogsOptions, NetworkOptions, PerfOptions } from '../../client-types.ts'; +import { NETWORK_INCLUDE_MODES, type NetworkIncludeMode } from '../../contracts.ts'; +import { AppError } from '../../utils/errors.ts'; +import { parseStringMember } from '../../utils/string-enum.ts'; +import type { CommandSchemaOverride } from '../../utils/cli-command-schema-types.ts'; +import { booleanField, enumField, integerField, requiredField, stringField } from '../command-input.ts'; +import { defineExecutableCommand } from '../command-contract.ts'; +import { defineFieldCommandMetadata } from '../field-command-contract.ts'; +import { LOG_ACTION_VALUES, type LogAction } from '../log-command-contract.ts'; +import { + isPerfAction, + isPerfArea, + isPerfKind, + isPerfSubject, + PERF_ACTION_ERROR_MESSAGE, + PERF_ACTION_VALUES, + PERF_AREA_ERROR_MESSAGE, + PERF_AREA_VALUES, + PERF_KIND_ERROR_MESSAGE, + PERF_KIND_VALUES, + PERF_SUBJECT_ERROR_MESSAGE, + PERF_SUBJECT_VALUES, + type PerfAction, + type PerfArea, + type PerfKind, + type PerfSubject, +} from '../perf-command-contract.ts'; +import { + commonInputFromFlags, + direct, + optionalCliNumber, + optionalNumber, + optionalString, + request, +} from '../cli-grammar/common.ts'; +import type { CliReader, DaemonWriter } from '../cli-grammar/types.ts'; + +export const PERF_COMMAND_NAME = 'perf'; +export const LOGS_COMMAND_NAME = 'logs'; +export const NETWORK_COMMAND_NAME = 'network'; +export const DEBUG_COMMAND_NAME = 'debug'; +const NETWORK_ACTION_VALUES = ['dump', 'log'] as const; +const DEBUG_ACTION_VALUES = ['symbols'] as const; + +export const perfCommandDescription = + 'Show session performance, frame health, and memory diagnostics.'; +export const logsCommandDescription = 'Manage session app logs.'; +export const networkCommandDescription = 'Show recent HTTP traffic.'; +export const debugCommandDescription = 'Symbolicate crash artifacts with matching debug symbols.'; + +export const observabilityCommandDescriptions = { + [PERF_COMMAND_NAME]: perfCommandDescription, + [LOGS_COMMAND_NAME]: logsCommandDescription, + [NETWORK_COMMAND_NAME]: networkCommandDescription, + [DEBUG_COMMAND_NAME]: debugCommandDescription, +} as const; + +export const perfCommandMetadata = defineFieldCommandMetadata( + PERF_COMMAND_NAME, + perfCommandDescription, + { + area: enumField(PERF_AREA_VALUES), + subject: enumField(PERF_SUBJECT_VALUES), + action: enumField(PERF_ACTION_VALUES), + kind: enumField(PERF_KIND_VALUES), + template: stringField('xctrace template name, for example Time Profiler.'), + out: stringField(), + tracePath: stringField('Existing .trace path to report, defaults to the latest session trace.'), + }, +); + +export const logsCommandMetadata = defineFieldCommandMetadata( + LOGS_COMMAND_NAME, + logsCommandDescription, + { + action: enumField(LOG_ACTION_VALUES), + message: stringField(), + restart: booleanField(), + }, +); + +export const networkCommandMetadata = defineFieldCommandMetadata( + NETWORK_COMMAND_NAME, + networkCommandDescription, + { + action: enumField(NETWORK_ACTION_VALUES), + limit: integerField(), + include: enumField(NETWORK_INCLUDE_MODES), + }, +); + +export const debugCommandMetadata = defineFieldCommandMetadata( + DEBUG_COMMAND_NAME, + debugCommandDescription, + { + action: requiredField(enumField(DEBUG_ACTION_VALUES)), + artifact: requiredField(stringField('Apple crash artifact path (.ips, .crash, or .log).')), + dsym: stringField('Path to a matching .dSYM bundle.'), + searchPath: stringField('Directory to scan for matching .dSYM bundles.'), + out: stringField('Output path for the symbolicated artifact.'), + }, +); + +export const observabilityCommandMetadata = [ + perfCommandMetadata, + logsCommandMetadata, + networkCommandMetadata, + debugCommandMetadata, +] as const; + +export const perfCommandDefinition = defineExecutableCommand(perfCommandMetadata, (client, input) => + client.observability.perf(input), +); + +export const logsCommandDefinition = defineExecutableCommand(logsCommandMetadata, (client, input) => + client.observability.logs(input), +); + +export const networkCommandDefinition = defineExecutableCommand( + networkCommandMetadata, + (client, input) => client.observability.network(input), +); + +export const debugCommandDefinition = defineExecutableCommand(debugCommandMetadata, (client, input) => + client.debug.symbols(input), +); + +export const observabilityCommandDefinitions = [ + perfCommandDefinition, + logsCommandDefinition, + networkCommandDefinition, + debugCommandDefinition, +] as const; + +export const perfCliSchema = { + usageOverride: + 'perf [metrics|frames|memory] [sample|snapshot]\n agent-device perf memory sample --json\n agent-device perf memory snapshot [--kind android-hprof|memgraph] [--out ]\n agent-device perf cpu profile start|stop|report --kind xctrace [--template ] --out \n agent-device perf trace start|stop --kind xctrace [--template ] --out \n agent-device perf cpu profile start|stop|report --kind simpleperf [--out ]\n agent-device perf trace start|stop --kind perfetto [--out ]', + listUsageOverride: + 'perf [metrics|frames|memory] | perf cpu profile start|stop|report | perf trace start|stop', + helpDescription: + 'Show session performance metrics, focused frame/jank health, memory diagnostics artifacts, Apple xctrace artifacts, or Android native Simpleperf/Perfetto artifacts. Bare perf and metrics are aliases for perf metrics. Native perf output is agent evidence: compact state, artifact path, and size only; raw profiles/traces stay on disk.', + summary: 'Show performance metrics or collect native perf artifacts', + positionalArgs: ['area?', 'subjectOrAction?', 'action?'], + allowedFlags: ['kind', 'perfTemplate', 'out'], +} as const satisfies CommandSchemaOverride; + +export const logsCliSchema = { + usageOverride: + 'logs path | logs start | logs stop | logs clear [--restart] | logs doctor | logs mark [message...]', + helpDescription: 'Session app log info, start/stop streaming, diagnostics, and markers', + summary: 'Manage session app logs', + positionalArgs: ['path|start|stop|clear|doctor|mark', 'message?'], + allowsExtraPositionals: true, + allowedFlags: ['restart'], +} as const satisfies CommandSchemaOverride; + +export const networkCliSchema = { + usageOverride: + 'network dump [limit] [summary|headers|body|all] [--include summary|headers|body|all] | network log [limit] [summary|headers|body|all] [--include summary|headers|body|all]', + helpDescription: 'Dump recent HTTP(s) traffic parsed from the session app log', + summary: 'Show recent HTTP traffic', + positionalArgs: ['dump|log', 'limit?', 'include?'], + allowedFlags: ['networkInclude'], +} as const satisfies CommandSchemaOverride; + +export const debugCliSchema = { + usageOverride: + 'debug symbols --artifact (--dsym | --search-path ) [--out ]', + listUsageOverride: 'debug symbols --artifact --dsym ', + helpDescription: + 'Symbolicate Apple crash artifacts with matching dSYM UUIDs. This debug namespace is intentionally narrow: use logs for app logs, network for HTTP evidence, perf for performance samples, record/trace for media and traces, and react-devtools for React Native profiles.', + summary: 'Symbolicate Apple crash artifacts', + positionalArgs: ['symbols'], + allowedFlags: ['artifact', 'dsym', 'searchPath', 'out'], +} as const satisfies CommandSchemaOverride; + +export const observabilityCliSchemas = { + [PERF_COMMAND_NAME]: perfCliSchema, + [LOGS_COMMAND_NAME]: logsCliSchema, + [NETWORK_COMMAND_NAME]: networkCliSchema, + [DEBUG_COMMAND_NAME]: debugCliSchema, +} as const satisfies Record; + +export const perfCliReader: CliReader = (positionals, flags) => ({ + ...commonInputFromFlags(flags), + ...readPerfPositionals(positionals, { + kind: readPerfKindFlag(flags.kind), + template: flags.perfTemplate, + out: flags.out, + }), +}); + +export const logsCliReader: CliReader = (positionals, flags) => ({ + ...commonInputFromFlags(flags), + action: readLogsAction(positionals[0]), + message: positionals.slice(1).join(' ') || undefined, + restart: flags.restart, +}); + +export const networkCliReader: CliReader = (positionals, flags) => ({ + ...commonInputFromFlags(flags), + action: readNetworkAction(positionals[0]), + limit: optionalCliNumber(positionals[1]), + include: flags.networkInclude ?? readNetworkInclude(positionals[2]), +}); + +export const debugCliReader: CliReader = (positionals, flags) => ({ + ...commonInputFromFlags(flags), + action: readDebugAction(positionals[0]), + artifact: flags.artifact, + dsym: flags.dsym, + searchPath: flags.searchPath, + out: flags.out, +}); + +export const observabilityCliReaders = { + perf: perfCliReader, + logs: logsCliReader, + network: networkCliReader, + debug: debugCliReader, +} satisfies Record; + +export const perfDaemonWriter: DaemonWriter = direct(PERF_COMMAND_NAME, (input) => + perfPositionals(input as PerfOptions), +); + +export const logsDaemonWriter: DaemonWriter = direct(LOGS_COMMAND_NAME, (input) => + logsPositionals(input as LogsOptions), +); + +export const networkDaemonWriter: DaemonWriter = (input) => + request(NETWORK_COMMAND_NAME, networkPositionals(input as NetworkOptions), { + ...input, + networkInclude: input.include, + }); + +export const observabilityDaemonWriters = { + perf: perfDaemonWriter, + logs: logsDaemonWriter, + network: networkDaemonWriter, +} satisfies Record; + +function perfPositionals(input: PerfOptions): string[] { + const area = input.area ?? (input.action ? 'metrics' : undefined); + if (area === 'cpu') { + return nativePerfPositionals( + [ + ...optionalString(area), + ...optionalString(input.subject), + ...optionalString(input.action), + ...optionalString(input.kind), + ], + input, + ); + } + if (area === 'trace') { + return nativePerfPositionals( + [...optionalString(area), ...optionalString(input.action), ...optionalString(input.kind)], + input, + ); + } + return [...optionalString(area), ...optionalString(input.action)]; +} + +function nativePerfPositionals(base: string[], input: PerfOptions): string[] { + const positionals = [...base]; + if (input.template || input.out || input.tracePath) { + positionals.push(input.template ?? ''); + } + if (input.out || input.tracePath) { + positionals.push(input.out ?? ''); + } + if (input.tracePath) { + positionals.push(input.tracePath); + } + return positionals; +} + +function readPerfPositionals( + positionals: string[], + flags: Pick = {}, +): Pick { + if (positionals[0] !== undefined && positionals[1] === undefined) { + const action = readPerfAction(positionals[0], { allowUndefined: true }); + if (action) return { action, kind: readPerfKind(flags.kind), out: flags.out }; + } + const area = readPerfArea(positionals[0]); + if (area === 'cpu') { + return { + area, + subject: readPerfSubject(positionals[1]), + action: readPerfAction(positionals[2]), + kind: readPerfKind(flags.kind), + template: flags.template, + out: flags.out, + }; + } + if (area === 'trace') { + return { + area, + action: readPerfAction(positionals[1]), + kind: readPerfKind(flags.kind), + template: flags.template, + out: flags.out, + }; + } + return { + area, + action: readPerfAction(positionals[1]), + kind: readPerfKind(flags.kind), + out: flags.out, + }; +} + +function logsPositionals(input: { action?: string; message?: string }): string[] { + return [input.action ?? 'path', ...optionalString(input.message)]; +} + +function networkPositionals(input: NetworkOptions): string[] { + return [...(input.action ? [input.action] : []), ...optionalNumber(input.limit)]; +} + +function readPerfArea(value: string | undefined): PerfArea | undefined { + if (value === undefined) return undefined; + const normalized = value.toLowerCase(); + if (isPerfArea(normalized)) return normalized; + throw new AppError('INVALID_ARGS', PERF_AREA_ERROR_MESSAGE); +} + +function readPerfAction( + value: string | undefined, + options: { allowUndefined?: boolean } = {}, +): PerfAction | undefined { + if (value === undefined) return undefined; + const normalized = value.toLowerCase(); + if (isPerfAction(normalized)) return normalized; + if (options.allowUndefined) return undefined; + throw new AppError('INVALID_ARGS', PERF_ACTION_ERROR_MESSAGE); +} + +function readPerfSubject(value: string | undefined): PerfSubject { + const normalized = value?.toLowerCase(); + if (normalized !== undefined && isPerfSubject(normalized)) return normalized; + throw new AppError('INVALID_ARGS', PERF_SUBJECT_ERROR_MESSAGE); +} + +function readPerfKind(value: string | undefined): PerfKind | undefined { + if (value === undefined) return undefined; + const normalized = value.toLowerCase(); + if (isPerfKind(normalized)) return normalized; + throw new AppError('INVALID_ARGS', PERF_KIND_ERROR_MESSAGE); +} + +function readPerfKindFlag(value: unknown): PerfKind | undefined { + return typeof value === 'string' ? readPerfKind(value) : undefined; +} + +function readLogsAction(value: string | undefined): LogAction | undefined { + if (value === undefined) return undefined; + return parseStringMember(LOG_ACTION_VALUES, value, { + message: 'logs requires path, start, stop, doctor, mark, or clear', + }); +} + +function readNetworkAction(value: string | undefined): 'dump' | 'log' | undefined { + if (value === undefined) return undefined; + if (value === 'dump' || value === 'log') return value; + throw new AppError('INVALID_ARGS', 'network requires dump or log'); +} + +function readNetworkInclude(value: string | undefined): NetworkIncludeMode | undefined { + if (value === undefined) return undefined; + return parseStringMember(NETWORK_INCLUDE_MODES, value, { + message: 'network include must be summary, headers, body, or all', + }); +} + +function readDebugAction(value: string | undefined): 'symbols' { + if (value === 'symbols') return value; + throw new AppError('INVALID_ARGS', 'debug requires symbols'); +} diff --git a/src/commands/react-native/index.test.ts b/src/commands/react-native/index.test.ts new file mode 100644 index 000000000..61081a037 --- /dev/null +++ b/src/commands/react-native/index.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from 'vitest'; +import type { CliFlags } from '../../utils/cli-flags.ts'; +import { + reactNativeCliReader, + reactNativeCommandDefinition, + reactNativeCommandMetadata, + reactNativeDaemonWriter, +} from './index.ts'; + +const NO_FLAGS = {} as CliFlags; + +function expectInvalidArgs(fn: () => unknown, messageFragment: string) { + expect(fn).toThrow( + expect.objectContaining({ + code: 'INVALID_ARGS', + message: expect.stringContaining(messageFragment), + }), + ); +} + +describe('react-native command interface', () => { + test('owns its public metadata', () => { + expect(reactNativeCommandMetadata.name).toBe('react-native'); + expect(reactNativeCommandDefinition.name).toBe('react-native'); + expect(reactNativeCommandMetadata.description).toContain('React Native'); + }); + + test('reads the dismiss-overlay CLI action', () => { + expect(reactNativeCliReader(['dismiss-overlay'], NO_FLAGS)).toEqual({ + action: 'dismiss-overlay', + }); + }); + + test('rejects unsupported CLI actions', () => { + expectInvalidArgs( + () => reactNativeCliReader(['reload'], NO_FLAGS), + 'react-native supports only', + ); + }); + + test('writes daemon request positionals', () => { + expect(reactNativeDaemonWriter({ action: 'dismiss-overlay' })).toMatchObject({ + command: 'react-native', + positionals: ['dismiss-overlay'], + options: { action: 'dismiss-overlay' }, + }); + }); +}); diff --git a/src/commands/react-native/index.ts b/src/commands/react-native/index.ts new file mode 100644 index 000000000..70bceb860 --- /dev/null +++ b/src/commands/react-native/index.ts @@ -0,0 +1,63 @@ +import { AppError } from '../../utils/errors.ts'; +import type { CommandSchemaOverride } from '../../utils/cli-command-schema-types.ts'; +import { defineExecutableCommand } from '../command-contract.ts'; +import { enumField, requiredField } from '../command-input.ts'; +import { defineFieldCommandMetadata } from '../field-command-contract.ts'; +import { commonInputFromFlags, direct, requiredDaemonString } from '../cli-grammar/common.ts'; +import type { CliReader, DaemonWriter } from '../cli-grammar/types.ts'; + +export const REACT_NATIVE_COMMAND_NAME = 'react-native'; +export const REACT_NATIVE_ACTION_VALUES = ['dismiss-overlay'] as const; + +export const reactNativeCommandDescription = 'Run supported React Native app automation helpers.'; + +export const reactNativeCommandDescriptions = { + [REACT_NATIVE_COMMAND_NAME]: reactNativeCommandDescription, +} as const; + +export const reactNativeCommandMetadata = defineFieldCommandMetadata( + REACT_NATIVE_COMMAND_NAME, + reactNativeCommandDescription, + { + action: requiredField(enumField(REACT_NATIVE_ACTION_VALUES)), + }, +); + +export const reactNativeCommandDefinition = defineExecutableCommand( + reactNativeCommandMetadata, + (client, input) => client.command.reactNative(input), +); + +export const reactNativeCliSchema = { + usageOverride: 'react-native dismiss-overlay', + listUsageOverride: 'react-native dismiss-overlay', + positionalArgs: ['dismiss-overlay'], +} as const satisfies CommandSchemaOverride; + +export const reactNativeCliSchemas = { + [REACT_NATIVE_COMMAND_NAME]: reactNativeCliSchema, +} as const satisfies Record; + +export const reactNativeCliReader: CliReader = (positionals, flags) => ({ + ...commonInputFromFlags(flags), + action: readReactNativeAction(positionals[0]), +}); + +export const reactNativeCliReaders = { + 'react-native': reactNativeCliReader, +} satisfies Record; + +export const reactNativeDaemonWriter: DaemonWriter = direct(REACT_NATIVE_COMMAND_NAME, (input) => [ + requiredDaemonString(input.action, 'react-native requires action'), +]); + +export const reactNativeDaemonWriters = { + 'react-native': reactNativeDaemonWriter, +} satisfies Record; + +function readReactNativeAction(value: string | undefined): 'dismiss-overlay' { + if (value === 'dismiss-overlay') return value; + throw new AppError('INVALID_ARGS', 'react-native supports only: dismiss-overlay'); +} + +export * from './overlay.ts'; diff --git a/src/commands/recording/index.test.ts b/src/commands/recording/index.test.ts new file mode 100644 index 000000000..8b7f39dba --- /dev/null +++ b/src/commands/recording/index.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from 'vitest'; +import type { CliFlags } from '../../utils/cli-flags.ts'; +import { + recordCliReader, + recordCommandDefinition, + recordCommandMetadata, + recordDaemonWriter, + traceCliReader, + traceCommandDefinition, + traceCommandMetadata, + traceDaemonWriter, +} from './index.ts'; + +const NO_FLAGS = {} as CliFlags; + +function expectInvalidArgs(fn: () => unknown, messageFragment: string) { + expect(fn).toThrow( + expect.objectContaining({ + code: 'INVALID_ARGS', + message: expect.stringContaining(messageFragment), + }), + ); +} + +describe('recording command interface', () => { + test('owns record and trace public metadata', () => { + expect(recordCommandMetadata.name).toBe('record'); + expect(recordCommandDefinition.name).toBe('record'); + expect(traceCommandMetadata.name).toBe('trace'); + expect(traceCommandDefinition.name).toBe('trace'); + }); + + test('reads record CLI input with recording flags', () => { + expect( + recordCliReader(['start', './capture.mp4'], { + fps: 30, + quality: 7, + hideTouches: true, + } as CliFlags), + ).toEqual({ + action: 'start', + path: './capture.mp4', + fps: 30, + quality: 7, + hideTouches: true, + }); + }); + + test('reads trace CLI input', () => { + expect(traceCliReader(['stop', './diagnostics.trace'], NO_FLAGS)).toEqual({ + action: 'stop', + path: './diagnostics.trace', + }); + }); + + test('rejects unsupported recording actions', () => { + expectInvalidArgs(() => recordCliReader(['pause'], NO_FLAGS), 'record requires start|stop'); + expectInvalidArgs(() => traceCliReader(['pause'], NO_FLAGS), 'trace requires start|stop'); + }); + + test('writes record and trace daemon request positionals', () => { + expect(recordDaemonWriter({ action: 'start', path: './capture.mp4' })).toMatchObject({ + command: 'record', + positionals: ['start', './capture.mp4'], + }); + expect(traceDaemonWriter({ action: 'stop', path: './diagnostics.trace' })).toMatchObject({ + command: 'trace', + positionals: ['stop', './diagnostics.trace'], + }); + }); +}); diff --git a/src/commands/recording/index.ts b/src/commands/recording/index.ts new file mode 100644 index 000000000..f1104c121 --- /dev/null +++ b/src/commands/recording/index.ts @@ -0,0 +1,133 @@ +import type { RecordOptions } from '../../client-types.ts'; +import { AppError } from '../../utils/errors.ts'; +import type { CommandSchemaOverride } from '../../utils/cli-command-schema-types.ts'; +import { defineExecutableCommand } from '../command-contract.ts'; +import { + booleanField, + enumField, + integerField, + integerSchema, + jsonSchemaField, + requiredField, + stringField, +} from '../command-input.ts'; +import { defineFieldCommandMetadata } from '../field-command-contract.ts'; +import { commonInputFromFlags, direct, optionalString } from '../cli-grammar/common.ts'; +import type { CliReader, DaemonWriter } from '../cli-grammar/types.ts'; + +export const RECORD_COMMAND_NAME = 'record'; +export const TRACE_COMMAND_NAME = 'trace'; +export const RECORDING_ACTION_VALUES = ['start', 'stop'] as const; + +export const recordCommandDescription = 'Start or stop screen recording.'; +export const traceCommandDescription = 'Start or stop trace capture.'; + +export const recordingCommandDescriptions = { + [RECORD_COMMAND_NAME]: recordCommandDescription, + [TRACE_COMMAND_NAME]: traceCommandDescription, +} as const; + +export const recordCommandMetadata = defineFieldCommandMetadata( + RECORD_COMMAND_NAME, + recordCommandDescription, + { + action: requiredField(enumField(RECORDING_ACTION_VALUES)), + path: stringField(), + fps: integerField(), + quality: jsonSchemaField(integerSchema()), + hideTouches: booleanField(), + }, +); + +export const traceCommandMetadata = defineFieldCommandMetadata( + TRACE_COMMAND_NAME, + traceCommandDescription, + { + action: requiredField(enumField(RECORDING_ACTION_VALUES)), + path: stringField(), + }, +); + +export const recordingCommandMetadata = [recordCommandMetadata, traceCommandMetadata] as const; + +export const recordCommandDefinition = defineExecutableCommand( + recordCommandMetadata, + (client, input) => client.recording.record(input as RecordOptions), +); + +export const traceCommandDefinition = defineExecutableCommand( + traceCommandMetadata, + (client, input) => client.recording.trace(input), +); + +export const recordingCommandDefinitions = [ + recordCommandDefinition, + traceCommandDefinition, +] as const; + +export const recordCliSchema = { + usageOverride: + 'record start [path] [--fps ] [--quality <5-10>] [--hide-touches] | record stop', + listUsageOverride: 'record start [path] | record stop', + helpDescription: + 'Start/stop screen recording; Android recordings longer than the 180s adb screenrecord limit are returned as multiple MP4 chunks', + summary: 'Start or stop screen recording', + positionalArgs: ['start|stop', 'path?'], + allowedFlags: ['fps', 'quality', 'hideTouches'], +} as const satisfies CommandSchemaOverride; + +export const traceCliSchema = { + usageOverride: 'trace start | trace stop ', + listUsageOverride: 'trace start | trace stop ', + helpDescription: + 'Start/stop trace log capture; when an artifact path is requested, pass the same positional path to start and stop', + summary: 'Start or stop trace capture', + positionalArgs: ['start|stop', 'path?'], +} as const satisfies CommandSchemaOverride; + +export const recordingCliSchemas = { + [RECORD_COMMAND_NAME]: recordCliSchema, + [TRACE_COMMAND_NAME]: traceCliSchema, +} as const satisfies Record; + +export const recordCliReader: CliReader = (positionals, flags) => ({ + ...commonInputFromFlags(flags), + action: readRecordingAction(positionals[0], RECORD_COMMAND_NAME), + path: positionals[1], + fps: flags.fps, + quality: flags.quality as RecordOptions['quality'], + hideTouches: flags.hideTouches, +}); + +export const traceCliReader: CliReader = (positionals, flags) => ({ + ...commonInputFromFlags(flags), + action: readRecordingAction(positionals[0], TRACE_COMMAND_NAME), + path: positionals[1], +}); + +export const recordingCliReaders = { + record: recordCliReader, + trace: traceCliReader, +} satisfies Record; + +export const recordDaemonWriter: DaemonWriter = direct(RECORD_COMMAND_NAME, (input) => + recordingPositionals(input as RecordOptions), +); + +export const traceDaemonWriter: DaemonWriter = direct(TRACE_COMMAND_NAME, (input) => + recordingPositionals(input as RecordOptions), +); + +export const recordingDaemonWriters = { + record: recordDaemonWriter, + trace: traceDaemonWriter, +} satisfies Record; + +function recordingPositionals(input: RecordOptions): string[] { + return [input.action, ...optionalString(input.path)]; +} + +function readRecordingAction(value: string | undefined, command: string): 'start' | 'stop' { + if (value === 'start' || value === 'stop') return value; + throw new AppError('INVALID_ARGS', `${command} requires start|stop`); +} diff --git a/src/commands/replay/index.test.ts b/src/commands/replay/index.test.ts new file mode 100644 index 000000000..bda3c1fda --- /dev/null +++ b/src/commands/replay/index.test.ts @@ -0,0 +1,129 @@ +import { afterEach, describe, expect, test } from 'vitest'; +import type { CliFlags } from '../../utils/cli-flags.ts'; +import { + replayCliReader, + replayCommandDefinition, + replayCommandMetadata, + replayDaemonWriter, + testCliReader, + testCommandDefinition, + testCommandMetadata, + testDaemonWriter, +} from './index.ts'; + +const ORIGINAL_AD_VAR = process.env.AD_VAR_REPLAY_TEST; + +function flags(overrides: Partial = {}): CliFlags { + return overrides as CliFlags; +} + +function expectInvalidArgs(fn: () => unknown, messageFragment: string) { + expect(fn).toThrow( + expect.objectContaining({ + code: 'INVALID_ARGS', + message: expect.stringContaining(messageFragment), + }), + ); +} + +afterEach(() => { + if (ORIGINAL_AD_VAR === undefined) { + delete process.env.AD_VAR_REPLAY_TEST; + } else { + process.env.AD_VAR_REPLAY_TEST = ORIGINAL_AD_VAR; + } +}); + +describe('replay command interface', () => { + test('owns replay and test public metadata', () => { + expect(replayCommandMetadata.name).toBe('replay'); + expect(replayCommandDefinition.name).toBe('replay'); + expect(testCommandMetadata.name).toBe('test'); + expect(testCommandDefinition.name).toBe('test'); + }); + + test('reads replay CLI input', () => { + expect( + replayCliReader( + ['./checkout.ad'], + flags({ + replayUpdate: true, + replayMaestro: true, + replayEnv: ['FOO=bar'], + }), + ), + ).toEqual({ + path: './checkout.ad', + update: true, + backend: 'maestro', + env: ['FOO=bar'], + }); + }); + + test('rejects missing replay path', () => { + expectInvalidArgs(() => replayCliReader([], flags()), 'replay requires path'); + }); + + test('reads test CLI input', () => { + expect( + testCliReader( + ['./suite-a.ad', './suite-b.ad'], + flags({ + replayUpdate: true, + replayMaestro: true, + replayEnv: ['FOO=bar'], + failFast: true, + timeoutMs: 10_000, + retries: 2, + recordVideo: true, + artifactsDir: './artifacts', + reportJunit: './junit.xml', + shardAll: 4, + shardSplit: 2, + }), + ), + ).toMatchObject({ + paths: ['./suite-a.ad', './suite-b.ad'], + update: true, + backend: 'maestro', + env: ['FOO=bar'], + failFast: true, + timeoutMs: 10_000, + retries: 2, + recordVideo: true, + artifactsDir: './artifacts', + reportJunit: './junit.xml', + shardAll: 4, + shardSplit: 2, + }); + }); + + test('writes daemon replay and test requests with replay flags', () => { + process.env.AD_VAR_REPLAY_TEST = 'enabled'; + expect( + replayDaemonWriter({ + path: './checkout.ad', + update: true, + backend: 'maestro', + env: ['FOO=bar'], + }), + ).toMatchObject({ + command: 'replay', + positionals: ['./checkout.ad'], + options: { + replayUpdate: true, + replayBackend: 'maestro', + replayEnv: ['FOO=bar'], + replayShellEnv: { AD_VAR_REPLAY_TEST: 'enabled' }, + }, + }); + + expect(testDaemonWriter({ paths: ['./suite.ad'], maestro: true })).toMatchObject({ + command: 'test', + positionals: ['./suite.ad'], + options: { + replayBackend: 'maestro', + }, + }); + }); +}); diff --git a/src/commands/replay/index.ts b/src/commands/replay/index.ts new file mode 100644 index 000000000..a2891b20f --- /dev/null +++ b/src/commands/replay/index.ts @@ -0,0 +1,173 @@ +import type { CommandSchemaOverride } from '../../utils/cli-command-schema-types.ts'; +import { defineExecutableCommand } from '../command-contract.ts'; +import { + booleanField, + integerField, + requiredField, + stringArrayField, + stringField, +} from '../command-input.ts'; +import { defineFieldCommandMetadata } from '../field-command-contract.ts'; +import { + commonInputFromFlags, + request, + requiredDaemonString, + requiredString, +} from '../cli-grammar/common.ts'; +import type { CliReader, CommandInput, DaemonWriter } from '../cli-grammar/types.ts'; +import { REPLAY_FLAGS } from '../../utils/cli-flags.ts'; + +export const REPLAY_COMMAND_NAME = 'replay'; +export const TEST_COMMAND_NAME = 'test'; + +const REPLAY_SHELL_ENV_PREFIX = 'AD_VAR_'; + +export const replayCommandDescription = 'Replay a recorded session.'; +export const testCommandDescription = 'Run one or more replay scripts.'; + +export const replayCommandDescriptions = { + [REPLAY_COMMAND_NAME]: replayCommandDescription, + [TEST_COMMAND_NAME]: testCommandDescription, +} as const; + +export const replayCommandMetadata = defineFieldCommandMetadata( + REPLAY_COMMAND_NAME, + replayCommandDescription, + { + path: requiredField(stringField()), + update: booleanField(), + backend: stringField(), + maestro: booleanField(), + env: stringArrayField(), + }, +); + +export const testCommandMetadata = defineFieldCommandMetadata( + TEST_COMMAND_NAME, + testCommandDescription, + { + paths: requiredField(stringArrayField()), + update: booleanField(), + backend: stringField(), + maestro: booleanField(), + env: stringArrayField(), + failFast: booleanField(), + timeoutMs: integerField(), + retries: integerField(), + recordVideo: booleanField(), + artifactsDir: stringField(), + reportJunit: stringField(), + shardAll: integerField(), + shardSplit: integerField(), + }, +); + +export const replayCommandMetadataList = [replayCommandMetadata, testCommandMetadata] as const; + +export const replayCommandDefinition = defineExecutableCommand( + replayCommandMetadata, + (client, input) => client.replay.run(input), +); + +export const testCommandDefinition = defineExecutableCommand(testCommandMetadata, (client, input) => + client.replay.test(input), +); + +export const replayCommandDefinitions = [replayCommandDefinition, testCommandDefinition] as const; + +export const replayCliSchema = { + usageOverride: 'replay | replay export [--format maestro] [--out ]', + positionalArgs: ['path'], + allowsExtraPositionals: true, + allowedFlags: ['replayMaestro', 'replayExportFormat', ...REPLAY_FLAGS, 'timeoutMs', 'out'], +} as const satisfies CommandSchemaOverride; + +export const testCliSchema = { + usageOverride: 'test ...', + listUsageOverride: 'test ...', + helpDescription: 'Run one or more replay scripts as a serial test suite', + summary: 'Run replay test suites', + positionalArgs: ['pathOrGlob'], + allowsExtraPositionals: true, + allowedFlags: [ + 'replayMaestro', + ...REPLAY_FLAGS, + 'failFast', + 'timeoutMs', + 'retries', + 'recordVideo', + 'artifactsDir', + 'reportJunit', + 'shardAll', + 'shardSplit', + ], +} as const satisfies CommandSchemaOverride; + +export const replayCliSchemas = { + [REPLAY_COMMAND_NAME]: replayCliSchema, + [TEST_COMMAND_NAME]: testCliSchema, +} as const satisfies Record; + +export const replayCliReader: CliReader = (positionals, flags) => ({ + ...commonInputFromFlags(flags), + path: requiredString(positionals[0], 'replay requires path'), + update: flags.replayUpdate, + backend: flags.replayMaestro ? 'maestro' : undefined, + env: flags.replayEnv, +}); + +export const testCliReader: CliReader = (positionals, flags) => ({ + ...commonInputFromFlags(flags), + paths: positionals, + update: flags.replayUpdate, + backend: flags.replayMaestro ? 'maestro' : undefined, + env: flags.replayEnv, + failFast: flags.failFast, + timeoutMs: flags.timeoutMs, + retries: flags.retries, + recordVideo: flags.recordVideo, + artifactsDir: flags.artifactsDir, + reportJunit: flags.reportJunit, + shardAll: flags.shardAll, + shardSplit: flags.shardSplit, +}); + +export const replayDaemonWriter: DaemonWriter = (input) => + request(REPLAY_COMMAND_NAME, [requiredDaemonString(input.path, 'replay requires path')], { + ...input, + replayUpdate: input.update, + replayBackend: readReplayBackend(input), + replayEnv: input.env, + replayShellEnv: collectReplayClientShellEnv(process.env), + }); + +export const testDaemonWriter: DaemonWriter = (input) => + request(TEST_COMMAND_NAME, input.paths ?? [], { + ...input, + replayUpdate: input.update, + replayBackend: readReplayBackend(input), + replayEnv: input.env, + replayShellEnv: collectReplayClientShellEnv(process.env), + }); + +export const replayCliReaders = { + replay: replayCliReader, + test: testCliReader, +} satisfies Record; + +export const replayDaemonWriters = { + replay: replayDaemonWriter, + test: testDaemonWriter, +} satisfies Record; + +function readReplayBackend(input: CommandInput): string | undefined { + return input.backend ?? (input.maestro === true ? 'maestro' : undefined); +} + +function collectReplayClientShellEnv(env: NodeJS.ProcessEnv): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(env)) { + if (typeof value === 'string' && key.startsWith(REPLAY_SHELL_ENV_PREFIX)) result[key] = value; + } + return result; +} diff --git a/src/commands/system/index.test.ts b/src/commands/system/index.test.ts new file mode 100644 index 000000000..8539cc9fb --- /dev/null +++ b/src/commands/system/index.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, test } from 'vitest'; +import type { CliFlags } from '../../utils/cli-flags.ts'; +import { + appStateCliReader, + appStateDaemonWriter, + appSwitcherCliReader, + appSwitcherDaemonWriter, + backCliReader, + backDaemonWriter, + clipboardCliReader, + clipboardDaemonWriter, + homeCliReader, + homeDaemonWriter, + keyboardCliReader, + keyboardDaemonWriter, + rotateCliReader, + rotateDaemonWriter, +} from './index.ts'; + +function flags(overrides: Partial = {}): CliFlags { + return overrides as CliFlags; +} + +function expectInvalidArgs(fn: () => unknown, messageFragment: string) { + expect(fn).toThrow( + expect.objectContaining({ + code: 'INVALID_ARGS', + message: expect.stringContaining(messageFragment), + }), + ); +} + +describe('system command interface', () => { + test('parameterless readers project common selection flags through', () => { + for (const reader of [appStateCliReader, homeCliReader, appSwitcherCliReader]) { + expect(reader([], flags({ platform: 'ios' }))).toEqual({ + platform: 'ios', + }); + } + }); + + test('parameterless daemon writers emit command names with no positionals', () => { + expect(appStateDaemonWriter({})).toMatchObject({ command: 'appstate', positionals: [] }); + expect(homeDaemonWriter({})).toMatchObject({ command: 'home', positionals: [] }); + expect(appSwitcherDaemonWriter({})).toMatchObject({ + command: 'app-switcher', + positionals: [], + }); + }); + + test('back reader and writer normalize back mode', () => { + expect(backCliReader([], flags({ backMode: 'system' }))).toMatchObject({ + mode: 'system', + }); + expect(backDaemonWriter({ mode: 'in-app' }).options).toMatchObject({ + backMode: 'in-app', + }); + expect( + ( + backDaemonWriter({ mode: 'teleport' } as unknown as Record) + .options as Record + ).backMode, + ).toBeUndefined(); + }); + + test('rotate reader and writer normalize orientation', () => { + expect(rotateCliReader(['left'], flags())).toMatchObject({ + orientation: 'landscape-left', + }); + expect(rotateDaemonWriter({ orientation: 'portrait' }).positionals).toEqual(['portrait']); + }); + + test('rotate reader and writer reject missing orientation', () => { + expectInvalidArgs(() => rotateCliReader([], flags()), 'rotate requires an orientation'); + expectInvalidArgs(() => rotateDaemonWriter({}), 'rotate requires orientation'); + }); + + test('keyboard reader maps aliases and validates arguments', () => { + expect(keyboardCliReader(['get'], flags())).toMatchObject({ action: 'status' }); + expect(keyboardCliReader([], flags())).not.toHaveProperty('action'); + expectInvalidArgs( + () => keyboardCliReader(['dismiss', 'extra'], flags()), + 'at most one action argument', + ); + expectInvalidArgs(() => keyboardCliReader(['wiggle'], flags()), 'keyboard action must be'); + }); + + test('keyboard writer forwards action when present', () => { + expect(keyboardDaemonWriter({ action: 'dismiss' }).positionals).toEqual(['dismiss']); + expect(keyboardDaemonWriter({}).positionals).toEqual([]); + }); + + test('clipboard reader parses read and write subcommands', () => { + expect(clipboardCliReader(['read'], flags())).toMatchObject({ action: 'read' }); + expect(clipboardCliReader(['write', 'hello', 'world'], flags())).toMatchObject({ + action: 'write', + text: 'hello world', + }); + }); + + test('clipboard reader rejects invalid subcommands', () => { + expectInvalidArgs(() => clipboardCliReader([], flags()), 'read or write'); + expectInvalidArgs( + () => clipboardCliReader(['read', 'oops'], flags()), + 'does not accept additional arguments', + ); + expectInvalidArgs( + () => clipboardCliReader(['write'], flags()), + 'clipboard write requires text', + ); + }); + + test('clipboard writer serializes read and write subcommands', () => { + expect(clipboardDaemonWriter({ action: 'read' }).positionals).toEqual(['read']); + expect(clipboardDaemonWriter({ action: 'write', text: 'copied' }).positionals).toEqual([ + 'write', + 'copied', + ]); + }); +}); diff --git a/src/commands/system/index.ts b/src/commands/system/index.ts new file mode 100644 index 000000000..b759ec871 --- /dev/null +++ b/src/commands/system/index.ts @@ -0,0 +1,305 @@ +import type { ClipboardCommandOptions } from '../../client-types.ts'; +import type { BackMode } from '../../core/back-mode.ts'; +import { BACK_MODES } from '../../core/back-mode.ts'; +import { parseDeviceRotation, DEVICE_ROTATIONS } from '../../core/device-rotation.ts'; +import { AppError } from '../../utils/errors.ts'; +import type { CommandSchemaOverride } from '../../utils/cli-command-schema-types.ts'; +import { defineExecutableCommand } from '../command-contract.ts'; +import { compactRecord, enumField, requiredField, stringField } from '../command-input.ts'; +import { defineFieldCommandMetadata } from '../field-command-contract.ts'; +import { + commonInputFromFlags, + direct, + optionalString, + request, + requiredDaemonString, +} from '../cli-grammar/common.ts'; +import type { CliReader, DaemonWriter } from '../cli-grammar/types.ts'; + +export const APPSTATE_COMMAND_NAME = 'appstate'; +export const BACK_COMMAND_NAME = 'back'; +export const HOME_COMMAND_NAME = 'home'; +export const ROTATE_COMMAND_NAME = 'rotate'; +export const APP_SWITCHER_COMMAND_NAME = 'app-switcher'; +export const KEYBOARD_COMMAND_NAME = 'keyboard'; +export const CLIPBOARD_COMMAND_NAME = 'clipboard'; + +const CLIPBOARD_ACTION_VALUES = ['read', 'write'] as const; +const KEYBOARD_METADATA_ACTION_VALUES = ['status', 'dismiss'] as const; + +export const appStateCommandDescription = 'Show foreground app or activity.'; +export const backCommandDescription = 'Navigate back.'; +export const homeCommandDescription = 'Go to the home screen.'; +export const rotateCommandDescription = 'Rotate device orientation.'; +export const appSwitcherCommandDescription = 'Open the app switcher.'; +export const keyboardCommandDescription = 'Inspect or dismiss the keyboard.'; +export const clipboardCommandDescription = 'Read or write clipboard text.'; + +export const systemCommandDescriptions = { + [APPSTATE_COMMAND_NAME]: appStateCommandDescription, + [BACK_COMMAND_NAME]: backCommandDescription, + [HOME_COMMAND_NAME]: homeCommandDescription, + [ROTATE_COMMAND_NAME]: rotateCommandDescription, + [APP_SWITCHER_COMMAND_NAME]: appSwitcherCommandDescription, + [KEYBOARD_COMMAND_NAME]: keyboardCommandDescription, + [CLIPBOARD_COMMAND_NAME]: clipboardCommandDescription, +} as const; + +export const appStateCommandMetadata = defineFieldCommandMetadata( + APPSTATE_COMMAND_NAME, + appStateCommandDescription, + {}, +); + +export const backCommandMetadata = defineFieldCommandMetadata( + BACK_COMMAND_NAME, + backCommandDescription, + { + mode: enumField(BACK_MODES), + }, +); + +export const homeCommandMetadata = defineFieldCommandMetadata( + HOME_COMMAND_NAME, + homeCommandDescription, + {}, +); + +export const rotateCommandMetadata = defineFieldCommandMetadata( + ROTATE_COMMAND_NAME, + rotateCommandDescription, + { + orientation: requiredField(enumField(DEVICE_ROTATIONS)), + }, +); + +export const appSwitcherCommandMetadata = defineFieldCommandMetadata( + APP_SWITCHER_COMMAND_NAME, + appSwitcherCommandDescription, + {}, +); + +export const keyboardCommandMetadata = defineFieldCommandMetadata( + KEYBOARD_COMMAND_NAME, + keyboardCommandDescription, + { + action: enumField(KEYBOARD_METADATA_ACTION_VALUES), + }, +); + +export const clipboardCommandMetadata = defineFieldCommandMetadata( + CLIPBOARD_COMMAND_NAME, + clipboardCommandDescription, + { + action: requiredField(enumField(CLIPBOARD_ACTION_VALUES)), + text: stringField(), + }, +); + +export const systemCommandMetadata = [ + appStateCommandMetadata, + backCommandMetadata, + homeCommandMetadata, + rotateCommandMetadata, + appSwitcherCommandMetadata, + keyboardCommandMetadata, + clipboardCommandMetadata, +] as const; + +export const appStateCommandDefinition = defineExecutableCommand( + appStateCommandMetadata, + (client, input) => client.command.appState(input), +); + +export const backCommandDefinition = defineExecutableCommand(backCommandMetadata, (client, input) => + client.command.back(input), +); + +export const homeCommandDefinition = defineExecutableCommand(homeCommandMetadata, (client, input) => + client.command.home(input), +); + +export const rotateCommandDefinition = defineExecutableCommand( + rotateCommandMetadata, + (client, input) => client.command.rotate(input), +); + +export const appSwitcherCommandDefinition = defineExecutableCommand( + appSwitcherCommandMetadata, + (client, input) => client.command.appSwitcher(input), +); + +export const keyboardCommandDefinition = defineExecutableCommand( + keyboardCommandMetadata, + (client, input) => client.command.keyboard(input), +); + +export const clipboardCommandDefinition = defineExecutableCommand( + clipboardCommandMetadata, + (client, input) => client.command.clipboard(input as ClipboardCommandOptions), +); + +export const systemCommandDefinitions = [ + appStateCommandDefinition, + backCommandDefinition, + homeCommandDefinition, + rotateCommandDefinition, + appSwitcherCommandDefinition, + keyboardCommandDefinition, + clipboardCommandDefinition, +] as const; + +export const appStateCliSchema = { + helpDescription: 'Show foreground app/activity', +} as const satisfies CommandSchemaOverride; + +export const backCliSchema = { + usageOverride: 'back [--in-app|--system]', + allowedFlags: ['backMode'], +} as const satisfies CommandSchemaOverride; + +export const rotateCliSchema = { + usageOverride: 'rotate ', + helpDescription: 'Rotate device orientation on iOS and Android', + positionalArgs: ['orientation'], +} as const satisfies CommandSchemaOverride; + +export const keyboardCliSchema = { + usageOverride: 'keyboard [status|get|dismiss|enter|return]', + helpDescription: 'Inspect Android keyboard visibility/type or press/dismiss the device keyboard', + summary: 'Inspect, press, or dismiss the device keyboard', + positionalArgs: ['action?'], +} as const satisfies CommandSchemaOverride; + +export const clipboardCliSchema = { + usageOverride: 'clipboard read | clipboard write ', + listUsageOverride: 'clipboard read | clipboard write ', + helpDescription: 'Read or write device clipboard text', + positionalArgs: ['read|write', 'text?'], + allowsExtraPositionals: true, +} as const satisfies CommandSchemaOverride; + +export const systemCliSchemas = { + [APPSTATE_COMMAND_NAME]: appStateCliSchema, + [BACK_COMMAND_NAME]: backCliSchema, + [ROTATE_COMMAND_NAME]: rotateCliSchema, + [KEYBOARD_COMMAND_NAME]: keyboardCliSchema, + [CLIPBOARD_COMMAND_NAME]: clipboardCliSchema, +} as const satisfies Record; + +export const appStateCliReader: CliReader = (_positionals, flags) => commonInputFromFlags(flags); +export const homeCliReader: CliReader = (_positionals, flags) => commonInputFromFlags(flags); +export const appSwitcherCliReader: CliReader = (_positionals, flags) => commonInputFromFlags(flags); + +export const backCliReader: CliReader = (_positionals, flags) => ({ + ...commonInputFromFlags(flags), + mode: flags.backMode, +}); + +export const rotateCliReader: CliReader = (positionals, flags) => ({ + ...commonInputFromFlags(flags), + orientation: parseDeviceRotation(positionals[0]), +}); + +export const keyboardCliReader: CliReader = (positionals, flags) => ({ + ...commonInputFromFlags(flags), + ...readKeyboardInput(positionals), +}); + +export const clipboardCliReader: CliReader = (positionals, flags) => ({ + ...commonInputFromFlags(flags), + ...readClipboardInput(positionals), +}); + +export const systemCliReaders = { + appstate: appStateCliReader, + home: homeCliReader, + 'app-switcher': appSwitcherCliReader, + back: backCliReader, + rotate: rotateCliReader, + keyboard: keyboardCliReader, + clipboard: clipboardCliReader, +} satisfies Record; + +export const appStateDaemonWriter: DaemonWriter = direct(APPSTATE_COMMAND_NAME); + +export const backDaemonWriter: DaemonWriter = (input) => + request(BACK_COMMAND_NAME, [], { ...input, backMode: readBackMode(input.mode) }); + +export const homeDaemonWriter: DaemonWriter = direct(HOME_COMMAND_NAME); + +export const rotateDaemonWriter: DaemonWriter = direct(ROTATE_COMMAND_NAME, (input) => [ + requiredDaemonString(input.orientation, 'rotate requires orientation'), +]); + +export const appSwitcherDaemonWriter: DaemonWriter = direct(APP_SWITCHER_COMMAND_NAME); + +export const keyboardDaemonWriter: DaemonWriter = direct(KEYBOARD_COMMAND_NAME, (input) => + optionalString(input.action), +); + +export const clipboardDaemonWriter: DaemonWriter = direct(CLIPBOARD_COMMAND_NAME, (input) => + clipboardPositionals(input as ClipboardCommandOptions), +); + +export const systemDaemonWriters = { + appstate: appStateDaemonWriter, + back: backDaemonWriter, + home: homeDaemonWriter, + rotate: rotateDaemonWriter, + 'app-switcher': appSwitcherDaemonWriter, + keyboard: keyboardDaemonWriter, + clipboard: clipboardDaemonWriter, +} satisfies Record; + +function readBackMode(value: unknown): BackMode | undefined { + return value === 'in-app' || value === 'system' ? value : undefined; +} + +function clipboardPositionals(input: ClipboardCommandOptions): string[] { + return input.action === 'read' ? ['read'] : ['write', input.text]; +} + +function readKeyboardInput(positionals: string[]): Record { + if (positionals.length > 1) { + throw new AppError('INVALID_ARGS', 'keyboard accepts at most one action argument.'); + } + return compactRecord({ action: readKeyboardAction(positionals[0]) }); +} + +function readClipboardInput(positionals: string[]): Record { + const action = positionals[0]?.toLowerCase(); + if (action !== 'read' && action !== 'write') { + throw new AppError('INVALID_ARGS', 'clipboard requires a subcommand: read or write.'); + } + if (action === 'read') { + if (positionals.length !== 1) { + throw new AppError('INVALID_ARGS', 'clipboard read does not accept additional arguments.'); + } + return { action }; + } + if (positionals.length < 2) { + throw new AppError('INVALID_ARGS', 'clipboard write requires text.'); + } + return { action, text: positionals.slice(1).join(' ') }; +} + +function readKeyboardAction( + value: string | undefined, +): 'status' | 'dismiss' | 'enter' | 'return' | undefined { + const action = value?.toLowerCase(); + if (action === 'get') return 'status'; + if ( + action === undefined || + action === 'status' || + action === 'dismiss' || + action === 'enter' || + action === 'return' + ) { + return action; + } + throw new AppError( + 'INVALID_ARGS', + 'keyboard action must be status, get, dismiss, enter, or return.', + ); +} diff --git a/src/daemon/handlers/__tests__/snapshot.test.ts b/src/daemon/handlers/__tests__/snapshot.test.ts index c741e1651..cb1c65e85 100644 --- a/src/daemon/handlers/__tests__/snapshot.test.ts +++ b/src/daemon/handlers/__tests__/snapshot.test.ts @@ -1,6 +1,6 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; -import { parseWaitPositionals as parseWaitArgs } from '../../../commands/cli-grammar/capture.ts'; +import { parseWaitPositionals as parseWaitArgs } from '../../../core/wait-positionals.ts'; import { parseTimeout } from '../parse-utils.ts'; // --- parseTimeout --- diff --git a/src/utils/cli-command-overrides.ts b/src/utils/cli-command-overrides.ts index cb7c7575d..c60dd2c43 100644 --- a/src/utils/cli-command-overrides.ts +++ b/src/utils/cli-command-overrides.ts @@ -1,17 +1,16 @@ -import { SETTINGS_USAGE_OVERRIDE } from '../core/settings-contract.ts'; import type { CommandName } from '../commands/command-metadata.ts'; -import { DEFAULT_APPS_FILTER } from '../contracts/app-inventory.ts'; -import { SCREENSHOT_COMMAND_FLAG_KEYS } from '../contracts/screenshot.ts'; +import { captureCliSchemas } from '../commands/capture/index.ts'; +import { interactionCliSchemas } from '../commands/interaction/index.ts'; +import { managementCliSchemas } from '../commands/management/index.ts'; +import { metroCliSchemas } from '../commands/metro/index.ts'; +import { observabilityCliSchemas } from '../commands/observability/index.ts'; +import { reactNativeCliSchemas } from '../commands/react-native/index.ts'; +import { recordingCliSchemas } from '../commands/recording/index.ts'; +import { replayCliSchemas } from '../commands/replay/index.ts'; +import { systemCliSchemas } from '../commands/system/index.ts'; import type { LocalCliCommandName } from '../command-catalog.ts'; import type { CommandSchema, CommandSchemaOverride } from './cli-command-schema-types.ts'; -import { - METRO_PREPARE_FLAGS, - METRO_RELOAD_FLAGS, - REPEATED_TOUCH_FLAGS, - REPLAY_FLAGS, - SELECTOR_SNAPSHOT_FLAGS, - SNAPSHOT_FLAGS, -} from './cli-flags.ts'; +import { METRO_PREPARE_FLAGS } from './cli-flags.ts'; type SchemaOnlyCliCommandName = Exclude; @@ -61,209 +60,13 @@ const SCHEMA_ONLY_CLI_COMMAND_SCHEMAS = { } as const satisfies Record; const CLI_COMMAND_OVERRIDES = { - boot: { - summary: 'Boot target device/simulator', - allowedFlags: ['headless'], - }, - shutdown: { - summary: 'Shutdown target simulator/emulator', - }, - prepare: { - usageOverride: 'prepare ios-runner --platform ios|macos [--timeout ]', - listUsageOverride: 'prepare ios-runner --platform ios|macos', - helpDescription: - 'Prepare platform helper infrastructure. ios-runner builds/reuses, starts, and health-checks the XCTest runner so later Apple snapshots and interactions do not pay first-use startup cost. In CI, run it after boot/install and before replay/test; if replay/test starts a separate daemon, run clean:daemon after prepare to release the prepared runner lease. Runner build/start output is written to the session runner.log; daemon.log is for daemon lifecycle/startup issues.', - summary: 'Prepare platform helpers', - positionalArgs: ['ios-runner'], - allowedFlags: ['timeoutMs'], - }, - debug: { - usageOverride: - 'debug symbols --artifact (--dsym | --search-path ) [--out ]', - listUsageOverride: 'debug symbols --artifact --dsym ', - helpDescription: - 'Symbolicate Apple crash artifacts with matching dSYM UUIDs. This debug namespace is intentionally narrow: use logs for app logs, network for HTTP evidence, perf for performance samples, record/trace for media and traces, and react-devtools for React Native profiles.', - summary: 'Symbolicate Apple crash artifacts', - positionalArgs: ['symbols'], - allowedFlags: ['artifact', 'dsym', 'searchPath', 'out'], - }, - open: { - helpDescription: - 'Boot device/simulator; optionally launch app or deep link URL (macOS also supports --surface app|frontmost-app|desktop|menubar)', - summary: 'Open an app, deep link or URL, save replays', - positionalArgs: ['appOrUrl?', 'url?'], - allowedFlags: [ - 'activity', - 'launchConsole', - 'launchArgs', - 'deviceHub', - 'saveScript', - 'relaunch', - 'surface', - ], - }, - close: { - positionalArgs: ['app?'], - allowedFlags: ['saveScript', 'shutdown'], - }, - reinstall: { - positionalArgs: ['app', 'path'], - }, - install: { - positionalArgs: ['app', 'path'], - }, - 'install-from-source': { - usageOverride: - 'install-from-source | install-from-source --github-actions-artifact ', - listUsageOverride: 'install-from-source | install-from-source --github-actions-artifact', - helpDescription: 'Install app from a URL or remote-resolved source', - summary: 'Install app from a source', - positionalArgs: ['url?'], - allowedFlags: [ - 'header', - 'githubActionsArtifact', - 'installSource', - 'retainPaths', - 'retentionMs', - ], - }, - apps: { - helpDescription: 'List user-installed apps; use --all to include system/OEM apps', - summary: 'List installed apps', - allowedFlags: ['appsFilter'], - defaults: { appsFilter: DEFAULT_APPS_FILTER }, - }, - push: { - positionalArgs: ['bundleOrPackage', 'payloadOrJson'], - }, - snapshot: { - usageOverride: - 'snapshot [--diff] [-i] [-c] [-d ] [-s ] [--raw] [--force-full] [--timeout ]', - helpDescription: 'Capture accessibility tree or diff against the previous session baseline', - allowedFlags: ['snapshotDiff', ...SNAPSHOT_FLAGS, 'snapshotForceFull', 'timeoutMs'], - }, - diff: { - usageOverride: - 'diff snapshot | diff screenshot --baseline [current.png] [--out ] [--threshold <0-1>] [--overlay-refs]', - helpDescription: 'Diff accessibility snapshot or compare screenshots pixel-by-pixel', - summary: 'Diff snapshot or screenshot', - positionalArgs: ['kind', 'current?'], - allowedFlags: [...SNAPSHOT_FLAGS, 'baseline', 'threshold', 'out', 'overlayRefs'], - }, - screenshot: { - helpDescription: - 'Capture screenshot (macOS app sessions default to the app window; use --fullscreen for full desktop, --max-size to downscale, --overlay-refs to annotate current refs, or --no-stabilize for low-latency Android capture loops)', - positionalArgs: ['path?'], - allowedFlags: SCREENSHOT_COMMAND_FLAG_KEYS, - }, - appstate: { - helpDescription: 'Show foreground app/activity', - }, - perf: { - usageOverride: - 'perf [metrics|frames|memory] [sample|snapshot]\n agent-device perf memory sample --json\n agent-device perf memory snapshot [--kind android-hprof|memgraph] [--out ]\n agent-device perf cpu profile start|stop|report --kind xctrace [--template ] --out \n agent-device perf trace start|stop --kind xctrace [--template ] --out \n agent-device perf cpu profile start|stop|report --kind simpleperf [--out ]\n agent-device perf trace start|stop --kind perfetto [--out ]', - listUsageOverride: - 'perf [metrics|frames|memory] | perf cpu profile start|stop|report | perf trace start|stop', - helpDescription: - 'Show session performance metrics, focused frame/jank health, memory diagnostics artifacts, Apple xctrace artifacts, or Android native Simpleperf/Perfetto artifacts. Bare perf and metrics are aliases for perf metrics. Native perf output is agent evidence: compact state, artifact path, and size only; raw profiles/traces stay on disk.', - summary: 'Show performance metrics or collect native perf artifacts', - positionalArgs: ['area?', 'subjectOrAction?', 'action?'], - allowedFlags: ['kind', 'perfTemplate', 'out'], - }, - metro: { - usageOverride: - 'metro prepare (--public-base-url | --proxy-base-url ) [--project-root ] [--port ] [--kind auto|react-native|expo]\n agent-device metro reload [--metro-host ] [--metro-port ] [--bundle-url ]', - listUsageOverride: - 'metro prepare --public-base-url | --proxy-base-url ; metro reload', - helpDescription: - 'Prepare a local Metro runtime or ask Metro to reload connected React Native apps', - summary: 'Prepare Metro or reload apps', - positionalArgs: ['prepare|reload'], - allowedFlags: [...METRO_RELOAD_FLAGS, ...METRO_PREPARE_FLAGS], - }, - clipboard: { - usageOverride: 'clipboard read | clipboard write ', - listUsageOverride: 'clipboard read | clipboard write ', - helpDescription: 'Read or write device clipboard text', - positionalArgs: ['read|write', 'text?'], - allowsExtraPositionals: true, - }, - keyboard: { - usageOverride: 'keyboard [status|get|dismiss|enter|return]', - helpDescription: - 'Inspect Android keyboard visibility/type or press/dismiss the device keyboard', - summary: 'Inspect, press, or dismiss the device keyboard', - positionalArgs: ['action?'], - }, - back: { - usageOverride: 'back [--in-app|--system]', - allowedFlags: ['backMode'], - }, - rotate: { - usageOverride: 'rotate ', - helpDescription: 'Rotate device orientation on iOS and Android', - positionalArgs: ['orientation'], - }, - wait: { - usageOverride: 'wait |text |@ref| [timeoutMs]', - positionalArgs: ['durationOrSelector', 'timeoutMs?'], - allowsExtraPositionals: true, - allowedFlags: [...SELECTOR_SNAPSHOT_FLAGS], - }, - get: { - usageOverride: 'get text|attrs <@ref|selector>', - positionalArgs: ['subcommand', 'target'], - allowedFlags: [...SELECTOR_SNAPSHOT_FLAGS], - }, - find: { - usageOverride: 'find [value] [--first|--last]', - helpDescription: 'Find by text/label/value/role/id and run action', - summary: 'Find an element and act', - positionalArgs: ['query', 'action', 'value?'], - allowsExtraPositionals: true, - allowedFlags: ['snapshotDepth', 'snapshotRaw', 'findFirst', 'findLast'], - }, - is: { - positionalArgs: ['predicate', 'selector', 'value?'], - allowsExtraPositionals: true, - allowedFlags: [...SELECTOR_SNAPSHOT_FLAGS], - }, - alert: { - usageOverride: 'alert [get|accept|dismiss|wait] [timeout]', - positionalArgs: ['action?', 'timeout?'], - }, - click: { - usageOverride: 'click ', - positionalArgs: ['target'], - allowsExtraPositionals: true, - allowedFlags: [...REPEATED_TOUCH_FLAGS, 'clickButton', ...SELECTOR_SNAPSHOT_FLAGS], - }, - replay: { - usageOverride: 'replay | replay export [--format maestro] [--out ]', - positionalArgs: ['path'], - allowsExtraPositionals: true, - allowedFlags: ['replayMaestro', 'replayExportFormat', ...REPLAY_FLAGS, 'timeoutMs', 'out'], - }, - test: { - usageOverride: 'test ...', - listUsageOverride: 'test ...', - helpDescription: 'Run one or more replay scripts as a serial test suite', - summary: 'Run replay test suites', - positionalArgs: ['pathOrGlob'], - allowsExtraPositionals: true, - allowedFlags: [ - 'replayMaestro', - ...REPLAY_FLAGS, - 'failFast', - 'timeoutMs', - 'retries', - 'recordVideo', - 'artifactsDir', - 'reportJunit', - 'shardAll', - 'shardSplit', - ], - }, + ...managementCliSchemas, + ...captureCliSchemas, + ...systemCliSchemas, + ...interactionCliSchemas, + ...observabilityCliSchemas, + ...metroCliSchemas, + ...replayCliSchemas, batch: { usageOverride: 'batch [--steps | --steps-file ]', listUsageOverride: 'batch --steps | --steps-file ', @@ -271,111 +74,8 @@ const CLI_COMMAND_OVERRIDES = { summary: 'Run multiple commands', allowedFlags: ['steps', 'stepsFile', 'batchOnError', 'batchMaxSteps', 'out'], }, - press: { - usageOverride: 'press ', - positionalArgs: ['targetOrX', 'y?'], - allowsExtraPositionals: true, - allowedFlags: [...REPEATED_TOUCH_FLAGS, ...SELECTOR_SNAPSHOT_FLAGS], - }, - longpress: { - usageOverride: 'longpress [durationMs]', - positionalArgs: ['targetOrX', 'yOrDurationMs?', 'durationMs?'], - allowsExtraPositionals: true, - allowedFlags: [...SELECTOR_SNAPSHOT_FLAGS], - }, - swipe: { - helpDescription: 'Swipe coordinates with optional repeat pattern', - positionalArgs: ['x1', 'y1', 'x2', 'y2', 'durationMs?'], - allowedFlags: ['count', 'pauseMs', 'pattern'], - }, - gesture: { - usageOverride: 'gesture ...', - listUsageOverride: 'gesture ...', - helpDescription: - 'Run touch gestures: pan [durationMs], fling [distance] [durationMs], swipe [durationMs], pinch [x] [y], rotate [x] [y] [velocity], or transform [durationMs]', - summary: 'Run pan, fling, swipe, pinch, rotate, or transform gestures', - positionalArgs: ['pan|fling|swipe|pinch|rotate|transform', 'args?'], - allowsExtraPositionals: true, - }, - focus: { - positionalArgs: ['x', 'y'], - }, - type: { - positionalArgs: ['text'], - allowsExtraPositionals: true, - allowedFlags: ['delayMs'], - }, - fill: { - usageOverride: 'fill | fill <@ref|selector> ', - positionalArgs: ['targetOrX', 'yOrText', 'text?'], - allowsExtraPositionals: true, - allowedFlags: [...SELECTOR_SNAPSHOT_FLAGS, 'delayMs'], - }, - scroll: { - usageOverride: 'scroll [amount] [--pixels ]', - helpDescription: 'Scroll in direction, or verify hidden content and scroll toward top/bottom', - summary: 'Scroll in a direction or to an edge', - positionalArgs: ['directionOrEdge', 'amount?'], - allowedFlags: ['pixels'], - }, - 'trigger-app-event': { - usageOverride: 'trigger-app-event [payloadJson]', - positionalArgs: ['event', 'payloadJson?'], - }, - record: { - usageOverride: - 'record start [path] [--fps ] [--quality <5-10>] [--hide-touches] | record stop', - listUsageOverride: 'record start [path] | record stop', - helpDescription: - 'Start/stop screen recording; Android recordings longer than the 180s adb screenrecord limit are returned as multiple MP4 chunks', - summary: 'Start or stop screen recording', - positionalArgs: ['start|stop', 'path?'], - allowedFlags: ['fps', 'quality', 'hideTouches'], - }, - 'react-native': { - usageOverride: 'react-native dismiss-overlay', - listUsageOverride: 'react-native dismiss-overlay', - positionalArgs: ['dismiss-overlay'], - }, - trace: { - usageOverride: 'trace start | trace stop ', - listUsageOverride: 'trace start | trace stop ', - helpDescription: - 'Start/stop trace log capture; when an artifact path is requested, pass the same positional path to start and stop', - summary: 'Start or stop trace capture', - positionalArgs: ['start|stop', 'path?'], - }, - logs: { - usageOverride: - 'logs path | logs start | logs stop | logs clear [--restart] | logs doctor | logs mark [message...]', - helpDescription: 'Session app log info, start/stop streaming, diagnostics, and markers', - summary: 'Manage session app logs', - positionalArgs: ['path|start|stop|clear|doctor|mark', 'message?'], - allowsExtraPositionals: true, - allowedFlags: ['restart'], - }, - network: { - usageOverride: - 'network dump [limit] [summary|headers|body|all] [--include summary|headers|body|all] | network log [limit] [summary|headers|body|all] [--include summary|headers|body|all]', - helpDescription: 'Dump recent HTTP(s) traffic parsed from the session app log', - summary: 'Show recent HTTP traffic', - positionalArgs: ['dump|log', 'limit?', 'include?'], - allowedFlags: ['networkInclude'], - }, - settings: { - usageOverride: SETTINGS_USAGE_OVERRIDE, - listUsageOverride: 'settings [area] [options]', - helpDescription: - 'Toggle OS settings, animation scales, appearance, and app permissions (macOS supports only settings appearance and settings permission ; wifi|airplane|location|animations remain unsupported on macOS; mobile permission actions use the active session app)', - summary: 'Change OS settings and app permissions', - positionalArgs: ['setting', 'state', 'target?', 'mode?'], - }, - session: { - usageOverride: 'session list | session state-dir', - listUsageOverride: 'session list', - helpDescription: 'List active sessions or print the effective daemon state directory', - positionalArgs: ['list|state-dir?'], - }, + ...recordingCliSchemas, + ...reactNativeCliSchemas, } as const satisfies Partial>; export function getSchemaOnlyCliCommandSchema(command: string): CommandSchema | undefined { From a9e32b6d9e2d7e41215d9132c7391bda86bab096 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 11 Jun 2026 17:27:27 +0200 Subject: [PATCH 02/11] refactor: localize command runtime modules --- src/client-types.ts | 2 +- src/commands/app-inventory-contract.ts | 1 - src/commands/batch-command.ts | 23 - .../batch/cli.test.ts} | 4 +- src/commands/batch/index.ts | 55 ++ .../metadata.ts} | 8 +- src/commands/batch/output.ts | 56 ++ src/commands/batch/projection.ts | 111 ++++ .../batch/public.test.ts} | 4 +- src/commands/capture-screenshot-options.ts | 1 - src/commands/capture/index.ts | 7 +- src/commands/capture/output.ts | 44 ++ .../capture/runtime/diff-screenshot.test.ts} | 12 +- .../runtime/diff-screenshot.ts} | 20 +- src/commands/capture/runtime/index.ts | 50 ++ .../runtime/screenshot.ts} | 12 +- .../runtime}/snapshot-unchanged.test.ts | 4 +- .../runtime}/snapshot-unchanged.ts | 4 +- .../capture/runtime/snapshot.test.ts} | 12 +- .../runtime/snapshot.ts} | 26 +- .../screenshot-options.test.ts} | 2 +- src/commands/capture/screenshot-options.ts | 1 + .../{ => capture}/wait-command-contract.ts | 0 src/commands/cli-grammar/registry.ts | 10 +- src/commands/cli-output.ts | 109 +--- src/commands/client-output.ts | 326 ---------- src/commands/command-descriptions.ts | 3 +- src/commands/command-metadata.ts | 6 +- src/commands/command-projection.ts | 99 +-- src/commands/command-surface.ts | 6 +- src/commands/index.ts | 608 +++--------------- src/commands/interaction-targeting.ts | 1 - src/commands/interaction/interactions.ts | 2 +- src/commands/interaction/metadata.ts | 2 +- src/commands/interaction/output.ts | 54 ++ .../runtime/gestures.ts} | 24 +- src/commands/interaction/runtime/index.ts | 197 ++++++ .../interaction/runtime/interactions.test.ts} | 20 +- .../{ => interaction/runtime}/interactions.ts | 32 +- .../runtime/resolution.ts} | 28 +- .../runtime}/selector-read-shared.ts | 14 +- .../runtime}/selector-read-utils.ts | 6 +- .../runtime/selector-read.test.ts} | 12 +- .../runtime}/selector-read.ts | 27 +- src/commands/interaction/runtime/targeting.ts | 1 + src/commands/log-command-contract.ts | 1 - .../management/app-inventory-contract.ts | 1 + src/commands/management/index.ts | 2 +- src/commands/management/output.test.ts | 20 + src/commands/management/output.ts | 127 ++++ .../management/runtime/admin-router.test.ts} | 107 +-- .../{ => management/runtime}/admin.ts | 16 +- .../management/runtime/apps.test.ts} | 10 +- src/commands/{ => management/runtime}/apps.ts | 18 +- src/commands/management/runtime/index.ts | 127 ++++ src/commands/metro/output.ts | 17 + src/commands/observability/index.ts | 4 +- .../observability/log-command-contract.ts | 1 + .../observability/perf-command-contract.ts | 1 + .../runtime}/diagnostics-format.ts | 4 +- .../runtime/diagnostics-router.test.ts} | 6 +- .../runtime}/diagnostics-types.ts | 2 +- .../runtime}/diagnostics.ts | 14 +- src/commands/observability/runtime/index.ts | 42 ++ .../runtime/output.ts} | 74 +-- src/commands/output-common.ts | 19 + src/commands/perf-command-contract.ts | 1 - .../output.test.ts} | 20 +- src/commands/recording/output.ts | 55 ++ src/commands/recording/runtime/index.ts | 32 + .../recording/runtime/recording.test.ts | 122 ++++ .../{ => recording/runtime}/recording.ts | 18 +- src/commands/system/output.ts | 78 +++ src/commands/system/runtime/index.ts | 79 +++ .../system/runtime/system.test.ts} | 6 +- src/commands/{ => system/runtime}/system.ts | 20 +- src/utils/cli-command-overrides.ts | 9 +- 77 files changed, 1688 insertions(+), 1411 deletions(-) delete mode 100644 src/commands/app-inventory-contract.ts delete mode 100644 src/commands/batch-command.ts rename src/{__tests__/cli-batch.test.ts => commands/batch/cli.test.ts} (98%) create mode 100644 src/commands/batch/index.ts rename src/commands/{batch-command-metadata.ts => batch/metadata.ts} (96%) create mode 100644 src/commands/batch/output.ts create mode 100644 src/commands/batch/projection.ts rename src/{__tests__/batch-public.test.ts => commands/batch/public.test.ts} (96%) delete mode 100644 src/commands/capture-screenshot-options.ts create mode 100644 src/commands/capture/output.ts rename src/{__tests__/runtime-diff-screenshot.test.ts => commands/capture/runtime/diff-screenshot.test.ts} (96%) rename src/commands/{capture-diff-screenshot.ts => capture/runtime/diff-screenshot.ts} (93%) create mode 100644 src/commands/capture/runtime/index.ts rename src/commands/{capture-screenshot.ts => capture/runtime/screenshot.ts} (82%) rename src/commands/{__tests__ => capture/runtime}/snapshot-unchanged.test.ts (97%) rename src/commands/{ => capture/runtime}/snapshot-unchanged.ts (96%) rename src/{__tests__/runtime-snapshot.test.ts => commands/capture/runtime/snapshot.test.ts} (98%) rename src/commands/{capture-snapshot.ts => capture/runtime/snapshot.ts} (94%) rename src/commands/{__tests__/capture-screenshot-options.test.ts => capture/screenshot-options.test.ts} (98%) create mode 100644 src/commands/capture/screenshot-options.ts rename src/commands/{ => capture}/wait-command-contract.ts (100%) delete mode 100644 src/commands/client-output.ts delete mode 100644 src/commands/interaction-targeting.ts create mode 100644 src/commands/interaction/output.ts rename src/commands/{interaction-gestures.ts => interaction/runtime/gestures.ts} (96%) create mode 100644 src/commands/interaction/runtime/index.ts rename src/{__tests__/runtime-interactions.test.ts => commands/interaction/runtime/interactions.test.ts} (98%) rename src/commands/{ => interaction/runtime}/interactions.ts (87%) rename src/commands/{interaction-resolution.ts => interaction/runtime/resolution.ts} (92%) rename src/commands/{ => interaction/runtime}/selector-read-shared.ts (87%) rename src/commands/{ => interaction/runtime}/selector-read-utils.ts (75%) rename src/{__tests__/runtime-selector-read.test.ts => commands/interaction/runtime/selector-read.test.ts} (96%) rename src/commands/{ => interaction/runtime}/selector-read.ts (95%) create mode 100644 src/commands/interaction/runtime/targeting.ts delete mode 100644 src/commands/log-command-contract.ts create mode 100644 src/commands/management/app-inventory-contract.ts create mode 100644 src/commands/management/output.test.ts create mode 100644 src/commands/management/output.ts rename src/{__tests__/runtime-admin-router.test.ts => commands/management/runtime/admin-router.test.ts} (63%) rename src/commands/{ => management/runtime}/admin.ts (95%) rename src/{__tests__/runtime-apps.test.ts => commands/management/runtime/apps.test.ts} (96%) rename src/commands/{ => management/runtime}/apps.ts (95%) create mode 100644 src/commands/management/runtime/index.ts create mode 100644 src/commands/metro/output.ts create mode 100644 src/commands/observability/log-command-contract.ts create mode 100644 src/commands/observability/perf-command-contract.ts rename src/commands/{ => observability/runtime}/diagnostics-format.ts (99%) rename src/{__tests__/runtime-diagnostics-router.test.ts => commands/observability/runtime/diagnostics-router.test.ts} (97%) rename src/commands/{ => observability/runtime}/diagnostics-types.ts (96%) rename src/commands/{ => observability/runtime}/diagnostics.ts (94%) create mode 100644 src/commands/observability/runtime/index.ts rename src/commands/{runtime-output.ts => observability/runtime/output.ts} (86%) create mode 100644 src/commands/output-common.ts delete mode 100644 src/commands/perf-command-contract.ts rename src/commands/{__tests__/client-output.test.ts => recording/output.test.ts} (74%) create mode 100644 src/commands/recording/output.ts create mode 100644 src/commands/recording/runtime/index.ts create mode 100644 src/commands/recording/runtime/recording.test.ts rename src/commands/{ => recording/runtime}/recording.ts (91%) create mode 100644 src/commands/system/output.ts create mode 100644 src/commands/system/runtime/index.ts rename src/{__tests__/runtime-system.test.ts => commands/system/runtime/system.test.ts} (96%) rename src/commands/{ => system/runtime}/system.ts (95%) diff --git a/src/client-types.ts b/src/client-types.ts index 312b19a4e..e6fd9cfd1 100644 --- a/src/client-types.ts +++ b/src/client-types.ts @@ -22,7 +22,7 @@ import type { SwipePreset, TransformGestureParams, } from './core/scroll-gesture.ts'; -import type { ScrollInputDirection } from './commands/interaction-gestures.ts'; +import type { ScrollInputDirection } from './commands/interaction/runtime/gestures.ts'; import type { LogAction } from './contracts/logs.ts'; import type { SessionSurface } from './core/session-surface.ts'; import type { FindLocator } from './utils/finders.ts'; diff --git a/src/commands/app-inventory-contract.ts b/src/commands/app-inventory-contract.ts deleted file mode 100644 index 406c8203b..000000000 --- a/src/commands/app-inventory-contract.ts +++ /dev/null @@ -1 +0,0 @@ -export * from '../contracts/app-inventory.ts'; diff --git a/src/commands/batch-command.ts b/src/commands/batch-command.ts deleted file mode 100644 index b43fb23bb..000000000 --- a/src/commands/batch-command.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { BatchRunOptions } from '../client-types.ts'; -import { defineExecutableCommand } from './command-contract.ts'; -import { type DaemonCommandName } from './command-projection.ts'; -import { commonToClientOptions } from './command-input.ts'; -import { createBatchCommandMetadata, type BatchInput } from './batch-command-metadata.ts'; - -export function createBatchCommand( - nestedCommands: readonly TCommand[], -) { - return defineExecutableCommand(createBatchCommandMetadata(nestedCommands), (client, input) => - client.batch.run(toBatchOptions(input)), - ); -} - -function toBatchOptions(input: BatchInput): BatchRunOptions { - return { - ...commonToClientOptions(input), - steps: input.steps, - onError: input.onError, - maxSteps: input.maxSteps, - out: input.out, - }; -} diff --git a/src/__tests__/cli-batch.test.ts b/src/commands/batch/cli.test.ts similarity index 98% rename from src/__tests__/cli-batch.test.ts rename to src/commands/batch/cli.test.ts index e07a71a70..43258b650 100644 --- a/src/__tests__/cli-batch.test.ts +++ b/src/commands/batch/cli.test.ts @@ -3,13 +3,13 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import type { DaemonResponse } from '../daemon-client.ts'; +import type { DaemonResponse } from '../../daemon-client.ts'; import { runCliCapture as captureCli, type CapturedCliRun, type CapturedDaemonRequest, type CliCaptureOptions, -} from './cli-capture.ts'; +} from '../../__tests__/cli-capture.ts'; const batchDefaultResponse: DaemonResponse = { ok: true, diff --git a/src/commands/batch/index.ts b/src/commands/batch/index.ts new file mode 100644 index 000000000..07be63bd9 --- /dev/null +++ b/src/commands/batch/index.ts @@ -0,0 +1,55 @@ +import type { BatchRunOptions } from '../../client-types.ts'; +import type { CommandSchemaOverride } from '../../utils/cli-command-schema-types.ts'; +import { commonInputFromFlags } from '../cli-grammar/common.ts'; +import type { CliReader } from '../cli-grammar/types.ts'; +import { defineExecutableCommand } from '../command-contract.ts'; +import { commonToClientOptions } from '../command-input.ts'; +import { createBatchCommandMetadata, type BatchInput } from './metadata.ts'; +import { createBatchDaemonWriter } from './projection.ts'; + +export const batchCommandDescription = + 'Run multiple structured command steps in one daemon request.'; + +export const batchCommandDescriptions = { + batch: batchCommandDescription, +} as const; + +export const batchCommandMetadata = createBatchCommandMetadata(); + +export const batchCommandDefinition = defineExecutableCommand( + batchCommandMetadata, + (client, input) => client.batch.run(toBatchOptions(input)), +); + +export const batchCliSchemas = { + batch: { + usageOverride: 'batch [--steps | --steps-file ]', + listUsageOverride: 'batch --steps | --steps-file ', + helpDescription: 'Execute multiple commands in one daemon request', + summary: 'Run multiple commands', + allowedFlags: ['steps', 'stepsFile', 'batchOnError', 'batchMaxSteps', 'out'], + }, +} as const satisfies Record; + +export const batchCliReaders = { + batch: ((_positionals, flags) => ({ + ...commonInputFromFlags(flags), + steps: flags.batchSteps ?? [], + onError: flags.batchOnError, + maxSteps: flags.batchMaxSteps, + out: flags.out, + })) satisfies CliReader, +} as const; + +export { createBatchDaemonWriter }; +export type { BatchCommandName } from './projection.ts'; + +function toBatchOptions(input: BatchInput): BatchRunOptions { + return { + ...commonToClientOptions(input), + steps: input.steps, + onError: input.onError, + maxSteps: input.maxSteps, + out: input.out, + }; +} diff --git a/src/commands/batch-command-metadata.ts b/src/commands/batch/metadata.ts similarity index 96% rename from src/commands/batch-command-metadata.ts rename to src/commands/batch/metadata.ts index ea2530aab..588fb9032 100644 --- a/src/commands/batch-command-metadata.ts +++ b/src/commands/batch/metadata.ts @@ -1,10 +1,10 @@ -import { BATCH_COMMAND_NAMES } from '../command-catalog.ts'; -import { DEFAULT_BATCH_MAX_STEPS } from '../batch-contract.ts'; +import { BATCH_COMMAND_NAMES } from '../../command-catalog.ts'; +import { DEFAULT_BATCH_MAX_STEPS } from '../../batch-contract.ts'; import { defineCommandMetadata, type CommandMetadata, type JsonSchema, -} from './command-contract.ts'; +} from '../command-contract.ts'; import { assertAllowedKeys, customField, @@ -17,7 +17,7 @@ import { stringField, type CommandFieldMap, type InferCommandInput, -} from './command-input.ts'; +} from '../command-input.ts'; export type BatchCommandStep = { command: string; diff --git a/src/commands/batch/output.ts b/src/commands/batch/output.ts new file mode 100644 index 000000000..85abe2d14 --- /dev/null +++ b/src/commands/batch/output.ts @@ -0,0 +1,56 @@ +import type { CommandRequestResult } from '../../client-types.ts'; +import { readCommandMessage } from '../../utils/success-text.ts'; +import type { CliOutput } from '../command-contract.ts'; +import { resultOutput, type CliOutputFormatter } from '../output-common.ts'; + +export function batchCliOutput(result: CommandRequestResult): CliOutput { + const data = result as Record; + const total = typeof data.total === 'number' ? data.total : 0; + const executed = typeof data.executed === 'number' ? data.executed : 0; + const durationMs = typeof data.totalDurationMs === 'number' ? data.totalDurationMs : undefined; + const lines = [ + `Batch completed: ${executed}/${total} steps${durationMs !== undefined ? ` in ${durationMs}ms` : ''}`, + ]; + const results = Array.isArray(data.results) ? data.results : []; + for (const entry of results) { + const line = renderBatchStepLine(entry); + if (line) lines.push(line); + } + return { data, text: lines.join('\n') }; +} + +export const batchCliOutputFormatters = { + batch: resultOutput(batchCliOutput), +} as const satisfies Record; + +function renderBatchStepLine(entry: unknown): string | undefined { + const result = readRecord(entry); + if (!result) return undefined; + const step = typeof result.step === 'number' ? result.step : undefined; + const command = typeof result.command === 'string' ? result.command : 'step'; + const stepOk = result.ok !== false; + const description = readBatchStepDescription(result, stepOk, command); + const prefix = step !== undefined ? `${step}. ` : '- '; + const durationMs = typeof result.durationMs === 'number' ? result.durationMs : undefined; + const durationSuffix = durationMs !== undefined ? ` (${durationMs}ms)` : ''; + return `${prefix}${stepOk ? 'OK' : 'FAILED'} ${description}${durationSuffix}`; +} + +function readBatchStepDescription( + result: Record, + stepOk: boolean, + command: string, +): string { + if (stepOk) return readCommandMessage(readRecord(result.data)) ?? command; + return readBatchStepFailure(readRecord(result.error)) ?? command; +} + +function readBatchStepFailure(error: Record | undefined): string | null { + return typeof error?.message === 'string' && error.message.length > 0 ? error.message : null; +} + +function readRecord(value: unknown): Record | undefined { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : undefined; +} diff --git a/src/commands/batch/projection.ts b/src/commands/batch/projection.ts new file mode 100644 index 000000000..9a0acc809 --- /dev/null +++ b/src/commands/batch/projection.ts @@ -0,0 +1,111 @@ +import { BATCH_COMMAND_NAMES, PUBLIC_COMMANDS } from '../../command-catalog.ts'; +import { buildFlags } from '../../client-normalizers.ts'; +import type { DaemonBatchStep } from '../../core/batch.ts'; +import { AppError } from '../../utils/errors.ts'; +import { commandNameSet, request } from '../cli-grammar/common.ts'; +import type { CommandInput, DaemonCommandRequest, DaemonWriter } from '../cli-grammar/types.ts'; + +export type BatchCommandName = (typeof BATCH_COMMAND_NAMES)[number]; + +type PrepareDaemonCommandRequest = (command: string, input: CommandInput) => DaemonCommandRequest; + +const batchNames = commandNameSet(BATCH_COMMAND_NAMES); + +export function createBatchDaemonWriter( + prepareDaemonCommandRequest: PrepareDaemonCommandRequest, +): DaemonWriter { + return (input) => + request(PUBLIC_COMMANDS.batch, [], { + ...input, + batchSteps: readBatchDaemonSteps(input.steps, prepareDaemonCommandRequest), + batchOnError: input.onError, + batchMaxSteps: input.maxSteps, + }); +} + +function readBatchDaemonSteps( + steps: unknown, + prepareDaemonCommandRequest: PrepareDaemonCommandRequest, +): DaemonBatchStep[] { + if (!Array.isArray(steps) || steps.length === 0) { + throw new AppError('INVALID_ARGS', 'batch requires a non-empty steps array.'); + } + return steps.map((step, index) => + readBatchDaemonStep(step, index + 1, prepareDaemonCommandRequest), + ); +} + +function readBatchDaemonStep( + step: unknown, + stepNumber: number, + prepareDaemonCommandRequest: PrepareDaemonCommandRequest, +): DaemonBatchStep { + const record = readBatchStepRecord(step, stepNumber); + const command = readBatchStepCommand(record, stepNumber); + const input = readBatchStepInput(record, stepNumber); + const runtime = readBatchStepRuntime(record, stepNumber); + const prepared = prepareBatchStep(command, input, prepareDaemonCommandRequest); + return { + ...prepared, + runtime: runtime ?? prepared.runtime, + }; +} + +function prepareBatchStep( + command: BatchCommandName, + input: CommandInput, + prepareDaemonCommandRequest: PrepareDaemonCommandRequest, +): DaemonBatchStep { + const prepared = prepareDaemonCommandRequest(command, input); + return { + command: prepared.command, + positionals: prepared.positionals, + flags: buildFlags(prepared.options), + runtime: prepared.options.runtime, + }; +} + +function readBatchStepRecord(step: unknown, stepNumber: number): Record { + if (!step || typeof step !== 'object' || Array.isArray(step)) { + throw new AppError('INVALID_ARGS', `Invalid batch step ${stepNumber}.`); + } + return step as Record; +} + +function readBatchStepCommand( + record: Record, + stepNumber: number, +): BatchCommandName { + const command = typeof record.command === 'string' ? record.command.trim().toLowerCase() : ''; + if (isBatchCommandName(command)) return command; + throw new AppError( + 'INVALID_ARGS', + `Batch step ${stepNumber} command is not available through command batch: ${String(record.command)}`, + ); +} + +function isBatchCommandName(name: string): name is BatchCommandName { + return batchNames.has(name); +} + +function readBatchStepInput(record: Record, stepNumber: number): CommandInput { + const input = record.input; + if (!input || typeof input !== 'object' || Array.isArray(input)) { + throw new AppError('INVALID_ARGS', `Batch step ${stepNumber} input must be an object.`); + } + return input as CommandInput; +} + +function readBatchStepRuntime( + record: Record, + stepNumber: number, +): Record | undefined { + const runtime = record.runtime; + if ( + runtime !== undefined && + (!runtime || typeof runtime !== 'object' || Array.isArray(runtime)) + ) { + throw new AppError('INVALID_ARGS', `Batch step ${stepNumber} runtime must be an object.`); + } + return runtime as Record | undefined; +} diff --git a/src/__tests__/batch-public.test.ts b/src/commands/batch/public.test.ts similarity index 96% rename from src/__tests__/batch-public.test.ts rename to src/commands/batch/public.test.ts index 3b3509768..3a1f75dee 100644 --- a/src/__tests__/batch-public.test.ts +++ b/src/commands/batch/public.test.ts @@ -8,8 +8,8 @@ import { runBatch, validateAndNormalizeBatchSteps, type BatchStepResult, -} from '../batch.ts'; -import type { DaemonRequest } from '../contracts.ts'; +} from '../../batch.ts'; +import type { DaemonRequest } from '../../contracts.ts'; test('public batch entrypoint exports daemon-compatible orchestration helpers', async () => { const seenCommands: string[] = []; diff --git a/src/commands/capture-screenshot-options.ts b/src/commands/capture-screenshot-options.ts deleted file mode 100644 index a22fd2036..000000000 --- a/src/commands/capture-screenshot-options.ts +++ /dev/null @@ -1 +0,0 @@ -export * from '../contracts/screenshot.ts'; diff --git a/src/commands/capture/index.ts b/src/commands/capture/index.ts index b531361c8..d68ec01e2 100644 --- a/src/commands/capture/index.ts +++ b/src/commands/capture/index.ts @@ -13,10 +13,7 @@ import type { CommandSchemaOverride } from '../../utils/cli-command-schema-types import { SELECTOR_SNAPSHOT_FLAGS, SNAPSHOT_FLAGS, type CliFlags } from '../../utils/cli-flags.ts'; import { AppError } from '../../utils/errors.ts'; import { tryParseSelectorChain } from '../../utils/selectors-parse.ts'; -import { - screenshotFlagsFromOptions, - screenshotOptionsFromFlags, -} from '../capture-screenshot-options.ts'; +import { screenshotFlagsFromOptions, screenshotOptionsFromFlags } from './screenshot-options.ts'; import { booleanField, compactRecord, @@ -29,7 +26,7 @@ import { } from '../command-input.ts'; import { defineExecutableCommand } from '../command-contract.ts'; import { defineFieldCommandMetadata } from '../field-command-contract.ts'; -import { WAIT_KIND_VALUES } from '../wait-command-contract.ts'; +import { WAIT_KIND_VALUES } from './wait-command-contract.ts'; import { commonInputFromFlags, direct, diff --git a/src/commands/capture/output.ts b/src/commands/capture/output.ts new file mode 100644 index 000000000..7425cc823 --- /dev/null +++ b/src/commands/capture/output.ts @@ -0,0 +1,44 @@ +import { serializeSnapshotResult } from '../../client-shared.ts'; +import type { CaptureSnapshotResult } from '../../client-types.ts'; +import { formatSnapshotText } from '../../utils/output.ts'; +import type { CliOutput } from '../command-contract.ts'; +import { messageOutput, type CliOutputFormatter } from '../output-common.ts'; + +export function snapshotCliOutput(params: { + result: CaptureSnapshotResult; + raw?: boolean; + interactiveOnly?: boolean; + scope?: string; + depth?: number; +}): CliOutput { + const data = serializeSnapshotResult(params.result); + return { + data, + // Programmatic SDK callers can see `unchanged`; CLI --json hides it for schema compatibility. + jsonData: withoutUnchanged(data), + text: formatSnapshotText(data, { + raw: params.raw, + flatten: params.interactiveOnly, + scoped: typeof params.scope === 'string' && params.scope.trim().length > 0, + depthLimited: typeof params.depth === 'number', + }), + }; +} + +export const captureCliOutputFormatters = { + snapshot: ({ input, result }) => + snapshotCliOutput({ + result: result as Parameters[0]['result'], + raw: input.raw as boolean | undefined, + interactiveOnly: input.interactiveOnly as boolean | undefined, + scope: input.scope as string | undefined, + depth: input.depth as number | undefined, + }), + wait: messageOutput, + alert: messageOutput, +} as const satisfies Record; + +function withoutUnchanged(data: Record): Record { + const { unchanged: _unchanged, ...outputData } = data; + return outputData; +} diff --git a/src/__tests__/runtime-diff-screenshot.test.ts b/src/commands/capture/runtime/diff-screenshot.test.ts similarity index 96% rename from src/__tests__/runtime-diff-screenshot.test.ts rename to src/commands/capture/runtime/diff-screenshot.test.ts index e326fbd68..8a18021e2 100644 --- a/src/__tests__/runtime-diff-screenshot.test.ts +++ b/src/commands/capture/runtime/diff-screenshot.test.ts @@ -2,15 +2,19 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { PNG } from '../utils/png.ts'; +import { PNG } from '../../../utils/png.ts'; import { test } from 'vitest'; import type { AgentDeviceBackend, BackendScreenshotOptions, BackendScreenshotResult, -} from '../backend.ts'; -import { createLocalArtifactAdapter } from '../io.ts'; -import { createAgentDevice, localCommandPolicy, type CommandSessionStore } from '../runtime.ts'; +} from '../../../backend.ts'; +import { createLocalArtifactAdapter } from '../../../io.ts'; +import { + createAgentDevice, + localCommandPolicy, + type CommandSessionStore, +} from '../../../runtime.ts'; const sessions = { get: () => undefined, diff --git a/src/commands/capture-diff-screenshot.ts b/src/commands/capture/runtime/diff-screenshot.ts similarity index 93% rename from src/commands/capture-diff-screenshot.ts rename to src/commands/capture/runtime/diff-screenshot.ts index 378b4e3c8..95d784f0f 100644 --- a/src/commands/capture-diff-screenshot.ts +++ b/src/commands/capture/runtime/diff-screenshot.ts @@ -1,19 +1,23 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; -import type { BackendScreenshotOptions, BackendScreenshotResult } from '../backend.ts'; +import type { BackendScreenshotOptions, BackendScreenshotResult } from '../../../backend.ts'; import type { ArtifactDescriptor, FileInputRef, FileOutputRef, ReservedOutputFile, ResolvedInputFile, -} from '../io.ts'; -import type { AgentDeviceRuntime, CommandContext } from '../runtime-contract.ts'; -import { AppError } from '../utils/errors.ts'; -import { compareScreenshots, type ScreenshotDiffResult } from '../utils/screenshot-diff.ts'; -import { attachCurrentOverlayMatches } from '../utils/screenshot-diff-overlay-matches.ts'; -import type { RuntimeCommand } from './runtime-types.ts'; -import { createCommandTempFile, reserveCommandOutput, resolveCommandInput } from './io-policy.ts'; +} from '../../../io.ts'; +import type { AgentDeviceRuntime, CommandContext } from '../../../runtime-contract.ts'; +import { AppError } from '../../../utils/errors.ts'; +import { compareScreenshots, type ScreenshotDiffResult } from '../../../utils/screenshot-diff.ts'; +import { attachCurrentOverlayMatches } from '../../../utils/screenshot-diff-overlay-matches.ts'; +import type { RuntimeCommand } from '../../runtime-types.ts'; +import { + createCommandTempFile, + reserveCommandOutput, + resolveCommandInput, +} from '../../io-policy.ts'; export type LiveScreenshotInputRef = { kind: 'live'; diff --git a/src/commands/capture/runtime/index.ts b/src/commands/capture/runtime/index.ts new file mode 100644 index 000000000..14bd7f2fe --- /dev/null +++ b/src/commands/capture/runtime/index.ts @@ -0,0 +1,50 @@ +import type { AgentDeviceRuntime } from '../../../runtime-contract.ts'; +import type { + BoundRuntimeCommand, + DiffSnapshotCommandOptions, + RuntimeCommand, + ScreenshotCommandOptions, + SnapshotCommandOptions, +} from '../../runtime-types.ts'; +import { + diffScreenshotCommand, + type DiffScreenshotCommandOptions, + type DiffScreenshotCommandResult, +} from './diff-screenshot.ts'; +import { screenshotCommand, type ScreenshotCommandResult } from './screenshot.ts'; +import { + diffSnapshotCommand, + snapshotCommand, + type DiffSnapshotCommandResult, + type SnapshotCommandResult, +} from './snapshot.ts'; + +export type CaptureCommands = { + screenshot: RuntimeCommand; + diffScreenshot: RuntimeCommand; + snapshot: RuntimeCommand; + diffSnapshot: RuntimeCommand; +}; + +export type BoundCaptureCommands = { + screenshot: BoundRuntimeCommand; + diffScreenshot: BoundRuntimeCommand; + snapshot: BoundRuntimeCommand; + diffSnapshot: BoundRuntimeCommand; +}; + +export const captureCommands: CaptureCommands = { + screenshot: screenshotCommand, + diffScreenshot: diffScreenshotCommand, + snapshot: snapshotCommand, + diffSnapshot: diffSnapshotCommand, +}; + +export function bindCaptureCommands(runtime: AgentDeviceRuntime): BoundCaptureCommands { + return { + screenshot: (options) => captureCommands.screenshot(runtime, options), + diffScreenshot: (options) => captureCommands.diffScreenshot(runtime, options), + snapshot: (options) => captureCommands.snapshot(runtime, options), + diffSnapshot: (options) => captureCommands.diffSnapshot(runtime, options), + }; +} diff --git a/src/commands/capture-screenshot.ts b/src/commands/capture/runtime/screenshot.ts similarity index 82% rename from src/commands/capture-screenshot.ts rename to src/commands/capture/runtime/screenshot.ts index 282e1d729..cdb32d5c7 100644 --- a/src/commands/capture-screenshot.ts +++ b/src/commands/capture/runtime/screenshot.ts @@ -1,9 +1,9 @@ -import { AppError } from '../utils/errors.ts'; -import { successText } from '../utils/success-text.ts'; -import { resizePngFileToMaxSize } from '../utils/png-resize.ts'; -import type { ArtifactDescriptor } from '../io.ts'; -import type { RuntimeCommand, ScreenshotCommandOptions } from './runtime-types.ts'; -import { reserveCommandOutput } from './io-policy.ts'; +import { AppError } from '../../../utils/errors.ts'; +import { successText } from '../../../utils/success-text.ts'; +import { resizePngFileToMaxSize } from '../../../utils/png-resize.ts'; +import type { ArtifactDescriptor } from '../../../io.ts'; +import type { RuntimeCommand, ScreenshotCommandOptions } from '../../runtime-types.ts'; +import { reserveCommandOutput } from '../../io-policy.ts'; export type ScreenshotCommandResult = { path: string; diff --git a/src/commands/__tests__/snapshot-unchanged.test.ts b/src/commands/capture/runtime/snapshot-unchanged.test.ts similarity index 97% rename from src/commands/__tests__/snapshot-unchanged.test.ts rename to src/commands/capture/runtime/snapshot-unchanged.test.ts index 0f214d5b0..78200b591 100644 --- a/src/commands/__tests__/snapshot-unchanged.test.ts +++ b/src/commands/capture/runtime/snapshot-unchanged.test.ts @@ -2,8 +2,8 @@ import { expect, test } from 'vitest'; import { buildUnchangedSnapshotMetadata, ensureSnapshotPresentationKey, -} from '../snapshot-unchanged.ts'; -import type { SnapshotState } from '../../utils/snapshot.ts'; +} from './snapshot-unchanged.ts'; +import type { SnapshotState } from '../../../utils/snapshot.ts'; function snapshot( label: string, diff --git a/src/commands/snapshot-unchanged.ts b/src/commands/capture/runtime/snapshot-unchanged.ts similarity index 96% rename from src/commands/snapshot-unchanged.ts rename to src/commands/capture/runtime/snapshot-unchanged.ts index 6749a244c..3d706baa1 100644 --- a/src/commands/snapshot-unchanged.ts +++ b/src/commands/capture/runtime/snapshot-unchanged.ts @@ -1,10 +1,10 @@ -import type { SnapshotCommandOptions } from './runtime-types.ts'; +import type { SnapshotCommandOptions } from '../../runtime-types.ts'; import { buildSnapshotPresentationKey, type SnapshotNode, type SnapshotState, type SnapshotUnchanged, -} from '../utils/snapshot.ts'; +} from '../../../utils/snapshot.ts'; type SnapshotIdentity = { previousAppBundleId?: string; diff --git a/src/__tests__/runtime-snapshot.test.ts b/src/commands/capture/runtime/snapshot.test.ts similarity index 98% rename from src/__tests__/runtime-snapshot.test.ts rename to src/commands/capture/runtime/snapshot.test.ts index 4496d864c..163edfe35 100644 --- a/src/__tests__/runtime-snapshot.test.ts +++ b/src/commands/capture/runtime/snapshot.test.ts @@ -1,9 +1,13 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; -import type { AgentDeviceBackend, BackendSnapshotResult } from '../backend.ts'; -import { createLocalArtifactAdapter } from '../io.ts'; -import { createAgentDevice, localCommandPolicy, type CommandSessionStore } from '../runtime.ts'; -import { makeSnapshotState } from './test-utils/index.ts'; +import type { AgentDeviceBackend, BackendSnapshotResult } from '../../../backend.ts'; +import { createLocalArtifactAdapter } from '../../../io.ts'; +import { + createAgentDevice, + localCommandPolicy, + type CommandSessionStore, +} from '../../../runtime.ts'; +import { makeSnapshotState } from '../../../__tests__/test-utils/index.ts'; test('runtime snapshot captures nodes and updates the session baseline', async () => { let stored: Parameters[0] | undefined; diff --git a/src/commands/capture-snapshot.ts b/src/commands/capture/runtime/snapshot.ts similarity index 94% rename from src/commands/capture-snapshot.ts rename to src/commands/capture/runtime/snapshot.ts index f8629e696..cb37a914d 100644 --- a/src/commands/capture-snapshot.ts +++ b/src/commands/capture/runtime/snapshot.ts @@ -1,23 +1,23 @@ -import type { BackendSnapshotResult } from '../backend.ts'; -import type { AgentDeviceRuntime, CommandSessionRecord } from '../runtime-contract.ts'; +import type { BackendSnapshotResult } from '../../../backend.ts'; +import type { AgentDeviceRuntime, CommandSessionRecord } from '../../../runtime-contract.ts'; import { publicSnapshotCaptureAnnotations, snapshotCaptureAnnotationsFrom, type PublicSnapshotCaptureAnnotations, type SnapshotCaptureAnnotations, -} from '../snapshot-capture-annotations.ts'; -import { renderSnapshotQualityWarnings } from '../utils/snapshot-quality.ts'; -import { AppError } from '../utils/errors.ts'; -import { buildSnapshotDiff, countSnapshotComparableLines } from '../utils/snapshot-diff.ts'; -import type { SnapshotDiffLine, SnapshotDiffSummary } from '../utils/snapshot-diff.ts'; +} from '../../../snapshot-capture-annotations.ts'; +import { renderSnapshotQualityWarnings } from '../../../utils/snapshot-quality.ts'; +import { AppError } from '../../../utils/errors.ts'; +import { buildSnapshotDiff, countSnapshotComparableLines } from '../../../utils/snapshot-diff.ts'; +import type { SnapshotDiffLine, SnapshotDiffSummary } from '../../../utils/snapshot-diff.ts'; import type { SnapshotNode, SnapshotState, SnapshotUnchanged, SnapshotVisibility, -} from '../utils/snapshot.ts'; -import { buildSnapshotVisibility } from '../utils/snapshot-visibility.ts'; -import { formatReactNativeOverlayWarning } from './react-native/overlay.ts'; +} from '../../../utils/snapshot.ts'; +import { buildSnapshotVisibility } from '../../../utils/snapshot-visibility.ts'; +import { formatReactNativeOverlayWarning } from '../../react-native/overlay.ts'; import { buildUnchangedSnapshotMetadata, ensureSnapshotPresentationKey, @@ -26,10 +26,10 @@ import type { DiffSnapshotCommandOptions, RuntimeCommand, SnapshotCommandOptions, -} from './runtime-types.ts'; -import { now } from './selector-read-utils.ts'; +} from '../../runtime-types.ts'; +import { now } from '../../interaction/runtime/selector-read-utils.ts'; -export type { SnapshotDiffLine, SnapshotDiffSummary } from '../utils/snapshot-diff.ts'; +export type { SnapshotDiffLine, SnapshotDiffSummary } from '../../../utils/snapshot-diff.ts'; export type SnapshotCommandResult = { nodes: SnapshotNode[]; diff --git a/src/commands/__tests__/capture-screenshot-options.test.ts b/src/commands/capture/screenshot-options.test.ts similarity index 98% rename from src/commands/__tests__/capture-screenshot-options.test.ts rename to src/commands/capture/screenshot-options.test.ts index d998a81b4..e18926080 100644 --- a/src/commands/__tests__/capture-screenshot-options.test.ts +++ b/src/commands/capture/screenshot-options.test.ts @@ -8,7 +8,7 @@ import { readScreenshotScriptFlag, screenshotFlagsFromOptions, screenshotOptionsFromFlags, -} from '../capture-screenshot-options.ts'; +} from './screenshot-options.ts'; test('screenshot flag projection maps CLI flags to runtime options', () => { assert.deepEqual( diff --git a/src/commands/capture/screenshot-options.ts b/src/commands/capture/screenshot-options.ts new file mode 100644 index 000000000..7b9445baa --- /dev/null +++ b/src/commands/capture/screenshot-options.ts @@ -0,0 +1 @@ +export * from '../../contracts/screenshot.ts'; diff --git a/src/commands/wait-command-contract.ts b/src/commands/capture/wait-command-contract.ts similarity index 100% rename from src/commands/wait-command-contract.ts rename to src/commands/capture/wait-command-contract.ts diff --git a/src/commands/cli-grammar/registry.ts b/src/commands/cli-grammar/registry.ts index fb7e8bab1..553daf656 100644 --- a/src/commands/cli-grammar/registry.ts +++ b/src/commands/cli-grammar/registry.ts @@ -1,6 +1,6 @@ import type { CliFlags } from '../../utils/cli-flags.ts'; +import { batchCliReaders } from '../batch/index.ts'; import { captureCliReaders } from '../capture/index.ts'; -import { commonInputFromFlags } from './common.ts'; import type { CliReader } from './types.ts'; import type { CommandName } from '../command-metadata.ts'; import { @@ -28,13 +28,7 @@ const cliReaders = { ...replayCliReaders, ...systemCliReaders, ...metroCliReaders, - batch: (_positionals, flags) => ({ - ...commonInputFromFlags(flags), - steps: flags.batchSteps ?? [], - onError: flags.batchOnError, - maxSteps: flags.batchMaxSteps, - out: flags.out, - }), + ...batchCliReaders, } satisfies Record; export function readInputFromCli( diff --git a/src/commands/cli-output.ts b/src/commands/cli-output.ts index aecab3324..27513b73b 100644 --- a/src/commands/cli-output.ts +++ b/src/commands/cli-output.ts @@ -1,97 +1,24 @@ -import type { CommandRequestResult } from '../client.ts'; -import type { CommandName } from './command-metadata.ts'; +import { batchCliOutputFormatters } from './batch/output.ts'; +import { captureCliOutputFormatters } from './capture/output.ts'; import type { CliOutput } from './command-contract.ts'; -import { - appStateCliOutput, - appsCliOutput, - bootCliOutput, - clipboardCliOutput, - closeCliOutput, - debugSymbolsCliOutput, - deployCliOutput, - devicesCliOutput, - findCliOutput, - getCliOutput, - installFromSourceCliOutput, - isCliOutput, - keyboardCliOutput, - messageCliOutput, - metroCliOutput, - openCliOutput, - recordCliOutput, - sessionCliOutput, - shutdownCliOutput, - snapshotCliOutput, - tapCliOutput, -} from './client-output.ts'; -import { - batchCliOutput, - logsCliOutput, - networkCliOutput, - perfCliOutput, -} from './runtime-output.ts'; - -type CliOutputFormatter = (params: { - input: Record; - result: unknown; -}) => CliOutput; - -function resultOutput(formatter: (result: TResult) => CliOutput): CliOutputFormatter { - return ({ result }) => formatter(result as TResult); -} - -const messageOutput = resultOutput(messageCliOutput); +import { interactionCliOutputFormatters } from './interaction/output.ts'; +import { managementCliOutputFormatters } from './management/output.ts'; +import { metroCliOutputFormatters } from './metro/output.ts'; +import { observabilityCliOutputFormatters } from './observability/runtime/output.ts'; +import type { CliOutputFormatter } from './output-common.ts'; +import { recordingCliOutputFormatters } from './recording/output.ts'; +import { systemCliOutputFormatters } from './system/output.ts'; +import type { CommandName } from './command-metadata.ts'; const cliOutputFormatters: Partial> = { - boot: resultOutput(bootCliOutput), - shutdown: resultOutput(shutdownCliOutput), - click: resultOutput(tapCliOutput), - press: resultOutput(tapCliOutput), - batch: resultOutput(batchCliOutput), - devices: resultOutput(devicesCliOutput), - apps: ({ input, result }) => - appsCliOutput({ - result: result as Parameters[0]['result'], - appsFilter: input.appsFilter as Parameters[0]['appsFilter'], - }), - session: resultOutput(sessionCliOutput), - debug: resultOutput(debugSymbolsCliOutput), - open: resultOutput(openCliOutput), - close: resultOutput(closeCliOutput), - install: resultOutput(deployCliOutput), - reinstall: resultOutput(deployCliOutput), - 'install-from-source': resultOutput(installFromSourceCliOutput), - snapshot: ({ input, result }) => - snapshotCliOutput({ - result: result as Parameters[0]['result'], - raw: input.raw as boolean | undefined, - interactiveOnly: input.interactiveOnly as boolean | undefined, - scope: input.scope as string | undefined, - depth: input.depth as number | undefined, - }), - wait: messageOutput, - alert: messageOutput, - appstate: resultOutput(appStateCliOutput), - back: messageOutput, - home: messageOutput, - rotate: messageOutput, - 'app-switcher': messageOutput, - keyboard: resultOutput(keyboardCliOutput), - clipboard: resultOutput(clipboardCliOutput), - get: ({ input, result }) => - getCliOutput({ - result: result as CommandRequestResult, - format: input.format as Parameters[0]['format'], - }), - is: resultOutput(isCliOutput), - find: resultOutput(findCliOutput), - perf: resultOutput(perfCliOutput), - prepare: messageOutput, - logs: resultOutput(logsCliOutput), - network: resultOutput(networkCliOutput), - record: resultOutput(recordCliOutput), - metro: ({ input, result }) => - metroCliOutput({ result, action: input.action as string | undefined }), + ...managementCliOutputFormatters, + ...captureCliOutputFormatters, + ...systemCliOutputFormatters, + ...interactionCliOutputFormatters, + ...observabilityCliOutputFormatters, + ...batchCliOutputFormatters, + ...recordingCliOutputFormatters, + ...metroCliOutputFormatters, }; export function formatCliOutput(params: { diff --git a/src/commands/client-output.ts b/src/commands/client-output.ts deleted file mode 100644 index f5f83c454..000000000 --- a/src/commands/client-output.ts +++ /dev/null @@ -1,326 +0,0 @@ -import { - serializeCloseResult, - serializeDeployResult, - serializeDevice, - serializeInstallFromSourceResult, - serializeOpenResult, - serializeSessionListEntry, - serializeSnapshotResult, -} from '../client-shared.ts'; -import type { - AgentDeviceDevice, - AgentDeviceSession, - AppStateCommandResult, - AppCloseResult, - AppDeployResult, - AppInstallFromSourceResult, - AppOpenResult, - CaptureSnapshotResult, - ClipboardCommandResult, - CommandRequestResult, - DebugSymbolsResult, - KeyboardCommandResult, - SessionCloseResult, -} from '../client-types.ts'; -import { formatSnapshotText } from '../utils/output.ts'; -import { readCommandMessage } from '../utils/success-text.ts'; -import type { CliOutput } from './command-contract.ts'; - -export function devicesCliOutput(result: AgentDeviceDevice[]): CliOutput { - const data = { devices: result.map(serializeDevice) }; - return { data, text: result.map(formatDeviceLine).join('\n') }; -} - -export function appsCliOutput(params: { - result: string[]; - appsFilter?: 'user-installed' | 'all'; -}): CliOutput { - const data = { apps: params.result }; - return { - data, - stderr: - params.appsFilter === 'all' - ? 'Showing all apps, including system apps.\n' - : 'Showing user-installed apps. Use --all to include system apps.\n', - text: - params.result.length > 0 - ? params.result.join('\n') - : params.appsFilter === 'all' - ? 'No apps found.' - : 'No user-installed apps found.', - }; -} - -export function sessionCliOutput( - result: { sessions: AgentDeviceSession[] } | { stateDir: string }, -): CliOutput { - if ('stateDir' in result) { - return { data: result, text: result.stateDir }; - } - const data = { sessions: result.sessions.map(serializeSessionListEntry) }; - return { data, text: JSON.stringify(data, null, 2) }; -} - -export function openCliOutput(result: AppOpenResult): CliOutput { - const data = serializeOpenResult(result); - const lines = [readCommandMessage(data)].filter((line): line is string => Boolean(line)); - if (typeof data.sessionStateDir === 'string') { - lines.push(`Session state: ${data.sessionStateDir}`); - } - return { data, text: lines.join('\n') || null }; -} - -export function closeCliOutput(result: AppCloseResult | SessionCloseResult): CliOutput { - return messageOutput(serializeCloseResult(result)); -} - -export function messageCliOutput(result: Record): CliOutput { - return messageOutput(result); -} - -export function appStateCliOutput(result: AppStateCommandResult): CliOutput { - return { - data: result, - text: formatAppState(result), - }; -} - -export function keyboardCliOutput(result: KeyboardCommandResult): CliOutput { - if (result.platform === 'android' && result.action === 'status') { - const lines = [ - `Keyboard visible: ${result.visible === true ? 'yes' : 'no'}`, - `Input type: ${result.type ?? result.inputType ?? 'unknown'}`, - `Input owner: ${result.inputOwner ?? 'unknown'}`, - ]; - if (result.inputMethodPackage) lines.push(`Input method: ${result.inputMethodPackage}`); - if (result.focusedPackage) lines.push(`Focused package: ${result.focusedPackage}`); - if (result.focusedResourceId) lines.push(`Focused resource: ${result.focusedResourceId}`); - lines.push(`Next action: ${androidKeyboardNextAction(result.visible, result.inputOwner)}`); - return { data: result, text: lines.join('\n') }; - } - return messageOutput(result); -} - -export function clipboardCliOutput(result: ClipboardCommandResult): CliOutput { - if (result.action === 'read') return { data: result, text: result.text }; - return messageOutput(result); -} - -export function deployCliOutput(result: AppDeployResult): CliOutput { - return messageOutput(serializeDeployResult(result)); -} - -export function debugSymbolsCliOutput(result: DebugSymbolsResult): CliOutput { - const lines = [result.outPath, result.message]; - lines.push(...formatDebugCrashSummary(result)); - for (const image of result.matchedImages) { - lines.push(`Matched: ${image.name} ${image.uuid}${image.arch ? ` ${image.arch}` : ''}`); - } - for (const warning of result.warnings ?? []) { - lines.push(`Warning: ${warning}`); - } - return { data: result, text: lines.join('\n') }; -} - -function formatDebugCrashSummary(result: DebugSymbolsResult): string[] { - const crash = result.crash; - const lines = [ - `Crash: ${crash.appName ?? 'unknown app'}${crash.crashedThread === undefined ? '' : ` thread ${crash.crashedThread}`}`, - ]; - if (crash.bundleId) lines.push(`Bundle: ${crash.bundleId}`); - if (crash.exceptionType) lines.push(`Exception: ${crash.exceptionType}`); - if (crash.terminationReason) lines.push(`Termination: ${crash.terminationReason}`); - for (const frame of crash.topFrames) { - lines.push(`Frame ${frame.index}: ${frame.image} ${frame.symbol ?? frame.address}`); - } - for (const finding of crash.findings) { - lines.push(`Finding: ${finding}`); - } - return lines; -} - -export function installFromSourceCliOutput(result: AppInstallFromSourceResult): CliOutput { - return messageOutput(serializeInstallFromSourceResult(result)); -} - -export function snapshotCliOutput(params: { - result: CaptureSnapshotResult; - raw?: boolean; - interactiveOnly?: boolean; - scope?: string; - depth?: number; -}): CliOutput { - const data = serializeSnapshotResult(params.result); - return { - data, - // Programmatic SDK callers can see `unchanged`; CLI --json hides it for schema compatibility. - jsonData: withoutUnchanged(data), - text: formatSnapshotText(data, { - raw: params.raw, - flatten: params.interactiveOnly, - scoped: typeof params.scope === 'string' && params.scope.trim().length > 0, - depthLimited: typeof params.depth === 'number', - }), - }; -} - -export function metroCliOutput(params: { result: unknown; action?: string }): CliOutput { - return { - data: params.result, - text: - params.action === 'reload' - ? `Reloaded React Native apps via ${(params.result as { reloadUrl?: unknown }).reloadUrl}` - : JSON.stringify(params.result, null, 2), - }; -} - -export function bootCliOutput(result: CommandRequestResult): CliOutput { - const data = result as Record; - const platform = data.platform ?? 'unknown'; - const device = data.device ?? data.id ?? 'unknown'; - return { data, text: `Boot ready: ${device} (${platform})` }; -} - -export function shutdownCliOutput(result: CommandRequestResult): CliOutput { - const data = result as Record; - const platform = data.platform ?? 'unknown'; - const device = data.device ?? data.id ?? 'unknown'; - const shutdown = data.shutdown; - const success = - shutdown && typeof shutdown === 'object' && 'success' in shutdown - ? (shutdown as { success?: unknown }).success === true - : false; - const status = success ? 'Shutdown' : 'Shutdown failed'; - return { data, text: `${status}: ${device} (${platform})` }; -} - -export function getCliOutput(params: { result: CommandRequestResult; format?: string }): CliOutput { - const data = params.result as Record; - if (params.format === 'text') { - return { data, text: typeof data.text === 'string' ? data.text : '' }; - } - if (params.format === 'attrs') { - return { data, text: JSON.stringify(data.node ?? {}, null, 2) }; - } - return defaultCommandCliOutput(data); -} - -export function findCliOutput(result: CommandRequestResult): CliOutput { - const data = result as Record; - if (typeof data.text === 'string') return { data, text: data.text }; - if (typeof data.found === 'boolean') return { data, text: `Found: ${data.found}` }; - if (data.node) return { data, text: JSON.stringify(data.node, null, 2) }; - return defaultCommandCliOutput(data); -} - -export function isCliOutput(result: CommandRequestResult): CliOutput { - const data = result as Record; - return { data, text: `Passed: is ${data.predicate ?? 'assertion'}` }; -} - -export function tapCliOutput(result: CommandRequestResult): CliOutput { - const data = result as Record; - const ref = data.ref ?? ''; - const x = data.x; - const y = data.y; - if (!ref || typeof x !== 'number' || typeof y !== 'number') { - return defaultCommandCliOutput(data); - } - return { data, text: `Tapped @${ref} (${x}, ${y})` }; -} - -export function recordCliOutput(result: CommandRequestResult): CliOutput { - const data = result as Record; - const outPath = typeof data.outPath === 'string' ? data.outPath : ''; - const chunks = readRecordingChunks(data); - if (chunks.length <= 1) { - return { data, text: formatRecordSingleOutput(data, outPath) }; - } - - const lines = ['Recording chunks:']; - for (const chunk of chunks) { - lines.push(` ${chunk.index}: ${chunk.path}`); - } - if (typeof data.telemetryPath === 'string') { - lines.push(`Telemetry: ${data.telemetryPath}`); - } - if (typeof data.warning === 'string') { - lines.push(`Warning: ${data.warning}`); - } - if (typeof data.overlayWarning === 'string') { - lines.push(`Overlay warning: ${data.overlayWarning}`); - } - return { data, text: lines.join('\n') }; -} - -function defaultCommandCliOutput(result: CommandRequestResult): CliOutput { - return messageOutput(result as Record); -} - -function formatRecordSingleOutput(data: Record, outPath: string): string { - const lines: string[] = []; - if (outPath) lines.push(outPath); - if (typeof data.sessionStateDir === 'string') - lines.push(`Session state: ${data.sessionStateDir}`); - if (typeof data.warning === 'string') lines.push(`Warning: ${data.warning}`); - if (typeof data.overlayWarning === 'string') - lines.push(`Overlay warning: ${data.overlayWarning}`); - return lines.join('\n'); -} - -function readRecordingChunks( - data: Record, -): Array<{ index: number; path: string }> { - const rawChunks = data.chunks; - if (!Array.isArray(rawChunks)) return []; - return rawChunks.flatMap((chunk) => { - if (!chunk || typeof chunk !== 'object') return []; - const candidate = chunk as Record; - if (typeof candidate.index !== 'number' || typeof candidate.path !== 'string') return []; - return [{ index: candidate.index, path: candidate.path }]; - }); -} - -function messageOutput(data: Record): CliOutput { - return { data, text: readCommandMessage(data) }; -} - -function formatAppState(data: AppStateCommandResult): string | null { - if (data.platform === 'ios') { - const lines = [`Foreground app: ${data.appName ?? data.appBundleId ?? 'unknown'}`]; - if (data.appBundleId) lines.push(`Bundle: ${data.appBundleId}`); - if (data.source) lines.push(`Source: ${data.source}`); - return lines.join('\n'); - } - if (data.platform === 'android') { - const lines = [`Foreground app: ${data.package ?? 'unknown'}`]; - if (data.activity) lines.push(`Activity: ${data.activity}`); - return lines.join('\n'); - } - return null; -} - -function androidKeyboardNextAction( - visible: boolean | undefined, - inputOwner: KeyboardCommandResult['inputOwner'], -): string { - if (inputOwner === 'ime') { - return 'Focused input appears to be owned by the keyboard/IME; dismiss or change the IME before retrying text entry.'; - } - if (visible === true) { - return 'Keyboard is visible and focused input appears app-owned; fill/type can proceed.'; - } - return 'Keyboard is hidden; focus an app field before type, or use fill with a concrete target.'; -} - -function formatDeviceLine(device: AgentDeviceDevice): string { - const kind = device.kind ? ` ${device.kind}` : ''; - const target = device.target ? ` target=${device.target}` : ''; - const booted = typeof device.booted === 'boolean' ? ` booted=${device.booted}` : ''; - return `${device.name} (${device.platform}${kind}${target})${booted}`; -} - -function withoutUnchanged(data: Record): Record { - const { unchanged: _unchanged, ...outputData } = data; - return outputData; -} diff --git a/src/commands/command-descriptions.ts b/src/commands/command-descriptions.ts index 3fd3d4ed6..7fc326c6f 100644 --- a/src/commands/command-descriptions.ts +++ b/src/commands/command-descriptions.ts @@ -1,3 +1,4 @@ +import { batchCommandDescriptions } from './batch/index.ts'; import { captureCommandDescriptions } from './capture/index.ts'; import { interactionCommandDescriptions } from './interaction/index.ts'; import { managementCommandDescriptions } from './management/index.ts'; @@ -18,7 +19,7 @@ const COMMAND_DESCRIPTIONS = { ...observabilityCommandDescriptions, ...recordingCommandDescriptions, ...metroCommandDescriptions, - batch: 'Run multiple structured command steps in one daemon request.', + ...batchCommandDescriptions, } as const; export type DescribedCommandName = keyof typeof COMMAND_DESCRIPTIONS; diff --git a/src/commands/command-metadata.ts b/src/commands/command-metadata.ts index 442c432be..c52023e8f 100644 --- a/src/commands/command-metadata.ts +++ b/src/commands/command-metadata.ts @@ -1,11 +1,9 @@ -import { BATCH_COMMAND_NAMES, listMcpExposedCommandNames } from '../command-catalog.ts'; -import { createBatchCommandMetadata } from './batch-command-metadata.ts'; +import { listMcpExposedCommandNames } from '../command-catalog.ts'; +import { batchCommandMetadata } from './batch/index.ts'; import { clientCommandMetadata } from './client-command-metadata.ts'; import type { CommandMetadata } from './command-contract.ts'; import { interactionCommandMetadata } from './interaction/index.ts'; -const batchCommandMetadata = createBatchCommandMetadata(BATCH_COMMAND_NAMES); - const commandMetadata = [ ...interactionCommandMetadata, ...clientCommandMetadata, diff --git a/src/commands/command-projection.ts b/src/commands/command-projection.ts index 8f4ebc834..391680f95 100644 --- a/src/commands/command-projection.ts +++ b/src/commands/command-projection.ts @@ -1,9 +1,5 @@ -import { BATCH_COMMAND_NAMES, PUBLIC_COMMANDS } from '../command-catalog.ts'; -import { buildFlags } from '../client-normalizers.ts'; -import type { DaemonBatchStep } from '../core/batch.ts'; -import { AppError } from '../utils/errors.ts'; +import { createBatchDaemonWriter, type BatchCommandName } from './batch/index.ts'; import { captureDaemonWriters } from './capture/index.ts'; -import { commandNameSet, request } from './cli-grammar/common.ts'; import type { CommandInput, DaemonCommandRequest, DaemonWriter } from './cli-grammar/types.ts'; import { gestureDaemonWriters, @@ -28,95 +24,22 @@ const daemonWriters = { ...recordingDaemonWriters, ...replayDaemonWriters, ...systemDaemonWriters, - batch: (input) => - request(PUBLIC_COMMANDS.batch, [], { - ...input, - batchSteps: readBatchDaemonSteps(input.steps), - batchOnError: input.onError, - batchMaxSteps: input.maxSteps, - }), + batch: createBatchDaemonWriter(prepareBatchDaemonCommandRequest), } satisfies Record; export type DaemonCommandName = keyof typeof daemonWriters; -export type BatchCommandName = (typeof BATCH_COMMAND_NAMES)[number]; +export type { BatchCommandName }; -export const batchCommandNames = BATCH_COMMAND_NAMES satisfies readonly DaemonCommandName[]; - -const batchNames = commandNameSet(batchCommandNames); - -function isBatchCommandName(name: string): name is BatchCommandName { - return batchNames.has(name); -} - -function prepareBatchStep(command: DaemonCommandName, input: CommandInput): DaemonBatchStep { - const prepared = prepareDaemonCommandRequest(command, input); - return { - command: prepared.command, - positionals: prepared.positionals, - flags: buildFlags(prepared.options), - runtime: prepared.options.runtime, - }; -} - -function readBatchDaemonSteps(steps: unknown): DaemonBatchStep[] { - if (!Array.isArray(steps) || steps.length === 0) { - throw new AppError('INVALID_ARGS', 'batch requires a non-empty steps array.'); - } - return steps.map((step, index) => readBatchDaemonStep(step, index + 1)); -} - -function readBatchDaemonStep(step: unknown, stepNumber: number): DaemonBatchStep { - const record = readBatchStepRecord(step, stepNumber); - const command = readBatchStepCommand(record, stepNumber); - const input = readBatchStepInput(record, stepNumber); - const runtime = readBatchStepRuntime(record, stepNumber); - const prepared = prepareBatchStep(command, input); - return { - ...prepared, - runtime: runtime ?? prepared.runtime, - }; -} - -function readBatchStepRecord(step: unknown, stepNumber: number): Record { - if (!step || typeof step !== 'object' || Array.isArray(step)) { - throw new AppError('INVALID_ARGS', `Invalid batch step ${stepNumber}.`); - } - return step as Record; -} - -function readBatchStepCommand( - record: Record, - stepNumber: number, -): BatchCommandName { - const command = typeof record.command === 'string' ? record.command.trim().toLowerCase() : ''; - if (isBatchCommandName(command)) return command; - throw new AppError( - 'INVALID_ARGS', - `Batch step ${stepNumber} command is not available through command batch: ${String(record.command)}`, - ); -} - -function readBatchStepInput(record: Record, stepNumber: number): CommandInput { - const input = record.input; - if (!input || typeof input !== 'object' || Array.isArray(input)) { - throw new AppError('INVALID_ARGS', `Batch step ${stepNumber} input must be an object.`); - } - return input as CommandInput; -} - -function readBatchStepRuntime( - record: Record, - stepNumber: number, -): Record | undefined { - const runtime = record.runtime; - if ( - runtime !== undefined && - (!runtime || typeof runtime !== 'object' || Array.isArray(runtime)) - ) { - throw new AppError('INVALID_ARGS', `Batch step ${stepNumber} runtime must be an object.`); +function prepareBatchDaemonCommandRequest( + command: string, + input: CommandInput, +): DaemonCommandRequest { + const writer = (daemonWriters as Readonly>)[command]; + if (!writer) { + throw new Error(`Missing daemon writer for batch command: ${command}`); } - return runtime as Record | undefined; + return writer(input); } export function prepareDaemonCommandRequest( diff --git a/src/commands/command-surface.ts b/src/commands/command-surface.ts index 078bed84f..84a2a5094 100644 --- a/src/commands/command-surface.ts +++ b/src/commands/command-surface.ts @@ -1,9 +1,9 @@ import type { AgentDeviceClient } from '../client-types.ts'; -import { createBatchCommand } from './batch-command.ts'; +import { batchCommandDefinition } from './batch/index.ts'; import { clientCommandDefinitions } from './client-command-contracts.ts'; import type { JsonSchema } from './command-contract.ts'; import { interactionCommandDefinitions } from './interaction/index.ts'; -import { batchCommandNames, type BatchCommandName } from './command-projection.ts'; +import type { BatchCommandName } from './command-projection.ts'; import type { CommandName } from './command-metadata.ts'; type AnyExecutableCommand = { @@ -13,8 +13,6 @@ type AnyExecutableCommand = { invoke: (client: AgentDeviceClient, input: unknown) => Promise; }; -const batchCommandDefinition = createBatchCommand(batchCommandNames); - const commandSurface = [ ...interactionCommandDefinitions, ...clientCommandDefinitions, diff --git a/src/commands/index.ts b/src/commands/index.ts index 856d04edc..5ca29e96d 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -1,175 +1,63 @@ import type { AgentDeviceRuntime } from '../runtime-contract.ts'; -import type { - BoundRuntimeCommand, - DiffSnapshotCommandOptions, - RuntimeCommand, - ScreenshotCommandOptions, - SnapshotCommandOptions, -} from './runtime-types.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, - getAttrsCommand, - getCommand, - getTextCommand, - isHiddenCommand, - isVisibleCommand, - isCommand, - waitCommand, - waitForTextCommand, - type ElementTarget, - type FindReadCommandOptions, - type FindReadCommandResult, - type GetAttrsCommandOptions, - type GetCommandOptions, - type GetCommandResult, - type GetTextCommandOptions, - type IsCommandOptions, - type IsCommandResult, - type IsSelectorCommandOptions, - type SelectorTarget, - type WaitCommandOptions, - type WaitCommandResult, - type WaitForTextCommandOptions, -} from './selector-read.ts'; + bindCaptureCommands, + captureCommands, + type BoundCaptureCommands, + type CaptureCommands, +} from './capture/runtime/index.ts'; import { - clickCommand, - fillCommand, - focusCommand, - longPressCommand, - pinchCommand, - pressCommand, - scrollCommand, - swipeCommand, - typeTextCommand, - type ClickCommandOptions, - type FillCommandOptions, - type FillCommandResult, - type FocusCommandOptions, - type FocusCommandResult, - type InteractionTarget, - 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'; + bindInteractionCommands, + bindSelectorCommands, + interactionCommands, + selectorCommands, + type BoundInteractionCommands, + type BoundSelectorCommands, + type InteractionCommands, + type SelectorCommands, +} from './interaction/runtime/index.ts'; import { - alertCommand, - appSwitcherCommand, - backCommand, - clipboardCommand, - homeCommand, - 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'; + adminCommands, + appCommands, + bindAdminCommands, + bindAppCommands, + type AdminCommands, + type AppCommands, + type BoundAdminCommands, + type BoundAppCommands, +} from './management/runtime/index.ts'; import { - closeAppCommand, - getAppStateCommand, - listAppsCommand, - 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 { resolveAppsFilter } from './app-inventory-contract.ts'; + bindObservabilityCommands, + diagnosticsCommands, + type BoundObservabilityCommands, + type DiagnosticsCommands, +} from './observability/runtime/index.ts'; import { - bootCommand, - devicesCommand, - installCommand, - installFromSourceCommand, - reinstallCommand, - shutdownCommand, - type AdminBootCommandOptions, - type AdminBootCommandResult, - type AdminDevicesCommandOptions, - type AdminDevicesCommandResult, - type AdminInstallCommandOptions, - type AdminInstallCommandResult, - type AdminInstallFromSourceCommandOptions, - type AdminReinstallCommandOptions, - type AdminShutdownCommandOptions, - type AdminShutdownCommandResult, -} from './admin.ts'; + bindRecordingCommands, + recordingCommands, + type BoundRecordingCommands, + type RecordingCommands, +} from './recording/runtime/index.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'; + bindSystemCommands, + systemCommands, + type BoundSystemCommands, + type SystemCommands, +} from './system/runtime/index.ts'; -export type { ScreenshotCommandResult } from './capture-screenshot.ts'; +export type { ScreenshotCommandResult } from './capture/runtime/screenshot.ts'; export type { DiffScreenshotCommandOptions, DiffScreenshotCommandResult, LiveScreenshotInputRef, -} from './capture-diff-screenshot.ts'; +} from './capture/runtime/diff-screenshot.ts'; export type { DiffSnapshotCommandResult, SnapshotCommandResult, SnapshotDiffLine, SnapshotDiffSummary, -} from './capture-snapshot.ts'; +} from './capture/runtime/snapshot.ts'; export type { + ElementTarget, FindReadCommandOptions, FindReadCommandResult, GetAttrsCommandOptions, @@ -179,15 +67,14 @@ export type { IsCommandOptions, IsCommandResult, IsSelectorCommandOptions, - ElementTarget, RefTarget, ResolvedTarget, - SelectorTarget, SelectorSnapshotOptions, + SelectorTarget, WaitCommandOptions, WaitCommandResult, WaitForTextCommandOptions, -} from './selector-read.ts'; +} from './interaction/runtime/selector-read.ts'; export type { ClickCommandOptions, FillCommandOptions, @@ -211,25 +98,7 @@ export type { SwipeOptions, TypeTextCommandOptions, TypeTextCommandResult, -} from './interactions.ts'; -export type { - SystemAlertCommandOptions, - SystemAlertCommandResult, - SystemAppSwitcherCommandOptions, - SystemAppSwitcherCommandResult, - SystemBackCommandOptions, - SystemBackCommandResult, - SystemClipboardCommandOptions, - SystemClipboardCommandResult, - SystemHomeCommandOptions, - SystemHomeCommandResult, - SystemKeyboardCommandOptions, - SystemKeyboardCommandResult, - SystemRotateCommandOptions, - SystemRotateCommandResult, - SystemSettingsCommandOptions, - SystemSettingsCommandResult, -} from './system.ts'; +} from './interaction/runtime/interactions.ts'; export type { AppPushInput, CloseAppCommandOptions, @@ -244,7 +113,7 @@ export type { PushAppCommandResult, TriggerAppEventCommandOptions, TriggerAppEventCommandResult, -} from './apps.ts'; +} from './management/runtime/apps.ts'; export type { AdminBootCommandOptions, AdminBootCommandResult, @@ -256,13 +125,7 @@ export type { AdminReinstallCommandOptions, AdminShutdownCommandOptions, AdminShutdownCommandResult, -} from './admin.ts'; -export type { - RecordingRecordCommandOptions, - RecordingRecordCommandResult, - RecordingTraceCommandOptions, - RecordingTraceCommandResult, -} from './recording.ts'; +} from './management/runtime/admin.ts'; export type { DiagnosticsLogsCommandOptions, DiagnosticsLogsCommandResult, @@ -270,8 +133,32 @@ export type { DiagnosticsNetworkCommandResult, DiagnosticsPerfCommandOptions, DiagnosticsPerfCommandResult, -} from './diagnostics.ts'; -export { ref, selector } from './selector-read.ts'; +} from './observability/runtime/diagnostics.ts'; +export type { + RecordingRecordCommandOptions, + RecordingRecordCommandResult, + RecordingTraceCommandOptions, + RecordingTraceCommandResult, +} from './recording/runtime/recording.ts'; +export type { + SystemAlertCommandOptions, + SystemAlertCommandResult, + SystemAppSwitcherCommandOptions, + SystemAppSwitcherCommandResult, + SystemBackCommandOptions, + SystemBackCommandResult, + SystemClipboardCommandOptions, + SystemClipboardCommandResult, + SystemHomeCommandOptions, + SystemHomeCommandResult, + SystemKeyboardCommandOptions, + SystemKeyboardCommandResult, + SystemRotateCommandOptions, + SystemRotateCommandResult, + SystemSettingsCommandOptions, + SystemSettingsCommandResult, +} from './system/runtime/system.ts'; +export { ref, selector } from './interaction/runtime/selector-read.ts'; export type { BoundRuntimeCommand, @@ -283,332 +170,47 @@ export type { } from './runtime-types.ts'; export type AgentDeviceCommands = { - capture: { - screenshot: RuntimeCommand; - diffScreenshot: RuntimeCommand; - snapshot: RuntimeCommand; - diffSnapshot: RuntimeCommand; - }; - selectors: { - find: RuntimeCommand; - get: RuntimeCommand; - getText: RuntimeCommand>; - getAttrs: RuntimeCommand>; - is: RuntimeCommand; - isVisible: RuntimeCommand; - isHidden: RuntimeCommand; - wait: RuntimeCommand; - waitForText: RuntimeCommand< - WaitForTextCommandOptions, - Extract - >; - }; - interactions: { - click: RuntimeCommand; - press: RuntimeCommand; - fill: RuntimeCommand; - typeText: RuntimeCommand; - focus: RuntimeCommand; - longPress: RuntimeCommand; - swipe: RuntimeCommand; - scroll: RuntimeCommand; - pinch: RuntimeCommand; - }; - system: { - back: RuntimeCommand; - home: RuntimeCommand; - rotate: RuntimeCommand; - keyboard: RuntimeCommand; - clipboard: RuntimeCommand; - settings: RuntimeCommand; - alert: RuntimeCommand; - appSwitcher: RuntimeCommand< - SystemAppSwitcherCommandOptions | undefined, - SystemAppSwitcherCommandResult - >; - }; - apps: { - open: RuntimeCommand; - close: RuntimeCommand; - list: RuntimeCommand; - state: RuntimeCommand; - push: RuntimeCommand; - triggerEvent: RuntimeCommand; - }; - admin: { - devices: RuntimeCommand; - boot: RuntimeCommand; - shutdown: RuntimeCommand; - install: RuntimeCommand; - reinstall: RuntimeCommand; - installFromSource: RuntimeCommand< - AdminInstallFromSourceCommandOptions, - AdminInstallCommandResult - >; - }; - recording: { - record: RuntimeCommand; - trace: RuntimeCommand; - }; - diagnostics: { - logs: RuntimeCommand; - network: RuntimeCommand< - DiagnosticsNetworkCommandOptions | undefined, - DiagnosticsNetworkCommandResult - >; - perf: RuntimeCommand; - }; + capture: CaptureCommands; + selectors: SelectorCommands; + interactions: InteractionCommands; + system: SystemCommands; + apps: AppCommands; + admin: AdminCommands; + recording: RecordingCommands; + diagnostics: DiagnosticsCommands; }; export type BoundAgentDeviceCommands = { - capture: { - screenshot: BoundRuntimeCommand; - diffScreenshot: BoundRuntimeCommand; - snapshot: BoundRuntimeCommand; - diffSnapshot: BoundRuntimeCommand; - }; - selectors: { - find: BoundRuntimeCommand; - get: BoundRuntimeCommand; - getText: ( - target: ElementTarget, - options?: Omit, - ) => Promise>; - getAttrs: ( - target: ElementTarget, - options?: Omit, - ) => Promise>; - is: BoundRuntimeCommand; - isVisible: ( - target: SelectorTarget, - options?: Omit, - ) => Promise; - isHidden: ( - target: SelectorTarget, - options?: Omit, - ) => Promise; - wait: BoundRuntimeCommand; - waitForText: ( - text: string, - options?: Omit, - ) => Promise>; - }; - interactions: { - click: ( - target: InteractionTarget, - options?: Omit, - ) => Promise; - press: ( - target: InteractionTarget, - options?: Omit, - ) => Promise; - fill: ( - target: InteractionTarget, - text: string, - options?: Omit, - ) => Promise; - typeText: ( - text: string, - options?: Omit, - ) => Promise; - focus: ( - target: InteractionTarget, - options?: Omit, - ) => Promise; - longPress: ( - target: InteractionTarget, - options?: Omit, - ) => Promise; - swipe: BoundRuntimeCommand; - scroll: BoundRuntimeCommand; - pinch: BoundRuntimeCommand; - }; - system: { - back: (options?: SystemBackCommandOptions) => Promise; - home: (options?: SystemHomeCommandOptions) => Promise; - rotate: BoundRuntimeCommand; - keyboard: (options?: SystemKeyboardCommandOptions) => Promise; - clipboard: BoundRuntimeCommand; - settings: (options?: SystemSettingsCommandOptions) => Promise; - alert: (options?: SystemAlertCommandOptions) => Promise; - appSwitcher: ( - options?: SystemAppSwitcherCommandOptions, - ) => Promise; - }; - apps: { - open: BoundRuntimeCommand; - close: (options?: CloseAppCommandOptions) => Promise; - list: (options?: ListAppsCommandOptions) => Promise; - state: BoundRuntimeCommand; - push: BoundRuntimeCommand; - triggerEvent: BoundRuntimeCommand; - }; - admin: { - devices: (options?: AdminDevicesCommandOptions) => Promise; - boot: (options?: AdminBootCommandOptions) => Promise; - shutdown: (options?: AdminShutdownCommandOptions) => Promise; - 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; - }; + capture: BoundCaptureCommands; + selectors: BoundSelectorCommands; + interactions: BoundInteractionCommands; + system: BoundSystemCommands; + apps: BoundAppCommands; + admin: BoundAdminCommands; + recording: BoundRecordingCommands; + observability: BoundObservabilityCommands; }; export const commands: AgentDeviceCommands = { - capture: { - screenshot: screenshotCommand, - diffScreenshot: diffScreenshotCommand, - snapshot: snapshotCommand, - diffSnapshot: diffSnapshotCommand, - }, - selectors: { - find: findCommand, - get: getCommand, - getText: getTextCommand, - getAttrs: getAttrsCommand, - is: isCommand, - isVisible: isVisibleCommand, - isHidden: isHiddenCommand, - wait: waitCommand, - waitForText: waitForTextCommand, - }, - interactions: { - click: clickCommand, - press: pressCommand, - fill: fillCommand, - typeText: typeTextCommand, - focus: focusCommand, - longPress: longPressCommand, - swipe: swipeCommand, - scroll: scrollCommand, - pinch: pinchCommand, - }, - system: { - back: backCommand, - home: homeCommand, - rotate: rotateCommand, - keyboard: keyboardCommand, - clipboard: clipboardCommand, - settings: settingsCommand, - alert: alertCommand, - appSwitcher: appSwitcherCommand, - }, - apps: { - open: openAppCommand, - close: closeAppCommand, - list: listAppsCommand, - state: getAppStateCommand, - push: pushAppCommand, - triggerEvent: triggerAppEventCommand, - }, - admin: { - devices: devicesCommand, - boot: bootCommand, - shutdown: shutdownCommand, - install: installCommand, - reinstall: reinstallCommand, - installFromSource: installFromSourceCommand, - }, - recording: { - record: recordCommand, - trace: traceCommand, - }, - diagnostics: { - logs: logsCommand, - network: networkCommand, - perf: perfCommand, - }, + capture: captureCommands, + selectors: selectorCommands, + interactions: interactionCommands, + system: systemCommands, + apps: appCommands, + admin: adminCommands, + recording: recordingCommands, + diagnostics: diagnosticsCommands, }; export function bindCommands(runtime: AgentDeviceRuntime): BoundAgentDeviceCommands { return { - capture: { - screenshot: (options) => commands.capture.screenshot(runtime, options), - diffScreenshot: (options) => commands.capture.diffScreenshot(runtime, options), - snapshot: (options) => commands.capture.snapshot(runtime, options), - diffSnapshot: (options) => commands.capture.diffSnapshot(runtime, options), - }, - selectors: { - find: (options) => commands.selectors.find(runtime, options), - get: (options) => commands.selectors.get(runtime, options), - getText: (target, options = {}) => - commands.selectors.getText(runtime, { ...options, target }), - getAttrs: (target, options = {}) => - commands.selectors.getAttrs(runtime, { ...options, target }), - is: (options) => commands.selectors.is(runtime, options), - isVisible: (target, options = {}) => - commands.selectors.isVisible(runtime, { ...options, target }), - isHidden: (target, options = {}) => - commands.selectors.isHidden(runtime, { ...options, target }), - wait: (options) => commands.selectors.wait(runtime, options), - waitForText: (text, options = {}) => - commands.selectors.waitForText(runtime, { ...options, text }), - }, - interactions: { - click: (target, options = {}) => commands.interactions.click(runtime, { ...options, target }), - press: (target, options = {}) => commands.interactions.press(runtime, { ...options, target }), - fill: (target, text, options = {}) => - commands.interactions.fill(runtime, { ...options, target, text }), - typeText: (text, options = {}) => - commands.interactions.typeText(runtime, { ...options, text }), - focus: (target, options = {}) => commands.interactions.focus(runtime, { ...options, target }), - longPress: (target, options = {}) => - commands.interactions.longPress(runtime, { ...options, target }), - swipe: (options) => commands.interactions.swipe(runtime, options), - scroll: (options) => commands.interactions.scroll(runtime, options), - pinch: (options) => commands.interactions.pinch(runtime, options), - }, - system: { - back: (options) => commands.system.back(runtime, options), - home: (options) => commands.system.home(runtime, options), - rotate: (options) => commands.system.rotate(runtime, options), - keyboard: (options) => commands.system.keyboard(runtime, options), - clipboard: (options) => commands.system.clipboard(runtime, options), - settings: (options) => commands.system.settings(runtime, options), - alert: (options) => commands.system.alert(runtime, options), - appSwitcher: (options) => commands.system.appSwitcher(runtime, options), - }, - apps: { - open: (options) => commands.apps.open(runtime, options), - close: (options) => commands.apps.close(runtime, options), - list: (options = {}) => - commands.apps.list(runtime, { - ...options, - filter: resolveAppsFilter(options.filter), - }), - state: (options) => commands.apps.state(runtime, options), - 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), - shutdown: (options) => commands.admin.shutdown(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), - }, + capture: bindCaptureCommands(runtime), + selectors: bindSelectorCommands(runtime), + interactions: bindInteractionCommands(runtime), + system: bindSystemCommands(runtime), + apps: bindAppCommands(runtime), + admin: bindAdminCommands(runtime), + recording: bindRecordingCommands(runtime), + observability: bindObservabilityCommands(runtime), }; } diff --git a/src/commands/interaction-targeting.ts b/src/commands/interaction-targeting.ts deleted file mode 100644 index 9a995028f..000000000 --- a/src/commands/interaction-targeting.ts +++ /dev/null @@ -1 +0,0 @@ -export * from '../core/interaction-targeting.ts'; diff --git a/src/commands/interaction/interactions.ts b/src/commands/interaction/interactions.ts index 9ef663da1..23ce1e674 100644 --- a/src/commands/interaction/interactions.ts +++ b/src/commands/interaction/interactions.ts @@ -11,7 +11,7 @@ import { readInteractionTargetFromPositionals, } from '../../core/interaction-positionals.ts'; import { AppError } from '../../utils/errors.ts'; -import type { ScrollInputDirection } from '../interaction-gestures.ts'; +import type { ScrollInputDirection } from './runtime/gestures.ts'; import { commonInputFromFlags, direct, diff --git a/src/commands/interaction/metadata.ts b/src/commands/interaction/metadata.ts index d540a9c80..081b006e8 100644 --- a/src/commands/interaction/metadata.ts +++ b/src/commands/interaction/metadata.ts @@ -34,7 +34,7 @@ import { type ScrollDirection, type SwipePreset, } from '../../core/scroll-gesture.ts'; -import { SCROLL_INPUT_DIRECTIONS } from '../interaction-gestures.ts'; +import { SCROLL_INPUT_DIRECTIONS } from './runtime/gestures.ts'; import { FIND_LOCATORS } from '../../utils/finders.ts'; const FIND_ACTION_VALUES = [ diff --git a/src/commands/interaction/output.ts b/src/commands/interaction/output.ts new file mode 100644 index 000000000..2e260b050 --- /dev/null +++ b/src/commands/interaction/output.ts @@ -0,0 +1,54 @@ +import type { CommandRequestResult } from '../../client-types.ts'; +import type { CliOutput } from '../command-contract.ts'; +import { messageCliOutput, resultOutput, type CliOutputFormatter } from '../output-common.ts'; + +export function getCliOutput(params: { result: CommandRequestResult; format?: string }): CliOutput { + const data = params.result as Record; + if (params.format === 'text') { + return { data, text: typeof data.text === 'string' ? data.text : '' }; + } + if (params.format === 'attrs') { + return { data, text: JSON.stringify(data.node ?? {}, null, 2) }; + } + return defaultCommandCliOutput(data); +} + +export function findCliOutput(result: CommandRequestResult): CliOutput { + const data = result as Record; + if (typeof data.text === 'string') return { data, text: data.text }; + if (typeof data.found === 'boolean') return { data, text: `Found: ${data.found}` }; + if (data.node) return { data, text: JSON.stringify(data.node, null, 2) }; + return defaultCommandCliOutput(data); +} + +export function isCliOutput(result: CommandRequestResult): CliOutput { + const data = result as Record; + return { data, text: `Passed: is ${data.predicate ?? 'assertion'}` }; +} + +export function tapCliOutput(result: CommandRequestResult): CliOutput { + const data = result as Record; + const ref = data.ref ?? ''; + const x = data.x; + const y = data.y; + if (!ref || typeof x !== 'number' || typeof y !== 'number') { + return defaultCommandCliOutput(data); + } + return { data, text: `Tapped @${ref} (${x}, ${y})` }; +} + +export const interactionCliOutputFormatters = { + click: resultOutput(tapCliOutput), + press: resultOutput(tapCliOutput), + get: ({ input, result }) => + getCliOutput({ + result: result as CommandRequestResult, + format: input.format as Parameters[0]['format'], + }), + is: resultOutput(isCliOutput), + find: resultOutput(findCliOutput), +} as const satisfies Record; + +function defaultCommandCliOutput(result: CommandRequestResult): CliOutput { + return messageCliOutput(result as Record); +} diff --git a/src/commands/interaction-gestures.ts b/src/commands/interaction/runtime/gestures.ts similarity index 96% rename from src/commands/interaction-gestures.ts rename to src/commands/interaction/runtime/gestures.ts index f418e0b1e..42f53062c 100644 --- a/src/commands/interaction-gestures.ts +++ b/src/commands/interaction/runtime/gestures.ts @@ -1,17 +1,17 @@ -import { AppError } from '../utils/errors.ts'; -import type { Point, Rect, SnapshotNode, SnapshotState } from '../utils/snapshot.ts'; -import { centerOfRect } from '../utils/snapshot.ts'; +import { AppError } from '../../../utils/errors.ts'; +import type { Point, Rect, SnapshotNode, SnapshotState } from '../../../utils/snapshot.ts'; +import { centerOfRect } from '../../../utils/snapshot.ts'; import { buildSwipePresetGesturePlan, parseSwipePreset, type GestureReferenceFrame, type ScrollDirection, type SwipePreset, -} from '../core/scroll-gesture.ts'; -import type { AgentDeviceRuntime, CommandContext } from '../runtime-contract.ts'; -import { requireIntInRange } from '../utils/validation.ts'; -import { successText } from '../utils/success-text.ts'; -import { isNodeVisibleInEffectiveViewport } from '../utils/mobile-snapshot-semantics.ts'; +} from '../../../core/scroll-gesture.ts'; +import type { AgentDeviceRuntime, CommandContext } from '../../../runtime-contract.ts'; +import { requireIntInRange } from '../../../utils/validation.ts'; +import { successText } from '../../../utils/success-text.ts'; +import { isNodeVisibleInEffectiveViewport } from '../../../utils/mobile-snapshot-semantics.ts'; import { captureScrollEdgeState, formatScrollEdgeMessage, @@ -19,21 +19,21 @@ import { type ScrollEdge, type ScrollEdgeState, type ScrollEdgeTarget, -} from '../utils/scroll-edge-state.ts'; +} from '../../../utils/scroll-edge-state.ts'; import { toBackendResult, type BackendResultEnvelope, type BackendResultVariant, type RuntimeCommand, -} from './runtime-types.ts'; -import type { LongPressCommandResult } from '../contracts/interaction.ts'; +} from '../../runtime-types.ts'; +import type { LongPressCommandResult } from '../../../contracts/interaction.ts'; import { assertSupportedInteractionSurface, captureInteractionSnapshot, type InteractionTarget, type ResolvedInteractionTarget, resolveInteractionTarget, -} from './interaction-resolution.ts'; +} from './resolution.ts'; import { toBackendContext } from './selector-read-utils.ts'; export type FocusCommandOptions = CommandContext & { diff --git a/src/commands/interaction/runtime/index.ts b/src/commands/interaction/runtime/index.ts new file mode 100644 index 000000000..db5a29601 --- /dev/null +++ b/src/commands/interaction/runtime/index.ts @@ -0,0 +1,197 @@ +import type { AgentDeviceRuntime } from '../../../runtime-contract.ts'; +import type { BoundRuntimeCommand, RuntimeCommand } from '../../runtime-types.ts'; +import { + clickCommand, + fillCommand, + focusCommand, + longPressCommand, + pinchCommand, + pressCommand, + scrollCommand, + swipeCommand, + typeTextCommand, + type ClickCommandOptions, + type FillCommandOptions, + type FillCommandResult, + type FocusCommandOptions, + type FocusCommandResult, + type InteractionTarget, + 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 { + findCommand, + getAttrsCommand, + getCommand, + getTextCommand, + isCommand, + isHiddenCommand, + isVisibleCommand, + waitCommand, + waitForTextCommand, + type ElementTarget, + type FindReadCommandOptions, + type FindReadCommandResult, + type GetAttrsCommandOptions, + type GetCommandOptions, + type GetCommandResult, + type GetTextCommandOptions, + type IsCommandOptions, + type IsCommandResult, + type IsSelectorCommandOptions, + type SelectorTarget, + type WaitCommandOptions, + type WaitCommandResult, + type WaitForTextCommandOptions, +} from './selector-read.ts'; + +export type SelectorCommands = { + find: RuntimeCommand; + get: RuntimeCommand; + getText: RuntimeCommand>; + getAttrs: RuntimeCommand>; + is: RuntimeCommand; + isVisible: RuntimeCommand; + isHidden: RuntimeCommand; + wait: RuntimeCommand; + waitForText: RuntimeCommand< + WaitForTextCommandOptions, + Extract + >; +}; + +export type InteractionCommands = { + click: RuntimeCommand; + press: RuntimeCommand; + fill: RuntimeCommand; + typeText: RuntimeCommand; + focus: RuntimeCommand; + longPress: RuntimeCommand; + swipe: RuntimeCommand; + scroll: RuntimeCommand; + pinch: RuntimeCommand; +}; + +export type BoundSelectorCommands = { + find: BoundRuntimeCommand; + get: BoundRuntimeCommand; + getText: ( + target: ElementTarget, + options?: Omit, + ) => Promise>; + getAttrs: ( + target: ElementTarget, + options?: Omit, + ) => Promise>; + is: BoundRuntimeCommand; + isVisible: ( + target: SelectorTarget, + options?: Omit, + ) => Promise; + isHidden: ( + target: SelectorTarget, + options?: Omit, + ) => Promise; + wait: BoundRuntimeCommand; + waitForText: ( + text: string, + options?: Omit, + ) => Promise>; +}; + +export type BoundInteractionCommands = { + click: ( + target: InteractionTarget, + options?: Omit, + ) => Promise; + press: ( + target: InteractionTarget, + options?: Omit, + ) => Promise; + fill: ( + target: InteractionTarget, + text: string, + options?: Omit, + ) => Promise; + typeText: ( + text: string, + options?: Omit, + ) => Promise; + focus: ( + target: InteractionTarget, + options?: Omit, + ) => Promise; + longPress: ( + target: InteractionTarget, + options?: Omit, + ) => Promise; + swipe: BoundRuntimeCommand; + scroll: BoundRuntimeCommand; + pinch: BoundRuntimeCommand; +}; + +export const selectorCommands: SelectorCommands = { + find: findCommand, + get: getCommand, + getText: getTextCommand, + getAttrs: getAttrsCommand, + is: isCommand, + isVisible: isVisibleCommand, + isHidden: isHiddenCommand, + wait: waitCommand, + waitForText: waitForTextCommand, +}; + +export const interactionCommands: InteractionCommands = { + click: clickCommand, + press: pressCommand, + fill: fillCommand, + typeText: typeTextCommand, + focus: focusCommand, + longPress: longPressCommand, + swipe: swipeCommand, + scroll: scrollCommand, + pinch: pinchCommand, +}; + +export function bindSelectorCommands(runtime: AgentDeviceRuntime): BoundSelectorCommands { + return { + find: (options) => selectorCommands.find(runtime, options), + get: (options) => selectorCommands.get(runtime, options), + getText: (target, options = {}) => selectorCommands.getText(runtime, { ...options, target }), + getAttrs: (target, options = {}) => selectorCommands.getAttrs(runtime, { ...options, target }), + is: (options) => selectorCommands.is(runtime, options), + isVisible: (target, options = {}) => + selectorCommands.isVisible(runtime, { ...options, target }), + isHidden: (target, options = {}) => selectorCommands.isHidden(runtime, { ...options, target }), + wait: (options) => selectorCommands.wait(runtime, options), + waitForText: (text, options = {}) => + selectorCommands.waitForText(runtime, { ...options, text }), + }; +} + +export function bindInteractionCommands(runtime: AgentDeviceRuntime): BoundInteractionCommands { + return { + click: (target, options = {}) => interactionCommands.click(runtime, { ...options, target }), + press: (target, options = {}) => interactionCommands.press(runtime, { ...options, target }), + fill: (target, text, options = {}) => + interactionCommands.fill(runtime, { ...options, target, text }), + typeText: (text, options = {}) => interactionCommands.typeText(runtime, { ...options, text }), + focus: (target, options = {}) => interactionCommands.focus(runtime, { ...options, target }), + longPress: (target, options = {}) => + interactionCommands.longPress(runtime, { ...options, target }), + swipe: (options) => interactionCommands.swipe(runtime, options), + scroll: (options) => interactionCommands.scroll(runtime, options), + pinch: (options) => interactionCommands.pinch(runtime, options), + }; +} diff --git a/src/__tests__/runtime-interactions.test.ts b/src/commands/interaction/runtime/interactions.test.ts similarity index 98% rename from src/__tests__/runtime-interactions.test.ts rename to src/commands/interaction/runtime/interactions.test.ts index bd54a83b9..fc6e9fecc 100644 --- a/src/__tests__/runtime-interactions.test.ts +++ b/src/commands/interaction/runtime/interactions.test.ts @@ -1,13 +1,17 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; -import type { AgentDeviceBackend, BackendSnapshotOptions } from '../backend.ts'; -import { commands, ref, selector } from '../commands/index.ts'; -import { resolveActionableTouchResolution } from '../core/interaction-targeting.ts'; -import { createLocalArtifactAdapter } from '../io.ts'; -import { createAgentDevice, createMemorySessionStore, localCommandPolicy } from '../runtime.ts'; -import { AppError } from '../utils/errors.ts'; -import type { Point, SnapshotState } from '../utils/snapshot.ts'; -import { makeSnapshotState } from './test-utils/index.ts'; +import type { AgentDeviceBackend, BackendSnapshotOptions } from '../../../backend.ts'; +import { commands, ref, selector } from '../../index.ts'; +import { resolveActionableTouchResolution } from '../../../core/interaction-targeting.ts'; +import { createLocalArtifactAdapter } from '../../../io.ts'; +import { + createAgentDevice, + createMemorySessionStore, + localCommandPolicy, +} from '../../../runtime.ts'; +import { AppError } from '../../../utils/errors.ts'; +import type { Point, SnapshotState } from '../../../utils/snapshot.ts'; +import { makeSnapshotState } from '../../../__tests__/test-utils/index.ts'; test('runtime click taps an explicit point without requiring a snapshot', async () => { const calls: Array<{ point: Point; count?: number }> = []; diff --git a/src/commands/interactions.ts b/src/commands/interaction/runtime/interactions.ts similarity index 87% rename from src/commands/interactions.ts rename to src/commands/interaction/runtime/interactions.ts index 8e18f56b8..376d7eb8b 100644 --- a/src/commands/interactions.ts +++ b/src/commands/interaction/runtime/interactions.ts @@ -1,23 +1,23 @@ -import { AppError } from '../utils/errors.ts'; -import type { ClickButton } from '../core/click-button.ts'; -import type { AgentDeviceRuntime, CommandContext } from '../runtime-contract.ts'; -import { isFillableType } from '../utils/snapshot-processing.ts'; -import { requireIntInRange } from '../utils/validation.ts'; -import { successText } from '../utils/success-text.ts'; -import { findMistargetedTypeRefToken } from '../utils/type-target-warning.ts'; +import { AppError } from '../../../utils/errors.ts'; +import type { ClickButton } from '../../../core/click-button.ts'; +import type { AgentDeviceRuntime, CommandContext } from '../../../runtime-contract.ts'; +import { isFillableType } from '../../../utils/snapshot-processing.ts'; +import { requireIntInRange } from '../../../utils/validation.ts'; +import { successText } from '../../../utils/success-text.ts'; +import { findMistargetedTypeRefToken } from '../../../utils/type-target-warning.ts'; import type { FillCommandResult, PressCommandResult, ResolvedTarget, -} from '../contracts/interaction.ts'; +} from '../../../contracts/interaction.ts'; import { toBackendContext } from './selector-read-utils.ts'; import { toBackendResult, type BackendResultEnvelope, type RuntimeCommand, -} from './runtime-types.ts'; -import type { RepeatedInput } from './command-input.ts'; -import { type InteractionTarget, resolveInteractionTarget } from './interaction-resolution.ts'; +} from '../../runtime-types.ts'; +import type { RepeatedInput } from '../../command-input.ts'; +import { type InteractionTarget, resolveInteractionTarget } from './resolution.ts'; export { focusCommand, @@ -25,7 +25,7 @@ export { pinchCommand, scrollCommand, swipeCommand, -} from './interaction-gestures.ts'; +} from './gestures.ts'; export type { FocusCommandOptions, FocusCommandResult, @@ -40,12 +40,8 @@ export type { SwipeCommandOptions, SwipeCommandResult, SwipeOptions, -} from './interaction-gestures.ts'; -export type { - InteractionTarget, - PointTarget, - ResolvedInteractionTarget, -} from './interaction-resolution.ts'; +} from './gestures.ts'; +export type { InteractionTarget, PointTarget, ResolvedInteractionTarget } from './resolution.ts'; export type PressCommandOptions = CommandContext & RepeatedInput & { diff --git a/src/commands/interaction-resolution.ts b/src/commands/interaction/runtime/resolution.ts similarity index 92% rename from src/commands/interaction-resolution.ts rename to src/commands/interaction/runtime/resolution.ts index 943555b62..483765af8 100644 --- a/src/commands/interaction-resolution.ts +++ b/src/commands/interaction/runtime/resolution.ts @@ -1,22 +1,26 @@ -import { AppError } from '../utils/errors.ts'; -import type { Point, SnapshotNode, SnapshotState } from '../utils/snapshot.ts'; -import { findNodeByRef, normalizeRef } from '../utils/snapshot.ts'; -import { resolveRectCenter } from '../utils/rect-center.ts'; -import type { AgentDeviceRuntime, CommandContext } from '../runtime-contract.ts'; -import { formatSelectorFailure, parseSelectorChain, resolveSelectorChain } from '../selectors.ts'; -import { buildSelectorChainForNode } from '../utils/selector-build.ts'; -import { findNodeByLabel, resolveRefLabel } from '../utils/snapshot-processing.ts'; +import { AppError } from '../../../utils/errors.ts'; +import type { Point, SnapshotNode, SnapshotState } from '../../../utils/snapshot.ts'; +import { findNodeByRef, normalizeRef } from '../../../utils/snapshot.ts'; +import { resolveRectCenter } from '../../../utils/rect-center.ts'; +import type { AgentDeviceRuntime, CommandContext } from '../../../runtime-contract.ts'; +import { + formatSelectorFailure, + parseSelectorChain, + resolveSelectorChain, +} from '../../../selectors.ts'; +import { buildSelectorChainForNode } from '../../../utils/selector-build.ts'; +import { findNodeByLabel, resolveRefLabel } from '../../../utils/snapshot-processing.ts'; import { isNodeVisibleInEffectiveViewport, resolveEffectiveViewportRect, -} from '../utils/mobile-snapshot-semantics.ts'; -import { isSnapshotNodeInteractionBlocked } from '../utils/snapshot-occlusion.ts'; +} from '../../../utils/mobile-snapshot-semantics.ts'; +import { isSnapshotNodeInteractionBlocked } from '../../../utils/snapshot-occlusion.ts'; import type { InteractionTarget, PointTarget, ResolvedInteractionTarget, -} from '../contracts/interaction.ts'; -import { resolveActionableTouchResolution } from './interaction-targeting.ts'; +} from '../../../contracts/interaction.ts'; +import { resolveActionableTouchResolution } from './targeting.ts'; import { now, toBackendContext } from './selector-read-utils.ts'; export type { InteractionTarget, PointTarget, ResolvedInteractionTarget }; diff --git a/src/commands/selector-read-shared.ts b/src/commands/interaction/runtime/selector-read-shared.ts similarity index 87% rename from src/commands/selector-read-shared.ts rename to src/commands/interaction/runtime/selector-read-shared.ts index d60683e99..58ab40f7f 100644 --- a/src/commands/selector-read-shared.ts +++ b/src/commands/interaction/runtime/selector-read-shared.ts @@ -2,14 +2,14 @@ import type { AgentDeviceRuntime, CommandContext, CommandSessionRecord, -} from '../runtime-contract.ts'; -import { AppError } from '../utils/errors.ts'; -import type { SnapshotNode, SnapshotState } from '../utils/snapshot.ts'; -import { findNodeByRef, normalizeRef } from '../utils/snapshot.ts'; -import { isSparseSnapshotQualityVerdict } from '../utils/snapshot-quality.ts'; -import { extractReadableText } from '../utils/text-surface.ts'; +} from '../../../runtime-contract.ts'; +import { AppError } from '../../../utils/errors.ts'; +import type { SnapshotNode, SnapshotState } from '../../../utils/snapshot.ts'; +import { findNodeByRef, normalizeRef } from '../../../utils/snapshot.ts'; +import { isSparseSnapshotQualityVerdict } from '../../../utils/snapshot-quality.ts'; +import { extractReadableText } from '../../../utils/text-surface.ts'; import { findNodeByLabel, now, toBackendContext } from './selector-read-utils.ts'; -import type { SelectorSnapshotInput } from './command-input.ts'; +import type { SelectorSnapshotInput } from '../../command-input.ts'; export type CapturedSnapshot = { sessionName: string; diff --git a/src/commands/selector-read-utils.ts b/src/commands/interaction/runtime/selector-read-utils.ts similarity index 75% rename from src/commands/selector-read-utils.ts rename to src/commands/interaction/runtime/selector-read-utils.ts index 31cd72c9d..e05c1be1b 100644 --- a/src/commands/selector-read-utils.ts +++ b/src/commands/interaction/runtime/selector-read-utils.ts @@ -1,7 +1,7 @@ -import type { BackendCommandContext } from '../backend.ts'; -import type { AgentDeviceRuntime, CommandContext } from '../runtime-contract.ts'; +import type { BackendCommandContext } from '../../../backend.ts'; +import type { AgentDeviceRuntime, CommandContext } from '../../../runtime-contract.ts'; -export { findNodeByLabel, resolveRefLabel } from '../utils/snapshot-processing.ts'; +export { findNodeByLabel, resolveRefLabel } from '../../../utils/snapshot-processing.ts'; export function shouldScopeFind(locator: string): boolean { return locator === 'text' || locator === 'label' || locator === 'any'; diff --git a/src/__tests__/runtime-selector-read.test.ts b/src/commands/interaction/runtime/selector-read.test.ts similarity index 96% rename from src/__tests__/runtime-selector-read.test.ts rename to src/commands/interaction/runtime/selector-read.test.ts index 8e50c318d..642296af4 100644 --- a/src/__tests__/runtime-selector-read.test.ts +++ b/src/commands/interaction/runtime/selector-read.test.ts @@ -4,17 +4,17 @@ import type { AgentDeviceBackend, BackendSnapshotOptions, BackendSnapshotResult, -} from '../backend.ts'; -import { createLocalArtifactAdapter } from '../io.ts'; +} from '../../../backend.ts'; +import { createLocalArtifactAdapter } from '../../../io.ts'; import { createAgentDevice, createMemorySessionStore, localCommandPolicy, type CommandSessionStore, -} from '../runtime.ts'; -import { ref, selector } from '../commands/index.ts'; -import type { SnapshotState } from '../utils/snapshot.ts'; -import { makeSnapshotState } from './test-utils/index.ts'; +} from '../../../runtime.ts'; +import { ref, selector } from '../../index.ts'; +import type { SnapshotState } from '../../../utils/snapshot.ts'; +import { makeSnapshotState } from '../../../__tests__/test-utils/index.ts'; test('runtime get reads text from a selector target', async () => { const snapshot = selectorSnapshot(); diff --git a/src/commands/selector-read.ts b/src/commands/interaction/runtime/selector-read.ts similarity index 95% rename from src/commands/selector-read.ts rename to src/commands/interaction/runtime/selector-read.ts index 3aba94b09..398413755 100644 --- a/src/commands/selector-read.ts +++ b/src/commands/interaction/runtime/selector-read.ts @@ -1,28 +1,31 @@ -import type { FindAction, FindLocator } from '../utils/finders.ts'; -import { findBestMatchesByLocator } from '../utils/finders.ts'; -import type { SnapshotNode } from '../utils/snapshot.ts'; -import { findNodeByRef, normalizeRef } from '../utils/snapshot.ts'; +import type { FindAction, FindLocator } from '../../../utils/finders.ts'; +import { findBestMatchesByLocator } from '../../../utils/finders.ts'; +import type { SnapshotNode } from '../../../utils/snapshot.ts'; +import { findNodeByRef, normalizeRef } from '../../../utils/snapshot.ts'; import { isSparseSnapshotQualityVerdict, type SnapshotQualityVerdict, -} from '../utils/snapshot-quality.ts'; -import type { AgentDeviceRuntime, CommandContext } from '../runtime-contract.ts'; -import { AppError } from '../utils/errors.ts'; +} from '../../../utils/snapshot-quality.ts'; +import type { AgentDeviceRuntime, CommandContext } from '../../../runtime-contract.ts'; +import { AppError } from '../../../utils/errors.ts'; import { findSelectorChainMatch, formatSelectorFailure, parseSelectorChain, resolveSelectorChain, -} from '../selectors.ts'; -import { buildSelectorChainForNode } from '../utils/selector-build.ts'; -import { evaluateIsPredicate, isSupportedPredicate } from '../utils/selector-is-predicates.ts'; +} from '../../../selectors.ts'; +import { buildSelectorChainForNode } from '../../../utils/selector-build.ts'; +import { + evaluateIsPredicate, + isSupportedPredicate, +} from '../../../utils/selector-is-predicates.ts'; import type { ElementTarget, RefTarget, ResolvedTarget, SelectorTarget, -} from '../contracts/interaction.ts'; -import type { RuntimeCommand } from './runtime-types.ts'; +} from '../../../contracts/interaction.ts'; +import type { RuntimeCommand } from '../../runtime-types.ts'; import { type CapturedSnapshot, type SelectorSnapshotOptions, diff --git a/src/commands/interaction/runtime/targeting.ts b/src/commands/interaction/runtime/targeting.ts new file mode 100644 index 000000000..f6983dbc6 --- /dev/null +++ b/src/commands/interaction/runtime/targeting.ts @@ -0,0 +1 @@ +export * from '../../../core/interaction-targeting.ts'; diff --git a/src/commands/log-command-contract.ts b/src/commands/log-command-contract.ts deleted file mode 100644 index fe7fa36d6..000000000 --- a/src/commands/log-command-contract.ts +++ /dev/null @@ -1 +0,0 @@ -export * from '../contracts/logs.ts'; diff --git a/src/commands/management/app-inventory-contract.ts b/src/commands/management/app-inventory-contract.ts new file mode 100644 index 000000000..5b47228dd --- /dev/null +++ b/src/commands/management/app-inventory-contract.ts @@ -0,0 +1 @@ +export * from '../../contracts/app-inventory.ts'; diff --git a/src/commands/management/index.ts b/src/commands/management/index.ts index 731f4a07e..abc9a9ad2 100644 --- a/src/commands/management/index.ts +++ b/src/commands/management/index.ts @@ -10,7 +10,7 @@ import type { CliFlags } from '../../utils/cli-flags.ts'; import type { CommandSchemaOverride } from '../../utils/cli-command-schema-types.ts'; import { AppError } from '../../utils/errors.ts'; import { parseGitHubActionsArtifactInstallSourceSpec } from '../../utils/install-source-config.ts'; -import { assertResolvedAppsFilter } from '../app-inventory-contract.ts'; +import { assertResolvedAppsFilter } from './app-inventory-contract.ts'; import { defineExecutableCommand } from '../command-contract.ts'; import { booleanField, diff --git a/src/commands/management/output.test.ts b/src/commands/management/output.test.ts new file mode 100644 index 000000000..dfe7e176f --- /dev/null +++ b/src/commands/management/output.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from 'vitest'; +import { openCliOutput } from './output.ts'; + +describe('openCliOutput', () => { + test('prints session state directory on a second line', () => { + const output = openCliOutput({ + session: 'default', + sessionStateDir: '/tmp/agent-device/sessions/cwd_123_default', + identifiers: { session: 'default' }, + }); + + expect(output.text).toBe( + ['Opened: default', 'Session state: /tmp/agent-device/sessions/cwd_123_default'].join('\n'), + ); + expect(output.data).toMatchObject({ + session: 'default', + sessionStateDir: '/tmp/agent-device/sessions/cwd_123_default', + }); + }); +}); diff --git a/src/commands/management/output.ts b/src/commands/management/output.ts new file mode 100644 index 000000000..af09ad47d --- /dev/null +++ b/src/commands/management/output.ts @@ -0,0 +1,127 @@ +import { + serializeCloseResult, + serializeDeployResult, + serializeDevice, + serializeInstallFromSourceResult, + serializeOpenResult, + serializeSessionListEntry, +} from '../../client-shared.ts'; +import type { + AgentDeviceDevice, + AgentDeviceSession, + AppCloseResult, + AppDeployResult, + AppInstallFromSourceResult, + AppOpenResult, + CommandRequestResult, + SessionCloseResult, +} from '../../client-types.ts'; +import { readCommandMessage } from '../../utils/success-text.ts'; +import type { CliOutput } from '../command-contract.ts'; +import { + messageCliOutput, + messageOutput, + resultOutput, + type CliOutputFormatter, +} from '../output-common.ts'; + +export function devicesCliOutput(result: AgentDeviceDevice[]): CliOutput { + const data = { devices: result.map(serializeDevice) }; + return { data, text: result.map(formatDeviceLine).join('\n') }; +} + +export function appsCliOutput(params: { + result: string[]; + appsFilter?: 'user-installed' | 'all'; +}): CliOutput { + const data = { apps: params.result }; + return { + data, + stderr: + params.appsFilter === 'all' + ? 'Showing all apps, including system apps.\n' + : 'Showing user-installed apps. Use --all to include system apps.\n', + text: + params.result.length > 0 + ? params.result.join('\n') + : params.appsFilter === 'all' + ? 'No apps found.' + : 'No user-installed apps found.', + }; +} + +export function sessionCliOutput( + result: { sessions: AgentDeviceSession[] } | { stateDir: string }, +): CliOutput { + if ('stateDir' in result) { + return { data: result, text: result.stateDir }; + } + const data = { sessions: result.sessions.map(serializeSessionListEntry) }; + return { data, text: JSON.stringify(data, null, 2) }; +} + +export function openCliOutput(result: AppOpenResult): CliOutput { + const data = serializeOpenResult(result); + const lines = [readCommandMessage(data)].filter((line): line is string => Boolean(line)); + if (typeof data.sessionStateDir === 'string') { + lines.push(`Session state: ${data.sessionStateDir}`); + } + return { data, text: lines.join('\n') || null }; +} + +export function closeCliOutput(result: AppCloseResult | SessionCloseResult): CliOutput { + return messageCliOutput(serializeCloseResult(result)); +} + +export function deployCliOutput(result: AppDeployResult): CliOutput { + return messageCliOutput(serializeDeployResult(result)); +} + +export function installFromSourceCliOutput(result: AppInstallFromSourceResult): CliOutput { + return messageCliOutput(serializeInstallFromSourceResult(result)); +} + +export function bootCliOutput(result: CommandRequestResult): CliOutput { + const data = result as Record; + const platform = data.platform ?? 'unknown'; + const device = data.device ?? data.id ?? 'unknown'; + return { data, text: `Boot ready: ${device} (${platform})` }; +} + +export function shutdownCliOutput(result: CommandRequestResult): CliOutput { + const data = result as Record; + const platform = data.platform ?? 'unknown'; + const device = data.device ?? data.id ?? 'unknown'; + const shutdown = data.shutdown; + const success = + shutdown && typeof shutdown === 'object' && 'success' in shutdown + ? (shutdown as { success?: unknown }).success === true + : false; + const status = success ? 'Shutdown' : 'Shutdown failed'; + return { data, text: `${status}: ${device} (${platform})` }; +} + +export const managementCliOutputFormatters = { + boot: resultOutput(bootCliOutput), + shutdown: resultOutput(shutdownCliOutput), + devices: resultOutput(devicesCliOutput), + apps: ({ input, result }) => + appsCliOutput({ + result: result as Parameters[0]['result'], + appsFilter: input.appsFilter as Parameters[0]['appsFilter'], + }), + session: resultOutput(sessionCliOutput), + open: resultOutput(openCliOutput), + close: resultOutput(closeCliOutput), + install: resultOutput(deployCliOutput), + reinstall: resultOutput(deployCliOutput), + 'install-from-source': resultOutput(installFromSourceCliOutput), + prepare: messageOutput, +} as const satisfies Record; + +function formatDeviceLine(device: AgentDeviceDevice): string { + const kind = device.kind ? ` ${device.kind}` : ''; + const target = device.target ? ` target=${device.target}` : ''; + const booted = typeof device.booted === 'boolean' ? ` booted=${device.booted}` : ''; + return `${device.name} (${device.platform}${kind}${target})${booted}`; +} diff --git a/src/__tests__/runtime-admin-router.test.ts b/src/commands/management/runtime/admin-router.test.ts similarity index 63% rename from src/__tests__/runtime-admin-router.test.ts rename to src/commands/management/runtime/admin-router.test.ts index 72fa5cc9e..8f6fc2507 100644 --- a/src/__tests__/runtime-admin-router.test.ts +++ b/src/commands/management/runtime/admin-router.test.ts @@ -1,8 +1,12 @@ 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 type { AgentDeviceBackend, BackendInstallSource } from '../../../backend.ts'; +import type { ArtifactAdapter, FileInputRef } from '../../../io.ts'; +import { + createAgentDevice, + localCommandPolicy, + restrictedCommandPolicy, +} from '../../../runtime.ts'; const artifacts = { resolveInput: async (ref: FileInputRef) => ({ @@ -138,103 +142,6 @@ test('admin install cleans materialized input when backend source resolution fai assert.equal(installCalled, false); }); -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); -}); - function createAdminBackend( calls: string[], onInstallSource?: (source: BackendInstallSource) => void, diff --git a/src/commands/admin.ts b/src/commands/management/runtime/admin.ts similarity index 95% rename from src/commands/admin.ts rename to src/commands/management/runtime/admin.ts index 7015278f6..fae869c2a 100644 --- a/src/commands/admin.ts +++ b/src/commands/management/runtime/admin.ts @@ -5,19 +5,19 @@ import type { BackendDeviceTarget, BackendInstallResult, BackendInstallSource, -} from '../backend.ts'; -import type { AgentDeviceRuntime, CommandContext } from '../runtime-contract.ts'; -import { AppError } from '../utils/errors.ts'; -import { successText } from '../utils/success-text.ts'; +} from '../../../backend.ts'; +import type { AgentDeviceRuntime, CommandContext } from '../../../runtime-contract.ts'; +import { AppError } from '../../../utils/errors.ts'; +import { successText } from '../../../utils/success-text.ts'; import { toBackendResult, type BackendResultEnvelope, type BackendResultVariant, type RuntimeCommand, -} from './runtime-types.ts'; -import { resolveCommandInput } from './io-policy.ts'; -import { toBackendContext } from './selector-read-utils.ts'; -import { normalizeOptionalText, requireText } from './text.ts'; +} from '../../runtime-types.ts'; +import { resolveCommandInput } from '../../io-policy.ts'; +import { toBackendContext } from '../../interaction/runtime/selector-read-utils.ts'; +import { normalizeOptionalText, requireText } from '../../text.ts'; export type AdminDevicesCommandOptions = CommandContext & { filter?: BackendDeviceFilter; diff --git a/src/__tests__/runtime-apps.test.ts b/src/commands/management/runtime/apps.test.ts similarity index 96% rename from src/__tests__/runtime-apps.test.ts rename to src/commands/management/runtime/apps.test.ts index ecf372e82..afc349450 100644 --- a/src/__tests__/runtime-apps.test.ts +++ b/src/commands/management/runtime/apps.test.ts @@ -5,9 +5,13 @@ import type { BackendAppEvent, BackendOpenTarget, BackendPushInput, -} from '../backend.ts'; -import { createLocalArtifactAdapter } from '../io.ts'; -import { createAgentDevice, localCommandPolicy, restrictedCommandPolicy } from '../runtime.ts'; +} from '../../../backend.ts'; +import { createLocalArtifactAdapter } from '../../../io.ts'; +import { + createAgentDevice, + localCommandPolicy, + restrictedCommandPolicy, +} from '../../../runtime.ts'; test('runtime app commands call typed backend lifecycle primitives', async () => { const calls: unknown[] = []; diff --git a/src/commands/apps.ts b/src/commands/management/runtime/apps.ts similarity index 95% rename from src/commands/apps.ts rename to src/commands/management/runtime/apps.ts index 25833982d..b78f22f7f 100644 --- a/src/commands/apps.ts +++ b/src/commands/management/runtime/apps.ts @@ -5,19 +5,19 @@ import type { BackendCommandContext, BackendOpenTarget, BackendPushInput, -} from '../backend.ts'; -import type { FileInputRef } from '../io.ts'; -import type { AgentDeviceRuntime, CommandContext } from '../runtime-contract.ts'; -import { assertResolvedAppsFilter } from './app-inventory-contract.ts'; -import { AppError } from '../utils/errors.ts'; -import { successText } from '../utils/success-text.ts'; -import { resolveCommandInput } from './io-policy.ts'; +} from '../../../backend.ts'; +import type { FileInputRef } from '../../../io.ts'; +import type { AgentDeviceRuntime, CommandContext } from '../../../runtime-contract.ts'; +import { assertResolvedAppsFilter } from '../app-inventory-contract.ts'; +import { AppError } from '../../../utils/errors.ts'; +import { successText } from '../../../utils/success-text.ts'; +import { resolveCommandInput } from '../../io-policy.ts'; import { toBackendResult, type BackendResultEnvelope, type RuntimeCommand, -} from './runtime-types.ts'; -import { normalizeOptionalText, requireText } from './text.ts'; +} from '../../runtime-types.ts'; +import { normalizeOptionalText, requireText } from '../../text.ts'; const APP_EVENT_NAME_PATTERN = /^[A-Za-z0-9_.:-]{1,64}$/; const MAX_APP_EVENT_PAYLOAD_BYTES = 8 * 1024; diff --git a/src/commands/management/runtime/index.ts b/src/commands/management/runtime/index.ts new file mode 100644 index 000000000..53e719eb6 --- /dev/null +++ b/src/commands/management/runtime/index.ts @@ -0,0 +1,127 @@ +import type { AgentDeviceRuntime } from '../../../runtime-contract.ts'; +import type { BoundRuntimeCommand, RuntimeCommand } from '../../runtime-types.ts'; +import { resolveAppsFilter } from '../app-inventory-contract.ts'; +import { + bootCommand, + devicesCommand, + installCommand, + installFromSourceCommand, + reinstallCommand, + shutdownCommand, + type AdminBootCommandOptions, + type AdminBootCommandResult, + type AdminDevicesCommandOptions, + type AdminDevicesCommandResult, + type AdminInstallCommandOptions, + type AdminInstallCommandResult, + type AdminInstallFromSourceCommandOptions, + type AdminReinstallCommandOptions, + type AdminShutdownCommandOptions, + type AdminShutdownCommandResult, +} from './admin.ts'; +import { + closeAppCommand, + getAppStateCommand, + listAppsCommand, + 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'; + +export type AppCommands = { + open: RuntimeCommand; + close: RuntimeCommand; + list: RuntimeCommand; + state: RuntimeCommand; + push: RuntimeCommand; + triggerEvent: RuntimeCommand; +}; + +export type AdminCommands = { + devices: RuntimeCommand; + boot: RuntimeCommand; + shutdown: RuntimeCommand; + install: RuntimeCommand; + reinstall: RuntimeCommand; + installFromSource: RuntimeCommand< + AdminInstallFromSourceCommandOptions, + AdminInstallCommandResult + >; +}; + +export type BoundAppCommands = { + open: BoundRuntimeCommand; + close: (options?: CloseAppCommandOptions) => Promise; + list: (options?: ListAppsCommandOptions) => Promise; + state: BoundRuntimeCommand; + push: BoundRuntimeCommand; + triggerEvent: BoundRuntimeCommand; +}; + +export type BoundAdminCommands = { + devices: (options?: AdminDevicesCommandOptions) => Promise; + boot: (options?: AdminBootCommandOptions) => Promise; + shutdown: (options?: AdminShutdownCommandOptions) => Promise; + install: BoundRuntimeCommand; + reinstall: BoundRuntimeCommand; + installFromSource: BoundRuntimeCommand< + AdminInstallFromSourceCommandOptions, + AdminInstallCommandResult + >; +}; + +export const appCommands: AppCommands = { + open: openAppCommand, + close: closeAppCommand, + list: listAppsCommand, + state: getAppStateCommand, + push: pushAppCommand, + triggerEvent: triggerAppEventCommand, +}; + +export const adminCommands: AdminCommands = { + devices: devicesCommand, + boot: bootCommand, + shutdown: shutdownCommand, + install: installCommand, + reinstall: reinstallCommand, + installFromSource: installFromSourceCommand, +}; + +export function bindAppCommands(runtime: AgentDeviceRuntime): BoundAppCommands { + return { + open: (options) => appCommands.open(runtime, options), + close: (options) => appCommands.close(runtime, options), + list: (options = {}) => + appCommands.list(runtime, { + ...options, + filter: resolveAppsFilter(options.filter), + }), + state: (options) => appCommands.state(runtime, options), + push: (options) => appCommands.push(runtime, options), + triggerEvent: (options) => appCommands.triggerEvent(runtime, options), + }; +} + +export function bindAdminCommands(runtime: AgentDeviceRuntime): BoundAdminCommands { + return { + devices: (options) => adminCommands.devices(runtime, options), + boot: (options) => adminCommands.boot(runtime, options), + shutdown: (options) => adminCommands.shutdown(runtime, options), + install: (options) => adminCommands.install(runtime, options), + reinstall: (options) => adminCommands.reinstall(runtime, options), + installFromSource: (options) => adminCommands.installFromSource(runtime, options), + }; +} diff --git a/src/commands/metro/output.ts b/src/commands/metro/output.ts new file mode 100644 index 000000000..638bb12a3 --- /dev/null +++ b/src/commands/metro/output.ts @@ -0,0 +1,17 @@ +import type { CliOutput } from '../command-contract.ts'; +import type { CliOutputFormatter } from '../output-common.ts'; + +export function metroCliOutput(params: { result: unknown; action?: string }): CliOutput { + return { + data: params.result, + text: + params.action === 'reload' + ? `Reloaded React Native apps via ${(params.result as { reloadUrl?: unknown }).reloadUrl}` + : JSON.stringify(params.result, null, 2), + }; +} + +export const metroCliOutputFormatters = { + metro: ({ input, result }) => + metroCliOutput({ result, action: input.action as string | undefined }), +} as const satisfies Record; diff --git a/src/commands/observability/index.ts b/src/commands/observability/index.ts index baa1247cb..a7bb68564 100644 --- a/src/commands/observability/index.ts +++ b/src/commands/observability/index.ts @@ -6,7 +6,7 @@ import type { CommandSchemaOverride } from '../../utils/cli-command-schema-types import { booleanField, enumField, integerField, requiredField, stringField } from '../command-input.ts'; import { defineExecutableCommand } from '../command-contract.ts'; import { defineFieldCommandMetadata } from '../field-command-contract.ts'; -import { LOG_ACTION_VALUES, type LogAction } from '../log-command-contract.ts'; +import { LOG_ACTION_VALUES, type LogAction } from './log-command-contract.ts'; import { isPerfAction, isPerfArea, @@ -24,7 +24,7 @@ import { type PerfArea, type PerfKind, type PerfSubject, -} from '../perf-command-contract.ts'; +} from './perf-command-contract.ts'; import { commonInputFromFlags, direct, diff --git a/src/commands/observability/log-command-contract.ts b/src/commands/observability/log-command-contract.ts new file mode 100644 index 000000000..0c7fab38e --- /dev/null +++ b/src/commands/observability/log-command-contract.ts @@ -0,0 +1 @@ +export * from '../../contracts/logs.ts'; diff --git a/src/commands/observability/perf-command-contract.ts b/src/commands/observability/perf-command-contract.ts new file mode 100644 index 000000000..d69a17cb5 --- /dev/null +++ b/src/commands/observability/perf-command-contract.ts @@ -0,0 +1 @@ +export * from '../../contracts/perf.ts'; diff --git a/src/commands/diagnostics-format.ts b/src/commands/observability/runtime/diagnostics-format.ts similarity index 99% rename from src/commands/diagnostics-format.ts rename to src/commands/observability/runtime/diagnostics-format.ts index 1e0e9f9a7..6aa7bb352 100644 --- a/src/commands/diagnostics-format.ts +++ b/src/commands/observability/runtime/diagnostics-format.ts @@ -3,7 +3,7 @@ import type { BackendMeasurePerfResult, BackendNetworkIncludeMode, BackendReadLogsResult, -} from '../backend.ts'; +} from '../../../backend.ts'; import type { DiagnosticsLogsCommandResult, DiagnosticsNetworkCommandResult, @@ -12,7 +12,7 @@ import type { import { redactNetworkLogText as redactText, redactNetworkUrl, -} from '../observability-redaction.ts'; +} from '../../../observability-redaction.ts'; const PAYLOAD_MAX_CHARS = 2048; const MESSAGE_MAX_CHARS = 4096; diff --git a/src/__tests__/runtime-diagnostics-router.test.ts b/src/commands/observability/runtime/diagnostics-router.test.ts similarity index 97% rename from src/__tests__/runtime-diagnostics-router.test.ts rename to src/commands/observability/runtime/diagnostics-router.test.ts index 28c78a30d..5a6b33e5c 100644 --- a/src/__tests__/runtime-diagnostics-router.test.ts +++ b/src/commands/observability/runtime/diagnostics-router.test.ts @@ -1,12 +1,12 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; -import type { AgentDeviceBackend, BackendCommandContext } from '../backend.ts'; -import type { ArtifactAdapter } from '../io.ts'; +import type { AgentDeviceBackend, BackendCommandContext } from '../../../backend.ts'; +import type { ArtifactAdapter } from '../../../io.ts'; import { createAgentDevice, createMemorySessionStore, restrictedCommandPolicy, -} from '../runtime.ts'; +} from '../../../runtime.ts'; const artifacts = { resolveInput: async () => ({ path: '/tmp/input' }), diff --git a/src/commands/diagnostics-types.ts b/src/commands/observability/runtime/diagnostics-types.ts similarity index 96% rename from src/commands/diagnostics-types.ts rename to src/commands/observability/runtime/diagnostics-types.ts index 073aa63f3..b0e01ce6c 100644 --- a/src/commands/diagnostics-types.ts +++ b/src/commands/observability/runtime/diagnostics-types.ts @@ -3,7 +3,7 @@ import type { BackendLogEntry, BackendNetworkEntry, BackendPerfMetric, -} from '../backend.ts'; +} from '../../../backend.ts'; export type DiagnosticsLogsCommandResult = { kind: 'diagnosticsLogs'; diff --git a/src/commands/diagnostics.ts b/src/commands/observability/runtime/diagnostics.ts similarity index 94% rename from src/commands/diagnostics.ts rename to src/commands/observability/runtime/diagnostics.ts index 77a24b9d6..1ce430db4 100644 --- a/src/commands/diagnostics.ts +++ b/src/commands/observability/runtime/diagnostics.ts @@ -6,19 +6,19 @@ import type { BackendMeasurePerfOptions, BackendNetworkIncludeMode, BackendReadLogsOptions, -} from '../backend.ts'; -import type { AgentDeviceRuntime, CommandContext } from '../runtime-contract.ts'; -import { AppError } from '../utils/errors.ts'; -import { requireIntInRange } from '../utils/validation.ts'; +} from '../../../backend.ts'; +import type { AgentDeviceRuntime, CommandContext } from '../../../runtime-contract.ts'; +import { AppError } from '../../../utils/errors.ts'; +import { requireIntInRange } from '../../../utils/validation.ts'; import { formatLogsResult, formatNetworkResult, formatPerfResult } from './diagnostics-format.ts'; import type { DiagnosticsLogsCommandResult, DiagnosticsNetworkCommandResult, DiagnosticsPerfCommandResult, } from './diagnostics-types.ts'; -import type { RuntimeCommand } from './runtime-types.ts'; -import { toBackendContext } from './selector-read-utils.ts'; -import { requireText } from './text.ts'; +import type { RuntimeCommand } from '../../runtime-types.ts'; +import { toBackendContext } from '../../interaction/runtime/selector-read-utils.ts'; +import { requireText } from '../../text.ts'; export type DiagnosticsPageOptions = CommandContext & { appId?: string; diff --git a/src/commands/observability/runtime/index.ts b/src/commands/observability/runtime/index.ts new file mode 100644 index 000000000..150be884b --- /dev/null +++ b/src/commands/observability/runtime/index.ts @@ -0,0 +1,42 @@ +import type { AgentDeviceRuntime } from '../../../runtime-contract.ts'; +import type { RuntimeCommand } from '../../runtime-types.ts'; +import { + logsCommand, + networkCommand, + perfCommand, + type DiagnosticsLogsCommandOptions, + type DiagnosticsLogsCommandResult, + type DiagnosticsNetworkCommandOptions, + type DiagnosticsNetworkCommandResult, + type DiagnosticsPerfCommandOptions, + type DiagnosticsPerfCommandResult, +} from './diagnostics.ts'; + +export type DiagnosticsCommands = { + logs: RuntimeCommand; + network: RuntimeCommand< + DiagnosticsNetworkCommandOptions | undefined, + DiagnosticsNetworkCommandResult + >; + perf: RuntimeCommand; +}; + +export type BoundObservabilityCommands = { + logs: (options?: DiagnosticsLogsCommandOptions) => Promise; + network: (options?: DiagnosticsNetworkCommandOptions) => Promise; + perf: (options?: DiagnosticsPerfCommandOptions) => Promise; +}; + +export const diagnosticsCommands: DiagnosticsCommands = { + logs: logsCommand, + network: networkCommand, + perf: perfCommand, +}; + +export function bindObservabilityCommands(runtime: AgentDeviceRuntime): BoundObservabilityCommands { + return { + logs: (options) => diagnosticsCommands.logs(runtime, options), + network: (options) => diagnosticsCommands.network(runtime, options), + perf: (options) => diagnosticsCommands.perf(runtime, options), + }; +} diff --git a/src/commands/runtime-output.ts b/src/commands/observability/runtime/output.ts similarity index 86% rename from src/commands/runtime-output.ts rename to src/commands/observability/runtime/output.ts index df167def7..3f6ae9647 100644 --- a/src/commands/runtime-output.ts +++ b/src/commands/observability/runtime/output.ts @@ -1,22 +1,6 @@ -import type { CommandRequestResult } from '../client-types.ts'; -import { readCommandMessage } from '../utils/success-text.ts'; -import type { CliOutput } from './command-contract.ts'; - -export function batchCliOutput(result: CommandRequestResult): CliOutput { - const data = result as Record; - const total = typeof data.total === 'number' ? data.total : 0; - const executed = typeof data.executed === 'number' ? data.executed : 0; - const durationMs = typeof data.totalDurationMs === 'number' ? data.totalDurationMs : undefined; - const lines = [ - `Batch completed: ${executed}/${total} steps${durationMs !== undefined ? ` in ${durationMs}ms` : ''}`, - ]; - const results = Array.isArray(data.results) ? data.results : []; - for (const entry of results) { - const line = renderBatchStepLine(entry); - if (line) lines.push(line); - } - return { data, text: lines.join('\n') }; -} +import type { CommandRequestResult, DebugSymbolsResult } from '../../../client-types.ts'; +import type { CliOutput } from '../../command-contract.ts'; +import { resultOutput, type CliOutputFormatter } from '../../output-common.ts'; export function logsCliOutput(result: CommandRequestResult): CliOutput { const data = result as Record; @@ -68,30 +52,40 @@ export function perfCliOutput(result: CommandRequestResult): CliOutput { return { data, text: formatPerfCliOutput(data) }; } -function renderBatchStepLine(entry: unknown): string | undefined { - const result = readRecord(entry); - if (!result) return undefined; - const step = typeof result.step === 'number' ? result.step : undefined; - const command = typeof result.command === 'string' ? result.command : 'step'; - const stepOk = result.ok !== false; - const description = readBatchStepDescription(result, stepOk, command); - const prefix = step !== undefined ? `${step}. ` : '- '; - const durationMs = typeof result.durationMs === 'number' ? result.durationMs : undefined; - const durationSuffix = durationMs !== undefined ? ` (${durationMs}ms)` : ''; - return `${prefix}${stepOk ? 'OK' : 'FAILED'} ${description}${durationSuffix}`; +export function debugSymbolsCliOutput(result: DebugSymbolsResult): CliOutput { + const lines = [result.outPath, result.message]; + lines.push(...formatDebugCrashSummary(result)); + for (const image of result.matchedImages) { + lines.push(`Matched: ${image.name} ${image.uuid}${image.arch ? ` ${image.arch}` : ''}`); + } + for (const warning of result.warnings ?? []) { + lines.push(`Warning: ${warning}`); + } + return { data: result, text: lines.join('\n') }; } -function readBatchStepDescription( - result: Record, - stepOk: boolean, - command: string, -): string { - if (stepOk) return readCommandMessage(readRecord(result.data)) ?? command; - return readBatchStepFailure(readRecord(result.error)) ?? command; -} +export const observabilityCliOutputFormatters = { + perf: resultOutput(perfCliOutput), + logs: resultOutput(logsCliOutput), + network: resultOutput(networkCliOutput), + debug: resultOutput(debugSymbolsCliOutput), +} as const satisfies Record; -function readBatchStepFailure(error: Record | undefined): string | null { - return typeof error?.message === 'string' && error.message.length > 0 ? error.message : null; +function formatDebugCrashSummary(result: DebugSymbolsResult): string[] { + const crash = result.crash; + const lines = [ + `Crash: ${crash.appName ?? 'unknown app'}${crash.crashedThread === undefined ? '' : ` thread ${crash.crashedThread}`}`, + ]; + if (crash.bundleId) lines.push(`Bundle: ${crash.bundleId}`); + if (crash.exceptionType) lines.push(`Exception: ${crash.exceptionType}`); + if (crash.terminationReason) lines.push(`Termination: ${crash.terminationReason}`); + for (const frame of crash.topFrames) { + lines.push(`Frame ${frame.index}: ${frame.image} ${frame.symbol ?? frame.address}`); + } + for (const finding of crash.findings) { + lines.push(`Finding: ${finding}`); + } + return lines; } function formatActionFields(data: Record): string | undefined { diff --git a/src/commands/output-common.ts b/src/commands/output-common.ts new file mode 100644 index 000000000..e8dbe6c28 --- /dev/null +++ b/src/commands/output-common.ts @@ -0,0 +1,19 @@ +import { readCommandMessage } from '../utils/success-text.ts'; +import type { CliOutput } from './command-contract.ts'; + +export type CliOutputFormatter = (params: { + input: Record; + result: unknown; +}) => CliOutput; + +export function resultOutput( + formatter: (result: TResult) => CliOutput, +): CliOutputFormatter { + return ({ result }) => formatter(result as TResult); +} + +export const messageOutput = resultOutput(messageCliOutput); + +export function messageCliOutput(result: Record): CliOutput { + return { data: result, text: readCommandMessage(result) }; +} diff --git a/src/commands/perf-command-contract.ts b/src/commands/perf-command-contract.ts deleted file mode 100644 index 20d3b97f2..000000000 --- a/src/commands/perf-command-contract.ts +++ /dev/null @@ -1 +0,0 @@ -export * from '../contracts/perf.ts'; diff --git a/src/commands/__tests__/client-output.test.ts b/src/commands/recording/output.test.ts similarity index 74% rename from src/commands/__tests__/client-output.test.ts rename to src/commands/recording/output.test.ts index e11c3cabf..10fcc6f03 100644 --- a/src/commands/__tests__/client-output.test.ts +++ b/src/commands/recording/output.test.ts @@ -1,23 +1,5 @@ import { describe, expect, test } from 'vitest'; -import { openCliOutput, recordCliOutput } from '../client-output.ts'; - -describe('openCliOutput', () => { - test('prints session state directory on a second line', () => { - const output = openCliOutput({ - session: 'default', - sessionStateDir: '/tmp/agent-device/sessions/cwd_123_default', - identifiers: { session: 'default' }, - }); - - expect(output.text).toBe( - ['Opened: default', 'Session state: /tmp/agent-device/sessions/cwd_123_default'].join('\n'), - ); - expect(output.data).toMatchObject({ - session: 'default', - sessionStateDir: '/tmp/agent-device/sessions/cwd_123_default', - }); - }); -}); +import { recordCliOutput } from './output.ts'; describe('recordCliOutput', () => { test('prints session state directory for record-created sessions', () => { diff --git a/src/commands/recording/output.ts b/src/commands/recording/output.ts new file mode 100644 index 000000000..d36ed9c61 --- /dev/null +++ b/src/commands/recording/output.ts @@ -0,0 +1,55 @@ +import type { CommandRequestResult } from '../../client-types.ts'; +import type { CliOutput } from '../command-contract.ts'; +import { resultOutput, type CliOutputFormatter } from '../output-common.ts'; + +export function recordCliOutput(result: CommandRequestResult): CliOutput { + const data = result as Record; + const outPath = typeof data.outPath === 'string' ? data.outPath : ''; + const chunks = readRecordingChunks(data); + if (chunks.length <= 1) { + return { data, text: formatRecordSingleOutput(data, outPath) }; + } + + const lines = ['Recording chunks:']; + for (const chunk of chunks) { + lines.push(` ${chunk.index}: ${chunk.path}`); + } + if (typeof data.telemetryPath === 'string') { + lines.push(`Telemetry: ${data.telemetryPath}`); + } + if (typeof data.warning === 'string') { + lines.push(`Warning: ${data.warning}`); + } + if (typeof data.overlayWarning === 'string') { + lines.push(`Overlay warning: ${data.overlayWarning}`); + } + return { data, text: lines.join('\n') }; +} + +export const recordingCliOutputFormatters = { + record: resultOutput(recordCliOutput), +} as const satisfies Record; + +function formatRecordSingleOutput(data: Record, outPath: string): string { + const lines: string[] = []; + if (outPath) lines.push(outPath); + if (typeof data.sessionStateDir === 'string') + lines.push(`Session state: ${data.sessionStateDir}`); + if (typeof data.warning === 'string') lines.push(`Warning: ${data.warning}`); + if (typeof data.overlayWarning === 'string') + lines.push(`Overlay warning: ${data.overlayWarning}`); + return lines.join('\n'); +} + +function readRecordingChunks( + data: Record, +): Array<{ index: number; path: string }> { + const rawChunks = data.chunks; + if (!Array.isArray(rawChunks)) return []; + return rawChunks.flatMap((chunk) => { + if (!chunk || typeof chunk !== 'object') return []; + const candidate = chunk as Record; + if (typeof candidate.index !== 'number' || typeof candidate.path !== 'string') return []; + return [{ index: candidate.index, path: candidate.path }]; + }); +} diff --git a/src/commands/recording/runtime/index.ts b/src/commands/recording/runtime/index.ts new file mode 100644 index 000000000..81b8240f5 --- /dev/null +++ b/src/commands/recording/runtime/index.ts @@ -0,0 +1,32 @@ +import type { AgentDeviceRuntime } from '../../../runtime-contract.ts'; +import type { BoundRuntimeCommand, RuntimeCommand } from '../../runtime-types.ts'; +import { + recordCommand, + traceCommand, + type RecordingRecordCommandOptions, + type RecordingRecordCommandResult, + type RecordingTraceCommandOptions, + type RecordingTraceCommandResult, +} from './recording.ts'; + +export type RecordingCommands = { + record: RuntimeCommand; + trace: RuntimeCommand; +}; + +export type BoundRecordingCommands = { + record: BoundRuntimeCommand; + trace: BoundRuntimeCommand; +}; + +export const recordingCommands: RecordingCommands = { + record: recordCommand, + trace: traceCommand, +}; + +export function bindRecordingCommands(runtime: AgentDeviceRuntime): BoundRecordingCommands { + return { + record: (options) => recordingCommands.record(runtime, options), + trace: (options) => recordingCommands.trace(runtime, options), + }; +} diff --git a/src/commands/recording/runtime/recording.test.ts b/src/commands/recording/runtime/recording.test.ts new file mode 100644 index 000000000..d7216f2aa --- /dev/null +++ b/src/commands/recording/runtime/recording.test.ts @@ -0,0 +1,122 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import type { ArtifactAdapter, FileInputRef } from '../../../io.ts'; +import { + createAgentDevice, + localCommandPolicy, + restrictedCommandPolicy, +} from '../../../runtime.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('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); +}); diff --git a/src/commands/recording.ts b/src/commands/recording/runtime/recording.ts similarity index 91% rename from src/commands/recording.ts rename to src/commands/recording/runtime/recording.ts index 4d51bae13..7c8046743 100644 --- a/src/commands/recording.ts +++ b/src/commands/recording/runtime/recording.ts @@ -3,19 +3,19 @@ import type { BackendRecordingResult, BackendTraceOptions, BackendTraceResult, -} from '../backend.ts'; -import type { ArtifactDescriptor, FileOutputRef } from '../io.ts'; -import type { CommandContext } from '../runtime-contract.ts'; -import { AppError } from '../utils/errors.ts'; -import { successText } from '../utils/success-text.ts'; -import { requireIntInRange } from '../utils/validation.ts'; +} from '../../../backend.ts'; +import type { ArtifactDescriptor, FileOutputRef } from '../../../io.ts'; +import type { CommandContext } from '../../../runtime-contract.ts'; +import { AppError } from '../../../utils/errors.ts'; +import { successText } from '../../../utils/success-text.ts'; +import { requireIntInRange } from '../../../utils/validation.ts'; import type { BackendResultEnvelope, BackendResultVariant, RuntimeCommand, -} from './runtime-types.ts'; -import { reserveCommandOutput } from './io-policy.ts'; -import { toBackendContext } from './selector-read-utils.ts'; +} from '../../runtime-types.ts'; +import { reserveCommandOutput } from '../../io-policy.ts'; +import { toBackendContext } from '../../interaction/runtime/selector-read-utils.ts'; export type RecordingRecordCommandOptions = CommandContext & { action: 'start' | 'stop'; diff --git a/src/commands/system/output.ts b/src/commands/system/output.ts new file mode 100644 index 000000000..0a8fb4ac1 --- /dev/null +++ b/src/commands/system/output.ts @@ -0,0 +1,78 @@ +import type { + AppStateCommandResult, + ClipboardCommandResult, + KeyboardCommandResult, +} from '../../client-types.ts'; +import type { CliOutput } from '../command-contract.ts'; +import { + messageCliOutput, + messageOutput, + resultOutput, + type CliOutputFormatter, +} from '../output-common.ts'; + +export function appStateCliOutput(result: AppStateCommandResult): CliOutput { + return { + data: result, + text: formatAppState(result), + }; +} + +export function keyboardCliOutput(result: KeyboardCommandResult): CliOutput { + if (result.platform === 'android' && result.action === 'status') { + const lines = [ + `Keyboard visible: ${result.visible === true ? 'yes' : 'no'}`, + `Input type: ${result.type ?? result.inputType ?? 'unknown'}`, + `Input owner: ${result.inputOwner ?? 'unknown'}`, + ]; + if (result.inputMethodPackage) lines.push(`Input method: ${result.inputMethodPackage}`); + if (result.focusedPackage) lines.push(`Focused package: ${result.focusedPackage}`); + if (result.focusedResourceId) lines.push(`Focused resource: ${result.focusedResourceId}`); + lines.push(`Next action: ${androidKeyboardNextAction(result.visible, result.inputOwner)}`); + return { data: result, text: lines.join('\n') }; + } + return messageCliOutput(result); +} + +export function clipboardCliOutput(result: ClipboardCommandResult): CliOutput { + if (result.action === 'read') return { data: result, text: result.text }; + return messageCliOutput(result); +} + +export const systemCliOutputFormatters = { + appstate: resultOutput(appStateCliOutput), + back: messageOutput, + home: messageOutput, + rotate: messageOutput, + 'app-switcher': messageOutput, + keyboard: resultOutput(keyboardCliOutput), + clipboard: resultOutput(clipboardCliOutput), +} as const satisfies Record; + +function formatAppState(data: AppStateCommandResult): string | null { + if (data.platform === 'ios') { + const lines = [`Foreground app: ${data.appName ?? data.appBundleId ?? 'unknown'}`]; + if (data.appBundleId) lines.push(`Bundle: ${data.appBundleId}`); + if (data.source) lines.push(`Source: ${data.source}`); + return lines.join('\n'); + } + if (data.platform === 'android') { + const lines = [`Foreground app: ${data.package ?? 'unknown'}`]; + if (data.activity) lines.push(`Activity: ${data.activity}`); + return lines.join('\n'); + } + return null; +} + +function androidKeyboardNextAction( + visible: boolean | undefined, + inputOwner: KeyboardCommandResult['inputOwner'], +): string { + if (inputOwner === 'ime') { + return 'Focused input appears to be owned by the keyboard/IME; dismiss or change the IME before retrying text entry.'; + } + if (visible === true) { + return 'Keyboard is visible and focused input appears app-owned; fill/type can proceed.'; + } + return 'Keyboard is hidden; focus an app field before type, or use fill with a concrete target.'; +} diff --git a/src/commands/system/runtime/index.ts b/src/commands/system/runtime/index.ts new file mode 100644 index 000000000..f9494b50c --- /dev/null +++ b/src/commands/system/runtime/index.ts @@ -0,0 +1,79 @@ +import type { AgentDeviceRuntime } from '../../../runtime-contract.ts'; +import type { BoundRuntimeCommand, RuntimeCommand } from '../../runtime-types.ts'; +import { + alertCommand, + appSwitcherCommand, + backCommand, + clipboardCommand, + homeCommand, + 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'; + +export type SystemCommands = { + back: RuntimeCommand; + home: RuntimeCommand; + rotate: RuntimeCommand; + keyboard: RuntimeCommand; + clipboard: RuntimeCommand; + settings: RuntimeCommand; + alert: RuntimeCommand; + appSwitcher: RuntimeCommand< + SystemAppSwitcherCommandOptions | undefined, + SystemAppSwitcherCommandResult + >; +}; + +export type BoundSystemCommands = { + back: (options?: SystemBackCommandOptions) => Promise; + home: (options?: SystemHomeCommandOptions) => Promise; + rotate: BoundRuntimeCommand; + keyboard: (options?: SystemKeyboardCommandOptions) => Promise; + clipboard: BoundRuntimeCommand; + settings: (options?: SystemSettingsCommandOptions) => Promise; + alert: (options?: SystemAlertCommandOptions) => Promise; + appSwitcher: ( + options?: SystemAppSwitcherCommandOptions, + ) => Promise; +}; + +export const systemCommands: SystemCommands = { + back: backCommand, + home: homeCommand, + rotate: rotateCommand, + keyboard: keyboardCommand, + clipboard: clipboardCommand, + settings: settingsCommand, + alert: alertCommand, + appSwitcher: appSwitcherCommand, +}; + +export function bindSystemCommands(runtime: AgentDeviceRuntime): BoundSystemCommands { + return { + back: (options) => systemCommands.back(runtime, options), + home: (options) => systemCommands.home(runtime, options), + rotate: (options) => systemCommands.rotate(runtime, options), + keyboard: (options) => systemCommands.keyboard(runtime, options), + clipboard: (options) => systemCommands.clipboard(runtime, options), + settings: (options) => systemCommands.settings(runtime, options), + alert: (options) => systemCommands.alert(runtime, options), + appSwitcher: (options) => systemCommands.appSwitcher(runtime, options), + }; +} diff --git a/src/__tests__/runtime-system.test.ts b/src/commands/system/runtime/system.test.ts similarity index 96% rename from src/__tests__/runtime-system.test.ts rename to src/commands/system/runtime/system.test.ts index 2f73c75e7..7fd4227bb 100644 --- a/src/__tests__/runtime-system.test.ts +++ b/src/commands/system/runtime/system.test.ts @@ -5,9 +5,9 @@ import type { BackendAlertAction, BackendDeviceOrientation, BackendKeyboardOptions, -} from '../backend.ts'; -import { createLocalArtifactAdapter } from '../io.ts'; -import { createAgentDevice, localCommandPolicy } from '../runtime.ts'; +} from '../../../backend.ts'; +import { createLocalArtifactAdapter } from '../../../io.ts'; +import { createAgentDevice, localCommandPolicy } from '../../../runtime.ts'; test('runtime system commands call typed backend primitives', async () => { const calls: unknown[] = []; diff --git a/src/commands/system.ts b/src/commands/system/runtime/system.ts similarity index 95% rename from src/commands/system.ts rename to src/commands/system/runtime/system.ts index 731eea48e..a91eb2637 100644 --- a/src/commands/system.ts +++ b/src/commands/system/runtime/system.ts @@ -4,21 +4,21 @@ import type { BackendAlertResult, BackendDeviceOrientation, BackendKeyboardResult, -} from '../backend.ts'; -import type { CommandContext } from '../runtime-contract.ts'; -import type { BackMode } from '../core/back-mode.ts'; -import { AppError } from '../utils/errors.ts'; -import { successText } from '../utils/success-text.ts'; -import { requireIntInRange } from '../utils/validation.ts'; -import { isKeyboardAction } from '../utils/keyboard-actions.ts'; +} from '../../../backend.ts'; +import type { CommandContext } from '../../../runtime-contract.ts'; +import type { BackMode } from '../../../core/back-mode.ts'; +import { AppError } from '../../../utils/errors.ts'; +import { successText } from '../../../utils/success-text.ts'; +import { requireIntInRange } from '../../../utils/validation.ts'; +import { isKeyboardAction } from '../../../utils/keyboard-actions.ts'; import { toBackendResult, type BackendResultEnvelope, type BackendResultVariant, type RuntimeCommand, -} from './runtime-types.ts'; -import { toBackendContext } from './selector-read-utils.ts'; -import { normalizeOptionalText } from './text.ts'; +} from '../../runtime-types.ts'; +import { toBackendContext } from '../../interaction/runtime/selector-read-utils.ts'; +import { normalizeOptionalText } from '../../text.ts'; export type SystemBackCommandOptions = CommandContext & { mode?: BackMode; diff --git a/src/utils/cli-command-overrides.ts b/src/utils/cli-command-overrides.ts index c60dd2c43..f255d4509 100644 --- a/src/utils/cli-command-overrides.ts +++ b/src/utils/cli-command-overrides.ts @@ -1,4 +1,5 @@ import type { CommandName } from '../commands/command-metadata.ts'; +import { batchCliSchemas } from '../commands/batch/index.ts'; import { captureCliSchemas } from '../commands/capture/index.ts'; import { interactionCliSchemas } from '../commands/interaction/index.ts'; import { managementCliSchemas } from '../commands/management/index.ts'; @@ -67,13 +68,7 @@ const CLI_COMMAND_OVERRIDES = { ...observabilityCliSchemas, ...metroCliSchemas, ...replayCliSchemas, - batch: { - usageOverride: 'batch [--steps | --steps-file ]', - listUsageOverride: 'batch --steps | --steps-file ', - helpDescription: 'Execute multiple commands in one daemon request', - summary: 'Run multiple commands', - allowedFlags: ['steps', 'stepsFile', 'batchOnError', 'batchMaxSteps', 'out'], - }, + ...batchCliSchemas, ...recordingCliSchemas, ...reactNativeCliSchemas, } as const satisfies Partial>; From c9fc50404cab2244ad8884a36fa148a80600b831 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 11 Jun 2026 21:18:38 +0200 Subject: [PATCH 03/11] refactor: tighten localized command exports --- scripts/integration-progress.mjs | 44 +++++-- src/commands/batch/index.ts | 3 +- src/commands/batch/output.ts | 2 +- src/commands/capture/index.ts | 107 ++++++++---------- src/commands/capture/output.ts | 2 +- .../capture/runtime/diff-screenshot.ts | 1 + src/commands/capture/runtime/snapshot.ts | 1 + src/commands/interaction/output.ts | 8 +- .../interaction/runtime/resolution.ts | 1 + src/commands/management/output.ts | 16 +-- src/commands/management/runtime/admin.ts | 1 + src/commands/metro/index.ts | 6 +- src/commands/metro/output.ts | 2 +- src/commands/observability/index.ts | 25 ++-- .../runtime/diagnostics-format.ts | 1 + .../runtime/diagnostics-router.test.ts | 1 + .../observability/runtime/diagnostics.ts | 1 + src/commands/observability/runtime/output.ts | 6 +- src/commands/react-native/index.ts | 8 +- src/commands/recording/index.ts | 14 +-- src/commands/replay/index.ts | 12 +- src/commands/system/index.ts | 77 ++++++------- src/commands/system/output.ts | 7 +- src/contracts/perf.ts | 2 +- 24 files changed, 182 insertions(+), 166 deletions(-) diff --git a/scripts/integration-progress.mjs b/scripts/integration-progress.mjs index 81dd2d3d1..b93ed1c86 100644 --- a/scripts/integration-progress.mjs +++ b/scripts/integration-progress.mjs @@ -9,10 +9,7 @@ const HANDLER_TEST_DIR = path.join(ROOT, 'src/daemon/handlers/__tests__'); const PROVIDER_SCENARIO_DIR = path.join(ROOT, 'test/integration/provider-scenarios'); const COVERAGE_SUMMARY = path.join(ROOT, 'coverage/coverage-summary.json'); const COMMAND_CATALOG = path.join(ROOT, 'src/command-catalog.ts'); -const COMMAND_CONTRACT_FILES = [ - path.join(ROOT, 'src/commands/client-command-contracts.ts'), - path.join(ROOT, 'src/commands/interaction-command-contracts.ts'), -]; +const COMMAND_CONTRACT_FILES = listFiles(path.join(ROOT, 'src/commands'), (file) => file.endsWith(`${path.sep}index.ts`)); const COMMAND_CATALOG_SOURCE = fs.readFileSync(COMMAND_CATALOG, 'utf8'); const clientCommandMethods = readClientCommandMethods(); @@ -284,6 +281,7 @@ function summarizeProviderScenarioFlagExclusions() { name: 'Metro and React Native runtime preparation', owner: 'Metro companion integration and parser tests', keys: [ + 'kind', 'metroHost', 'metroPort', 'metroProjectRoot', @@ -303,6 +301,11 @@ function summarizeProviderScenarioFlagExclusions() { 'launchUrl', ], }, + { + name: 'Apple launch and perf artifact options', + owner: 'iOS platform, observability command, and parser tests', + keys: ['deviceHub', 'launchArgs', 'perfTemplate'], + }, { name: 'parser/client-only command flags', owner: 'args, CLI, screenshot-diff, and batch tests', @@ -314,6 +317,10 @@ function summarizeProviderScenarioFlagExclusions() { 'threshold', 'reportJunit', 'replayMaestro', + 'replayExportFormat', + 'recordVideo', + 'shardAll', + 'shardSplit', 'stepsFile', ], }, @@ -326,10 +333,7 @@ function summarizeProviderScenarioFlagExclusions() { } function readPublicCliFlagKeys() { - const sources = [ - path.join(ROOT, 'src/utils/command-schema.ts'), - path.join(ROOT, 'src/commands/capture-screenshot-options.ts'), - ]; + const sources = [path.join(ROOT, 'src/utils/cli-flags.ts')]; const keys = new Set(); for (const source of sources) { const text = fs.readFileSync(source, 'utf8'); @@ -513,8 +517,26 @@ function readClientCommandMethods() { } function readCommandContractBlocks(text) { + const constants = new Map(); + for (const match of text.matchAll(/\bconst\s+([A-Z0-9_]+)\s*=\s*['"]([^'"]+)['"]/g)) { + constants.set(match[1], match[2]); + } + + const metadataNames = new Map(); + for (const match of text.matchAll( + /\bconst\s+([A-Za-z0-9_]+CommandMetadata)\s*=\s*defineFieldCommandMetadata\(\s*([^,\s)]+)/g, + )) { + metadataNames.set(match[1], readMetadataName(match[2], constants)); + } + const starts = [ ...text.matchAll(/defineExecutableCommand\(\s*metadata\(\s*['"]([^'"]+)['"]\s*\)/g), + ...[...text.matchAll(/defineExecutableCommand\(\s*([A-Za-z0-9_]+CommandMetadata)\b/g)].flatMap( + (match) => { + const name = metadataNames.get(match[1]); + return name ? [{ ...match, 1: name }] : []; + }, + ), ...text.matchAll(/defineFieldCommand\(\s*['"]([^'"]+)['"]/g), ...text.matchAll(/defineCommand\(\s*\{[\s\S]*?\bname:\s*['"]([^'"]+)['"]/g), ] @@ -533,6 +555,12 @@ function readCommandContractBlocks(text) { }); } +function readMetadataName(token, constants) { + const literal = token.match(/^['"]([^'"]+)['"]$/); + if (literal) return literal[1]; + return constants.get(token); +} + function extractProviderScenarioCommandReferences(text) { const commands = []; for (const match of text.matchAll(/\bcommand:\s*['"]([^'"]+)['"]|\.callCommand\(\s*['"]([^'"]+)['"]/g)) { diff --git a/src/commands/batch/index.ts b/src/commands/batch/index.ts index 07be63bd9..4301e04e4 100644 --- a/src/commands/batch/index.ts +++ b/src/commands/batch/index.ts @@ -7,8 +7,7 @@ import { commonToClientOptions } from '../command-input.ts'; import { createBatchCommandMetadata, type BatchInput } from './metadata.ts'; import { createBatchDaemonWriter } from './projection.ts'; -export const batchCommandDescription = - 'Run multiple structured command steps in one daemon request.'; +const batchCommandDescription = 'Run multiple structured command steps in one daemon request.'; export const batchCommandDescriptions = { batch: batchCommandDescription, diff --git a/src/commands/batch/output.ts b/src/commands/batch/output.ts index 85abe2d14..b8466c998 100644 --- a/src/commands/batch/output.ts +++ b/src/commands/batch/output.ts @@ -3,7 +3,7 @@ import { readCommandMessage } from '../../utils/success-text.ts'; import type { CliOutput } from '../command-contract.ts'; import { resultOutput, type CliOutputFormatter } from '../output-common.ts'; -export function batchCliOutput(result: CommandRequestResult): CliOutput { +function batchCliOutput(result: CommandRequestResult): CliOutput { const data = result as Record; const total = typeof data.total === 'number' ? data.total : 0; const executed = typeof data.executed === 'number' ? data.executed : 0; diff --git a/src/commands/capture/index.ts b/src/commands/capture/index.ts index d68ec01e2..4afb75df2 100644 --- a/src/commands/capture/index.ts +++ b/src/commands/capture/index.ts @@ -49,17 +49,17 @@ import { settingsDaemonWriter as settingsDaemonWriterImpl, } from './settings.ts'; -export const SNAPSHOT_COMMAND_NAME = 'snapshot'; -export const SCREENSHOT_COMMAND_NAME = 'screenshot'; -export const DIFF_COMMAND_NAME = 'diff'; -export const WAIT_COMMAND_NAME = 'wait'; -export const ALERT_COMMAND_NAME = 'alert'; - -export const snapshotCommandDescription = 'Capture an accessibility snapshot.'; -export const screenshotCommandDescription = 'Capture a screenshot.'; -export const diffCommandDescription = 'Diff accessibility snapshots.'; -export const waitCommandDescription = 'Wait for duration, text, ref, or selector.'; -export const alertCommandDescription = 'Inspect or handle platform alerts.'; +const SNAPSHOT_COMMAND_NAME = 'snapshot'; +const SCREENSHOT_COMMAND_NAME = 'screenshot'; +const DIFF_COMMAND_NAME = 'diff'; +const WAIT_COMMAND_NAME = 'wait'; +const ALERT_COMMAND_NAME = 'alert'; + +const snapshotCommandDescription = 'Capture an accessibility snapshot.'; +const screenshotCommandDescription = 'Capture a screenshot.'; +const diffCommandDescription = 'Diff accessibility snapshots.'; +const waitCommandDescription = 'Wait for duration, text, ref, or selector.'; +const alertCommandDescription = 'Inspect or handle platform alerts.'; export const captureCommandDescriptions = { [SNAPSHOT_COMMAND_NAME]: snapshotCommandDescription, @@ -70,7 +70,7 @@ export const captureCommandDescriptions = { [SETTINGS_COMMAND_NAME]: settingsCommandDescription, } as const; -export const snapshotCommandMetadata = defineFieldCommandMetadata( +const snapshotCommandMetadata = defineFieldCommandMetadata( SNAPSHOT_COMMAND_NAME, snapshotCommandDescription, { @@ -84,7 +84,7 @@ export const snapshotCommandMetadata = defineFieldCommandMetadata( }, ); -export const screenshotCommandMetadata = defineFieldCommandMetadata( +const screenshotCommandMetadata = defineFieldCommandMetadata( SCREENSHOT_COMMAND_NAME, screenshotCommandDescription, { @@ -97,37 +97,29 @@ export const screenshotCommandMetadata = defineFieldCommandMetadata( }, ); -export const diffCommandMetadata = defineFieldCommandMetadata( - DIFF_COMMAND_NAME, - diffCommandDescription, - { - kind: requiredField(jsonSchemaField<'snapshot'>({ type: 'string', const: 'snapshot' })), - out: stringField(), - interactiveOnly: booleanField(), - compact: booleanField(), - depth: integerField(), - scope: stringField(), - raw: booleanField(), - }, -); - -export const waitCommandMetadata = defineFieldCommandMetadata( - WAIT_COMMAND_NAME, - waitCommandDescription, - { - kind: enumField(WAIT_KIND_VALUES), - durationMs: integerField(), - text: stringField(), - ref: stringField(), - selector: stringField(), - timeoutMs: integerField(), - depth: integerField(), - scope: stringField(), - raw: booleanField(), - }, -); - -export const alertCommandMetadata = defineFieldCommandMetadata( +const diffCommandMetadata = defineFieldCommandMetadata(DIFF_COMMAND_NAME, diffCommandDescription, { + kind: requiredField(jsonSchemaField<'snapshot'>({ type: 'string', const: 'snapshot' })), + out: stringField(), + interactiveOnly: booleanField(), + compact: booleanField(), + depth: integerField(), + scope: stringField(), + raw: booleanField(), +}); + +const waitCommandMetadata = defineFieldCommandMetadata(WAIT_COMMAND_NAME, waitCommandDescription, { + kind: enumField(WAIT_KIND_VALUES), + durationMs: integerField(), + text: stringField(), + ref: stringField(), + selector: stringField(), + timeoutMs: integerField(), + depth: integerField(), + scope: stringField(), + raw: booleanField(), +}); + +const alertCommandMetadata = defineFieldCommandMetadata( ALERT_COMMAND_NAME, alertCommandDescription, { @@ -145,27 +137,26 @@ export const captureCommandMetadata = [ settingsCommandMetadata, ] as const; -export const snapshotCommandDefinition = defineExecutableCommand( +const snapshotCommandDefinition = defineExecutableCommand( snapshotCommandMetadata, (client, input) => client.capture.snapshot(input), ); -export const screenshotCommandDefinition = defineExecutableCommand( +const screenshotCommandDefinition = defineExecutableCommand( screenshotCommandMetadata, (client, input) => client.capture.screenshot(input), ); -export const diffCommandDefinition = defineExecutableCommand(diffCommandMetadata, (client, input) => +const diffCommandDefinition = defineExecutableCommand(diffCommandMetadata, (client, input) => client.capture.diff(input), ); -export const waitCommandDefinition = defineExecutableCommand(waitCommandMetadata, (client, input) => +const waitCommandDefinition = defineExecutableCommand(waitCommandMetadata, (client, input) => client.command.wait(waitInputToOptions(input)), ); -export const alertCommandDefinition = defineExecutableCommand( - alertCommandMetadata, - (client, input) => client.command.alert(input), +const alertCommandDefinition = defineExecutableCommand(alertCommandMetadata, (client, input) => + client.command.alert(input), ); export const captureCommandDefinitions = [ @@ -177,14 +168,14 @@ export const captureCommandDefinitions = [ settingsCommandDefinition, ] as const; -export const snapshotCliSchema = { +const snapshotCliSchema = { usageOverride: 'snapshot [--diff] [-i] [-c] [-d ] [-s ] [--raw] [--force-full] [--timeout ]', helpDescription: 'Capture accessibility tree or diff against the previous session baseline', allowedFlags: ['snapshotDiff', ...SNAPSHOT_FLAGS, 'snapshotForceFull', 'timeoutMs'], } as const satisfies CommandSchemaOverride; -export const diffCliSchema = { +const diffCliSchema = { usageOverride: 'diff snapshot | diff screenshot --baseline [current.png] [--out ] [--threshold <0-1>] [--overlay-refs]', helpDescription: 'Diff accessibility snapshot or compare screenshots pixel-by-pixel', @@ -193,21 +184,21 @@ export const diffCliSchema = { allowedFlags: [...SNAPSHOT_FLAGS, 'baseline', 'threshold', 'out', 'overlayRefs'], } as const satisfies CommandSchemaOverride; -export const screenshotCliSchema = { +const screenshotCliSchema = { helpDescription: 'Capture screenshot (macOS app sessions default to the app window; use --fullscreen for full desktop, --max-size to downscale, --overlay-refs to annotate current refs, or --no-stabilize for low-latency Android capture loops)', positionalArgs: ['path?'], allowedFlags: SCREENSHOT_COMMAND_FLAG_KEYS, } as const satisfies CommandSchemaOverride; -export const waitCliSchema = { +const waitCliSchema = { usageOverride: 'wait |text |@ref| [timeoutMs]', positionalArgs: ['durationOrSelector', 'timeoutMs?'], allowsExtraPositionals: true, allowedFlags: [...SELECTOR_SNAPSHOT_FLAGS], } as const satisfies CommandSchemaOverride; -export const alertCliSchema = { +const alertCliSchema = { usageOverride: 'alert [get|accept|dismiss|wait] [timeout]', positionalArgs: ['action?', 'timeout?'], } as const satisfies CommandSchemaOverride; @@ -289,9 +280,7 @@ export const captureDaemonWriters = { settings: settingsDaemonWriterImpl, } satisfies Record; -export const snapshotDaemonWriter = captureDaemonWriters.snapshot; export const screenshotDaemonWriter = captureDaemonWriters.screenshot; -export const diffDaemonWriter = captureDaemonWriters.diff; export const waitDaemonWriter = captureDaemonWriters.wait; export const alertDaemonWriter = captureDaemonWriters.alert; export const settingsDaemonWriter = captureDaemonWriters.settings; @@ -326,8 +315,6 @@ function readWaitOptionsFromPositionals( }; } -export { parseWaitPositionals }; - // fallow-ignore-next-line complexity function waitPositionals(options: WaitCommandOptions): string[] { const targets = [ diff --git a/src/commands/capture/output.ts b/src/commands/capture/output.ts index 7425cc823..a249b9d09 100644 --- a/src/commands/capture/output.ts +++ b/src/commands/capture/output.ts @@ -4,7 +4,7 @@ import { formatSnapshotText } from '../../utils/output.ts'; import type { CliOutput } from '../command-contract.ts'; import { messageOutput, type CliOutputFormatter } from '../output-common.ts'; -export function snapshotCliOutput(params: { +function snapshotCliOutput(params: { result: CaptureSnapshotResult; raw?: boolean; interactiveOnly?: boolean; diff --git a/src/commands/capture/runtime/diff-screenshot.ts b/src/commands/capture/runtime/diff-screenshot.ts index 95d784f0f..8b6cc8e10 100644 --- a/src/commands/capture/runtime/diff-screenshot.ts +++ b/src/commands/capture/runtime/diff-screenshot.ts @@ -139,6 +139,7 @@ async function captureLiveCurrentScreenshot( return temp; } +// fallow-ignore-next-line complexity async function maybeAttachCurrentOverlay( runtime: AgentDeviceRuntime, options: DiffScreenshotCommandOptions, diff --git a/src/commands/capture/runtime/snapshot.ts b/src/commands/capture/runtime/snapshot.ts index cb37a914d..2c86f613a 100644 --- a/src/commands/capture/runtime/snapshot.ts +++ b/src/commands/capture/runtime/snapshot.ts @@ -303,6 +303,7 @@ function buildMergedAccessibilityLeafWarnings(nodes: SnapshotState['nodes']): st }); } +// fallow-ignore-next-line complexity function buildEmptyAndroidInteractiveWarnings(params: { annotations: SnapshotCaptureAnnotations; snapshot: SnapshotState; diff --git a/src/commands/interaction/output.ts b/src/commands/interaction/output.ts index 2e260b050..7c9804b78 100644 --- a/src/commands/interaction/output.ts +++ b/src/commands/interaction/output.ts @@ -2,7 +2,7 @@ import type { CommandRequestResult } from '../../client-types.ts'; import type { CliOutput } from '../command-contract.ts'; import { messageCliOutput, resultOutput, type CliOutputFormatter } from '../output-common.ts'; -export function getCliOutput(params: { result: CommandRequestResult; format?: string }): CliOutput { +function getCliOutput(params: { result: CommandRequestResult; format?: string }): CliOutput { const data = params.result as Record; if (params.format === 'text') { return { data, text: typeof data.text === 'string' ? data.text : '' }; @@ -13,7 +13,7 @@ export function getCliOutput(params: { result: CommandRequestResult; format?: st return defaultCommandCliOutput(data); } -export function findCliOutput(result: CommandRequestResult): CliOutput { +function findCliOutput(result: CommandRequestResult): CliOutput { const data = result as Record; if (typeof data.text === 'string') return { data, text: data.text }; if (typeof data.found === 'boolean') return { data, text: `Found: ${data.found}` }; @@ -21,12 +21,12 @@ export function findCliOutput(result: CommandRequestResult): CliOutput { return defaultCommandCliOutput(data); } -export function isCliOutput(result: CommandRequestResult): CliOutput { +function isCliOutput(result: CommandRequestResult): CliOutput { const data = result as Record; return { data, text: `Passed: is ${data.predicate ?? 'assertion'}` }; } -export function tapCliOutput(result: CommandRequestResult): CliOutput { +function tapCliOutput(result: CommandRequestResult): CliOutput { const data = result as Record; const ref = data.ref ?? ''; const x = data.x; diff --git a/src/commands/interaction/runtime/resolution.ts b/src/commands/interaction/runtime/resolution.ts index 483765af8..f80196c85 100644 --- a/src/commands/interaction/runtime/resolution.ts +++ b/src/commands/interaction/runtime/resolution.ts @@ -99,6 +99,7 @@ async function resolveRefInteractionTarget( }; } +// fallow-ignore-next-line complexity async function resolveSelectorInteractionTarget( runtime: AgentDeviceRuntime, options: CommandContext, diff --git a/src/commands/management/output.ts b/src/commands/management/output.ts index af09ad47d..f2566766c 100644 --- a/src/commands/management/output.ts +++ b/src/commands/management/output.ts @@ -25,12 +25,12 @@ import { type CliOutputFormatter, } from '../output-common.ts'; -export function devicesCliOutput(result: AgentDeviceDevice[]): CliOutput { +function devicesCliOutput(result: AgentDeviceDevice[]): CliOutput { const data = { devices: result.map(serializeDevice) }; return { data, text: result.map(formatDeviceLine).join('\n') }; } -export function appsCliOutput(params: { +function appsCliOutput(params: { result: string[]; appsFilter?: 'user-installed' | 'all'; }): CliOutput { @@ -50,7 +50,7 @@ export function appsCliOutput(params: { }; } -export function sessionCliOutput( +function sessionCliOutput( result: { sessions: AgentDeviceSession[] } | { stateDir: string }, ): CliOutput { if ('stateDir' in result) { @@ -69,26 +69,26 @@ export function openCliOutput(result: AppOpenResult): CliOutput { return { data, text: lines.join('\n') || null }; } -export function closeCliOutput(result: AppCloseResult | SessionCloseResult): CliOutput { +function closeCliOutput(result: AppCloseResult | SessionCloseResult): CliOutput { return messageCliOutput(serializeCloseResult(result)); } -export function deployCliOutput(result: AppDeployResult): CliOutput { +function deployCliOutput(result: AppDeployResult): CliOutput { return messageCliOutput(serializeDeployResult(result)); } -export function installFromSourceCliOutput(result: AppInstallFromSourceResult): CliOutput { +function installFromSourceCliOutput(result: AppInstallFromSourceResult): CliOutput { return messageCliOutput(serializeInstallFromSourceResult(result)); } -export function bootCliOutput(result: CommandRequestResult): CliOutput { +function bootCliOutput(result: CommandRequestResult): CliOutput { const data = result as Record; const platform = data.platform ?? 'unknown'; const device = data.device ?? data.id ?? 'unknown'; return { data, text: `Boot ready: ${device} (${platform})` }; } -export function shutdownCliOutput(result: CommandRequestResult): CliOutput { +function shutdownCliOutput(result: CommandRequestResult): CliOutput { const data = result as Record; const platform = data.platform ?? 'unknown'; const device = data.device ?? data.id ?? 'unknown'; diff --git a/src/commands/management/runtime/admin.ts b/src/commands/management/runtime/admin.ts index fae869c2a..e766dba6b 100644 --- a/src/commands/management/runtime/admin.ts +++ b/src/commands/management/runtime/admin.ts @@ -265,6 +265,7 @@ function normalizeDeviceTarget( return Object.keys(normalized).length > 0 ? normalized : undefined; } +// fallow-ignore-next-line complexity function formatInstallResult( mode: 'install' | 'reinstall' | 'installFromSource', app: string | undefined, diff --git a/src/commands/metro/index.ts b/src/commands/metro/index.ts index ba1dc5a96..773535b87 100644 --- a/src/commands/metro/index.ts +++ b/src/commands/metro/index.ts @@ -21,10 +21,10 @@ import { defineFieldCommandMetadata } from '../field-command-contract.ts'; import type { CliReader } from '../cli-grammar/types.ts'; import { METRO_PREPARE_FLAGS, METRO_RELOAD_FLAGS } from '../../utils/cli-flags.ts'; -export const METRO_COMMAND_NAME = 'metro'; +const METRO_COMMAND_NAME = 'metro'; const METRO_ACTION_VALUES = ['prepare', 'reload'] as const; -export const metroCommandDescription = 'Prepare Metro runtime or reload React Native apps.'; +const metroCommandDescription = 'Prepare Metro runtime or reload React Native apps.'; export const metroCommandDescriptions = { [METRO_COMMAND_NAME]: metroCommandDescription, @@ -71,7 +71,7 @@ export const metroCommandDefinition = defineExecutableCommand( : await client.metro.reload(toMetroReloadOptions(input)), ); -export const metroCliSchema = { +const metroCliSchema = { usageOverride: 'metro prepare (--public-base-url | --proxy-base-url ) [--project-root ] [--port ] [--kind auto|react-native|expo]\n agent-device metro reload [--metro-host ] [--metro-port ] [--bundle-url ]', listUsageOverride: 'metro prepare --public-base-url | --proxy-base-url ; metro reload', diff --git a/src/commands/metro/output.ts b/src/commands/metro/output.ts index 638bb12a3..684c2fc9b 100644 --- a/src/commands/metro/output.ts +++ b/src/commands/metro/output.ts @@ -1,7 +1,7 @@ import type { CliOutput } from '../command-contract.ts'; import type { CliOutputFormatter } from '../output-common.ts'; -export function metroCliOutput(params: { result: unknown; action?: string }): CliOutput { +function metroCliOutput(params: { result: unknown; action?: string }): CliOutput { return { data: params.result, text: diff --git a/src/commands/observability/index.ts b/src/commands/observability/index.ts index a7bb68564..92c0c67ce 100644 --- a/src/commands/observability/index.ts +++ b/src/commands/observability/index.ts @@ -35,18 +35,17 @@ import { } from '../cli-grammar/common.ts'; import type { CliReader, DaemonWriter } from '../cli-grammar/types.ts'; -export const PERF_COMMAND_NAME = 'perf'; -export const LOGS_COMMAND_NAME = 'logs'; -export const NETWORK_COMMAND_NAME = 'network'; -export const DEBUG_COMMAND_NAME = 'debug'; +const PERF_COMMAND_NAME = 'perf'; +const LOGS_COMMAND_NAME = 'logs'; +const NETWORK_COMMAND_NAME = 'network'; +const DEBUG_COMMAND_NAME = 'debug'; const NETWORK_ACTION_VALUES = ['dump', 'log'] as const; const DEBUG_ACTION_VALUES = ['symbols'] as const; -export const perfCommandDescription = - 'Show session performance, frame health, and memory diagnostics.'; -export const logsCommandDescription = 'Manage session app logs.'; -export const networkCommandDescription = 'Show recent HTTP traffic.'; -export const debugCommandDescription = 'Symbolicate crash artifacts with matching debug symbols.'; +const perfCommandDescription = 'Show session performance, frame health, and memory diagnostics.'; +const logsCommandDescription = 'Manage session app logs.'; +const networkCommandDescription = 'Show recent HTTP traffic.'; +const debugCommandDescription = 'Symbolicate crash artifacts with matching debug symbols.'; export const observabilityCommandDescriptions = { [PERF_COMMAND_NAME]: perfCommandDescription, @@ -132,7 +131,7 @@ export const observabilityCommandDefinitions = [ debugCommandDefinition, ] as const; -export const perfCliSchema = { +const perfCliSchema = { usageOverride: 'perf [metrics|frames|memory] [sample|snapshot]\n agent-device perf memory sample --json\n agent-device perf memory snapshot [--kind android-hprof|memgraph] [--out ]\n agent-device perf cpu profile start|stop|report --kind xctrace [--template ] --out \n agent-device perf trace start|stop --kind xctrace [--template ] --out \n agent-device perf cpu profile start|stop|report --kind simpleperf [--out ]\n agent-device perf trace start|stop --kind perfetto [--out ]', listUsageOverride: @@ -144,7 +143,7 @@ export const perfCliSchema = { allowedFlags: ['kind', 'perfTemplate', 'out'], } as const satisfies CommandSchemaOverride; -export const logsCliSchema = { +const logsCliSchema = { usageOverride: 'logs path | logs start | logs stop | logs clear [--restart] | logs doctor | logs mark [message...]', helpDescription: 'Session app log info, start/stop streaming, diagnostics, and markers', @@ -154,7 +153,7 @@ export const logsCliSchema = { allowedFlags: ['restart'], } as const satisfies CommandSchemaOverride; -export const networkCliSchema = { +const networkCliSchema = { usageOverride: 'network dump [limit] [summary|headers|body|all] [--include summary|headers|body|all] | network log [limit] [summary|headers|body|all] [--include summary|headers|body|all]', helpDescription: 'Dump recent HTTP(s) traffic parsed from the session app log', @@ -163,7 +162,7 @@ export const networkCliSchema = { allowedFlags: ['networkInclude'], } as const satisfies CommandSchemaOverride; -export const debugCliSchema = { +const debugCliSchema = { usageOverride: 'debug symbols --artifact (--dsym | --search-path ) [--out ]', listUsageOverride: 'debug symbols --artifact --dsym ', diff --git a/src/commands/observability/runtime/diagnostics-format.ts b/src/commands/observability/runtime/diagnostics-format.ts index 6aa7bb352..f2e764a5c 100644 --- a/src/commands/observability/runtime/diagnostics-format.ts +++ b/src/commands/observability/runtime/diagnostics-format.ts @@ -48,6 +48,7 @@ export function formatNetworkResult( include: BackendNetworkIncludeMode, ): DiagnosticsNetworkCommandResult { let redacted = result.redacted === true; + // fallow-ignore-next-line complexity const entries = result.entries.map((entry) => { const url = entry.url ? redactUrl(entry.url) : undefined; const requestHeaders = diff --git a/src/commands/observability/runtime/diagnostics-router.test.ts b/src/commands/observability/runtime/diagnostics-router.test.ts index 5a6b33e5c..37acdd08f 100644 --- a/src/commands/observability/runtime/diagnostics-router.test.ts +++ b/src/commands/observability/runtime/diagnostics-router.test.ts @@ -22,6 +22,7 @@ const artifacts = { }), } satisfies ArtifactAdapter; +// fallow-ignore-next-line complexity test('diagnostics runtime commands call typed backend primitives and redact sensitive data', async () => { const contexts: BackendCommandContext[] = []; const device = createAgentDevice({ diff --git a/src/commands/observability/runtime/diagnostics.ts b/src/commands/observability/runtime/diagnostics.ts index 1ce430db4..2c1d239ce 100644 --- a/src/commands/observability/runtime/diagnostics.ts +++ b/src/commands/observability/runtime/diagnostics.ts @@ -114,6 +114,7 @@ export const perfCommand: RuntimeCommand< return formatPerfResult(result); }; +// fallow-ignore-next-line complexity async function toDiagnosticsBackendContext( runtime: AgentDeviceRuntime, options: CommandContext & { appId?: string; appBundleId?: string }, diff --git a/src/commands/observability/runtime/output.ts b/src/commands/observability/runtime/output.ts index 3f6ae9647..adc363250 100644 --- a/src/commands/observability/runtime/output.ts +++ b/src/commands/observability/runtime/output.ts @@ -2,7 +2,7 @@ import type { CommandRequestResult, DebugSymbolsResult } from '../../../client-t import type { CliOutput } from '../../command-contract.ts'; import { resultOutput, type CliOutputFormatter } from '../../output-common.ts'; -export function logsCliOutput(result: CommandRequestResult): CliOutput { +function logsCliOutput(result: CommandRequestResult): CliOutput { const data = result as Record; const pathOut = typeof data.path === 'string' ? data.path : ''; return { @@ -17,7 +17,7 @@ export function logsCliOutput(result: CommandRequestResult): CliOutput { }; } -export function networkCliOutput(result: CommandRequestResult): CliOutput { +function networkCliOutput(result: CommandRequestResult): CliOutput { const data = result as Record; const lines: string[] = []; const pathOut = typeof data.path === 'string' ? data.path : ''; @@ -47,7 +47,7 @@ export function networkCliOutput(result: CommandRequestResult): CliOutput { }; } -export function perfCliOutput(result: CommandRequestResult): CliOutput { +function perfCliOutput(result: CommandRequestResult): CliOutput { const data = result as Record; return { data, text: formatPerfCliOutput(data) }; } diff --git a/src/commands/react-native/index.ts b/src/commands/react-native/index.ts index 70bceb860..c114db24b 100644 --- a/src/commands/react-native/index.ts +++ b/src/commands/react-native/index.ts @@ -6,10 +6,10 @@ import { defineFieldCommandMetadata } from '../field-command-contract.ts'; import { commonInputFromFlags, direct, requiredDaemonString } from '../cli-grammar/common.ts'; import type { CliReader, DaemonWriter } from '../cli-grammar/types.ts'; -export const REACT_NATIVE_COMMAND_NAME = 'react-native'; -export const REACT_NATIVE_ACTION_VALUES = ['dismiss-overlay'] as const; +const REACT_NATIVE_COMMAND_NAME = 'react-native'; +const REACT_NATIVE_ACTION_VALUES = ['dismiss-overlay'] as const; -export const reactNativeCommandDescription = 'Run supported React Native app automation helpers.'; +const reactNativeCommandDescription = 'Run supported React Native app automation helpers.'; export const reactNativeCommandDescriptions = { [REACT_NATIVE_COMMAND_NAME]: reactNativeCommandDescription, @@ -28,7 +28,7 @@ export const reactNativeCommandDefinition = defineExecutableCommand( (client, input) => client.command.reactNative(input), ); -export const reactNativeCliSchema = { +const reactNativeCliSchema = { usageOverride: 'react-native dismiss-overlay', listUsageOverride: 'react-native dismiss-overlay', positionalArgs: ['dismiss-overlay'], diff --git a/src/commands/recording/index.ts b/src/commands/recording/index.ts index f1104c121..ce987fcad 100644 --- a/src/commands/recording/index.ts +++ b/src/commands/recording/index.ts @@ -15,12 +15,12 @@ import { defineFieldCommandMetadata } from '../field-command-contract.ts'; import { commonInputFromFlags, direct, optionalString } from '../cli-grammar/common.ts'; import type { CliReader, DaemonWriter } from '../cli-grammar/types.ts'; -export const RECORD_COMMAND_NAME = 'record'; -export const TRACE_COMMAND_NAME = 'trace'; -export const RECORDING_ACTION_VALUES = ['start', 'stop'] as const; +const RECORD_COMMAND_NAME = 'record'; +const TRACE_COMMAND_NAME = 'trace'; +const RECORDING_ACTION_VALUES = ['start', 'stop'] as const; -export const recordCommandDescription = 'Start or stop screen recording.'; -export const traceCommandDescription = 'Start or stop trace capture.'; +const recordCommandDescription = 'Start or stop screen recording.'; +const traceCommandDescription = 'Start or stop trace capture.'; export const recordingCommandDescriptions = { [RECORD_COMMAND_NAME]: recordCommandDescription, @@ -65,7 +65,7 @@ export const recordingCommandDefinitions = [ traceCommandDefinition, ] as const; -export const recordCliSchema = { +const recordCliSchema = { usageOverride: 'record start [path] [--fps ] [--quality <5-10>] [--hide-touches] | record stop', listUsageOverride: 'record start [path] | record stop', @@ -76,7 +76,7 @@ export const recordCliSchema = { allowedFlags: ['fps', 'quality', 'hideTouches'], } as const satisfies CommandSchemaOverride; -export const traceCliSchema = { +const traceCliSchema = { usageOverride: 'trace start | trace stop ', listUsageOverride: 'trace start | trace stop ', helpDescription: diff --git a/src/commands/replay/index.ts b/src/commands/replay/index.ts index a2891b20f..ff8e4619e 100644 --- a/src/commands/replay/index.ts +++ b/src/commands/replay/index.ts @@ -17,13 +17,13 @@ import { import type { CliReader, CommandInput, DaemonWriter } from '../cli-grammar/types.ts'; import { REPLAY_FLAGS } from '../../utils/cli-flags.ts'; -export const REPLAY_COMMAND_NAME = 'replay'; -export const TEST_COMMAND_NAME = 'test'; +const REPLAY_COMMAND_NAME = 'replay'; +const TEST_COMMAND_NAME = 'test'; const REPLAY_SHELL_ENV_PREFIX = 'AD_VAR_'; -export const replayCommandDescription = 'Replay a recorded session.'; -export const testCommandDescription = 'Run one or more replay scripts.'; +const replayCommandDescription = 'Replay a recorded session.'; +const testCommandDescription = 'Run one or more replay scripts.'; export const replayCommandDescriptions = { [REPLAY_COMMAND_NAME]: replayCommandDescription, @@ -75,14 +75,14 @@ export const testCommandDefinition = defineExecutableCommand(testCommandMetadata export const replayCommandDefinitions = [replayCommandDefinition, testCommandDefinition] as const; -export const replayCliSchema = { +const replayCliSchema = { usageOverride: 'replay | replay export [--format maestro] [--out ]', positionalArgs: ['path'], allowsExtraPositionals: true, allowedFlags: ['replayMaestro', 'replayExportFormat', ...REPLAY_FLAGS, 'timeoutMs', 'out'], } as const satisfies CommandSchemaOverride; -export const testCliSchema = { +const testCliSchema = { usageOverride: 'test ...', listUsageOverride: 'test ...', helpDescription: 'Run one or more replay scripts as a serial test suite', diff --git a/src/commands/system/index.ts b/src/commands/system/index.ts index b759ec871..1daf5fc4e 100644 --- a/src/commands/system/index.ts +++ b/src/commands/system/index.ts @@ -16,24 +16,24 @@ import { } from '../cli-grammar/common.ts'; import type { CliReader, DaemonWriter } from '../cli-grammar/types.ts'; -export const APPSTATE_COMMAND_NAME = 'appstate'; -export const BACK_COMMAND_NAME = 'back'; -export const HOME_COMMAND_NAME = 'home'; -export const ROTATE_COMMAND_NAME = 'rotate'; -export const APP_SWITCHER_COMMAND_NAME = 'app-switcher'; -export const KEYBOARD_COMMAND_NAME = 'keyboard'; -export const CLIPBOARD_COMMAND_NAME = 'clipboard'; +const APPSTATE_COMMAND_NAME = 'appstate'; +const BACK_COMMAND_NAME = 'back'; +const HOME_COMMAND_NAME = 'home'; +const ROTATE_COMMAND_NAME = 'rotate'; +const APP_SWITCHER_COMMAND_NAME = 'app-switcher'; +const KEYBOARD_COMMAND_NAME = 'keyboard'; +const CLIPBOARD_COMMAND_NAME = 'clipboard'; const CLIPBOARD_ACTION_VALUES = ['read', 'write'] as const; const KEYBOARD_METADATA_ACTION_VALUES = ['status', 'dismiss'] as const; -export const appStateCommandDescription = 'Show foreground app or activity.'; -export const backCommandDescription = 'Navigate back.'; -export const homeCommandDescription = 'Go to the home screen.'; -export const rotateCommandDescription = 'Rotate device orientation.'; -export const appSwitcherCommandDescription = 'Open the app switcher.'; -export const keyboardCommandDescription = 'Inspect or dismiss the keyboard.'; -export const clipboardCommandDescription = 'Read or write clipboard text.'; +const appStateCommandDescription = 'Show foreground app or activity.'; +const backCommandDescription = 'Navigate back.'; +const homeCommandDescription = 'Go to the home screen.'; +const rotateCommandDescription = 'Rotate device orientation.'; +const appSwitcherCommandDescription = 'Open the app switcher.'; +const keyboardCommandDescription = 'Inspect or dismiss the keyboard.'; +const clipboardCommandDescription = 'Read or write clipboard text.'; export const systemCommandDescriptions = { [APPSTATE_COMMAND_NAME]: appStateCommandDescription, @@ -45,27 +45,23 @@ export const systemCommandDescriptions = { [CLIPBOARD_COMMAND_NAME]: clipboardCommandDescription, } as const; -export const appStateCommandMetadata = defineFieldCommandMetadata( +const appStateCommandMetadata = defineFieldCommandMetadata( APPSTATE_COMMAND_NAME, appStateCommandDescription, {}, ); -export const backCommandMetadata = defineFieldCommandMetadata( - BACK_COMMAND_NAME, - backCommandDescription, - { - mode: enumField(BACK_MODES), - }, -); +const backCommandMetadata = defineFieldCommandMetadata(BACK_COMMAND_NAME, backCommandDescription, { + mode: enumField(BACK_MODES), +}); -export const homeCommandMetadata = defineFieldCommandMetadata( +const homeCommandMetadata = defineFieldCommandMetadata( HOME_COMMAND_NAME, homeCommandDescription, {}, ); -export const rotateCommandMetadata = defineFieldCommandMetadata( +const rotateCommandMetadata = defineFieldCommandMetadata( ROTATE_COMMAND_NAME, rotateCommandDescription, { @@ -73,13 +69,13 @@ export const rotateCommandMetadata = defineFieldCommandMetadata( }, ); -export const appSwitcherCommandMetadata = defineFieldCommandMetadata( +const appSwitcherCommandMetadata = defineFieldCommandMetadata( APP_SWITCHER_COMMAND_NAME, appSwitcherCommandDescription, {}, ); -export const keyboardCommandMetadata = defineFieldCommandMetadata( +const keyboardCommandMetadata = defineFieldCommandMetadata( KEYBOARD_COMMAND_NAME, keyboardCommandDescription, { @@ -87,7 +83,7 @@ export const keyboardCommandMetadata = defineFieldCommandMetadata( }, ); -export const clipboardCommandMetadata = defineFieldCommandMetadata( +const clipboardCommandMetadata = defineFieldCommandMetadata( CLIPBOARD_COMMAND_NAME, clipboardCommandDescription, { @@ -106,35 +102,34 @@ export const systemCommandMetadata = [ clipboardCommandMetadata, ] as const; -export const appStateCommandDefinition = defineExecutableCommand( +const appStateCommandDefinition = defineExecutableCommand( appStateCommandMetadata, (client, input) => client.command.appState(input), ); -export const backCommandDefinition = defineExecutableCommand(backCommandMetadata, (client, input) => +const backCommandDefinition = defineExecutableCommand(backCommandMetadata, (client, input) => client.command.back(input), ); -export const homeCommandDefinition = defineExecutableCommand(homeCommandMetadata, (client, input) => +const homeCommandDefinition = defineExecutableCommand(homeCommandMetadata, (client, input) => client.command.home(input), ); -export const rotateCommandDefinition = defineExecutableCommand( - rotateCommandMetadata, - (client, input) => client.command.rotate(input), +const rotateCommandDefinition = defineExecutableCommand(rotateCommandMetadata, (client, input) => + client.command.rotate(input), ); -export const appSwitcherCommandDefinition = defineExecutableCommand( +const appSwitcherCommandDefinition = defineExecutableCommand( appSwitcherCommandMetadata, (client, input) => client.command.appSwitcher(input), ); -export const keyboardCommandDefinition = defineExecutableCommand( +const keyboardCommandDefinition = defineExecutableCommand( keyboardCommandMetadata, (client, input) => client.command.keyboard(input), ); -export const clipboardCommandDefinition = defineExecutableCommand( +const clipboardCommandDefinition = defineExecutableCommand( clipboardCommandMetadata, (client, input) => client.command.clipboard(input as ClipboardCommandOptions), ); @@ -149,29 +144,29 @@ export const systemCommandDefinitions = [ clipboardCommandDefinition, ] as const; -export const appStateCliSchema = { +const appStateCliSchema = { helpDescription: 'Show foreground app/activity', } as const satisfies CommandSchemaOverride; -export const backCliSchema = { +const backCliSchema = { usageOverride: 'back [--in-app|--system]', allowedFlags: ['backMode'], } as const satisfies CommandSchemaOverride; -export const rotateCliSchema = { +const rotateCliSchema = { usageOverride: 'rotate ', helpDescription: 'Rotate device orientation on iOS and Android', positionalArgs: ['orientation'], } as const satisfies CommandSchemaOverride; -export const keyboardCliSchema = { +const keyboardCliSchema = { usageOverride: 'keyboard [status|get|dismiss|enter|return]', helpDescription: 'Inspect Android keyboard visibility/type or press/dismiss the device keyboard', summary: 'Inspect, press, or dismiss the device keyboard', positionalArgs: ['action?'], } as const satisfies CommandSchemaOverride; -export const clipboardCliSchema = { +const clipboardCliSchema = { usageOverride: 'clipboard read | clipboard write ', listUsageOverride: 'clipboard read | clipboard write ', helpDescription: 'Read or write device clipboard text', diff --git a/src/commands/system/output.ts b/src/commands/system/output.ts index 0a8fb4ac1..9c3f62487 100644 --- a/src/commands/system/output.ts +++ b/src/commands/system/output.ts @@ -11,14 +11,15 @@ import { type CliOutputFormatter, } from '../output-common.ts'; -export function appStateCliOutput(result: AppStateCommandResult): CliOutput { +function appStateCliOutput(result: AppStateCommandResult): CliOutput { return { data: result, text: formatAppState(result), }; } -export function keyboardCliOutput(result: KeyboardCommandResult): CliOutput { +// fallow-ignore-next-line complexity +function keyboardCliOutput(result: KeyboardCommandResult): CliOutput { if (result.platform === 'android' && result.action === 'status') { const lines = [ `Keyboard visible: ${result.visible === true ? 'yes' : 'no'}`, @@ -34,7 +35,7 @@ export function keyboardCliOutput(result: KeyboardCommandResult): CliOutput { return messageCliOutput(result); } -export function clipboardCliOutput(result: ClipboardCommandResult): CliOutput { +function clipboardCliOutput(result: ClipboardCommandResult): CliOutput { if (result.action === 'read') return { data: result, text: result.text }; return messageCliOutput(result); } diff --git a/src/contracts/perf.ts b/src/contracts/perf.ts index 229433faa..453e0d43c 100644 --- a/src/contracts/perf.ts +++ b/src/contracts/perf.ts @@ -10,7 +10,7 @@ export const PERF_KIND_VALUES = [ 'android-hprof', 'memgraph', ] as const; -export const PERF_MEMORY_KIND_VALUES = ['android-hprof', 'memgraph'] as const; +const PERF_MEMORY_KIND_VALUES = ['android-hprof', 'memgraph'] as const; const PERF_AREAS = defineStringEnum(PERF_AREA_VALUES); const PERF_ACTIONS = defineStringEnum(PERF_ACTION_VALUES); const PERF_SUBJECTS = defineStringEnum(PERF_SUBJECT_VALUES); From 65c8b6a5c57dd2a6ff65c80ade15bd28fbe1b305 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 12 Jun 2026 09:12:40 +0200 Subject: [PATCH 04/11] refactor: address command localization review --- scripts/integration-progress.mjs | 3 +- src/commands/batch/output.ts | 8 +- src/commands/batch/projection.ts | 7 +- src/commands/capture/runtime/snapshot.ts | 2 +- src/commands/cli-output.ts | 2 +- src/commands/index.ts | 114 ------------------ src/commands/interaction/runtime/gestures.ts | 2 +- .../interaction/runtime/interactions.ts | 2 +- .../interaction/runtime/resolution.ts | 2 +- .../runtime/selector-read-shared.ts | 3 +- .../runtime/selector-read-utils.ts | 24 ---- .../interaction/runtime/selector-read.ts | 10 +- src/commands/management/runtime/admin.ts | 2 +- src/commands/observability/index.ts | 2 +- .../observability/{runtime => }/output.ts | 26 ++-- .../observability/runtime/diagnostics.ts | 2 +- src/commands/output-common.ts | 14 +++ src/commands/react-native/index.test.ts | 4 + src/commands/recording/runtime/recording.ts | 2 +- src/commands/runtime-common.ts | 23 ++++ src/commands/system/runtime/system.ts | 2 +- 21 files changed, 70 insertions(+), 186 deletions(-) rename src/commands/observability/{runtime => }/output.ts (95%) create mode 100644 src/commands/runtime-common.ts diff --git a/scripts/integration-progress.mjs b/scripts/integration-progress.mjs index b93ed1c86..fbf111957 100644 --- a/scripts/integration-progress.mjs +++ b/scripts/integration-progress.mjs @@ -281,7 +281,6 @@ function summarizeProviderScenarioFlagExclusions() { name: 'Metro and React Native runtime preparation', owner: 'Metro companion integration and parser tests', keys: [ - 'kind', 'metroHost', 'metroPort', 'metroProjectRoot', @@ -304,7 +303,7 @@ function summarizeProviderScenarioFlagExclusions() { { name: 'Apple launch and perf artifact options', owner: 'iOS platform, observability command, and parser tests', - keys: ['deviceHub', 'launchArgs', 'perfTemplate'], + keys: ['deviceHub', 'kind', 'launchArgs', 'perfTemplate'], }, { name: 'parser/client-only command flags', diff --git a/src/commands/batch/output.ts b/src/commands/batch/output.ts index b8466c998..f8e762b4f 100644 --- a/src/commands/batch/output.ts +++ b/src/commands/batch/output.ts @@ -1,7 +1,7 @@ import type { CommandRequestResult } from '../../client-types.ts'; import { readCommandMessage } from '../../utils/success-text.ts'; import type { CliOutput } from '../command-contract.ts'; -import { resultOutput, type CliOutputFormatter } from '../output-common.ts'; +import { readRecord, resultOutput, type CliOutputFormatter } from '../output-common.ts'; function batchCliOutput(result: CommandRequestResult): CliOutput { const data = result as Record; @@ -48,9 +48,3 @@ function readBatchStepDescription( function readBatchStepFailure(error: Record | undefined): string | null { return typeof error?.message === 'string' && error.message.length > 0 ? error.message : null; } - -function readRecord(value: unknown): Record | undefined { - return value && typeof value === 'object' && !Array.isArray(value) - ? (value as Record) - : undefined; -} diff --git a/src/commands/batch/projection.ts b/src/commands/batch/projection.ts index 9a0acc809..7376fea97 100644 --- a/src/commands/batch/projection.ts +++ b/src/commands/batch/projection.ts @@ -4,12 +4,15 @@ import type { DaemonBatchStep } from '../../core/batch.ts'; import { AppError } from '../../utils/errors.ts'; import { commandNameSet, request } from '../cli-grammar/common.ts'; import type { CommandInput, DaemonCommandRequest, DaemonWriter } from '../cli-grammar/types.ts'; +import type { DaemonCommandName } from '../command-projection.ts'; -export type BatchCommandName = (typeof BATCH_COMMAND_NAMES)[number]; +const batchCommandNames = BATCH_COMMAND_NAMES satisfies readonly DaemonCommandName[]; + +export type BatchCommandName = (typeof batchCommandNames)[number]; type PrepareDaemonCommandRequest = (command: string, input: CommandInput) => DaemonCommandRequest; -const batchNames = commandNameSet(BATCH_COMMAND_NAMES); +const batchNames = commandNameSet(batchCommandNames); export function createBatchDaemonWriter( prepareDaemonCommandRequest: PrepareDaemonCommandRequest, diff --git a/src/commands/capture/runtime/snapshot.ts b/src/commands/capture/runtime/snapshot.ts index 2c86f613a..42b7c4feb 100644 --- a/src/commands/capture/runtime/snapshot.ts +++ b/src/commands/capture/runtime/snapshot.ts @@ -27,7 +27,7 @@ import type { RuntimeCommand, SnapshotCommandOptions, } from '../../runtime-types.ts'; -import { now } from '../../interaction/runtime/selector-read-utils.ts'; +import { now } from '../../runtime-common.ts'; export type { SnapshotDiffLine, SnapshotDiffSummary } from '../../../utils/snapshot-diff.ts'; diff --git a/src/commands/cli-output.ts b/src/commands/cli-output.ts index 27513b73b..1ea87be1c 100644 --- a/src/commands/cli-output.ts +++ b/src/commands/cli-output.ts @@ -4,7 +4,7 @@ import type { CliOutput } from './command-contract.ts'; import { interactionCliOutputFormatters } from './interaction/output.ts'; import { managementCliOutputFormatters } from './management/output.ts'; import { metroCliOutputFormatters } from './metro/output.ts'; -import { observabilityCliOutputFormatters } from './observability/runtime/output.ts'; +import { observabilityCliOutputFormatters } from './observability/output.ts'; import type { CliOutputFormatter } from './output-common.ts'; import { recordingCliOutputFormatters } from './recording/output.ts'; import { systemCliOutputFormatters } from './system/output.ts'; diff --git a/src/commands/index.ts b/src/commands/index.ts index 5ca29e96d..d2c988d26 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -44,120 +44,6 @@ import { type SystemCommands, } from './system/runtime/index.ts'; -export type { ScreenshotCommandResult } from './capture/runtime/screenshot.ts'; -export type { - DiffScreenshotCommandOptions, - DiffScreenshotCommandResult, - LiveScreenshotInputRef, -} from './capture/runtime/diff-screenshot.ts'; -export type { - DiffSnapshotCommandResult, - SnapshotCommandResult, - SnapshotDiffLine, - SnapshotDiffSummary, -} from './capture/runtime/snapshot.ts'; -export type { - ElementTarget, - FindReadCommandOptions, - FindReadCommandResult, - GetAttrsCommandOptions, - GetCommandOptions, - GetCommandResult, - GetTextCommandOptions, - IsCommandOptions, - IsCommandResult, - IsSelectorCommandOptions, - RefTarget, - ResolvedTarget, - SelectorSnapshotOptions, - SelectorTarget, - WaitCommandOptions, - WaitCommandResult, - WaitForTextCommandOptions, -} from './interaction/runtime/selector-read.ts'; -export type { - ClickCommandOptions, - FillCommandOptions, - FillCommandResult, - FocusCommandOptions, - FocusCommandResult, - InteractionTarget, - LongPressCommandOptions, - LongPressCommandResult, - PinchCommandOptions, - PinchCommandResult, - PointTarget, - PressCommandOptions, - PressCommandResult, - ResolvedInteractionTarget, - ScrollCommandOptions, - ScrollCommandResult, - ScrollTarget, - SwipeCommandOptions, - SwipeCommandResult, - SwipeOptions, - TypeTextCommandOptions, - TypeTextCommandResult, -} from './interaction/runtime/interactions.ts'; -export type { - AppPushInput, - CloseAppCommandOptions, - CloseAppCommandResult, - GetAppStateCommandOptions, - GetAppStateCommandResult, - ListAppsCommandOptions, - ListAppsCommandResult, - OpenAppCommandOptions, - OpenAppCommandResult, - PushAppCommandOptions, - PushAppCommandResult, - TriggerAppEventCommandOptions, - TriggerAppEventCommandResult, -} from './management/runtime/apps.ts'; -export type { - AdminBootCommandOptions, - AdminBootCommandResult, - AdminDevicesCommandOptions, - AdminDevicesCommandResult, - AdminInstallCommandOptions, - AdminInstallCommandResult, - AdminInstallFromSourceCommandOptions, - AdminReinstallCommandOptions, - AdminShutdownCommandOptions, - AdminShutdownCommandResult, -} from './management/runtime/admin.ts'; -export type { - DiagnosticsLogsCommandOptions, - DiagnosticsLogsCommandResult, - DiagnosticsNetworkCommandOptions, - DiagnosticsNetworkCommandResult, - DiagnosticsPerfCommandOptions, - DiagnosticsPerfCommandResult, -} from './observability/runtime/diagnostics.ts'; -export type { - RecordingRecordCommandOptions, - RecordingRecordCommandResult, - RecordingTraceCommandOptions, - RecordingTraceCommandResult, -} from './recording/runtime/recording.ts'; -export type { - SystemAlertCommandOptions, - SystemAlertCommandResult, - SystemAppSwitcherCommandOptions, - SystemAppSwitcherCommandResult, - SystemBackCommandOptions, - SystemBackCommandResult, - SystemClipboardCommandOptions, - SystemClipboardCommandResult, - SystemHomeCommandOptions, - SystemHomeCommandResult, - SystemKeyboardCommandOptions, - SystemKeyboardCommandResult, - SystemRotateCommandOptions, - SystemRotateCommandResult, - SystemSettingsCommandOptions, - SystemSettingsCommandResult, -} from './system/runtime/system.ts'; export { ref, selector } from './interaction/runtime/selector-read.ts'; export type { diff --git a/src/commands/interaction/runtime/gestures.ts b/src/commands/interaction/runtime/gestures.ts index 42f53062c..414a2debd 100644 --- a/src/commands/interaction/runtime/gestures.ts +++ b/src/commands/interaction/runtime/gestures.ts @@ -20,6 +20,7 @@ import { type ScrollEdgeState, type ScrollEdgeTarget, } from '../../../utils/scroll-edge-state.ts'; +import { toBackendContext } from '../../runtime-common.ts'; import { toBackendResult, type BackendResultEnvelope, @@ -34,7 +35,6 @@ import { type ResolvedInteractionTarget, resolveInteractionTarget, } from './resolution.ts'; -import { toBackendContext } from './selector-read-utils.ts'; export type FocusCommandOptions = CommandContext & { target: InteractionTarget; diff --git a/src/commands/interaction/runtime/interactions.ts b/src/commands/interaction/runtime/interactions.ts index 376d7eb8b..9fdb4171e 100644 --- a/src/commands/interaction/runtime/interactions.ts +++ b/src/commands/interaction/runtime/interactions.ts @@ -10,7 +10,7 @@ import type { PressCommandResult, ResolvedTarget, } from '../../../contracts/interaction.ts'; -import { toBackendContext } from './selector-read-utils.ts'; +import { toBackendContext } from '../../runtime-common.ts'; import { toBackendResult, type BackendResultEnvelope, diff --git a/src/commands/interaction/runtime/resolution.ts b/src/commands/interaction/runtime/resolution.ts index f80196c85..d0381cc54 100644 --- a/src/commands/interaction/runtime/resolution.ts +++ b/src/commands/interaction/runtime/resolution.ts @@ -20,8 +20,8 @@ import type { PointTarget, ResolvedInteractionTarget, } from '../../../contracts/interaction.ts'; +import { now, toBackendContext } from '../../runtime-common.ts'; import { resolveActionableTouchResolution } from './targeting.ts'; -import { now, toBackendContext } from './selector-read-utils.ts'; export type { InteractionTarget, PointTarget, ResolvedInteractionTarget }; diff --git a/src/commands/interaction/runtime/selector-read-shared.ts b/src/commands/interaction/runtime/selector-read-shared.ts index 58ab40f7f..4cf313032 100644 --- a/src/commands/interaction/runtime/selector-read-shared.ts +++ b/src/commands/interaction/runtime/selector-read-shared.ts @@ -8,7 +8,8 @@ import type { SnapshotNode, SnapshotState } from '../../../utils/snapshot.ts'; import { findNodeByRef, normalizeRef } from '../../../utils/snapshot.ts'; import { isSparseSnapshotQualityVerdict } from '../../../utils/snapshot-quality.ts'; import { extractReadableText } from '../../../utils/text-surface.ts'; -import { findNodeByLabel, now, toBackendContext } from './selector-read-utils.ts'; +import { now, toBackendContext } from '../../runtime-common.ts'; +import { findNodeByLabel } from './selector-read-utils.ts'; import type { SelectorSnapshotInput } from '../../command-input.ts'; export type CapturedSnapshot = { diff --git a/src/commands/interaction/runtime/selector-read-utils.ts b/src/commands/interaction/runtime/selector-read-utils.ts index e05c1be1b..1ac93cb90 100644 --- a/src/commands/interaction/runtime/selector-read-utils.ts +++ b/src/commands/interaction/runtime/selector-read-utils.ts @@ -1,29 +1,5 @@ -import type { BackendCommandContext } from '../../../backend.ts'; -import type { AgentDeviceRuntime, CommandContext } from '../../../runtime-contract.ts'; - export { findNodeByLabel, resolveRefLabel } from '../../../utils/snapshot-processing.ts'; export function shouldScopeFind(locator: string): boolean { return locator === 'text' || locator === 'label' || locator === 'any'; } - -export function toBackendContext( - runtime: Pick, - options: CommandContext, -): BackendCommandContext { - return { - session: options.session, - requestId: options.requestId, - signal: options.signal ?? runtime.signal, - metadata: options.metadata, - }; -} - -export function now(runtime: AgentDeviceRuntime): number { - return runtime.clock?.now() ?? Date.now(); -} - -export async function sleep(runtime: AgentDeviceRuntime, ms: number): Promise { - if (runtime.clock) await runtime.clock.sleep(ms); - else await new Promise((resolve) => setTimeout(resolve, ms)); -} diff --git a/src/commands/interaction/runtime/selector-read.ts b/src/commands/interaction/runtime/selector-read.ts index 398413755..0f1a4ef97 100644 --- a/src/commands/interaction/runtime/selector-read.ts +++ b/src/commands/interaction/runtime/selector-read.ts @@ -34,14 +34,8 @@ import { requireSnapshotSession, resolveRefNode, } from './selector-read-shared.ts'; -import { - findNodeByLabel, - now, - resolveRefLabel, - shouldScopeFind, - sleep, - toBackendContext, -} from './selector-read-utils.ts'; +import { findNodeByLabel, resolveRefLabel, shouldScopeFind } from './selector-read-utils.ts'; +import { now, sleep, toBackendContext } from '../../runtime-common.ts'; export type { SelectorSnapshotOptions } from './selector-read-shared.ts'; export type { ElementTarget, RefTarget, ResolvedTarget, SelectorTarget }; diff --git a/src/commands/management/runtime/admin.ts b/src/commands/management/runtime/admin.ts index e766dba6b..ae018301f 100644 --- a/src/commands/management/runtime/admin.ts +++ b/src/commands/management/runtime/admin.ts @@ -16,7 +16,7 @@ import { type RuntimeCommand, } from '../../runtime-types.ts'; import { resolveCommandInput } from '../../io-policy.ts'; -import { toBackendContext } from '../../interaction/runtime/selector-read-utils.ts'; +import { toBackendContext } from '../../runtime-common.ts'; import { normalizeOptionalText, requireText } from '../../text.ts'; export type AdminDevicesCommandOptions = CommandContext & { diff --git a/src/commands/observability/index.ts b/src/commands/observability/index.ts index 92c0c67ce..aba0a6bee 100644 --- a/src/commands/observability/index.ts +++ b/src/commands/observability/index.ts @@ -63,7 +63,7 @@ export const perfCommandMetadata = defineFieldCommandMetadata( action: enumField(PERF_ACTION_VALUES), kind: enumField(PERF_KIND_VALUES), template: stringField('xctrace template name, for example Time Profiler.'), - out: stringField(), + out: stringField('Output artifact path.'), tracePath: stringField('Existing .trace path to report, defaults to the latest session trace.'), }, ); diff --git a/src/commands/observability/runtime/output.ts b/src/commands/observability/output.ts similarity index 95% rename from src/commands/observability/runtime/output.ts rename to src/commands/observability/output.ts index adc363250..6da5fd95a 100644 --- a/src/commands/observability/runtime/output.ts +++ b/src/commands/observability/output.ts @@ -1,6 +1,11 @@ -import type { CommandRequestResult, DebugSymbolsResult } from '../../../client-types.ts'; -import type { CliOutput } from '../../command-contract.ts'; -import { resultOutput, type CliOutputFormatter } from '../../output-common.ts'; +import type { CommandRequestResult, DebugSymbolsResult } from '../../client-types.ts'; +import type { CliOutput } from '../command-contract.ts'; +import { + readRecord, + readRecordArray, + resultOutput, + type CliOutputFormatter, +} from '../output-common.ts'; function logsCliOutput(result: CommandRequestResult): CliOutput { const data = result as Record; @@ -345,21 +350,6 @@ function formatMemoryPerfSummary(memory: Record | undefined): s return memoryKb !== undefined ? `memory ${formatMemoryKb(memoryKb)}` : undefined; } -function readRecord(value: unknown): Record | undefined { - return value && typeof value === 'object' && !Array.isArray(value) - ? (value as Record) - : undefined; -} - -function readRecordArray(value: unknown): Array> { - return Array.isArray(value) - ? value.filter( - (entry): entry is Record => - Boolean(entry) && typeof entry === 'object' && !Array.isArray(entry), - ) - : []; -} - function readFiniteNumber(value: unknown): number | undefined { return typeof value === 'number' && Number.isFinite(value) ? value : undefined; } diff --git a/src/commands/observability/runtime/diagnostics.ts b/src/commands/observability/runtime/diagnostics.ts index 2c1d239ce..fa0f0136d 100644 --- a/src/commands/observability/runtime/diagnostics.ts +++ b/src/commands/observability/runtime/diagnostics.ts @@ -17,7 +17,7 @@ import type { DiagnosticsPerfCommandResult, } from './diagnostics-types.ts'; import type { RuntimeCommand } from '../../runtime-types.ts'; -import { toBackendContext } from '../../interaction/runtime/selector-read-utils.ts'; +import { toBackendContext } from '../../runtime-common.ts'; import { requireText } from '../../text.ts'; export type DiagnosticsPageOptions = CommandContext & { diff --git a/src/commands/output-common.ts b/src/commands/output-common.ts index e8dbe6c28..42ff2e337 100644 --- a/src/commands/output-common.ts +++ b/src/commands/output-common.ts @@ -17,3 +17,17 @@ export const messageOutput = resultOutput(messageCliOutput); export function messageCliOutput(result: Record): CliOutput { return { data: result, text: readCommandMessage(result) }; } + +export function readRecord(value: unknown): Record | undefined { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +export function readRecordArray(value: unknown): Array> { + return Array.isArray(value) ? value.filter(isRecord) : []; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} diff --git a/src/commands/react-native/index.test.ts b/src/commands/react-native/index.test.ts index 61081a037..59f0701bb 100644 --- a/src/commands/react-native/index.test.ts +++ b/src/commands/react-native/index.test.ts @@ -45,4 +45,8 @@ describe('react-native command interface', () => { options: { action: 'dismiss-overlay' }, }); }); + + test('rejects daemon request without action', () => { + expectInvalidArgs(() => reactNativeDaemonWriter({}), 'react-native requires action'); + }); }); diff --git a/src/commands/recording/runtime/recording.ts b/src/commands/recording/runtime/recording.ts index 7c8046743..8826aaad7 100644 --- a/src/commands/recording/runtime/recording.ts +++ b/src/commands/recording/runtime/recording.ts @@ -15,7 +15,7 @@ import type { RuntimeCommand, } from '../../runtime-types.ts'; import { reserveCommandOutput } from '../../io-policy.ts'; -import { toBackendContext } from '../../interaction/runtime/selector-read-utils.ts'; +import { toBackendContext } from '../../runtime-common.ts'; export type RecordingRecordCommandOptions = CommandContext & { action: 'start' | 'stop'; diff --git a/src/commands/runtime-common.ts b/src/commands/runtime-common.ts new file mode 100644 index 000000000..9b5eecc7e --- /dev/null +++ b/src/commands/runtime-common.ts @@ -0,0 +1,23 @@ +import type { BackendCommandContext } from '../backend.ts'; +import type { AgentDeviceRuntime, CommandContext } from '../runtime-contract.ts'; + +export function toBackendContext( + runtime: Pick, + options: CommandContext, +): BackendCommandContext { + return { + session: options.session, + requestId: options.requestId, + signal: options.signal ?? runtime.signal, + metadata: options.metadata, + }; +} + +export function now(runtime: Pick): number { + return runtime.clock?.now() ?? Date.now(); +} + +export async function sleep(runtime: Pick, ms: number): Promise { + if (runtime.clock) await runtime.clock.sleep(ms); + else await new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/src/commands/system/runtime/system.ts b/src/commands/system/runtime/system.ts index a91eb2637..617f8793d 100644 --- a/src/commands/system/runtime/system.ts +++ b/src/commands/system/runtime/system.ts @@ -17,7 +17,7 @@ import { type BackendResultVariant, type RuntimeCommand, } from '../../runtime-types.ts'; -import { toBackendContext } from '../../interaction/runtime/selector-read-utils.ts'; +import { toBackendContext } from '../../runtime-common.ts'; import { normalizeOptionalText } from '../../text.ts'; export type SystemBackCommandOptions = CommandContext & { From 4e6ecbd72e7bfcb2d72e21d68d3b333a090d58ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 12 Jun 2026 10:49:00 +0200 Subject: [PATCH 05/11] refactor: deepen batch command policy --- src/batch-policy.ts | 92 ++++++++++++++++++++++++++++++++ src/command-catalog.ts | 46 ---------------- src/commands/batch/cli.test.ts | 12 +++++ src/commands/batch/metadata.ts | 25 +++++++-- src/commands/batch/projection.ts | 23 +++----- src/core/__tests__/batch.test.ts | 7 +++ src/core/batch.ts | 29 +++++----- 7 files changed, 153 insertions(+), 81 deletions(-) create mode 100644 src/batch-policy.ts diff --git a/src/batch-policy.ts b/src/batch-policy.ts new file mode 100644 index 000000000..3c690c476 --- /dev/null +++ b/src/batch-policy.ts @@ -0,0 +1,92 @@ +import { PUBLIC_COMMANDS } from './command-catalog.ts'; +import { AppError } from './utils/errors.ts'; + +export const STRUCTURED_BATCH_COMMAND_NAMES = [ + PUBLIC_COMMANDS.devices, + PUBLIC_COMMANDS.boot, + PUBLIC_COMMANDS.shutdown, + PUBLIC_COMMANDS.apps, + PUBLIC_COMMANDS.open, + PUBLIC_COMMANDS.close, + PUBLIC_COMMANDS.install, + PUBLIC_COMMANDS.reinstall, + PUBLIC_COMMANDS.installFromSource, + PUBLIC_COMMANDS.push, + PUBLIC_COMMANDS.triggerAppEvent, + PUBLIC_COMMANDS.snapshot, + PUBLIC_COMMANDS.screenshot, + PUBLIC_COMMANDS.diff, + PUBLIC_COMMANDS.wait, + PUBLIC_COMMANDS.alert, + PUBLIC_COMMANDS.settings, + PUBLIC_COMMANDS.click, + PUBLIC_COMMANDS.press, + PUBLIC_COMMANDS.longPress, + PUBLIC_COMMANDS.swipe, + PUBLIC_COMMANDS.focus, + PUBLIC_COMMANDS.type, + PUBLIC_COMMANDS.fill, + PUBLIC_COMMANDS.scroll, + PUBLIC_COMMANDS.get, + PUBLIC_COMMANDS.gesture, + PUBLIC_COMMANDS.is, + PUBLIC_COMMANDS.find, + PUBLIC_COMMANDS.perf, + PUBLIC_COMMANDS.logs, + PUBLIC_COMMANDS.network, + PUBLIC_COMMANDS.record, + PUBLIC_COMMANDS.trace, + PUBLIC_COMMANDS.test, + PUBLIC_COMMANDS.appState, + PUBLIC_COMMANDS.back, + PUBLIC_COMMANDS.home, + PUBLIC_COMMANDS.rotate, + PUBLIC_COMMANDS.appSwitcher, + PUBLIC_COMMANDS.keyboard, + PUBLIC_COMMANDS.clipboard, + PUBLIC_COMMANDS.reactNative, +] as const; + +export type StructuredBatchCommandName = (typeof STRUCTURED_BATCH_COMMAND_NAMES)[number]; + +export const BATCH_BLOCKED_COMMANDS: ReadonlySet = new Set(['batch', 'replay']); + +export const BATCH_DAEMON_STEP_KEYS = ['command', 'positionals', 'flags', 'runtime'] as const; + +export const INHERITED_PARENT_FLAG_KEYS = [ + 'platform', + 'target', + 'device', + 'udid', + 'serial', + 'verbose', + 'out', +] as const; + +const structuredBatchCommandNames = new Set(STRUCTURED_BATCH_COMMAND_NAMES); + +function isStructuredBatchCommandName(command: string): command is StructuredBatchCommandName { + return structuredBatchCommandNames.has(command); +} + +export function normalizeBatchCommandName(command: unknown): string { + return typeof command === 'string' ? command.trim().toLowerCase() : ''; +} + +export function readStructuredBatchCommandName( + command: unknown, + stepNumber: number, +): StructuredBatchCommandName { + const normalized = normalizeBatchCommandName(command); + if (isStructuredBatchCommandName(normalized)) return normalized; + throw new AppError( + 'INVALID_ARGS', + `Batch step ${stepNumber} command is not available through command batch: ${String(command)}`, + ); +} + +export function assertBatchRuntimeCommandAllowed(command: string, stepNumber: number): void { + if (BATCH_BLOCKED_COMMANDS.has(command)) { + throw new AppError('INVALID_ARGS', `Batch step ${stepNumber} cannot run ${command}.`); + } +} diff --git a/src/command-catalog.ts b/src/command-catalog.ts index a4d56cd2e..394a05b23 100644 --- a/src/command-catalog.ts +++ b/src/command-catalog.ts @@ -82,52 +82,6 @@ export type ClientBackedCliCommandName = | typeof LOCAL_CLI_COMMANDS.metro | typeof LOCAL_CLI_COMMANDS.session; -export const BATCH_COMMAND_NAMES = [ - PUBLIC_COMMANDS.devices, - PUBLIC_COMMANDS.boot, - PUBLIC_COMMANDS.shutdown, - PUBLIC_COMMANDS.apps, - PUBLIC_COMMANDS.open, - PUBLIC_COMMANDS.close, - PUBLIC_COMMANDS.install, - PUBLIC_COMMANDS.reinstall, - PUBLIC_COMMANDS.installFromSource, - PUBLIC_COMMANDS.push, - PUBLIC_COMMANDS.triggerAppEvent, - PUBLIC_COMMANDS.snapshot, - PUBLIC_COMMANDS.screenshot, - PUBLIC_COMMANDS.diff, - PUBLIC_COMMANDS.wait, - PUBLIC_COMMANDS.alert, - PUBLIC_COMMANDS.settings, - PUBLIC_COMMANDS.click, - PUBLIC_COMMANDS.press, - PUBLIC_COMMANDS.longPress, - PUBLIC_COMMANDS.swipe, - PUBLIC_COMMANDS.focus, - PUBLIC_COMMANDS.type, - PUBLIC_COMMANDS.fill, - PUBLIC_COMMANDS.scroll, - PUBLIC_COMMANDS.get, - PUBLIC_COMMANDS.gesture, - PUBLIC_COMMANDS.is, - PUBLIC_COMMANDS.find, - PUBLIC_COMMANDS.perf, - PUBLIC_COMMANDS.logs, - PUBLIC_COMMANDS.network, - PUBLIC_COMMANDS.record, - PUBLIC_COMMANDS.trace, - PUBLIC_COMMANDS.test, - PUBLIC_COMMANDS.appState, - PUBLIC_COMMANDS.back, - PUBLIC_COMMANDS.home, - PUBLIC_COMMANDS.rotate, - PUBLIC_COMMANDS.appSwitcher, - PUBLIC_COMMANDS.keyboard, - PUBLIC_COMMANDS.clipboard, - PUBLIC_COMMANDS.reactNative, -] as const; - const MCP_UNEXPOSED_CLI_COMMANDS = commandSet( LOCAL_CLI_COMMANDS.auth, LOCAL_CLI_COMMANDS.connect, diff --git a/src/commands/batch/cli.test.ts b/src/commands/batch/cli.test.ts index 43258b650..8071e46e1 100644 --- a/src/commands/batch/cli.test.ts +++ b/src/commands/batch/cli.test.ts @@ -78,6 +78,18 @@ test('batch structured interaction target is projected to positionals, not devic assert.equal(step?.flags?.count, 2); }); +test('batch rejects structured replay steps before daemon dispatch', async () => { + const result = await runCliCapture([ + 'batch', + '--steps', + '[{"command":"replay","input":{"path":"flow.ad"}}]', + ]); + + assert.equal(result.code, 1); + assert.equal(result.calls.length, 0); + assert.match(result.stderr, /not available through command batch/); +}); + test('batch accepts legacy positionals/flags steps with deprecation warning', async () => { const result = await runCliCapture([ 'batch', diff --git a/src/commands/batch/metadata.ts b/src/commands/batch/metadata.ts index 588fb9032..7c439c6b5 100644 --- a/src/commands/batch/metadata.ts +++ b/src/commands/batch/metadata.ts @@ -1,5 +1,8 @@ -import { BATCH_COMMAND_NAMES } from '../../command-catalog.ts'; import { DEFAULT_BATCH_MAX_STEPS } from '../../batch-contract.ts'; +import { + STRUCTURED_BATCH_COMMAND_NAMES, + readStructuredBatchCommandName, +} from '../../batch-policy.ts'; import { defineCommandMetadata, type CommandMetadata, @@ -12,7 +15,6 @@ import { fieldsInputSchema, integerField, readFieldInput, - requiredEnum, requiredField, stringField, type CommandFieldMap, @@ -33,7 +35,7 @@ export type BatchInput = InferCommandInput & { }; export function createBatchCommandMetadata( - nestedCommands: readonly string[] = BATCH_COMMAND_NAMES, + nestedCommands: readonly string[] = STRUCTURED_BATCH_COMMAND_NAMES, ): CommandMetadata<'batch', BatchInput> { const fields = batchFields(nestedCommands); return defineCommandMetadata({ @@ -121,12 +123,27 @@ function readBatchStep( const record = readBatchStepRecord(step, stepNumber); assertAllowedKeys(record, ['command', 'input', 'runtime'], `Batch step ${stepNumber}`); return { - command: requiredEnum(record, 'command', nestedCommands), + command: readBatchStepCommand(record, stepNumber, nestedCommands), input: readBatchStepInput(record, stepNumber), ...readBatchStepRuntimeProperty(record, stepNumber), }; } +function readBatchStepCommand( + record: Record, + stepNumber: number, + nestedCommands: readonly string[], +): string { + if (nestedCommands === STRUCTURED_BATCH_COMMAND_NAMES) { + return readStructuredBatchCommandName(record.command, stepNumber); + } + const command = record.command; + if (typeof command !== 'string' || !nestedCommands.includes(command)) { + throw new Error(`Expected command to be one of: ${nestedCommands.join(', ')}.`); + } + return command; +} + function readBatchStepRecord(step: unknown, stepNumber: number): Record { if (!step || typeof step !== 'object' || Array.isArray(step)) { throw new Error(`Invalid batch step ${stepNumber}.`); diff --git a/src/commands/batch/projection.ts b/src/commands/batch/projection.ts index 7376fea97..f689ef15a 100644 --- a/src/commands/batch/projection.ts +++ b/src/commands/batch/projection.ts @@ -1,19 +1,21 @@ -import { BATCH_COMMAND_NAMES, PUBLIC_COMMANDS } from '../../command-catalog.ts'; +import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; +import { + STRUCTURED_BATCH_COMMAND_NAMES, + readStructuredBatchCommandName, +} from '../../batch-policy.ts'; import { buildFlags } from '../../client-normalizers.ts'; import type { DaemonBatchStep } from '../../core/batch.ts'; import { AppError } from '../../utils/errors.ts'; -import { commandNameSet, request } from '../cli-grammar/common.ts'; +import { request } from '../cli-grammar/common.ts'; import type { CommandInput, DaemonCommandRequest, DaemonWriter } from '../cli-grammar/types.ts'; import type { DaemonCommandName } from '../command-projection.ts'; -const batchCommandNames = BATCH_COMMAND_NAMES satisfies readonly DaemonCommandName[]; +const batchCommandNames = STRUCTURED_BATCH_COMMAND_NAMES satisfies readonly DaemonCommandName[]; export type BatchCommandName = (typeof batchCommandNames)[number]; type PrepareDaemonCommandRequest = (command: string, input: CommandInput) => DaemonCommandRequest; -const batchNames = commandNameSet(batchCommandNames); - export function createBatchDaemonWriter( prepareDaemonCommandRequest: PrepareDaemonCommandRequest, ): DaemonWriter { @@ -79,16 +81,7 @@ function readBatchStepCommand( record: Record, stepNumber: number, ): BatchCommandName { - const command = typeof record.command === 'string' ? record.command.trim().toLowerCase() : ''; - if (isBatchCommandName(command)) return command; - throw new AppError( - 'INVALID_ARGS', - `Batch step ${stepNumber} command is not available through command batch: ${String(record.command)}`, - ); -} - -function isBatchCommandName(name: string): name is BatchCommandName { - return batchNames.has(name); + return readStructuredBatchCommandName(record.command, stepNumber); } function readBatchStepInput(record: Record, stepNumber: number): CommandInput { diff --git a/src/core/__tests__/batch.test.ts b/src/core/__tests__/batch.test.ts index afa818540..37dcbdaf6 100644 --- a/src/core/__tests__/batch.test.ts +++ b/src/core/__tests__/batch.test.ts @@ -18,3 +18,10 @@ test('validateAndNormalizeBatchSteps rejects unknown top-level step fields', () /unknown field\(s\): "args"/i, ); }); + +test('validateAndNormalizeBatchSteps blocks replay daemon steps', () => { + assert.throws( + () => validateAndNormalizeBatchSteps([{ command: 'replay' }], 10), + /cannot run replay/i, + ); +}); diff --git a/src/core/batch.ts b/src/core/batch.ts index 5a9b438e8..0cb8ea7d3 100644 --- a/src/core/batch.ts +++ b/src/core/batch.ts @@ -1,19 +1,18 @@ import type { DaemonRequest, DaemonResponse } from '../contracts.ts'; import { AppError, asAppError } from '../utils/errors.ts'; import { DEFAULT_BATCH_MAX_STEPS } from '../batch-contract.ts'; +import { + BATCH_BLOCKED_COMMANDS, + BATCH_DAEMON_STEP_KEYS, + INHERITED_PARENT_FLAG_KEYS, + assertBatchRuntimeCommandAllowed, + normalizeBatchCommandName, +} from '../batch-policy.ts'; export { DEFAULT_BATCH_MAX_STEPS }; -export const BATCH_BLOCKED_COMMANDS: ReadonlySet = new Set(['batch', 'replay']); -const BATCH_ALLOWED_STEP_KEYS = new Set(['command', 'positionals', 'flags', 'runtime']); -export const INHERITED_PARENT_FLAG_KEYS = [ - 'platform', - 'target', - 'device', - 'udid', - 'serial', - 'verbose', - 'out', -] as const; +export { BATCH_BLOCKED_COMMANDS, INHERITED_PARENT_FLAG_KEYS }; + +const batchAllowedStepKeys = new Set(BATCH_DAEMON_STEP_KEYS); export type DaemonBatchStep = { command: string; @@ -130,7 +129,7 @@ export function validateAndNormalizeBatchSteps( if (!step || typeof step !== 'object') { throw new AppError('INVALID_ARGS', `Invalid batch step at index ${index}.`); } - const unknownKeys = Object.keys(step).filter((key) => !BATCH_ALLOWED_STEP_KEYS.has(key)); + const unknownKeys = Object.keys(step).filter((key) => !batchAllowedStepKeys.has(key)); if (unknownKeys.length > 0) { const fields = unknownKeys.map((key) => `"${key}"`).join(', '); throw new AppError( @@ -138,13 +137,11 @@ export function validateAndNormalizeBatchSteps( `Batch step ${index + 1} has unknown field(s): ${fields}. Allowed fields: command, positionals, flags, runtime.`, ); } - const command = typeof step.command === 'string' ? step.command.trim().toLowerCase() : ''; + const command = normalizeBatchCommandName(step.command); if (!command) { throw new AppError('INVALID_ARGS', `Batch step ${index + 1} requires command.`); } - if (BATCH_BLOCKED_COMMANDS.has(command)) { - throw new AppError('INVALID_ARGS', `Batch step ${index + 1} cannot run ${command}.`); - } + assertBatchRuntimeCommandAllowed(command, index + 1); if (step.positionals !== undefined && !Array.isArray(step.positionals)) { throw new AppError('INVALID_ARGS', `Batch step ${index + 1} positionals must be an array.`); } From b75cb5c6d860277b58b033136aa9f91c8f97ded9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 12 Jun 2026 11:03:51 +0200 Subject: [PATCH 06/11] refactor: split provider progress model --- ...01-provider-first-integration-scenarios.md | 2 + scripts/integration-progress-model.mjs | 569 ++++++++++++++++++ scripts/integration-progress.mjs | 569 +----------------- 3 files changed, 598 insertions(+), 542 deletions(-) create mode 100644 scripts/integration-progress-model.mjs diff --git a/docs/adr/0001-provider-first-integration-scenarios.md b/docs/adr/0001-provider-first-integration-scenarios.md index 96f779732..dd9c9de1e 100644 --- a/docs/adr/0001-provider-first-integration-scenarios.md +++ b/docs/adr/0001-provider-first-integration-scenarios.md @@ -58,6 +58,8 @@ Coverage is expected to improve over the old handler-heavy unit suite, but the f Operational metrics are generated by `pnpm test:integration:progress`. CI runs `pnpm test:integration:progress:check` after integration tests so public-command coverage, device-observable workflow flag coverage, and public-flag classification cannot silently regress. The script is the source of truth for provider-backed integration size, handler-unit size, mock-heavy handler pressure, public-command coverage, command-family ownership, device-observable workflow flag coverage, provider transcript pressure, and low-coverage files after a coverage run. Config, remote transport, Metro preparation, parser/client-only, report-writing, and boot-fallback flags stay in their owning unit/CLI suites and are reported as explicit exclusions rather than silently missing from the denominator. Provider pressure separates semantic Apple `simctl`/`devicectl`, macOS helper, and macOS host usage from generic Apple host-tool usage, and separates semantic Linux desktop/accessibility/clipboard/screenshot/input usage from generic Linux tool usage, so remote-adapter pressure is visible without treating named subproviders as raw shell intent. +The progress CLI should stay a thin report and check runner over a progress model. The model owns discovery, coverage classification, provider-pressure accounting, and check-failure derivation; the CLI owns Markdown output and process exit behavior. Source parsing inside the model is an implementation detail, not the desired long-term interface. It should move to command-family metadata only when the progress script can consume that metadata without changing Node runtime assumptions or importing command modules with unrelated side effects. + Every public command should have at least one provider-backed integration scenario that runs through the request router and request-scoped provider seams. Unit tests remain for parser matrices, selector matching, capability maps, malformed inputs, state machines, cleanup behavior, provider scope routing, and platform error boundaries. The temporary migration roadmap is complete and intentionally removed from the repository. Future work should be justified from live pressure in the progress script, failing tests, or concrete adapter needs rather than from a standing refactor backlog. diff --git a/scripts/integration-progress-model.mjs b/scripts/integration-progress-model.mjs new file mode 100644 index 000000000..fd94b744a --- /dev/null +++ b/scripts/integration-progress-model.mjs @@ -0,0 +1,569 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +const EMPTY_COVERAGE_METRIC = { pct: 0 }; +const EMPTY_STATEMENT_COVERAGE = { covered: 0, pct: 0, total: 0 }; + +let ROOT = process.cwd(); +let COVERAGE_SUMMARY = path.join(ROOT, 'coverage/coverage-summary.json'); +let COMMAND_CATALOG_SOURCE = ''; +let clientCommandMethods = new Map(); + +export function buildIntegrationProgressModel({ root = process.cwd() } = {}) { + ROOT = root; + COVERAGE_SUMMARY = path.join(ROOT, 'coverage/coverage-summary.json'); + const handlerTestDir = path.join(ROOT, 'src/daemon/handlers/__tests__'); + const providerScenarioDir = path.join(ROOT, 'test/integration/provider-scenarios'); + const commandCatalog = path.join(ROOT, 'src/command-catalog.ts'); + const commandContractFiles = listFiles(path.join(ROOT, 'src/commands'), (file) => + file.endsWith(`${path.sep}index.ts`), + ); + COMMAND_CATALOG_SOURCE = fs.readFileSync(commandCatalog, 'utf8'); + clientCommandMethods = readClientCommandMethods(commandContractFiles); + + const handlerTests = listFiles(handlerTestDir, (file) => file.endsWith('.test.ts')); + const providerScenarioTests = listFiles(providerScenarioDir, (file) => file.endsWith('.test.ts')); + const providerScenarioSources = listFiles(providerScenarioDir, (file) => file.endsWith('.ts')); + const providerScenarioSupportSources = providerScenarioSources.filter((file) => !file.endsWith('.test.ts')); + const handlerStats = summarizeFiles(handlerTests); + const providerScenarioStats = summarizeFiles(providerScenarioTests); + const providerScenarioSupportStats = summarizeFiles(providerScenarioSupportSources); + const mockHeavyHandlerFiles = handlerTests.filter((file) => + fs.readFileSync(file, 'utf8').includes('vi.mock('), + ); + const mockHeavyHandlerRows = summarizeMockHeavyHandlerFiles(mockHeavyHandlerFiles); + const providerPressureRows = summarizeProviderPressure(providerScenarioSources); + const publicCommandRows = summarizePublicCommandCoverage(providerScenarioTests); + const missingPublicCommands = publicCommandRows.filter((command) => command.references === 0); + const flagCoverageRows = summarizeProviderScenarioFlagCoverage(providerScenarioTests); + const missingFlagRows = flagCoverageRows.filter((flag) => flag.references === 0); + const excludedFlagRows = summarizeProviderScenarioFlagExclusions(); + const publicCliFlagKeys = readPublicCliFlagKeys(); + const classifiedFlagKeys = new Set([ + ...flagCoverageRows.map((flag) => flag.key), + ...excludedFlagRows.flatMap((group) => group.keys), + ]); + const unclassifiedFlagKeys = [...publicCliFlagKeys].filter((key) => !classifiedFlagKeys.has(key)); + const coverage = readCoverageSummary(); + const lowCoverageFiles = readLowCoverageFiles(); + + const summaryRows = [ + ['Handler unit test files', String(handlerStats.files)], + ['Handler unit test LOC', String(handlerStats.lines)], + ['Handler unit tests', String(handlerStats.tests)], + ['Handler files with vi.mock', String(mockHeavyHandlerFiles.length)], + ['Provider scenario files', String(providerScenarioStats.files)], + ['Provider scenario LOC', String(providerScenarioStats.lines)], + ['Provider scenario tests', String(providerScenarioStats.tests)], + ['Provider scenario support files', String(providerScenarioSupportStats.files)], + ['Provider scenario support LOC', String(providerScenarioSupportStats.lines)], + ['Provider scenario / handler LOC', ratio(providerScenarioStats.lines, handlerStats.lines)], + [ + 'Public commands covered by provider-backed integration', + `${publicCommandRows.length - missingPublicCommands.length}/${publicCommandRows.length}`, + ], + ['Public commands missing provider-backed integration coverage', String(missingPublicCommands.length)], + [ + 'Device-observable workflow flags covered by provider-backed integration', + `${flagCoverageRows.length - missingFlagRows.length}/${flagCoverageRows.length}`, + ], + ['Device-observable workflow flags missing provider-backed integration coverage', String(missingFlagRows.length)], + [ + 'Public CLI flags intentionally outside provider-backed integration', + String(excludedFlagRows.reduce((sum, group) => sum + group.keys.length, 0)), + ], + ['Public CLI flags unclassified by progress script', String(unclassifiedFlagKeys.length)], + ]; + + if (coverage) { + summaryRows.push( + ['Coverage statements', formatPercent(coverage.statements)], + ['Coverage branches', formatPercent(coverage.branches)], + ['Coverage functions', formatPercent(coverage.functions)], + ['Coverage lines', formatPercent(coverage.lines)], + ); + } else { + summaryRows.push(['Coverage summary', 'not available; run pnpm test:coverage first']); + } + + return { + coverage, + excludedFlagRows, + flagCoverageRows, + lowCoverageFiles, + missingFlagRows, + missingPublicCommands, + mockHeavyHandlerRows, + providerPressureRows, + publicCommandRows, + summaryRows, + unclassifiedFlagKeys, + }; +} + +export function buildIntegrationProgressFailures(progress) { + const failures = []; + if (progress.missingPublicCommands.length > 0) { + failures.push( + `missing Provider-backed integration command coverage: ${progress.missingPublicCommands.map((row) => row.command).join(', ')}`, + ); + } + if (progress.missingFlagRows.length > 0) { + failures.push( + `missing Provider-backed integration workflow flag coverage: ${progress.missingFlagRows.map((row) => row.key).join(', ')}`, + ); + } + if (progress.unclassifiedFlagKeys.length > 0) { + failures.push(`unclassified public CLI flags: ${progress.unclassifiedFlagKeys.join(', ')}`); + } + return failures; +} + +function summarizeProviderScenarioFlagCoverage(files) { + const flagTargets = [ + ['platform', 'selection across platform-specific provider-backed integration flows'], + ['target', 'target-class routing such as tv/mobile/desktop'], + ['device', 'human-readable device selection'], + ['udid', 'Apple device selection'], + ['serial', 'Android device selection'], + ['iosSimulatorDeviceSet', 'iOS simulator-set scoping reaches inventory resolution'], + ['androidDeviceAllowlist', 'Android serial allowlist reaches inventory resolution'], + ['session', 'named session routing'], + ['surface', 'macOS app/frontmost/desktop/menubar surfaces'], + ['activity', 'Android explicit launch activity'], + ['launchConsole', 'iOS simulator launch console capture'], + ['saveScript', 'open/close replay recording output'], + ['relaunch', 'open terminates before launch'], + ['shutdown', 'close/disconnect shutdown behavior'], + ['appsFilter', 'apps --all vs default filtering'], + ['header', 'install-from-source URL headers', ['headers']], + ['retainPaths', 'retained install-source materialization'], + ['retentionMs', 'install-source materialization TTL'], + ['count', 'repeated press/click/swipe input'], + ['fps', 'recording frame-rate request'], + ['quality', 'recording quality scaling'], + ['hideTouches', 'recording without touch overlays'], + ['intervalMs', 'repeated press interval'], + ['delayMs', 'typing/fill delay'], + ['holdMs', 'press hold duration'], + ['jitterPx', 'press jitter'], + ['pixels', 'scroll distance'], + ['doubleTap', 'double tap gesture'], + ['clickButton', 'desktop mouse button selection', ['button']], + ['backMode', 'explicit app/system back behavior', ['mode']], + ['pauseMs', 'swipe repeat pause'], + ['pattern', 'swipe repeat pattern'], + ['snapshotInteractiveOnly', 'interactive snapshot/ref refresh', ['interactiveOnly']], + ['snapshotCompact', 'compact snapshot output', ['compact']], + ['snapshotDepth', 'scoped snapshot depth', ['depth']], + ['snapshotScope', 'scoped snapshot capture', ['scope']], + ['snapshotRaw', 'raw snapshot node output', ['raw']], + ['out', 'artifact output path plumbing'], + ['overlayRefs', 'screenshot ref overlay annotation'], + ['screenshotFullscreen', 'screenshot full-screen capture mode'], + ['screenshotMaxSize', 'screenshot max-size post-processing'], + ['screenshotNoStabilize', 'screenshot stabilization opt-out', ['stabilize']], + ['restart', 'logs clear --restart workflow'], + ['networkInclude', 'network dump include modes', ['include']], + ['noRecord', 'action recording suppression'], + ['replayUpdate', 'selector-healing replay update', ['update']], + ['replayEnv', 'replay/test variable injection', ['env']], + ['failFast', 'test suite stops after first failure'], + ['timeoutMs', 'wait/test timeout flags'], + ['retries', 'test suite retry budget flows through request path'], + ['artifactsDir', 'test artifact root'], + ['steps', 'batch inline steps'], + ['batchOnError', 'batch stop-on-error policy', ['onError']], + ['batchMaxSteps', 'batch max-step guard', ['maxSteps']], + ['findFirst', 'find first disambiguation'], + ['findLast', 'find last disambiguation'], + ]; + const sources = files.map((file) => fs.readFileSync(file, 'utf8')).join('\n'); + return flagTargets.map(([key, reason, aliases = []]) => { + const references = [key, ...aliases].reduce( + (count, candidate) => count + countFlagReferences(sources, candidate), + 0, + ); + return { key, reason, references }; + }); +} + +function countFlagReferences(text, key) { + const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return text.match(new RegExp(`\\b${escaped}\\s*:`, 'g'))?.length ?? 0; +} + +function summarizeProviderScenarioFlagExclusions() { + return [ + { + name: 'config, output, diagnostics, and transport', + owner: 'args/CLI transport/auth tests', + keys: [ + 'config', + 'remoteConfig', + 'stateDir', + 'daemonBaseUrl', + 'daemonAuthToken', + 'daemonTransport', + 'daemonServerMode', + 'tenant', + 'sessionIsolation', + 'runId', + 'leaseId', + 'leaseBackend', + 'json', + 'help', + 'version', + 'verbose', + ], + }, + { + name: 'remote connection and session-lock policy', + owner: 'connection/runtime/request policy tests', + keys: ['force', 'noLogin', 'sessionLock', 'sessionLocked', 'sessionLockConflicts'], + }, + { + name: 'Metro and React Native runtime preparation', + owner: 'Metro companion integration and parser tests', + keys: [ + 'metroHost', + 'metroPort', + 'metroProjectRoot', + 'metroKind', + 'metroPublicBaseUrl', + 'metroProxyBaseUrl', + 'metroBearerToken', + 'metroPreparePort', + 'metroListenHost', + 'metroStatusHost', + 'metroStartupTimeoutMs', + 'metroProbeTimeoutMs', + 'metroRuntimeFile', + 'metroNoReuseExisting', + 'metroNoInstallDeps', + 'bundleUrl', + 'launchUrl', + ], + }, + { + name: 'Apple launch and perf artifact options', + owner: 'iOS platform, observability command, and parser tests', + keys: ['deviceHub', 'kind', 'launchArgs', 'perfTemplate'], + }, + { + name: 'parser/client-only command flags', + owner: 'args, CLI, screenshot-diff, and batch tests', + keys: [ + 'githubActionsArtifact', + 'snapshotDiff', + 'snapshotForceFull', + 'baseline', + 'threshold', + 'reportJunit', + 'replayMaestro', + 'replayExportFormat', + 'recordVideo', + 'shardAll', + 'shardSplit', + 'stepsFile', + ], + }, + { + name: 'platform boot fallback without provider seam', + owner: 'handler and Android platform unit tests', + keys: ['headless'], + }, + ]; +} + +function readPublicCliFlagKeys() { + const text = fs.readFileSync(path.join(ROOT, 'src/utils/cli-flags.ts'), 'utf8'); + return new Set( + [...text.matchAll(/\{\s*key: '([^']+)'[\s\S]*?names:\s*\[([^\]]*)\]/g)] + .filter((match) => isPublicCliFlagNames(match[2] ?? '')) + .map((match) => match[1]), + ); +} + +function isPublicCliFlagNames(names) { + return names.includes("'--") || names.includes("'-"); +} + +function listFiles(dir, predicate) { + if (!fs.existsSync(dir)) return []; + const entries = fs.readdirSync(dir, { withFileTypes: true }); + return entries.flatMap((entry) => { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) return listFiles(fullPath, predicate); + return predicate(fullPath) ? [fullPath] : []; + }); +} + +function summarizeFiles(files) { + let lines = 0; + let tests = 0; + for (const file of files) { + const text = fs.readFileSync(file, 'utf8'); + lines += text.split('\n').length; + tests += countTestDeclarations(text); + } + return { files: files.length, lines, tests }; +} + +function summarizeMockHeavyHandlerFiles(files) { + return files + .map((file) => { + const text = fs.readFileSync(file, 'utf8'); + return { + file: path.relative(ROOT, file), + lines: text.split('\n').length, + tests: countTestDeclarations(text), + }; + }) + .sort((a, b) => b.lines - a.lines) + .slice(0, 12); +} + +function summarizeProviderPressure(files) { + const surfaces = [ + { + name: 'Android ADB provider', + pattern: /\bAndroidAdbProvider\b|\bandroidAdbProvider\b|\badbProvider\b|\badb\.(?:exec|installer|puller|portReverse)\b/g, + }, + { + name: 'Apple runner provider', + pattern: /\bAppleRunnerProvider\b|\bappleRunnerProvider\b|\b(?:ios|macos|tvos)\.runner\b/g, + }, + { + name: 'Apple simctl/devicectl provider', + pattern: + /\bsimctl\b|\bdevicectl\b|\brunXcrun\b|\bsimctl\s*:|\bdevicectl\s*:/g, + }, + { + name: 'Apple macOS helper provider', + pattern: /\bmacos-helper\b|\bagent-device-macos-helper\b|\bmacosHelper\s*:/g, + }, + { + name: 'Apple macOS host provider', + pattern: + /\bmacos-host\b|\bmacosHost\s*:|\bAppleMacOsHostProvider\b|\bopenBundle\b|\bopenTarget\b|\breadClipboard\b|\bwriteClipboard\b|\breadDarkMode\b|\bsetDarkMode\b|\blistApps\b/g, + }, + { + name: 'Apple generic host-tool provider', + pattern: + /\bxcrun\b|['"](?:open|pbcopy|pbpaste|plutil|osascript|swift|codesign|mdfind|ps|pkill)['"]/g, + }, + { + name: 'Linux semantic desktop provider', + pattern: /\bdesktop\b|\bopenTarget\b|\bcloseApp\b/g, + }, + { + name: 'Linux semantic accessibility/clipboard/screenshot provider', + pattern: + /\baccessibility\b|\bcaptureTree\b|\bclipboard\b|\breadText\b|\bwriteText\b|\bscreenshot\b|\bcapture\s*:/g, + }, + { + name: 'Linux semantic input provider', + pattern: /\bLinuxInputProvider\b|\bprovider\.input\b|\binput\s*:|\['input'/g, + }, + { + name: 'Linux generic tool provider', + pattern: + /\bLinuxToolProvider\b|\blinuxToolProvider\b|\brunCommand\b|\bwhichCommand\b|\bxdotool\b|\bydotool\b|\bxclip\b|\bscrot\b|\bgrim\b|\bwmctrl\b|\bpkill\b/g, + }, + { + name: 'Recording provider', + pattern: /\bRecordingProvider\b|\brecordingProvider\b|\bstartRecording\b/g, + }, + ]; + + return surfaces + .map((surface) => ({ name: surface.name, ...countSurfaceReferences(files, surface.pattern) })) + .filter((surface) => surface.references > 0); +} + +function countSurfaceReferences(files, pattern) { + let references = 0; + let filesWithReferences = 0; + for (const file of files) { + const matches = countPatternReferences(fs.readFileSync(file, 'utf8'), pattern); + references += matches; + filesWithReferences += matches > 0 ? 1 : 0; + } + return { references, files: filesWithReferences }; +} + +function countPatternReferences(text, pattern) { + return text.match(pattern)?.length ?? 0; +} + +function summarizePublicCommandCoverage(files) { + const publicCommands = readPublicCommands(); + const commandRefsByFile = files.map((file) => ({ + file, + commands: extractProviderScenarioCommandReferences(fs.readFileSync(file, 'utf8')), + })); + + return publicCommands.map((command) => { + let references = 0; + let filesWithReferences = 0; + for (const file of commandRefsByFile) { + const count = file.commands.filter((candidate) => candidate === command).length; + references += count; + if (count > 0) filesWithReferences += 1; + } + return { command, references, files: filesWithReferences }; + }); +} + +function readPublicCommands() { + return [...readPublicCommandEntries().values()].sort(); +} + +function readPublicCommandEntries() { + const match = COMMAND_CATALOG_SOURCE.match(/export const PUBLIC_COMMANDS = \{([\s\S]*?)\} as const;/); + if (!match) { + throw new Error('Unable to find PUBLIC_COMMANDS in src/command-catalog.ts'); + } + const commands = new Map(); + for (const command of match[1].matchAll(/\b([A-Za-z0-9_]+):\s*'([^']+)'/g)) { + commands.set(command[1], command[2]); + } + return commands; +} + +function readClientCommandMethods(commandContractFiles) { + const commands = new Map(); + for (const file of commandContractFiles) { + const text = fs.readFileSync(file, 'utf8'); + for (const block of readCommandContractBlocks(text)) { + for (const method of block.source.matchAll(/\bclient\.([A-Za-z0-9_]+)\.([A-Za-z0-9_]+)\s*\(/g)) { + commands.set(`${method[1]}.${method[2]}`, block.name); + } + } + } + return commands; +} + +function readCommandContractBlocks(text) { + const constants = new Map(); + for (const match of text.matchAll(/\bconst\s+([A-Z0-9_]+)\s*=\s*['"]([^'"]+)['"]/g)) { + constants.set(match[1], match[2]); + } + + const metadataNames = new Map(); + for (const match of text.matchAll( + /\bconst\s+([A-Za-z0-9_]+CommandMetadata)\s*=\s*defineFieldCommandMetadata\(\s*([^,\s)]+)/g, + )) { + metadataNames.set(match[1], readMetadataName(match[2], constants)); + } + + const starts = [ + ...text.matchAll(/defineExecutableCommand\(\s*metadata\(\s*['"]([^'"]+)['"]\s*\)/g), + ...[...text.matchAll(/defineExecutableCommand\(\s*([A-Za-z0-9_]+CommandMetadata)\b/g)].flatMap( + (match) => { + const name = metadataNames.get(match[1]); + return name ? [{ ...match, 1: name }] : []; + }, + ), + ...text.matchAll(/defineFieldCommand\(\s*['"]([^'"]+)['"]/g), + ...text.matchAll(/defineCommand\(\s*\{[\s\S]*?\bname:\s*['"]([^'"]+)['"]/g), + ] + .map((match) => ({ + index: match.index ?? 0, + name: match[1], + })) + .sort((a, b) => a.index - b.index); + + return starts.map((start, index) => { + const end = starts[index + 1]?.index ?? text.length; + return { + name: start.name, + source: text.slice(start.index, end), + }; + }); +} + +function readMetadataName(token, constants) { + const literal = token.match(/^['"]([^'"]+)['"]$/); + if (literal) return literal[1]; + return constants.get(token); +} + +function extractProviderScenarioCommandReferences(text) { + return [...extractLiteralCommandReferences(text), ...extractClientCommandReferences(text)]; +} + +function extractLiteralCommandReferences(text) { + const commands = []; + for (const match of text.matchAll(/\bcommand:\s*['"]([^'"]+)['"]|\.callCommand\(\s*['"]([^'"]+)['"]/g)) { + commands.push(match[1] ?? match[2]); + } + return commands; +} + +function extractClientCommandReferences(text) { + const commands = []; + for (const [method, command] of clientCommandMethods) { + const escapedMethod = method.replace('.', '\\.'); + const matches = countPatternReferences(text, new RegExp(`\\.${escapedMethod}\\s*\\(`, 'g')); + for (let index = 0; index < matches; index += 1) commands.push(command); + } + return commands; +} + +function countTestDeclarations(text) { + return [...text.matchAll(/(?:^|[^\w.])test\(/g)].length; +} + +function readCoverageSummary() { + const total = readCoverageSummaryJson()?.total; + if (!total) return null; + return { + statements: readCoveragePercent(total, 'statements'), + branches: readCoveragePercent(total, 'branches'), + functions: readCoveragePercent(total, 'functions'), + lines: readCoveragePercent(total, 'lines'), + }; +} + +function readLowCoverageFiles() { + const summary = readCoverageSummaryJson(); + if (!summary) return []; + return Object.entries(summary) + .filter(([file]) => file !== 'total') + .map(([file, value]) => readLowCoverageFile(file, value)) + .filter((file) => file.statementTotal >= 10 && file.statementPercent < 60) + .sort((a, b) => b.missingStatements - a.missingStatements) + .slice(0, 10); +} + +function readCoverageSummaryJson() { + if (!fs.existsSync(COVERAGE_SUMMARY)) return null; + return JSON.parse(fs.readFileSync(COVERAGE_SUMMARY, 'utf8')); +} + +function readCoveragePercent(total, key) { + return Number((total[key] ?? EMPTY_COVERAGE_METRIC).pct); +} + +function readLowCoverageFile(file, value) { + const statements = value.statements ?? EMPTY_STATEMENT_COVERAGE; + const statementTotal = Number(statements.total); + const statementCovered = Number(statements.covered); + return { + file: path.relative(ROOT, file), + statementPercent: Number(statements.pct), + statementTotal, + missingStatements: statementTotal - statementCovered, + }; +} + +function ratio(numerator, denominator) { + if (denominator === 0) return 'n/a'; + return `${((numerator / denominator) * 100).toFixed(1)}%`; +} + +export function formatPercent(value) { + return `${value.toFixed(2)}%`; +} diff --git a/scripts/integration-progress.mjs b/scripts/integration-progress.mjs index fbf111957..b2e7b59ba 100644 --- a/scripts/integration-progress.mjs +++ b/scripts/integration-progress.mjs @@ -1,621 +1,106 @@ #!/usr/bin/env node -import fs from 'node:fs'; -import path from 'node:path'; +import { + buildIntegrationProgressFailures, + buildIntegrationProgressModel, + formatPercent, +} from './integration-progress-model.mjs'; -const ROOT = process.cwd(); const CHECK_MODE = process.argv.includes('--check'); -const HANDLER_TEST_DIR = path.join(ROOT, 'src/daemon/handlers/__tests__'); -const PROVIDER_SCENARIO_DIR = path.join(ROOT, 'test/integration/provider-scenarios'); -const COVERAGE_SUMMARY = path.join(ROOT, 'coverage/coverage-summary.json'); -const COMMAND_CATALOG = path.join(ROOT, 'src/command-catalog.ts'); -const COMMAND_CONTRACT_FILES = listFiles(path.join(ROOT, 'src/commands'), (file) => file.endsWith(`${path.sep}index.ts`)); -const COMMAND_CATALOG_SOURCE = fs.readFileSync(COMMAND_CATALOG, 'utf8'); -const clientCommandMethods = readClientCommandMethods(); - -const handlerTests = listFiles(HANDLER_TEST_DIR, (file) => file.endsWith('.test.ts')); -const providerScenarioTests = listFiles(PROVIDER_SCENARIO_DIR, (file) => file.endsWith('.test.ts')); -const providerScenarioSources = listFiles(PROVIDER_SCENARIO_DIR, (file) => file.endsWith('.ts')); -const providerScenarioSupportSources = providerScenarioSources.filter((file) => !file.endsWith('.test.ts')); -const handlerStats = summarizeFiles(handlerTests); -const providerScenarioStats = summarizeFiles(providerScenarioTests); -const providerScenarioSupportStats = summarizeFiles(providerScenarioSupportSources); -const mockHeavyHandlerFiles = handlerTests.filter((file) => - fs.readFileSync(file, 'utf8').includes('vi.mock('), -); -const mockHeavyHandlerRows = summarizeMockHeavyHandlerFiles(mockHeavyHandlerFiles); -const providerPressureRows = summarizeProviderPressure(providerScenarioSources); -const publicCommandRows = summarizePublicCommandCoverage(providerScenarioTests); -const missingPublicCommands = publicCommandRows.filter((command) => command.references === 0); -const flagCoverageRows = summarizeProviderScenarioFlagCoverage(providerScenarioTests); -const missingFlagRows = flagCoverageRows.filter((flag) => flag.references === 0); -const excludedFlagRows = summarizeProviderScenarioFlagExclusions(); -const publicCliFlagKeys = readPublicCliFlagKeys(); -const classifiedFlagKeys = new Set([ - ...flagCoverageRows.map((flag) => flag.key), - ...excludedFlagRows.flatMap((group) => group.keys), -]); -const unclassifiedFlagKeys = [...publicCliFlagKeys].filter((key) => !classifiedFlagKeys.has(key)); -const coverage = readCoverageSummary(); -const lowCoverageFiles = readLowCoverageFiles(); - -const rows = [ - ['Handler unit test files', String(handlerStats.files)], - ['Handler unit test LOC', String(handlerStats.lines)], - ['Handler unit tests', String(handlerStats.tests)], - ['Handler files with vi.mock', String(mockHeavyHandlerFiles.length)], - ['Provider scenario files', String(providerScenarioStats.files)], - ['Provider scenario LOC', String(providerScenarioStats.lines)], - ['Provider scenario tests', String(providerScenarioStats.tests)], - ['Provider scenario support files', String(providerScenarioSupportStats.files)], - ['Provider scenario support LOC', String(providerScenarioSupportStats.lines)], - ['Provider scenario / handler LOC', ratio(providerScenarioStats.lines, handlerStats.lines)], - [ - 'Public commands covered by provider-backed integration', - `${publicCommandRows.length - missingPublicCommands.length}/${publicCommandRows.length}`, - ], - ['Public commands missing provider-backed integration coverage', String(missingPublicCommands.length)], - [ - 'Device-observable workflow flags covered by provider-backed integration', - `${flagCoverageRows.length - missingFlagRows.length}/${flagCoverageRows.length}`, - ], - ['Device-observable workflow flags missing provider-backed integration coverage', String(missingFlagRows.length)], - [ - 'Public CLI flags intentionally outside provider-backed integration', - String(excludedFlagRows.reduce((sum, group) => sum + group.keys.length, 0)), - ], - ['Public CLI flags unclassified by progress script', String(unclassifiedFlagKeys.length)], -]; - -if (coverage) { - rows.push( - ['Coverage statements', formatPercent(coverage.statements)], - ['Coverage branches', formatPercent(coverage.branches)], - ['Coverage functions', formatPercent(coverage.functions)], - ['Coverage lines', formatPercent(coverage.lines)], - ); -} else { - rows.push(['Coverage summary', 'not available; run pnpm test:coverage first']); -} +const progress = buildIntegrationProgressModel({ root: process.cwd() }); console.log('Provider-backed integration status'); console.log(''); console.log('| Measure | Value |'); console.log('| --- | ---: |'); -for (const [name, value] of rows) { +for (const [name, value] of progress.summaryRows) { console.log(`| ${name} | ${value} |`); } -if (mockHeavyHandlerRows.length > 0) { +if (progress.mockHeavyHandlerRows.length > 0) { console.log(''); console.log('Mock-heavy handler unit tests'); console.log(''); console.log('| Tests | LOC | File |'); console.log('| ---: | ---: | --- |'); - for (const file of mockHeavyHandlerRows) { + for (const file of progress.mockHeavyHandlerRows) { console.log(`| ${file.tests} | ${file.lines} | ${file.file} |`); } } -if (missingPublicCommands.length > 0) { +if (progress.missingPublicCommands.length > 0) { console.log(''); console.log('Public command coverage gaps'); console.log(''); console.log('| Command |'); console.log('| --- |'); - for (const command of missingPublicCommands) { + for (const command of progress.missingPublicCommands) { console.log(`| ${command.command} |`); } } -if (missingFlagRows.length > 0) { +if (progress.missingFlagRows.length > 0) { console.log(''); console.log('Device-observable workflow flag coverage gaps'); console.log(''); console.log('| Flag | Intended integration coverage |'); console.log('| --- | --- |'); - for (const flag of missingFlagRows) { + for (const flag of progress.missingFlagRows) { console.log(`| ${flag.key} | ${flag.reason} |`); } } -if (excludedFlagRows.length > 0) { +if (progress.excludedFlagRows.length > 0) { console.log(''); console.log('Public CLI flag coverage outside provider-backed integration'); console.log(''); console.log('| Bucket | Flags | Coverage owner |'); console.log('| --- | --- | --- |'); - for (const group of excludedFlagRows) { + for (const group of progress.excludedFlagRows) { console.log(`| ${group.name} | ${group.keys.join(', ')} | ${group.owner} |`); } } -if (unclassifiedFlagKeys.length > 0) { +if (progress.unclassifiedFlagKeys.length > 0) { console.log(''); console.log('Unclassified public CLI flags'); console.log(''); console.log('| Flag |'); console.log('| --- |'); - for (const key of unclassifiedFlagKeys) { + for (const key of progress.unclassifiedFlagKeys) { console.log(`| ${key} |`); } } -if (providerPressureRows.length > 0) { +if (progress.providerPressureRows.length > 0) { console.log(''); console.log('Provider transcript pressure'); console.log(''); console.log('| Contract surface | References | Files |'); console.log('| --- | ---: | ---: |'); - for (const pressure of providerPressureRows) { + for (const pressure of progress.providerPressureRows) { console.log(`| ${pressure.name} | ${pressure.references} | ${pressure.files} |`); } } -if (CHECK_MODE) { - const failures = []; - if (missingPublicCommands.length > 0) { - failures.push( - `missing Provider-backed integration command coverage: ${missingPublicCommands.map((row) => row.command).join(', ')}`, - ); - } - if (missingFlagRows.length > 0) { - failures.push( - `missing Provider-backed integration workflow flag coverage: ${missingFlagRows.map((row) => row.key).join(', ')}`, - ); - } - if (unclassifiedFlagKeys.length > 0) { - failures.push(`unclassified public CLI flags: ${unclassifiedFlagKeys.join(', ')}`); - } - if (failures.length > 0) { - console.error(''); - console.error(`provider-backed integration progress check failed: ${failures.join('; ')}`); - process.exit(1); - } -} - -function summarizeProviderScenarioFlagCoverage(files) { - const flagTargets = [ - ['platform', 'selection across platform-specific provider-backed integration flows'], - ['target', 'target-class routing such as tv/mobile/desktop'], - ['device', 'human-readable device selection'], - ['udid', 'Apple device selection'], - ['serial', 'Android device selection'], - ['iosSimulatorDeviceSet', 'iOS simulator-set scoping reaches inventory resolution'], - ['androidDeviceAllowlist', 'Android serial allowlist reaches inventory resolution'], - ['session', 'named session routing'], - ['surface', 'macOS app/frontmost/desktop/menubar surfaces'], - ['activity', 'Android explicit launch activity'], - ['launchConsole', 'iOS simulator launch console capture'], - ['saveScript', 'open/close replay recording output'], - ['relaunch', 'open terminates before launch'], - ['shutdown', 'close/disconnect shutdown behavior'], - ['appsFilter', 'apps --all vs default filtering'], - ['header', 'install-from-source URL headers', ['headers']], - ['retainPaths', 'retained install-source materialization'], - ['retentionMs', 'install-source materialization TTL'], - ['count', 'repeated press/click/swipe input'], - ['fps', 'recording frame-rate request'], - ['quality', 'recording quality scaling'], - ['hideTouches', 'recording without touch overlays'], - ['intervalMs', 'repeated press interval'], - ['delayMs', 'typing/fill delay'], - ['holdMs', 'press hold duration'], - ['jitterPx', 'press jitter'], - ['pixels', 'scroll distance'], - ['doubleTap', 'double tap gesture'], - ['clickButton', 'desktop mouse button selection', ['button']], - ['backMode', 'explicit app/system back behavior', ['mode']], - ['pauseMs', 'swipe repeat pause'], - ['pattern', 'swipe repeat pattern'], - ['snapshotInteractiveOnly', 'interactive snapshot/ref refresh', ['interactiveOnly']], - ['snapshotCompact', 'compact snapshot output', ['compact']], - ['snapshotDepth', 'scoped snapshot depth', ['depth']], - ['snapshotScope', 'scoped snapshot capture', ['scope']], - ['snapshotRaw', 'raw snapshot node output', ['raw']], - ['out', 'artifact output path plumbing'], - ['overlayRefs', 'screenshot ref overlay annotation'], - ['screenshotFullscreen', 'screenshot full-screen capture mode'], - ['screenshotMaxSize', 'screenshot max-size post-processing'], - ['screenshotNoStabilize', 'screenshot stabilization opt-out', ['stabilize']], - ['restart', 'logs clear --restart workflow'], - ['networkInclude', 'network dump include modes', ['include']], - ['noRecord', 'action recording suppression'], - ['replayUpdate', 'selector-healing replay update', ['update']], - ['replayEnv', 'replay/test variable injection', ['env']], - ['failFast', 'test suite stops after first failure'], - ['timeoutMs', 'wait/test timeout flags'], - ['retries', 'test suite retry budget flows through request path'], - ['artifactsDir', 'test artifact root'], - ['steps', 'batch inline steps'], - ['batchOnError', 'batch stop-on-error policy', ['onError']], - ['batchMaxSteps', 'batch max-step guard', ['maxSteps']], - ['findFirst', 'find first disambiguation'], - ['findLast', 'find last disambiguation'], - ]; - const sources = files.map((file) => fs.readFileSync(file, 'utf8')).join('\n'); - return flagTargets.map(([key, reason, aliases = []]) => { - const references = [key, ...aliases].reduce( - (count, candidate) => count + countFlagReferences(sources, candidate), - 0, - ); - return { key, reason, references }; - }); -} - -function countFlagReferences(text, key) { - const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - return text.match(new RegExp(`\\b${escaped}\\s*:`, 'g'))?.length ?? 0; -} - -function summarizeProviderScenarioFlagExclusions() { - return [ - { - name: 'config, output, diagnostics, and transport', - owner: 'args/CLI transport/auth tests', - keys: [ - 'config', - 'remoteConfig', - 'stateDir', - 'daemonBaseUrl', - 'daemonAuthToken', - 'daemonTransport', - 'daemonServerMode', - 'tenant', - 'sessionIsolation', - 'runId', - 'leaseId', - 'leaseBackend', - 'json', - 'help', - 'version', - 'verbose', - ], - }, - { - name: 'remote connection and session-lock policy', - owner: 'connection/runtime/request policy tests', - keys: ['force', 'noLogin', 'sessionLock', 'sessionLocked', 'sessionLockConflicts'], - }, - { - name: 'Metro and React Native runtime preparation', - owner: 'Metro companion integration and parser tests', - keys: [ - 'metroHost', - 'metroPort', - 'metroProjectRoot', - 'metroKind', - 'metroPublicBaseUrl', - 'metroProxyBaseUrl', - 'metroBearerToken', - 'metroPreparePort', - 'metroListenHost', - 'metroStatusHost', - 'metroStartupTimeoutMs', - 'metroProbeTimeoutMs', - 'metroRuntimeFile', - 'metroNoReuseExisting', - 'metroNoInstallDeps', - 'bundleUrl', - 'launchUrl', - ], - }, - { - name: 'Apple launch and perf artifact options', - owner: 'iOS platform, observability command, and parser tests', - keys: ['deviceHub', 'kind', 'launchArgs', 'perfTemplate'], - }, - { - name: 'parser/client-only command flags', - owner: 'args, CLI, screenshot-diff, and batch tests', - keys: [ - 'githubActionsArtifact', - 'snapshotDiff', - 'snapshotForceFull', - 'baseline', - 'threshold', - 'reportJunit', - 'replayMaestro', - 'replayExportFormat', - 'recordVideo', - 'shardAll', - 'shardSplit', - 'stepsFile', - ], - }, - { - name: 'platform boot fallback without provider seam', - owner: 'handler and Android platform unit tests', - keys: ['headless'], - }, - ]; -} - -function readPublicCliFlagKeys() { - const sources = [path.join(ROOT, 'src/utils/cli-flags.ts')]; - const keys = new Set(); - for (const source of sources) { - const text = fs.readFileSync(source, 'utf8'); - for (const match of text.matchAll(/\{\s*key: '([^']+)'[\s\S]*?names:\s*\[([^\]]*)\]/g)) { - const key = match[1]; - const names = match[2] ?? ''; - if (names.includes("'--") || names.includes("'-")) { - keys.add(key); - } - } - } - return keys; -} - -if (lowCoverageFiles.length > 0) { +if (progress.lowCoverageFiles.length > 0) { console.log(''); console.log('Lowest covered implementation files'); console.log(''); console.log('| Missing statements | Statements | Statement coverage | File |'); console.log('| ---: | ---: | ---: | --- |'); - for (const file of lowCoverageFiles) { + for (const file of progress.lowCoverageFiles) { console.log( `| ${file.missingStatements} | ${file.statementTotal} | ${formatPercent(file.statementPercent)} | ${file.file} |`, ); } } -function listFiles(dir, predicate) { - if (!fs.existsSync(dir)) return []; - const entries = fs.readdirSync(dir, { withFileTypes: true }); - return entries.flatMap((entry) => { - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) return listFiles(fullPath, predicate); - return predicate(fullPath) ? [fullPath] : []; - }); -} - -function summarizeFiles(files) { - let lines = 0; - let tests = 0; - for (const file of files) { - const text = fs.readFileSync(file, 'utf8'); - lines += text.split('\n').length; - tests += countTestDeclarations(text); - } - return { files: files.length, lines, tests }; -} - -function summarizeMockHeavyHandlerFiles(files) { - return files - .map((file) => { - const text = fs.readFileSync(file, 'utf8'); - return { - file: path.relative(ROOT, file), - lines: text.split('\n').length, - tests: countTestDeclarations(text), - }; - }) - .sort((a, b) => b.lines - a.lines) - .slice(0, 12); -} - -function summarizeProviderPressure(files) { - const surfaces = [ - { - name: 'Android ADB provider', - pattern: /\bAndroidAdbProvider\b|\bandroidAdbProvider\b|\badbProvider\b|\badb\.(?:exec|installer|puller|portReverse)\b/g, - }, - { - name: 'Apple runner provider', - pattern: /\bAppleRunnerProvider\b|\bappleRunnerProvider\b|\b(?:ios|macos|tvos)\.runner\b/g, - }, - { - name: 'Apple simctl/devicectl provider', - pattern: - /\bsimctl\b|\bdevicectl\b|\brunXcrun\b|\bsimctl\s*:|\bdevicectl\s*:/g, - }, - { - name: 'Apple macOS helper provider', - pattern: /\bmacos-helper\b|\bagent-device-macos-helper\b|\bmacosHelper\s*:/g, - }, - { - name: 'Apple macOS host provider', - pattern: - /\bmacos-host\b|\bmacosHost\s*:|\bAppleMacOsHostProvider\b|\bopenBundle\b|\bopenTarget\b|\breadClipboard\b|\bwriteClipboard\b|\breadDarkMode\b|\bsetDarkMode\b|\blistApps\b/g, - }, - { - name: 'Apple generic host-tool provider', - pattern: - /\bxcrun\b|['"](?:open|pbcopy|pbpaste|plutil|osascript|swift|codesign|mdfind|ps|pkill)['"]/g, - }, - { - name: 'Linux semantic desktop provider', - pattern: /\bdesktop\b|\bopenTarget\b|\bcloseApp\b/g, - }, - { - name: 'Linux semantic accessibility/clipboard/screenshot provider', - pattern: - /\baccessibility\b|\bcaptureTree\b|\bclipboard\b|\breadText\b|\bwriteText\b|\bscreenshot\b|\bcapture\s*:/g, - }, - { - name: 'Linux semantic input provider', - pattern: /\bLinuxInputProvider\b|\bprovider\.input\b|\binput\s*:|\['input'/g, - }, - { - name: 'Linux generic tool provider', - pattern: - /\bLinuxToolProvider\b|\blinuxToolProvider\b|\brunCommand\b|\bwhichCommand\b|\bxdotool\b|\bydotool\b|\bxclip\b|\bscrot\b|\bgrim\b|\bwmctrl\b|\bpkill\b/g, - }, - { - name: 'Recording provider', - pattern: /\bRecordingProvider\b|\brecordingProvider\b|\bstartRecording\b/g, - }, - ]; - - return surfaces - .map((surface) => { - let references = 0; - let filesWithReferences = 0; - for (const file of files) { - const text = fs.readFileSync(file, 'utf8'); - const matches = text.match(surface.pattern)?.length ?? 0; - references += matches; - if (matches > 0) filesWithReferences += 1; - } - return { - name: surface.name, - references, - files: filesWithReferences, - }; - }) - .filter((surface) => surface.references > 0); -} - -function summarizePublicCommandCoverage(files) { - const publicCommands = readPublicCommands(); - const commandRefsByFile = files.map((file) => ({ - file, - commands: extractProviderScenarioCommandReferences(fs.readFileSync(file, 'utf8')), - })); - - return publicCommands.map((command) => { - let references = 0; - let filesWithReferences = 0; - for (const file of commandRefsByFile) { - const count = file.commands.filter((candidate) => candidate === command).length; - references += count; - if (count > 0) filesWithReferences += 1; - } - return { command, references, files: filesWithReferences }; - }); -} - -function readPublicCommands() { - return [...readPublicCommandEntries().values()].sort(); -} - -function readPublicCommandEntries() { - const match = COMMAND_CATALOG_SOURCE.match(/export const PUBLIC_COMMANDS = \{([\s\S]*?)\} as const;/); - if (!match) { - throw new Error('Unable to find PUBLIC_COMMANDS in src/command-catalog.ts'); - } - const commands = new Map(); - for (const command of match[1].matchAll(/\b([A-Za-z0-9_]+):\s*'([^']+)'/g)) { - commands.set(command[1], command[2]); - } - return commands; -} - -function readClientCommandMethods() { - const commands = new Map(); - for (const file of COMMAND_CONTRACT_FILES) { - const text = fs.readFileSync(file, 'utf8'); - for (const block of readCommandContractBlocks(text)) { - for (const method of block.source.matchAll(/\bclient\.([A-Za-z0-9_]+)\.([A-Za-z0-9_]+)\s*\(/g)) { - commands.set(`${method[1]}.${method[2]}`, block.name); - } - } - } - return commands; -} - -function readCommandContractBlocks(text) { - const constants = new Map(); - for (const match of text.matchAll(/\bconst\s+([A-Z0-9_]+)\s*=\s*['"]([^'"]+)['"]/g)) { - constants.set(match[1], match[2]); - } - - const metadataNames = new Map(); - for (const match of text.matchAll( - /\bconst\s+([A-Za-z0-9_]+CommandMetadata)\s*=\s*defineFieldCommandMetadata\(\s*([^,\s)]+)/g, - )) { - metadataNames.set(match[1], readMetadataName(match[2], constants)); - } - - const starts = [ - ...text.matchAll(/defineExecutableCommand\(\s*metadata\(\s*['"]([^'"]+)['"]\s*\)/g), - ...[...text.matchAll(/defineExecutableCommand\(\s*([A-Za-z0-9_]+CommandMetadata)\b/g)].flatMap( - (match) => { - const name = metadataNames.get(match[1]); - return name ? [{ ...match, 1: name }] : []; - }, - ), - ...text.matchAll(/defineFieldCommand\(\s*['"]([^'"]+)['"]/g), - ...text.matchAll(/defineCommand\(\s*\{[\s\S]*?\bname:\s*['"]([^'"]+)['"]/g), - ] - .map((match) => ({ - index: match.index ?? 0, - name: match[1], - })) - .sort((a, b) => a.index - b.index); - - return starts.map((start, index) => { - const end = starts[index + 1]?.index ?? text.length; - return { - name: start.name, - source: text.slice(start.index, end), - }; - }); -} - -function readMetadataName(token, constants) { - const literal = token.match(/^['"]([^'"]+)['"]$/); - if (literal) return literal[1]; - return constants.get(token); -} - -function extractProviderScenarioCommandReferences(text) { - const commands = []; - for (const match of text.matchAll(/\bcommand:\s*['"]([^'"]+)['"]|\.callCommand\(\s*['"]([^'"]+)['"]/g)) { - commands.push(match[1] ?? match[2]); - } - for (const [method, command] of clientCommandMethods) { - const escapedMethod = method.replace('.', '\\.'); - const matches = text.match(new RegExp(`\\.${escapedMethod}\\s*\\(`, 'g'))?.length ?? 0; - for (let index = 0; index < matches; index += 1) commands.push(command); +if (CHECK_MODE) { + const failures = buildIntegrationProgressFailures(progress); + if (failures.length > 0) { + console.error(''); + console.error(`provider-backed integration progress check failed: ${failures.join('; ')}`); + process.exit(1); } - return commands; -} - -function countTestDeclarations(text) { - return [...text.matchAll(/(?:^|[^\w.])test\(/g)].length; -} - -function readCoverageSummary() { - if (!fs.existsSync(COVERAGE_SUMMARY)) return null; - const summary = JSON.parse(fs.readFileSync(COVERAGE_SUMMARY, 'utf8')); - const total = summary.total; - if (!total) return null; - return { - statements: Number(total.statements?.pct ?? 0), - branches: Number(total.branches?.pct ?? 0), - functions: Number(total.functions?.pct ?? 0), - lines: Number(total.lines?.pct ?? 0), - }; -} - -function readLowCoverageFiles() { - if (!fs.existsSync(COVERAGE_SUMMARY)) return []; - const summary = JSON.parse(fs.readFileSync(COVERAGE_SUMMARY, 'utf8')); - return Object.entries(summary) - .filter(([file]) => file !== 'total') - .map(([file, value]) => { - const statements = value.statements ?? {}; - const statementTotal = Number(statements.total ?? 0); - const statementCovered = Number(statements.covered ?? 0); - return { - file: path.relative(ROOT, file), - statementPercent: Number(statements.pct ?? 0), - statementTotal, - missingStatements: statementTotal - statementCovered, - }; - }) - .filter((file) => file.statementTotal >= 10 && file.statementPercent < 60) - .sort((a, b) => b.missingStatements - a.missingStatements) - .slice(0, 10); -} - -function ratio(numerator, denominator) { - if (denominator === 0) return 'n/a'; - return `${((numerator / denominator) * 100).toFixed(1)}%`; -} - -function formatPercent(value) { - return `${value.toFixed(2)}%`; } From e878e8990a678da7c4b9f6ac6d8a77142c490565 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 12 Jun 2026 11:16:19 +0200 Subject: [PATCH 07/11] refactor: collapse command client facets --- src/commands/batch/index.ts | 6 --- src/commands/capture/index.ts | 10 ---- src/commands/capture/settings.ts | 2 +- src/commands/client-command-contracts.ts | 19 ------- src/commands/client-command-facets.ts | 66 ++++++++++++++++++++++++ src/commands/client-command-metadata.ts | 19 ------- src/commands/command-descriptions.ts | 32 ++---------- src/commands/command-metadata.ts | 6 ++- src/commands/command-surface.ts | 2 +- src/commands/interaction/index.ts | 2 +- src/commands/interaction/metadata.ts | 2 +- src/commands/management/index.ts | 2 +- src/commands/metro/index.ts | 4 -- src/commands/observability/index.ts | 7 --- src/commands/react-native/index.ts | 4 -- src/commands/recording/index.ts | 5 -- src/commands/replay/index.ts | 5 -- src/commands/system/index.ts | 10 ---- 18 files changed, 81 insertions(+), 122 deletions(-) delete mode 100644 src/commands/client-command-contracts.ts create mode 100644 src/commands/client-command-facets.ts delete mode 100644 src/commands/client-command-metadata.ts diff --git a/src/commands/batch/index.ts b/src/commands/batch/index.ts index 4301e04e4..386dd752a 100644 --- a/src/commands/batch/index.ts +++ b/src/commands/batch/index.ts @@ -7,12 +7,6 @@ import { commonToClientOptions } from '../command-input.ts'; import { createBatchCommandMetadata, type BatchInput } from './metadata.ts'; import { createBatchDaemonWriter } from './projection.ts'; -const batchCommandDescription = 'Run multiple structured command steps in one daemon request.'; - -export const batchCommandDescriptions = { - batch: batchCommandDescription, -} as const; - export const batchCommandMetadata = createBatchCommandMetadata(); export const batchCommandDefinition = defineExecutableCommand( diff --git a/src/commands/capture/index.ts b/src/commands/capture/index.ts index 4afb75df2..b1d97699d 100644 --- a/src/commands/capture/index.ts +++ b/src/commands/capture/index.ts @@ -44,7 +44,6 @@ import { settingsCliReader as settingsCliReaderImpl, settingsCliSchema, settingsCommandDefinition, - settingsCommandDescription, settingsCommandMetadata, settingsDaemonWriter as settingsDaemonWriterImpl, } from './settings.ts'; @@ -61,15 +60,6 @@ const diffCommandDescription = 'Diff accessibility snapshots.'; const waitCommandDescription = 'Wait for duration, text, ref, or selector.'; const alertCommandDescription = 'Inspect or handle platform alerts.'; -export const captureCommandDescriptions = { - [SNAPSHOT_COMMAND_NAME]: snapshotCommandDescription, - [SCREENSHOT_COMMAND_NAME]: screenshotCommandDescription, - [DIFF_COMMAND_NAME]: diffCommandDescription, - [WAIT_COMMAND_NAME]: waitCommandDescription, - [ALERT_COMMAND_NAME]: alertCommandDescription, - [SETTINGS_COMMAND_NAME]: settingsCommandDescription, -} as const; - const snapshotCommandMetadata = defineFieldCommandMetadata( SNAPSHOT_COMMAND_NAME, snapshotCommandDescription, diff --git a/src/commands/capture/settings.ts b/src/commands/capture/settings.ts index 72f718c47..751a5bd44 100644 --- a/src/commands/capture/settings.ts +++ b/src/commands/capture/settings.ts @@ -18,7 +18,7 @@ import type { CliReader, DaemonWriter } from '../cli-grammar/types.ts'; import { defineFieldCommandMetadata } from '../field-command-contract.ts'; export const SETTINGS_COMMAND_NAME = 'settings'; -export const settingsCommandDescription = 'Change OS settings and app permissions.'; +const settingsCommandDescription = 'Change OS settings and app permissions.'; export const settingsCommandMetadata = defineFieldCommandMetadata( SETTINGS_COMMAND_NAME, diff --git a/src/commands/client-command-contracts.ts b/src/commands/client-command-contracts.ts deleted file mode 100644 index b91c0f5d5..000000000 --- a/src/commands/client-command-contracts.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { captureCommandDefinitions } from './capture/index.ts'; -import { managementCommandDefinitions } from './management/index.ts'; -import { metroCommandDefinition } from './metro/index.ts'; -import { observabilityCommandDefinitions } from './observability/index.ts'; -import { reactNativeCommandDefinition } from './react-native/index.ts'; -import { recordingCommandDefinitions } from './recording/index.ts'; -import { replayCommandDefinitions } from './replay/index.ts'; -import { systemCommandDefinitions } from './system/index.ts'; - -export const clientCommandDefinitions = [ - ...managementCommandDefinitions, - ...captureCommandDefinitions, - ...systemCommandDefinitions, - reactNativeCommandDefinition, - ...replayCommandDefinitions, - ...observabilityCommandDefinitions, - ...recordingCommandDefinitions, - metroCommandDefinition, -] as const; diff --git a/src/commands/client-command-facets.ts b/src/commands/client-command-facets.ts new file mode 100644 index 000000000..a9f3a217f --- /dev/null +++ b/src/commands/client-command-facets.ts @@ -0,0 +1,66 @@ +import { captureCommandDefinitions, captureCommandMetadata } from './capture/index.ts'; +import { managementCommandDefinitions, managementCommandMetadata } from './management/index.ts'; +import { metroCommandDefinition, metroCommandMetadata } from './metro/index.ts'; +import { + observabilityCommandDefinitions, + observabilityCommandMetadata, +} from './observability/index.ts'; +import { reactNativeCommandDefinition, reactNativeCommandMetadata } from './react-native/index.ts'; +import { recordingCommandDefinitions, recordingCommandMetadata } from './recording/index.ts'; +import { replayCommandDefinitions, replayCommandMetadataList } from './replay/index.ts'; +import { systemCommandDefinitions, systemCommandMetadata } from './system/index.ts'; + +const clientCommandFamilyFacets = [ + { + metadata: managementCommandMetadata, + definitions: managementCommandDefinitions, + }, + { + metadata: captureCommandMetadata, + definitions: captureCommandDefinitions, + }, + { + metadata: systemCommandMetadata, + definitions: systemCommandDefinitions, + }, + { + metadata: [reactNativeCommandMetadata], + definitions: [reactNativeCommandDefinition], + }, + { + metadata: replayCommandMetadataList, + definitions: replayCommandDefinitions, + }, + { + metadata: observabilityCommandMetadata, + definitions: observabilityCommandDefinitions, + }, + { + metadata: recordingCommandMetadata, + definitions: recordingCommandDefinitions, + }, + { + metadata: [metroCommandMetadata], + definitions: [metroCommandDefinition], + }, +] as const; + +export const clientCommandMetadata = readClientCommandMetadata(clientCommandFamilyFacets); + +export const clientCommandDefinitions = readClientCommandDefinitions(clientCommandFamilyFacets); + +function readClientCommandMetadata< + const TFacets extends readonly { metadata: readonly unknown[] }[], +>(facets: TFacets): Array { + return facets.flatMap((family) => [...family.metadata]) as Array< + TFacets[number]['metadata'][number] + >; +} + +function readClientCommandDefinitions< + const TFacets extends readonly { definitions: readonly unknown[] }[], +>(facets: TFacets): Array { + return facets.flatMap((family) => [...family.definitions]) as Array< + TFacets[number]['definitions'][number] + >; +} diff --git a/src/commands/client-command-metadata.ts b/src/commands/client-command-metadata.ts deleted file mode 100644 index accf4bd70..000000000 --- a/src/commands/client-command-metadata.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { captureCommandMetadata } from './capture/index.ts'; -import { managementCommandMetadata } from './management/index.ts'; -import { metroCommandMetadata } from './metro/index.ts'; -import { observabilityCommandMetadata } from './observability/index.ts'; -import { reactNativeCommandMetadata } from './react-native/index.ts'; -import { recordingCommandMetadata } from './recording/index.ts'; -import { replayCommandMetadataList } from './replay/index.ts'; -import { systemCommandMetadata } from './system/index.ts'; - -export const clientCommandMetadata = [ - ...managementCommandMetadata, - ...captureCommandMetadata, - ...systemCommandMetadata, - reactNativeCommandMetadata, - ...replayCommandMetadataList, - ...observabilityCommandMetadata, - ...recordingCommandMetadata, - metroCommandMetadata, -] as const; diff --git a/src/commands/command-descriptions.ts b/src/commands/command-descriptions.ts index 7fc326c6f..b733d271a 100644 --- a/src/commands/command-descriptions.ts +++ b/src/commands/command-descriptions.ts @@ -1,35 +1,13 @@ -import { batchCommandDescriptions } from './batch/index.ts'; -import { captureCommandDescriptions } from './capture/index.ts'; -import { interactionCommandDescriptions } from './interaction/index.ts'; -import { managementCommandDescriptions } from './management/index.ts'; -import { metroCommandDescriptions } from './metro/index.ts'; -import { observabilityCommandDescriptions } from './observability/index.ts'; -import { reactNativeCommandDescriptions } from './react-native/index.ts'; -import { recordingCommandDescriptions } from './recording/index.ts'; -import { replayCommandDescriptions } from './replay/index.ts'; -import { systemCommandDescriptions } from './system/index.ts'; +import { listCommandMetadata, type CommandName } from './command-metadata.ts'; -const COMMAND_DESCRIPTIONS = { - ...managementCommandDescriptions, - ...captureCommandDescriptions, - ...interactionCommandDescriptions, - ...systemCommandDescriptions, - ...reactNativeCommandDescriptions, - ...replayCommandDescriptions, - ...observabilityCommandDescriptions, - ...recordingCommandDescriptions, - ...metroCommandDescriptions, - ...batchCommandDescriptions, -} as const; - -export type DescribedCommandName = keyof typeof COMMAND_DESCRIPTIONS; +export type DescribedCommandName = CommandName; export function listCommandDescriptionMetadata(): Array<{ name: DescribedCommandName; description: string; }> { - return Object.entries(COMMAND_DESCRIPTIONS).map(([name, description]) => ({ - name: name as DescribedCommandName, - description, + return listCommandMetadata().map((metadata) => ({ + name: metadata.name, + description: metadata.description, })); } diff --git a/src/commands/command-metadata.ts b/src/commands/command-metadata.ts index c52023e8f..6c13f5e37 100644 --- a/src/commands/command-metadata.ts +++ b/src/commands/command-metadata.ts @@ -1,6 +1,6 @@ import { listMcpExposedCommandNames } from '../command-catalog.ts'; import { batchCommandMetadata } from './batch/index.ts'; -import { clientCommandMetadata } from './client-command-metadata.ts'; +import { clientCommandMetadata } from './client-command-facets.ts'; import type { CommandMetadata } from './command-contract.ts'; import { interactionCommandMetadata } from './interaction/index.ts'; @@ -18,6 +18,10 @@ const commandMetadataMap: ReadonlyMap = new Map commandMetadata.map((definition) => [definition.name, definition as AnyCommandMetadata]), ); +export function listCommandMetadata(): AnyCommandMetadata[] { + return [...commandMetadata]; +} + export function listMcpCommandMetadata(): AnyCommandMetadata[] { return listMcpExposedCommandNames().map((name) => { if (!isCommandName(name)) { diff --git a/src/commands/command-surface.ts b/src/commands/command-surface.ts index 84a2a5094..6e2d8f8f9 100644 --- a/src/commands/command-surface.ts +++ b/src/commands/command-surface.ts @@ -1,6 +1,6 @@ import type { AgentDeviceClient } from '../client-types.ts'; import { batchCommandDefinition } from './batch/index.ts'; -import { clientCommandDefinitions } from './client-command-contracts.ts'; +import { clientCommandDefinitions } from './client-command-facets.ts'; import type { JsonSchema } from './command-contract.ts'; import { interactionCommandDefinitions } from './interaction/index.ts'; import type { BatchCommandName } from './command-projection.ts'; diff --git a/src/commands/interaction/index.ts b/src/commands/interaction/index.ts index d22cff23d..b947c3c36 100644 --- a/src/commands/interaction/index.ts +++ b/src/commands/interaction/index.ts @@ -44,7 +44,7 @@ import { export { gestureCliReaders, gestureDaemonWriters } from './gesture.ts'; export { interactionCliReaders, interactionDaemonWriters } from './interactions.ts'; -export { interactionCommandDescriptions, interactionCommandMetadata } from './metadata.ts'; +export { interactionCommandMetadata } from './metadata.ts'; export { selectorCliReaders, selectorDaemonWriters } from './selectors.ts'; export const interactionCliSchemas = { diff --git a/src/commands/interaction/metadata.ts b/src/commands/interaction/metadata.ts index 081b006e8..8fac7575a 100644 --- a/src/commands/interaction/metadata.ts +++ b/src/commands/interaction/metadata.ts @@ -48,7 +48,7 @@ const FIND_ACTION_VALUES = [ 'type', ] as const; -export const interactionCommandDescriptions = { +const interactionCommandDescriptions = { click: 'Click or tap a semantic UI target by ref, selector, or point.', press: 'Press a semantic UI target by ref, selector, or point.', fill: 'Fill text into a semantic UI target by ref, selector, or point.', diff --git a/src/commands/management/index.ts b/src/commands/management/index.ts index abc9a9ad2..3bf31cc4c 100644 --- a/src/commands/management/index.ts +++ b/src/commands/management/index.ts @@ -40,7 +40,7 @@ import { DEFAULT_APPS_FILTER } from '../../contracts/app-inventory.ts'; const PREPARE_ACTION_VALUES = ['ios-runner'] as const; -export const managementCommandDescriptions = { +const managementCommandDescriptions = { devices: 'List available devices.', boot: 'Boot or prepare a selected device without using CLI positional arguments.', shutdown: 'Shutdown a selected simulator or emulator.', diff --git a/src/commands/metro/index.ts b/src/commands/metro/index.ts index 773535b87..3c56a56e4 100644 --- a/src/commands/metro/index.ts +++ b/src/commands/metro/index.ts @@ -26,10 +26,6 @@ const METRO_ACTION_VALUES = ['prepare', 'reload'] as const; const metroCommandDescription = 'Prepare Metro runtime or reload React Native apps.'; -export const metroCommandDescriptions = { - [METRO_COMMAND_NAME]: metroCommandDescription, -} as const; - export const metroCommandMetadata = defineFieldCommandMetadata( METRO_COMMAND_NAME, metroCommandDescription, diff --git a/src/commands/observability/index.ts b/src/commands/observability/index.ts index aba0a6bee..724f01736 100644 --- a/src/commands/observability/index.ts +++ b/src/commands/observability/index.ts @@ -47,13 +47,6 @@ const logsCommandDescription = 'Manage session app logs.'; const networkCommandDescription = 'Show recent HTTP traffic.'; const debugCommandDescription = 'Symbolicate crash artifacts with matching debug symbols.'; -export const observabilityCommandDescriptions = { - [PERF_COMMAND_NAME]: perfCommandDescription, - [LOGS_COMMAND_NAME]: logsCommandDescription, - [NETWORK_COMMAND_NAME]: networkCommandDescription, - [DEBUG_COMMAND_NAME]: debugCommandDescription, -} as const; - export const perfCommandMetadata = defineFieldCommandMetadata( PERF_COMMAND_NAME, perfCommandDescription, diff --git a/src/commands/react-native/index.ts b/src/commands/react-native/index.ts index c114db24b..b2b7c40e8 100644 --- a/src/commands/react-native/index.ts +++ b/src/commands/react-native/index.ts @@ -11,10 +11,6 @@ const REACT_NATIVE_ACTION_VALUES = ['dismiss-overlay'] as const; const reactNativeCommandDescription = 'Run supported React Native app automation helpers.'; -export const reactNativeCommandDescriptions = { - [REACT_NATIVE_COMMAND_NAME]: reactNativeCommandDescription, -} as const; - export const reactNativeCommandMetadata = defineFieldCommandMetadata( REACT_NATIVE_COMMAND_NAME, reactNativeCommandDescription, diff --git a/src/commands/recording/index.ts b/src/commands/recording/index.ts index ce987fcad..538c264d0 100644 --- a/src/commands/recording/index.ts +++ b/src/commands/recording/index.ts @@ -22,11 +22,6 @@ const RECORDING_ACTION_VALUES = ['start', 'stop'] as const; const recordCommandDescription = 'Start or stop screen recording.'; const traceCommandDescription = 'Start or stop trace capture.'; -export const recordingCommandDescriptions = { - [RECORD_COMMAND_NAME]: recordCommandDescription, - [TRACE_COMMAND_NAME]: traceCommandDescription, -} as const; - export const recordCommandMetadata = defineFieldCommandMetadata( RECORD_COMMAND_NAME, recordCommandDescription, diff --git a/src/commands/replay/index.ts b/src/commands/replay/index.ts index ff8e4619e..d6ac9dcc9 100644 --- a/src/commands/replay/index.ts +++ b/src/commands/replay/index.ts @@ -25,11 +25,6 @@ const REPLAY_SHELL_ENV_PREFIX = 'AD_VAR_'; const replayCommandDescription = 'Replay a recorded session.'; const testCommandDescription = 'Run one or more replay scripts.'; -export const replayCommandDescriptions = { - [REPLAY_COMMAND_NAME]: replayCommandDescription, - [TEST_COMMAND_NAME]: testCommandDescription, -} as const; - export const replayCommandMetadata = defineFieldCommandMetadata( REPLAY_COMMAND_NAME, replayCommandDescription, diff --git a/src/commands/system/index.ts b/src/commands/system/index.ts index 1daf5fc4e..41a4e691f 100644 --- a/src/commands/system/index.ts +++ b/src/commands/system/index.ts @@ -35,16 +35,6 @@ const appSwitcherCommandDescription = 'Open the app switcher.'; const keyboardCommandDescription = 'Inspect or dismiss the keyboard.'; const clipboardCommandDescription = 'Read or write clipboard text.'; -export const systemCommandDescriptions = { - [APPSTATE_COMMAND_NAME]: appStateCommandDescription, - [BACK_COMMAND_NAME]: backCommandDescription, - [HOME_COMMAND_NAME]: homeCommandDescription, - [ROTATE_COMMAND_NAME]: rotateCommandDescription, - [APP_SWITCHER_COMMAND_NAME]: appSwitcherCommandDescription, - [KEYBOARD_COMMAND_NAME]: keyboardCommandDescription, - [CLIPBOARD_COMMAND_NAME]: clipboardCommandDescription, -} as const; - const appStateCommandMetadata = defineFieldCommandMetadata( APPSTATE_COMMAND_NAME, appStateCommandDescription, From 7b4cbecd105270c218181486005ee8b2eb143833 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 12 Jun 2026 11:25:02 +0200 Subject: [PATCH 08/11] refactor: run progress metrics as TypeScript --- ...01-provider-first-integration-scenarios.md | 2 +- package.json | 4 +-- ...odel.mjs => integration-progress-model.ts} | 27 ++++++++----------- ...n-progress.mjs => integration-progress.ts} | 2 +- 4 files changed, 15 insertions(+), 20 deletions(-) rename scripts/{integration-progress-model.mjs => integration-progress-model.ts} (96%) rename scripts/{integration-progress.mjs => integration-progress.ts} (98%) diff --git a/docs/adr/0001-provider-first-integration-scenarios.md b/docs/adr/0001-provider-first-integration-scenarios.md index dd9c9de1e..b90563549 100644 --- a/docs/adr/0001-provider-first-integration-scenarios.md +++ b/docs/adr/0001-provider-first-integration-scenarios.md @@ -58,7 +58,7 @@ Coverage is expected to improve over the old handler-heavy unit suite, but the f Operational metrics are generated by `pnpm test:integration:progress`. CI runs `pnpm test:integration:progress:check` after integration tests so public-command coverage, device-observable workflow flag coverage, and public-flag classification cannot silently regress. The script is the source of truth for provider-backed integration size, handler-unit size, mock-heavy handler pressure, public-command coverage, command-family ownership, device-observable workflow flag coverage, provider transcript pressure, and low-coverage files after a coverage run. Config, remote transport, Metro preparation, parser/client-only, report-writing, and boot-fallback flags stay in their owning unit/CLI suites and are reported as explicit exclusions rather than silently missing from the denominator. Provider pressure separates semantic Apple `simctl`/`devicectl`, macOS helper, and macOS host usage from generic Apple host-tool usage, and separates semantic Linux desktop/accessibility/clipboard/screenshot/input usage from generic Linux tool usage, so remote-adapter pressure is visible without treating named subproviders as raw shell intent. -The progress CLI should stay a thin report and check runner over a progress model. The model owns discovery, coverage classification, provider-pressure accounting, and check-failure derivation; the CLI owns Markdown output and process exit behavior. Source parsing inside the model is an implementation detail, not the desired long-term interface. It should move to command-family metadata only when the progress script can consume that metadata without changing Node runtime assumptions or importing command modules with unrelated side effects. +The progress CLI should stay a thin report and check runner over a progress model. The model owns discovery, coverage classification, provider-pressure accounting, and check-failure derivation; the CLI owns Markdown output and process exit behavior. The progress script runs as a Node type-stripped TypeScript script and should consume command metadata directly when the metadata API exposes the needed facet. Source parsing inside the model is an implementation detail, not the desired long-term interface, and should remain limited to facets that are not yet represented as runtime metadata, such as mapping typed client method calls back to command names. Every public command should have at least one provider-backed integration scenario that runs through the request router and request-scoped provider seams. Unit tests remain for parser matrices, selector matching, capability maps, malformed inputs, state machines, cleanup behavior, provider scope routing, and platform error boundaries. diff --git a/package.json b/package.json index 38ceffd18..bf78e9c48 100644 --- a/package.json +++ b/package.json @@ -132,8 +132,8 @@ "test:unit": "vitest run --project unit", "test:coverage": "vitest run --coverage", "test:integration:provider": "vitest run --project provider-integration", - "test:integration:progress": "node scripts/integration-progress.mjs", - "test:integration:progress:check": "node scripts/integration-progress.mjs --check", + "test:integration:progress": "node --experimental-strip-types scripts/integration-progress.ts", + "test:integration:progress:check": "node --experimental-strip-types scripts/integration-progress.ts --check", "test:skillgym": "node test/skillgym/runner-environment.ts && pnpm build && skillgym run ./test/skillgym/suites/agent-device-smoke-suite.ts --config ./test/skillgym/skillgym.config.ts", "test:skillgym:case": "node test/skillgym/runner-environment.ts && pnpm build && skillgym run ./test/skillgym/suites/agent-device-smoke-suite.ts --config ./test/skillgym/skillgym.config.ts --case", "test:smoke": "node --test test/integration/smoke-*.test.ts", diff --git a/scripts/integration-progress-model.mjs b/scripts/integration-progress-model.ts similarity index 96% rename from scripts/integration-progress-model.mjs rename to scripts/integration-progress-model.ts index fd94b744a..385304bcd 100644 --- a/scripts/integration-progress-model.mjs +++ b/scripts/integration-progress-model.ts @@ -1,12 +1,13 @@ import fs from 'node:fs'; import path from 'node:path'; +import { PUBLIC_COMMANDS } from '../src/command-catalog.ts'; +import { listCommandMetadata } from '../src/commands/command-metadata.ts'; const EMPTY_COVERAGE_METRIC = { pct: 0 }; const EMPTY_STATEMENT_COVERAGE = { covered: 0, pct: 0, total: 0 }; let ROOT = process.cwd(); let COVERAGE_SUMMARY = path.join(ROOT, 'coverage/coverage-summary.json'); -let COMMAND_CATALOG_SOURCE = ''; let clientCommandMethods = new Map(); export function buildIntegrationProgressModel({ root = process.cwd() } = {}) { @@ -14,11 +15,9 @@ export function buildIntegrationProgressModel({ root = process.cwd() } = {}) { COVERAGE_SUMMARY = path.join(ROOT, 'coverage/coverage-summary.json'); const handlerTestDir = path.join(ROOT, 'src/daemon/handlers/__tests__'); const providerScenarioDir = path.join(ROOT, 'test/integration/provider-scenarios'); - const commandCatalog = path.join(ROOT, 'src/command-catalog.ts'); const commandContractFiles = listFiles(path.join(ROOT, 'src/commands'), (file) => file.endsWith(`${path.sep}index.ts`), ); - COMMAND_CATALOG_SOURCE = fs.readFileSync(commandCatalog, 'utf8'); clientCommandMethods = readClientCommandMethods(commandContractFiles); const handlerTests = listFiles(handlerTestDir, (file) => file.endsWith('.test.ts')); @@ -417,19 +416,15 @@ function summarizePublicCommandCoverage(files) { } function readPublicCommands() { - return [...readPublicCommandEntries().values()].sort(); -} - -function readPublicCommandEntries() { - const match = COMMAND_CATALOG_SOURCE.match(/export const PUBLIC_COMMANDS = \{([\s\S]*?)\} as const;/); - if (!match) { - throw new Error('Unable to find PUBLIC_COMMANDS in src/command-catalog.ts'); - } - const commands = new Map(); - for (const command of match[1].matchAll(/\b([A-Za-z0-9_]+):\s*'([^']+)'/g)) { - commands.set(command[1], command[2]); - } - return commands; + const metadataNames = new Set(listCommandMetadata().map((metadata) => metadata.name)); + return Object.values(PUBLIC_COMMANDS) + .map((name) => { + if (!metadataNames.has(name)) { + throw new Error(`Missing command metadata for public command: ${name}`); + } + return name; + }) + .sort(); } function readClientCommandMethods(commandContractFiles) { diff --git a/scripts/integration-progress.mjs b/scripts/integration-progress.ts similarity index 98% rename from scripts/integration-progress.mjs rename to scripts/integration-progress.ts index b2e7b59ba..f1aa352b1 100644 --- a/scripts/integration-progress.mjs +++ b/scripts/integration-progress.ts @@ -4,7 +4,7 @@ import { buildIntegrationProgressFailures, buildIntegrationProgressModel, formatPercent, -} from './integration-progress-model.mjs'; +} from './integration-progress-model.ts'; const CHECK_MODE = process.argv.includes('--check'); const progress = buildIntegrationProgressModel({ root: process.cwd() }); From 419af7e5033d0e25058fc4ff14e969e17842d814 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 12 Jun 2026 11:36:43 +0200 Subject: [PATCH 09/11] refactor: remove obsolete command shims --- scripts/integration-progress-model.ts | 75 +++++++++---------- src/commands/capture/index.ts | 7 +- .../capture/screenshot-options.test.ts | 2 +- src/commands/capture/screenshot-options.ts | 1 - src/commands/capture/wait-command-contract.ts | 1 - src/commands/command-descriptions.ts | 13 ---- .../interaction/runtime/resolution.ts | 2 +- src/commands/interaction/runtime/targeting.ts | 1 - src/utils/command-schema.ts | 8 +- 9 files changed, 47 insertions(+), 63 deletions(-) delete mode 100644 src/commands/capture/screenshot-options.ts delete mode 100644 src/commands/command-descriptions.ts delete mode 100644 src/commands/interaction/runtime/targeting.ts diff --git a/scripts/integration-progress-model.ts b/scripts/integration-progress-model.ts index 385304bcd..c2d5176f7 100644 --- a/scripts/integration-progress-model.ts +++ b/scripts/integration-progress-model.ts @@ -2,23 +2,19 @@ import fs from 'node:fs'; import path from 'node:path'; import { PUBLIC_COMMANDS } from '../src/command-catalog.ts'; import { listCommandMetadata } from '../src/commands/command-metadata.ts'; +import { getFlagDefinitions } from '../src/utils/cli-flags.ts'; const EMPTY_COVERAGE_METRIC = { pct: 0 }; const EMPTY_STATEMENT_COVERAGE = { covered: 0, pct: 0, total: 0 }; -let ROOT = process.cwd(); -let COVERAGE_SUMMARY = path.join(ROOT, 'coverage/coverage-summary.json'); -let clientCommandMethods = new Map(); - export function buildIntegrationProgressModel({ root = process.cwd() } = {}) { - ROOT = root; - COVERAGE_SUMMARY = path.join(ROOT, 'coverage/coverage-summary.json'); - const handlerTestDir = path.join(ROOT, 'src/daemon/handlers/__tests__'); - const providerScenarioDir = path.join(ROOT, 'test/integration/provider-scenarios'); - const commandContractFiles = listFiles(path.join(ROOT, 'src/commands'), (file) => + const coverageSummary = path.join(root, 'coverage/coverage-summary.json'); + const handlerTestDir = path.join(root, 'src/daemon/handlers/__tests__'); + const providerScenarioDir = path.join(root, 'test/integration/provider-scenarios'); + const commandContractFiles = listFiles(path.join(root, 'src/commands'), (file) => file.endsWith(`${path.sep}index.ts`), ); - clientCommandMethods = readClientCommandMethods(commandContractFiles); + const clientCommandMethods = readClientCommandMethods(commandContractFiles); const handlerTests = listFiles(handlerTestDir, (file) => file.endsWith('.test.ts')); const providerScenarioTests = listFiles(providerScenarioDir, (file) => file.endsWith('.test.ts')); @@ -30,9 +26,9 @@ export function buildIntegrationProgressModel({ root = process.cwd() } = {}) { const mockHeavyHandlerFiles = handlerTests.filter((file) => fs.readFileSync(file, 'utf8').includes('vi.mock('), ); - const mockHeavyHandlerRows = summarizeMockHeavyHandlerFiles(mockHeavyHandlerFiles); + const mockHeavyHandlerRows = summarizeMockHeavyHandlerFiles(root, mockHeavyHandlerFiles); const providerPressureRows = summarizeProviderPressure(providerScenarioSources); - const publicCommandRows = summarizePublicCommandCoverage(providerScenarioTests); + const publicCommandRows = summarizePublicCommandCoverage(providerScenarioTests, clientCommandMethods); const missingPublicCommands = publicCommandRows.filter((command) => command.references === 0); const flagCoverageRows = summarizeProviderScenarioFlagCoverage(providerScenarioTests); const missingFlagRows = flagCoverageRows.filter((flag) => flag.references === 0); @@ -43,8 +39,8 @@ export function buildIntegrationProgressModel({ root = process.cwd() } = {}) { ...excludedFlagRows.flatMap((group) => group.keys), ]); const unclassifiedFlagKeys = [...publicCliFlagKeys].filter((key) => !classifiedFlagKeys.has(key)); - const coverage = readCoverageSummary(); - const lowCoverageFiles = readLowCoverageFiles(); + const coverage = readCoverageSummary(coverageSummary); + const lowCoverageFiles = readLowCoverageFiles(root, coverageSummary); const summaryRows = [ ['Handler unit test files', String(handlerStats.files)], @@ -276,18 +272,13 @@ function summarizeProviderScenarioFlagExclusions() { } function readPublicCliFlagKeys() { - const text = fs.readFileSync(path.join(ROOT, 'src/utils/cli-flags.ts'), 'utf8'); return new Set( - [...text.matchAll(/\{\s*key: '([^']+)'[\s\S]*?names:\s*\[([^\]]*)\]/g)] - .filter((match) => isPublicCliFlagNames(match[2] ?? '')) - .map((match) => match[1]), + getFlagDefinitions() + .filter((definition) => definition.names.some((name) => name.startsWith('-'))) + .map((definition) => definition.key), ); } -function isPublicCliFlagNames(names) { - return names.includes("'--") || names.includes("'-"); -} - function listFiles(dir, predicate) { if (!fs.existsSync(dir)) return []; const entries = fs.readdirSync(dir, { withFileTypes: true }); @@ -309,12 +300,12 @@ function summarizeFiles(files) { return { files: files.length, lines, tests }; } -function summarizeMockHeavyHandlerFiles(files) { +function summarizeMockHeavyHandlerFiles(root, files) { return files .map((file) => { const text = fs.readFileSync(file, 'utf8'); return { - file: path.relative(ROOT, file), + file: path.relative(root, file), lines: text.split('\n').length, tests: countTestDeclarations(text), }; @@ -396,11 +387,14 @@ function countPatternReferences(text, pattern) { return text.match(pattern)?.length ?? 0; } -function summarizePublicCommandCoverage(files) { +function summarizePublicCommandCoverage(files, clientCommandMethods) { const publicCommands = readPublicCommands(); const commandRefsByFile = files.map((file) => ({ file, - commands: extractProviderScenarioCommandReferences(fs.readFileSync(file, 'utf8')), + commands: extractProviderScenarioCommandReferences( + fs.readFileSync(file, 'utf8'), + clientCommandMethods, + ), })); return publicCommands.map((command) => { @@ -485,8 +479,11 @@ function readMetadataName(token, constants) { return constants.get(token); } -function extractProviderScenarioCommandReferences(text) { - return [...extractLiteralCommandReferences(text), ...extractClientCommandReferences(text)]; +function extractProviderScenarioCommandReferences(text, clientCommandMethods) { + return [ + ...extractLiteralCommandReferences(text), + ...extractClientCommandReferences(text, clientCommandMethods), + ]; } function extractLiteralCommandReferences(text) { @@ -497,7 +494,7 @@ function extractLiteralCommandReferences(text) { return commands; } -function extractClientCommandReferences(text) { +function extractClientCommandReferences(text, clientCommandMethods) { const commands = []; for (const [method, command] of clientCommandMethods) { const escapedMethod = method.replace('.', '\\.'); @@ -511,8 +508,8 @@ function countTestDeclarations(text) { return [...text.matchAll(/(?:^|[^\w.])test\(/g)].length; } -function readCoverageSummary() { - const total = readCoverageSummaryJson()?.total; +function readCoverageSummary(coverageSummary) { + const total = readCoverageSummaryJson(coverageSummary)?.total; if (!total) return null; return { statements: readCoveragePercent(total, 'statements'), @@ -522,32 +519,32 @@ function readCoverageSummary() { }; } -function readLowCoverageFiles() { - const summary = readCoverageSummaryJson(); +function readLowCoverageFiles(root, coverageSummary) { + const summary = readCoverageSummaryJson(coverageSummary); if (!summary) return []; return Object.entries(summary) .filter(([file]) => file !== 'total') - .map(([file, value]) => readLowCoverageFile(file, value)) + .map(([file, value]) => readLowCoverageFile(root, file, value)) .filter((file) => file.statementTotal >= 10 && file.statementPercent < 60) .sort((a, b) => b.missingStatements - a.missingStatements) .slice(0, 10); } -function readCoverageSummaryJson() { - if (!fs.existsSync(COVERAGE_SUMMARY)) return null; - return JSON.parse(fs.readFileSync(COVERAGE_SUMMARY, 'utf8')); +function readCoverageSummaryJson(coverageSummary) { + if (!fs.existsSync(coverageSummary)) return null; + return JSON.parse(fs.readFileSync(coverageSummary, 'utf8')); } function readCoveragePercent(total, key) { return Number((total[key] ?? EMPTY_COVERAGE_METRIC).pct); } -function readLowCoverageFile(file, value) { +function readLowCoverageFile(root, file, value) { const statements = value.statements ?? EMPTY_STATEMENT_COVERAGE; const statementTotal = Number(statements.total); const statementCovered = Number(statements.covered); return { - file: path.relative(ROOT, file), + file: path.relative(root, file), statementPercent: Number(statements.pct), statementTotal, missingStatements: statementTotal - statementCovered, diff --git a/src/commands/capture/index.ts b/src/commands/capture/index.ts index b1d97699d..8edc7db06 100644 --- a/src/commands/capture/index.ts +++ b/src/commands/capture/index.ts @@ -8,12 +8,15 @@ import type { AlertAction } from '../../alert-contract.ts'; import { ALERT_ACTIONS } from '../../alert-contract.ts'; import { parseWaitPositionals } from '../../core/wait-positionals.ts'; import { SESSION_SURFACES } from '../../core/session-surface.ts'; -import { SCREENSHOT_COMMAND_FLAG_KEYS } from '../../contracts/screenshot.ts'; +import { + SCREENSHOT_COMMAND_FLAG_KEYS, + screenshotFlagsFromOptions, + screenshotOptionsFromFlags, +} from '../../contracts/screenshot.ts'; import type { CommandSchemaOverride } from '../../utils/cli-command-schema-types.ts'; import { SELECTOR_SNAPSHOT_FLAGS, SNAPSHOT_FLAGS, type CliFlags } from '../../utils/cli-flags.ts'; import { AppError } from '../../utils/errors.ts'; import { tryParseSelectorChain } from '../../utils/selectors-parse.ts'; -import { screenshotFlagsFromOptions, screenshotOptionsFromFlags } from './screenshot-options.ts'; import { booleanField, compactRecord, diff --git a/src/commands/capture/screenshot-options.test.ts b/src/commands/capture/screenshot-options.test.ts index e18926080..077e99fef 100644 --- a/src/commands/capture/screenshot-options.test.ts +++ b/src/commands/capture/screenshot-options.test.ts @@ -8,7 +8,7 @@ import { readScreenshotScriptFlag, screenshotFlagsFromOptions, screenshotOptionsFromFlags, -} from './screenshot-options.ts'; +} from '../../contracts/screenshot.ts'; test('screenshot flag projection maps CLI flags to runtime options', () => { assert.deepEqual( diff --git a/src/commands/capture/screenshot-options.ts b/src/commands/capture/screenshot-options.ts deleted file mode 100644 index 7b9445baa..000000000 --- a/src/commands/capture/screenshot-options.ts +++ /dev/null @@ -1 +0,0 @@ -export * from '../../contracts/screenshot.ts'; diff --git a/src/commands/capture/wait-command-contract.ts b/src/commands/capture/wait-command-contract.ts index e8e62d99c..d7cbb428c 100644 --- a/src/commands/capture/wait-command-contract.ts +++ b/src/commands/capture/wait-command-contract.ts @@ -1,2 +1 @@ export const WAIT_KIND_VALUES = ['duration', 'text', 'ref', 'selector'] as const; -export type WaitKind = (typeof WAIT_KIND_VALUES)[number]; diff --git a/src/commands/command-descriptions.ts b/src/commands/command-descriptions.ts deleted file mode 100644 index b733d271a..000000000 --- a/src/commands/command-descriptions.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { listCommandMetadata, type CommandName } from './command-metadata.ts'; - -export type DescribedCommandName = CommandName; - -export function listCommandDescriptionMetadata(): Array<{ - name: DescribedCommandName; - description: string; -}> { - return listCommandMetadata().map((metadata) => ({ - name: metadata.name, - description: metadata.description, - })); -} diff --git a/src/commands/interaction/runtime/resolution.ts b/src/commands/interaction/runtime/resolution.ts index d0381cc54..74aa27017 100644 --- a/src/commands/interaction/runtime/resolution.ts +++ b/src/commands/interaction/runtime/resolution.ts @@ -21,7 +21,7 @@ import type { ResolvedInteractionTarget, } from '../../../contracts/interaction.ts'; import { now, toBackendContext } from '../../runtime-common.ts'; -import { resolveActionableTouchResolution } from './targeting.ts'; +import { resolveActionableTouchResolution } from '../../../core/interaction-targeting.ts'; export type { InteractionTarget, PointTarget, ResolvedInteractionTarget }; diff --git a/src/commands/interaction/runtime/targeting.ts b/src/commands/interaction/runtime/targeting.ts deleted file mode 100644 index f6983dbc6..000000000 --- a/src/commands/interaction/runtime/targeting.ts +++ /dev/null @@ -1 +0,0 @@ -export * from '../../../core/interaction-targeting.ts'; diff --git a/src/utils/command-schema.ts b/src/utils/command-schema.ts index 82fdff869..0cca030c5 100644 --- a/src/utils/command-schema.ts +++ b/src/utils/command-schema.ts @@ -1,5 +1,5 @@ -import { listCommandDescriptionMetadata } from '../commands/command-descriptions.ts'; import type { CliCommandName } from '../command-catalog.ts'; +import { listCommandMetadata } from '../commands/command-metadata.ts'; import type { CommandSchema, CommandSchemaOverride } from './cli-command-schema-types.ts'; import { getCliCommandOverride, getSchemaOnlyCliCommandSchema } from './cli-command-overrides.ts'; import { @@ -17,9 +17,9 @@ export type { CommandSchema, CommandSchemaOverride }; export { getFlagDefinition, getFlagDefinitions, GLOBAL_FLAG_KEYS }; const COMMAND_SCHEMA_BASES = new Map( - listCommandDescriptionMetadata().map((definition) => [ - definition.name, - { helpDescription: definition.description }, + listCommandMetadata().map((metadata) => [ + metadata.name, + { helpDescription: metadata.description }, ]), ); From af4827be5383ea459f1dc410404e8220df5707ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 12 Jun 2026 12:46:58 +0200 Subject: [PATCH 10/11] fix: update localized snapshot output import --- src/commands/capture/output.ts | 2 +- src/daemon/handlers/__tests__/snapshot-handler.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commands/capture/output.ts b/src/commands/capture/output.ts index a249b9d09..7425cc823 100644 --- a/src/commands/capture/output.ts +++ b/src/commands/capture/output.ts @@ -4,7 +4,7 @@ import { formatSnapshotText } from '../../utils/output.ts'; import type { CliOutput } from '../command-contract.ts'; import { messageOutput, type CliOutputFormatter } from '../output-common.ts'; -function snapshotCliOutput(params: { +export function snapshotCliOutput(params: { result: CaptureSnapshotResult; raw?: boolean; interactiveOnly?: boolean; diff --git a/src/daemon/handlers/__tests__/snapshot-handler.test.ts b/src/daemon/handlers/__tests__/snapshot-handler.test.ts index 06ba4833b..d2c1bc873 100644 --- a/src/daemon/handlers/__tests__/snapshot-handler.test.ts +++ b/src/daemon/handlers/__tests__/snapshot-handler.test.ts @@ -12,7 +12,7 @@ import { AppError } from '../../../utils/errors.ts'; import { buildSnapshotSignatures } from '../../android-snapshot-freshness.ts'; import { buildInteractionSurfaceSignature } from '../../interaction-outcome-policy.ts'; import { buildSnapshotPresentationKey } from '../../../utils/snapshot.ts'; -import { snapshotCliOutput } from '../../../commands/client-output.ts'; +import { snapshotCliOutput } from '../../../commands/capture/output.ts'; import type { CaptureSnapshotResult } from '../../../client-types.ts'; vi.mock('../../../core/dispatch.ts', async (importOriginal) => { From 58d3291604bb5d468cd335e6111d15967439e151 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 12 Jun 2026 13:40:19 +0200 Subject: [PATCH 11/11] fix: preserve debug command localization --- scripts/integration-progress-model.ts | 5 ++++- src/commands/observability/index.ts | 19 ++++++++++++++----- src/commands/observability/output.ts | 2 +- 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/scripts/integration-progress-model.ts b/scripts/integration-progress-model.ts index c2d5176f7..019f05b5f 100644 --- a/scripts/integration-progress-model.ts +++ b/scripts/integration-progress-model.ts @@ -247,8 +247,10 @@ function summarizeProviderScenarioFlagExclusions() { }, { name: 'parser/client-only command flags', - owner: 'args, CLI, screenshot-diff, and batch tests', + owner: 'args, CLI, debug-symbols, screenshot-diff, and batch tests', keys: [ + 'artifact', + 'dsym', 'githubActionsArtifact', 'snapshotDiff', 'snapshotForceFull', @@ -260,6 +262,7 @@ function summarizeProviderScenarioFlagExclusions() { 'recordVideo', 'shardAll', 'shardSplit', + 'searchPath', 'stepsFile', ], }, diff --git a/src/commands/observability/index.ts b/src/commands/observability/index.ts index 724f01736..0b15c278d 100644 --- a/src/commands/observability/index.ts +++ b/src/commands/observability/index.ts @@ -3,7 +3,13 @@ import { NETWORK_INCLUDE_MODES, type NetworkIncludeMode } from '../../contracts. import { AppError } from '../../utils/errors.ts'; import { parseStringMember } from '../../utils/string-enum.ts'; import type { CommandSchemaOverride } from '../../utils/cli-command-schema-types.ts'; -import { booleanField, enumField, integerField, requiredField, stringField } from '../command-input.ts'; +import { + booleanField, + enumField, + integerField, + requiredField, + stringField, +} from '../command-input.ts'; import { defineExecutableCommand } from '../command-contract.ts'; import { defineFieldCommandMetadata } from '../field-command-contract.ts'; import { LOG_ACTION_VALUES, type LogAction } from './log-command-contract.ts'; @@ -81,7 +87,7 @@ export const networkCommandMetadata = defineFieldCommandMetadata( }, ); -export const debugCommandMetadata = defineFieldCommandMetadata( +const debugCommandMetadata = defineFieldCommandMetadata( DEBUG_COMMAND_NAME, debugCommandDescription, { @@ -113,7 +119,7 @@ export const networkCommandDefinition = defineExecutableCommand( (client, input) => client.observability.network(input), ); -export const debugCommandDefinition = defineExecutableCommand(debugCommandMetadata, (client, input) => +const debugCommandDefinition = defineExecutableCommand(debugCommandMetadata, (client, input) => client.debug.symbols(input), ); @@ -196,7 +202,7 @@ export const networkCliReader: CliReader = (positionals, flags) => ({ include: flags.networkInclude ?? readNetworkInclude(positionals[2]), }); -export const debugCliReader: CliReader = (positionals, flags) => ({ +const debugCliReader: CliReader = (positionals, flags) => ({ ...commonInputFromFlags(flags), action: readDebugAction(positionals[0]), artifact: flags.artifact, @@ -369,5 +375,8 @@ function readNetworkInclude(value: string | undefined): NetworkIncludeMode | und function readDebugAction(value: string | undefined): 'symbols' { if (value === 'symbols') return value; - throw new AppError('INVALID_ARGS', 'debug requires symbols'); + throw new AppError( + 'INVALID_ARGS', + 'debug supports only symbols; use logs, network, perf, record, trace, or react-devtools for other diagnostics', + ); } diff --git a/src/commands/observability/output.ts b/src/commands/observability/output.ts index 6da5fd95a..3e880a518 100644 --- a/src/commands/observability/output.ts +++ b/src/commands/observability/output.ts @@ -57,7 +57,7 @@ function perfCliOutput(result: CommandRequestResult): CliOutput { return { data, text: formatPerfCliOutput(data) }; } -export function debugSymbolsCliOutput(result: DebugSymbolsResult): CliOutput { +function debugSymbolsCliOutput(result: DebugSymbolsResult): CliOutput { const lines = [result.outPath, result.message]; lines.push(...formatDebugCrashSummary(result)); for (const image of result.matchedImages) {