diff --git a/AGENTS.md b/AGENTS.md index f17a22112..f537530bb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,6 +34,7 @@ Minimal operating guide for AI coding agents in this repo. - Surgical edits only. - Match existing style. - Remove imports/variables YOUR changes made unused; do not clean unrelated dead code. +- Keep tests minimal: if TypeScript can enforce a contract or invalid shape, prefer a type-level check over duplicating that assertion in runtime tests. - Keep modules small for agent context safety: - target <= 300 LOC per implementation file when practical. - if a file grows past 500 LOC, plan/extract focused submodules before adding new behavior. @@ -154,6 +155,7 @@ Command-only flags (like `find --first`) that don't flow to the platform layer o ## Testing Matrix - Docs/skills only: no tests required. - Non-TS, no behavior impact: no tests unless requested. +- Keep tests behavioral; do not assert shapes or cases TypeScript already proves. - Any TS change: `pnpm typecheck` or `pnpm check:quick`. - Tooling/config change (`package.json`, `tsconfig*.json`, `.oxlintrc.json`, `.oxfmtrc.json`): `pnpm check:tooling`. - Daemon handler/shared module change: `pnpm check:unit`. diff --git a/src/__tests__/cli-client-commands.test.ts b/src/__tests__/cli-client-commands.test.ts index b699ec5cb..850d23825 100644 --- a/src/__tests__/cli-client-commands.test.ts +++ b/src/__tests__/cli-client-commands.test.ts @@ -274,6 +274,59 @@ test('screenshot reports annotated ref count in non-json mode', async () => { assert.equal(stdout, 'Annotated 2 refs onto /tmp/screenshot.png\n'); }); +test('wait keeps CLI bare text behavior through the typed client command API', async () => { + let observed: Parameters[0] | undefined; + const client = createStubClient({ + installFromSource: async () => { + throw new Error('unexpected install call'); + }, + }); + client.command.wait = async (options) => { + observed = options; + return { text: 'Continue', waitedMs: 12 }; + }; + + const handled = await tryRunClientBackedCommand({ + command: 'wait', + positionals: ['Continue', '1500'], + flags: { + json: false, + help: false, + version: false, + }, + client, + }); + + assert.equal(handled, true); + assert.equal(observed?.text, 'Continue'); + assert.equal(observed?.timeoutMs, 1500); +}); + +test('clipboard read keeps human text output through the typed client command API', async () => { + const client = createStubClient({ + installFromSource: async () => { + throw new Error('unexpected install call'); + }, + }); + client.command.clipboard = async () => ({ action: 'read', text: 'hello' }); + + const stdout = await captureStdout(async () => { + const handled = await tryRunClientBackedCommand({ + command: 'clipboard', + positionals: ['read'], + flags: { + json: false, + help: false, + version: false, + }, + client, + }); + assert.equal(handled, true); + }); + + assert.equal(stdout, 'hello\n'); +}); + test('metro prepare wraps output in the standard success envelope for --json', async () => { const client = createStubClient({ installFromSource: async () => { @@ -634,9 +687,15 @@ function createStubClient(params: { open?: AgentDeviceClient['apps']['open']; screenshot?: AgentDeviceClient['capture']['screenshot']; }): AgentDeviceClient { + const unexpectedCommandCall = async (): Promise => { + throw new Error('unexpected command call'); + }; + const command = createThrowingMethodGroup(); return { + command, devices: { list: async () => [], + boot: unexpectedCommandCall, }, sessions: { list: async () => [], @@ -681,6 +740,8 @@ function createStubClient(params: { session: 'default', identifiers: { session: 'default' }, }), + push: unexpectedCommandCall, + triggerEvent: unexpectedCommandCall, }, materializations: { release: async (options) => ({ @@ -726,6 +787,22 @@ function createStubClient(params: { path: '/tmp/screenshot.png', identifiers: { session: 'default' }, })), - }, + diff: unexpectedCommandCall, + }, + interactions: createThrowingMethodGroup(), + replay: createThrowingMethodGroup(), + batch: createThrowingMethodGroup(), + observability: createThrowingMethodGroup(), + recording: createThrowingMethodGroup(), + settings: createThrowingMethodGroup(), + }; +} + +function createThrowingMethodGroup(): T { + const unexpectedCommandCall = async (): Promise => { + throw new Error('unexpected command call'); }; + return new Proxy({} as Partial, { + get: (target, property) => target[property as keyof T] ?? unexpectedCommandCall, + }) as T; } diff --git a/src/__tests__/client-public.test.ts b/src/__tests__/client-public.test.ts new file mode 100644 index 000000000..e97ac686a --- /dev/null +++ b/src/__tests__/client-public.test.ts @@ -0,0 +1,58 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import { + createAgentDeviceClient, + type AgentDeviceClient, + type CaptureScreenshotResult, + type CaptureSnapshotResult, + type Point, + type Rect, + type ScreenshotOverlayRef, + type SnapshotNode, + type SnapshotVisibility, + type SnapshotVisibilityReason, +} from '../index.ts'; + +const rect = { x: 1, y: 2, width: 3, height: 4 } satisfies Rect; +const point = { x: 2, y: 4 } satisfies Point; +const visibilityReason = 'offscreen-nodes' satisfies SnapshotVisibilityReason; + +const node = { + index: 0, + ref: 'e1', + type: 'Button', + label: 'Continue', + rect, +} satisfies SnapshotNode; + +const visibility = { + partial: true, + visibleNodeCount: 1, + totalNodeCount: 2, + reasons: [visibilityReason], +} satisfies SnapshotVisibility; + +({ + nodes: [node], + truncated: false, + visibility, + identifiers: { session: 'default' }, +}) satisfies CaptureSnapshotResult; + +const overlay = { + ref: 'e1', + rect, + overlayRect: rect, + center: point, +} satisfies ScreenshotOverlayRef; + +({ + path: '/tmp/screenshot.png', + overlayRefs: [overlay], + identifiers: { session: 'default' }, +}) satisfies CaptureScreenshotResult; + +test('package root exports createAgentDeviceClient', () => { + const client: AgentDeviceClient = createAgentDeviceClient(); + assert.equal(typeof client.capture.snapshot, 'function'); +}); diff --git a/src/__tests__/client.test.ts b/src/__tests__/client.test.ts index 3be91da30..4b3c78fde 100644 --- a/src/__tests__/client.test.ts +++ b/src/__tests__/client.test.ts @@ -1,5 +1,8 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; import { createAgentDeviceClient, type AgentDeviceClientConfig } from '../client.ts'; import type { DaemonRequest, DaemonResponse } from '../daemon/types.ts'; import { AppError } from '../utils/errors.ts'; @@ -371,6 +374,87 @@ test('client throws AppError for daemon failures', async () => { ); }); +test('client.command.wait prepares selector options and rejects invalid selectors', async () => { + const setup = createTransport(async () => ({ + ok: true, + data: {}, + })); + const client = createAgentDeviceClient(setup.config, { transport: setup.transport }); + + await client.command.wait({ + selector: 'role=button[name="Continue"]', + timeoutMs: 1_500, + depth: 3, + raw: true, + }); + + assert.equal(setup.calls.length, 1); + assert.equal(setup.calls[0]?.command, 'wait'); + assert.deepEqual(setup.calls[0]?.positionals, ['role=button[name="Continue"]', '1500']); + assert.equal(setup.calls[0]?.flags?.snapshotDepth, 3); + assert.equal(setup.calls[0]?.flags?.snapshotRaw, true); + + await assert.rejects( + async () => await client.command.wait({ selector: 'Continue' }), + /Invalid wait selector: Continue/, + ); + assert.equal(setup.calls.length, 1); +}); + +test('remote-config defaults apply across daemon-backed client methods', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-client-remote-scope-')); + try { + const remoteConfig = path.join(tempRoot, 'remote.json'); + fs.writeFileSync( + remoteConfig, + JSON.stringify({ + session: 'remote-session', + platform: 'android', + daemonBaseUrl: 'http://127.0.0.1:9124/agent-device', + tenant: 'remote-tenant', + sessionIsolation: 'tenant', + runId: 'remote-run', + leaseId: 'remote-lease', + }), + ); + const setup = createTransport(async () => ({ + ok: true, + data: {}, + })); + const client = createAgentDeviceClient( + { + remoteConfig, + cwd: tempRoot, + }, + { transport: setup.transport }, + ); + fs.writeFileSync(remoteConfig, '{'); + + await client.devices.list(); + await client.command.home(); + const snapshot = await client.capture.snapshot(); + + assert.equal(setup.calls[0]?.session, 'remote-session'); + assert.equal(setup.calls[0]?.command, 'devices'); + assert.equal(setup.calls[0]?.flags?.platform, 'android'); + assert.equal(setup.calls[0]?.flags?.daemonBaseUrl, 'http://127.0.0.1:9124/agent-device'); + assert.equal(setup.calls[0]?.meta?.tenantId, 'remote-tenant'); + assert.equal(setup.calls[1]?.session, 'remote-session'); + assert.equal(setup.calls[1]?.command, 'home'); + assert.equal(setup.calls[1]?.flags?.platform, 'android'); + assert.equal(setup.calls[1]?.flags?.daemonBaseUrl, 'http://127.0.0.1:9124/agent-device'); + assert.equal(setup.calls[1]?.meta?.tenantId, 'remote-tenant'); + assert.equal(setup.calls[1]?.meta?.runId, 'remote-run'); + assert.equal(setup.calls[1]?.meta?.leaseId, 'remote-lease'); + assert.equal(setup.calls[2]?.session, 'remote-session'); + assert.equal(setup.calls[2]?.command, 'snapshot'); + assert.equal(setup.calls[2]?.flags?.platform, 'android'); + assert.equal(snapshot.identifiers.session, 'remote-session'); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +}); + test('client capture.snapshot preserves visibility metadata from daemon responses', async () => { const setup = createTransport(async () => ({ ok: true, diff --git a/src/__tests__/close-remote-metro.test.ts b/src/__tests__/close-remote-metro.test.ts index 232e27d5a..7cad547aa 100644 --- a/src/__tests__/close-remote-metro.test.ts +++ b/src/__tests__/close-remote-metro.test.ts @@ -18,6 +18,36 @@ afterEach(() => { vi.restoreAllMocks(); }); +const unexpectedCommandCall = async (): Promise => { + throw new Error('unexpected call'); +}; + +function createThrowingMethodGroup(methods: Partial = {}): T { + return new Proxy(methods, { + get: (target, property) => target[property as keyof T] ?? unexpectedCommandCall, + }) as T; +} + +function createTestClient(groups: Partial = {}): AgentDeviceClient { + return { + command: createThrowingMethodGroup(), + devices: createThrowingMethodGroup(), + sessions: createThrowingMethodGroup(), + simulators: createThrowingMethodGroup(), + apps: createThrowingMethodGroup(), + materializations: createThrowingMethodGroup(), + metro: createThrowingMethodGroup(), + capture: createThrowingMethodGroup(), + interactions: createThrowingMethodGroup(), + replay: createThrowingMethodGroup(), + batch: createThrowingMethodGroup(), + observability: createThrowingMethodGroup(), + recording: createThrowingMethodGroup(), + settings: createThrowingMethodGroup(), + ...groups, + }; +} + test('close with remote-config stops the managed Metro companion for that project', async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-close-remote-metro-')); const remoteConfigPath = path.join(tempRoot, 'remote.json'); @@ -37,59 +67,14 @@ test('close with remote-config stops the managed Metro companion for that projec env: process.env, }); - const client: AgentDeviceClient = { - devices: { list: async () => [] }, - sessions: { - list: async () => [], + const client = createTestClient({ + sessions: createThrowingMethodGroup({ close: async () => ({ session: 'adc-android', identifiers: { session: 'adc-android' }, }), - }, - simulators: { - ensure: async () => { - throw new Error('unexpected call'); - }, - }, - apps: { - install: async () => { - throw new Error('unexpected call'); - }, - reinstall: async () => { - throw new Error('unexpected call'); - }, - installFromSource: async () => { - throw new Error('unexpected call'); - }, - list: async () => { - throw new Error('unexpected call'); - }, - open: async () => { - throw new Error('unexpected call'); - }, - close: async () => { - throw new Error('unexpected call'); - }, - }, - materializations: { - release: async () => { - throw new Error('unexpected call'); - }, - }, - metro: { - prepare: async () => { - throw new Error('unexpected call'); - }, - }, - capture: { - snapshot: async () => { - throw new Error('unexpected call'); - }, - screenshot: async () => { - throw new Error('unexpected call'); - }, - }, - }; + }), + }); vi.mocked(stopMetroCompanion).mockResolvedValue({ stopped: true, @@ -133,58 +118,13 @@ test('close with remote-config still stops the managed Metro companion when clos env: process.env, }); - const client: AgentDeviceClient = { - devices: { list: async () => [] }, - sessions: { - list: async () => [], + const client = createTestClient({ + sessions: createThrowingMethodGroup({ close: async () => { throw new Error('session close failed'); }, - }, - simulators: { - ensure: async () => { - throw new Error('unexpected call'); - }, - }, - apps: { - install: async () => { - throw new Error('unexpected call'); - }, - reinstall: async () => { - throw new Error('unexpected call'); - }, - installFromSource: async () => { - throw new Error('unexpected call'); - }, - list: async () => { - throw new Error('unexpected call'); - }, - open: async () => { - throw new Error('unexpected call'); - }, - close: async () => { - throw new Error('unexpected call'); - }, - }, - materializations: { - release: async () => { - throw new Error('unexpected call'); - }, - }, - metro: { - prepare: async () => { - throw new Error('unexpected call'); - }, - }, - capture: { - snapshot: async () => { - throw new Error('unexpected call'); - }, - screenshot: async () => { - throw new Error('unexpected call'); - }, - }, - }; + }), + }); vi.mocked(stopMetroCompanion).mockResolvedValue({ stopped: true, @@ -234,59 +174,14 @@ test('close app with remote-config stops the managed Metro companion for that se }, ); - const client: AgentDeviceClient = { - devices: { list: async () => [] }, - sessions: { - list: async () => [], - close: async () => { - throw new Error('unexpected call'); - }, - }, - simulators: { - ensure: async () => { - throw new Error('unexpected call'); - }, - }, - apps: { - install: async () => { - throw new Error('unexpected call'); - }, - reinstall: async () => { - throw new Error('unexpected call'); - }, - installFromSource: async () => { - throw new Error('unexpected call'); - }, - list: async () => { - throw new Error('unexpected call'); - }, - open: async () => { - throw new Error('unexpected call'); - }, + const client = createTestClient({ + apps: createThrowingMethodGroup({ close: async () => ({ session: 'adc-android', identifiers: { session: 'adc-android' }, }), - }, - materializations: { - release: async () => { - throw new Error('unexpected call'); - }, - }, - metro: { - prepare: async () => { - throw new Error('unexpected call'); - }, - }, - capture: { - snapshot: async () => { - throw new Error('unexpected call'); - }, - screenshot: async () => { - throw new Error('unexpected call'); - }, - }, - }; + }), + }); vi.mocked(stopMetroCompanion).mockResolvedValue({ stopped: true, @@ -333,59 +228,14 @@ test('close with remote-config still succeeds when the config file is gone befor }); fs.rmSync(remoteConfigPath); - const client: AgentDeviceClient = { - devices: { list: async () => [] }, - sessions: { - list: async () => [], + const client = createTestClient({ + sessions: createThrowingMethodGroup({ close: async () => ({ session: 'adc-android', identifiers: { session: 'adc-android' }, }), - }, - simulators: { - ensure: async () => { - throw new Error('unexpected call'); - }, - }, - apps: { - install: async () => { - throw new Error('unexpected call'); - }, - reinstall: async () => { - throw new Error('unexpected call'); - }, - installFromSource: async () => { - throw new Error('unexpected call'); - }, - list: async () => { - throw new Error('unexpected call'); - }, - open: async () => { - throw new Error('unexpected call'); - }, - close: async () => { - throw new Error('unexpected call'); - }, - }, - materializations: { - release: async () => { - throw new Error('unexpected call'); - }, - }, - metro: { - prepare: async () => { - throw new Error('unexpected call'); - }, - }, - capture: { - snapshot: async () => { - throw new Error('unexpected call'); - }, - screenshot: async () => { - throw new Error('unexpected call'); - }, - }, - }; + }), + }); const handled = await closeCommand({ positionals: [], diff --git a/src/cli.ts b/src/cli.ts index 3f1bef906..5afab4e2d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,23 +1,14 @@ -import { toDaemonFlags, usage, usageForCommand } from './utils/args.ts'; +import { usage, usageForCommand } from './utils/args.ts'; import { asAppError, AppError, normalizeError } from './utils/errors.ts'; -import { - formatSnapshotDiffText, - formatSnapshotText, - printHumanError, - printJson, -} from './utils/output.ts'; +import { printHumanError, printJson } from './utils/output.ts'; import { readVersion } from './utils/version.ts'; -import { readCommandMessage } from './utils/success-text.ts'; import { pathToFileURL } from 'node:url'; import { sendToDaemon } from './daemon-client.ts'; -import { throwDaemonError } from './daemon-error.ts'; import fs from 'node:fs'; import type { BatchStep } from './core/dispatch.ts'; import { parseBatchStepsJson } from './core/batch.ts'; import { createAgentDeviceClient, type AgentDeviceClientConfig } from './client.ts'; -import type { ReplaySuiteResult } from './daemon/types.ts'; import { tryRunClientBackedCommand } from './cli/commands/router.ts'; -import { announceReplayTestRun, renderReplayTestResponse } from './cli-test.ts'; import { createRequestId, emitDiagnostic, @@ -129,7 +120,6 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): configuredPlatform: parsed.flags.platform, configuredSession: parsed.flags.session, }); - const daemonFlags = toDaemonFlags(flags); const daemonPaths = resolveDaemonPaths(flags.stateDir); const sessionName = flags.session ?? 'default'; maybeRunUpgradeNotifier({ @@ -151,6 +141,7 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): daemonAuthToken: flags.daemonAuthToken, daemonTransport: flags.daemonTransport, daemonServerMode: flags.daemonServerMode, + remoteConfig: flags.remoteConfig, tenant: flags.tenant, sessionIsolation: flags.sessionIsolation, runId: flags.runId, @@ -161,28 +152,6 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): debug: Boolean(flags.verbose), }; const client = createAgentDeviceClient(clientConfig, { transport: deps.sendToDaemon }); - const sendDaemonRequest = async (payload: { - command: string; - positionals: string[]; - flags?: Record; - }) => - await deps.sendToDaemon({ - session: sessionName, - command: payload.command, - positionals: payload.positionals, - flags: payload.flags as any, - meta: { - requestId, - debug: Boolean(flags.verbose), - cwd: process.cwd(), - tenantId: flags.tenant, - runId: flags.runId, - leaseId: flags.leaseId, - sessionIsolation: flags.sessionIsolation, - lockPolicy: binding.lockPolicy, - lockPlatform: binding.defaultPlatform, - }, - }); try { if (command === 'batch') { if (positionals.length > 0) { @@ -192,65 +161,34 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): ...step, flags: binding.lockPolicy && flags.platform === undefined - ? { ...((step.flags ?? {}) as Partial) } - : applyDefaultPlatformBinding((step.flags ?? {}) as Partial, { + ? { ...((step.flags ?? {}) as Partial) } + : applyDefaultPlatformBinding((step.flags ?? {}) as Partial, { policyOverrides: flags, configuredPlatform: flags.platform, configuredSession: flags.session, inheritedPlatform: flags.platform, }), })); - const batchFlags = { ...daemonFlags, batchSteps }; - delete (batchFlags as Record).steps; - delete (batchFlags as Record).stepsFile; - - const response = await sendDaemonRequest({ - command: 'batch', - positionals, - flags: batchFlags, - }); - if (!response.ok) { - throwDaemonError(response.error); + if ( + await tryRunClientBackedCommand({ + command, + positionals, + flags: { ...flags, batchSteps }, + client, + }) + ) { + return; } - if (flags.json) { - printJson({ success: true, data: response.data ?? {} }); - } else { - renderBatchSummary(response.data ?? {}); - } - return; - } - - if (command === 'runtime') { + } else if (command === 'runtime') { throw new AppError( 'INVALID_ARGS', 'runtime command was removed. Use open --remote-config --relaunch for remote Metro launches, or metro prepare --remote-config for inspection.', ); - } - - if (await tryRunClientBackedCommand({ command, positionals, flags, client })) { - return; - } - - if (command === 'test') { - announceReplayTestRun({ json: flags.json }); - } - - const response = await sendDaemonRequest({ - command: command!, - positionals, - flags: daemonFlags, - }); - - if (response.ok) { - const exitCode = writeCommandCliOutput(command, positionals, flags, response.data ?? {}); - if (exitCode !== 0) { - if (logTailStopper) logTailStopper(); - process.exit(exitCode); - } + } else if (await tryRunClientBackedCommand({ command, positionals, flags, client })) { return; } - throwDaemonError(response.error); + throw new AppError('INVALID_ARGS', `Unknown command: ${command}`); } catch (err) { const appErr = asAppError(err); const normalized = normalizeError(appErr, { @@ -295,322 +233,6 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): ); } -function renderBatchSummary(data: Record): void { - 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; - process.stdout.write( - `Batch completed: ${executed}/${total} steps${durationMs !== undefined ? ` in ${durationMs}ms` : ''}\n`, - ); - const results = Array.isArray(data.results) ? data.results : []; - for (const entry of results) { - if (!entry || typeof entry !== 'object') continue; - const result = entry as Record; - const step = typeof result.step === 'number' ? result.step : undefined; - const command = typeof result.command === 'string' ? result.command : 'step'; - const stepOk = result.ok !== false; - const stepDurationMs = typeof result.durationMs === 'number' ? result.durationMs : undefined; - const stepData = - result.data && typeof result.data === 'object' - ? (result.data as Record) - : undefined; - const stepError = - result.error && typeof result.error === 'object' - ? (result.error as Record) - : undefined; - const description = stepOk - ? (readCommandMessage(stepData) ?? command) - : (readBatchStepFailure(stepError) ?? command); - const prefix = step !== undefined ? `${step}. ` : '- '; - const durationSuffix = stepDurationMs !== undefined ? ` (${stepDurationMs}ms)` : ''; - process.stdout.write(`${prefix}${stepOk ? 'OK' : 'FAILED'} ${description}${durationSuffix}\n`); - } -} - -function readBatchStepFailure(error: Record | undefined): string | null { - return typeof error?.message === 'string' && error.message.length > 0 ? error.message : null; -} - -function writeCommandCliOutput( - command: string, - positionals: string[], - flags: { - json?: boolean; - verbose?: boolean; - snapshotRaw?: boolean; - snapshotInteractiveOnly?: boolean; - reportJunit?: string; - }, - data: Record, -): number { - if (flags.json) { - if (command === 'test') { - return renderReplayTestResponse({ - suite: data as ReplaySuiteResult, - json: true, - reportJunit: flags.reportJunit, - }); - } - printJson({ success: true, data }); - return 0; - } - - if (command === 'snapshot') { - process.stdout.write( - formatSnapshotText(data, { - raw: flags.snapshotRaw, - flatten: flags.snapshotInteractiveOnly, - }), - ); - return 0; - } - if (command === 'test') { - return renderReplayTestResponse({ - suite: data as ReplaySuiteResult, - verbose: flags.verbose, - reportJunit: flags.reportJunit, - }); - } - if (command === 'diff' && positionals[0] === 'snapshot') { - process.stdout.write(formatSnapshotDiffText(data)); - return 0; - } - if (command === 'get') { - const sub = positionals[0]; - if (sub === 'text') { - process.stdout.write(`${(data as any)?.text ?? ''}\n`); - return 0; - } - if (sub === 'attrs') { - process.stdout.write(`${JSON.stringify((data as any)?.node ?? {}, null, 2)}\n`); - return 0; - } - } - if (command === 'find') { - if (typeof (data as any)?.text === 'string') { - process.stdout.write(`${(data as any).text}\n`); - return 0; - } - if (typeof (data as any)?.found === 'boolean') { - process.stdout.write(`Found: ${(data as any).found}\n`); - return 0; - } - if ((data as any)?.node) { - process.stdout.write(`${JSON.stringify((data as any).node, null, 2)}\n`); - return 0; - } - } - if (command === 'is') { - process.stdout.write(`Passed: is ${(data as any)?.predicate ?? 'assertion'}\n`); - return 0; - } - if (command === 'boot') { - const platform = (data as any)?.platform ?? 'unknown'; - const device = (data as any)?.device ?? (data as any)?.id ?? 'unknown'; - process.stdout.write(`Boot ready: ${device} (${platform})\n`); - return 0; - } - if (command === 'ensure-simulator') { - const udid = typeof data?.udid === 'string' ? data.udid : 'unknown'; - const device = typeof data?.device === 'string' ? data.device : 'unknown'; - const runtime = typeof data?.runtime === 'string' ? data.runtime : ''; - const action = data?.created === true ? 'Created' : 'Reused'; - const bootedSuffix = data?.booted === true ? ' (booted)' : ''; - process.stdout.write(`${action}: ${device} ${udid}${bootedSuffix}\n`); - if (runtime) process.stdout.write(`Runtime: ${runtime}\n`); - return 0; - } - if (command === 'screenshot') { - const pathOut = typeof (data as any)?.path === 'string' ? (data as any).path : ''; - if (pathOut) process.stdout.write(`${pathOut}\n`); - return 0; - } - if (command === 'record') { - const outPath = typeof data?.outPath === 'string' ? data.outPath : ''; - if (outPath) process.stdout.write(`${outPath}\n`); - return 0; - } - if (command === 'logs') { - writeLogsCliOutput(data, flags); - return 0; - } - if (command === 'clipboard') { - const action = ( - positionals[0] ?? (typeof data?.action === 'string' ? data.action : '') - ).toLowerCase(); - if (action === 'read') { - process.stdout.write(`${typeof data?.text === 'string' ? data.text : ''}\n`); - return 0; - } - if (action === 'write') { - process.stdout.write('Clipboard updated\n'); - return 0; - } - } - if (command === 'network') { - writeNetworkCliOutput(data); - return 0; - } - if (command === 'click' || command === 'press') { - const ref = (data as any)?.ref ?? ''; - const x = (data as any)?.x; - const y = (data as any)?.y; - if (ref && typeof x === 'number' && typeof y === 'number') { - process.stdout.write(`Tapped @${ref} (${x}, ${y})\n`); - return 0; - } - } - if (command === 'devices') { - const devices = Array.isArray((data as any).devices) ? (data as any).devices : []; - process.stdout.write( - `${devices - .map((d: any) => { - const name = d?.name ?? d?.id ?? 'unknown'; - const platform = d?.platform ?? 'unknown'; - const kind = d?.kind ? ` ${d.kind}` : ''; - const target = d?.target ? ` target=${d.target}` : ''; - const booted = typeof d?.booted === 'boolean' ? ` booted=${d.booted}` : ''; - return `${name} (${platform}${kind}${target})${booted}`; - }) - .join('\n')}\n`, - ); - return 0; - } - if (command === 'apps') { - const apps = Array.isArray((data as any).apps) ? (data as any).apps : []; - process.stdout.write( - `${apps - .map((app: any) => { - if (typeof app === 'string') return app; - if (app && typeof app === 'object') { - const bundleId = app.bundleId ?? app.package; - const name = app.name ?? app.label; - if (name && bundleId) return `${name} (${bundleId})`; - if (bundleId) return String(bundleId); - return JSON.stringify(app); - } - return String(app); - }) - .join('\n')}\n`, - ); - return 0; - } - if (command === 'appstate') { - const platform = (data as any)?.platform; - if (platform === 'ios') { - process.stdout.write( - `Foreground app: ${(data as any)?.appName ?? (data as any)?.appBundleId ?? 'unknown'}\n`, - ); - if ((data as any)?.appBundleId) - process.stdout.write(`Bundle: ${(data as any).appBundleId}\n`); - if ((data as any)?.source) process.stdout.write(`Source: ${(data as any).source}\n`); - return 0; - } - if (platform === 'android') { - process.stdout.write(`Foreground app: ${(data as any)?.package ?? 'unknown'}\n`); - if ((data as any)?.activity) process.stdout.write(`Activity: ${(data as any).activity}\n`); - return 0; - } - } - if (command === 'perf') { - process.stdout.write(`${JSON.stringify(data, null, 2)}\n`); - return 0; - } - const successText = readCommandMessage(data); - if (successText) { - process.stdout.write(`${successText}\n`); - for (const extraLine of readCommandSuccessLines(command, data)) { - process.stdout.write(`${extraLine}\n`); - } - } - return 0; -} - -function writeLogsCliOutput(data: Record, flags: { json?: boolean }): void { - const pathOut = typeof data?.path === 'string' ? data.path : ''; - if (!pathOut) return; - process.stdout.write(`${pathOut}\n`); - const metaFields = ['active', 'state', 'backend', 'sizeBytes'] as const; - const meta = metaFields - .map((key) => (data[key] !== undefined && data[key] !== null ? `${key}=${data[key]}` : '')) - .filter(Boolean) - .join(' '); - if (meta && !flags.json) process.stderr.write(`${meta}\n`); - const actionFields = [ - 'started', - 'stopped', - 'marked', - 'cleared', - 'restarted', - 'removedRotatedFiles', - ] as const; - const actionMeta = actionFields - .map((key) => { - const v = data[key]; - return v === true ? `${key}=true` : typeof v === 'number' ? `${key}=${v}` : ''; - }) - .filter(Boolean) - .join(' '); - if (actionMeta && !flags.json) process.stderr.write(`${actionMeta}\n`); - if (data?.hint && !flags.json) process.stderr.write(`${data.hint}\n`); - if (Array.isArray(data?.notes) && !flags.json) { - for (const note of data.notes) { - if (typeof note === 'string' && note.length > 0) process.stderr.write(`${note}\n`); - } - } -} - -function writeNetworkCliOutput(data: Record): void { - const pathOut = typeof data?.path === 'string' ? data.path : ''; - if (pathOut) process.stdout.write(`${pathOut}\n`); - const entries = Array.isArray(data?.entries) ? data.entries : []; - if (entries.length === 0) { - process.stdout.write('No recent HTTP(s) entries found.\n'); - } else { - for (const entry of entries as Array>) { - const method = typeof entry.method === 'string' ? entry.method : 'HTTP'; - const url = typeof entry.url === 'string' ? entry.url : ''; - const status = typeof entry.status === 'number' ? ` status=${entry.status}` : ''; - const timestamp = typeof entry.timestamp === 'string' ? `${entry.timestamp} ` : ''; - const durationMs = - typeof entry.durationMs === 'number' ? ` durationMs=${entry.durationMs}` : ''; - process.stdout.write(`${timestamp}${method} ${url}${status}${durationMs}\n`); - if (typeof entry.headers === 'string') process.stdout.write(` headers: ${entry.headers}\n`); - if (typeof entry.requestBody === 'string') - process.stdout.write(` request: ${entry.requestBody}\n`); - if (typeof entry.responseBody === 'string') - process.stdout.write(` response: ${entry.responseBody}\n`); - } - } - const networkMetaFields = [ - 'active', - 'state', - 'backend', - 'include', - 'scannedLines', - 'matchedLines', - ] as const; - const meta = networkMetaFields - .map((key) => (data[key] !== undefined && data[key] !== null ? `${key}=${data[key]}` : '')) - .filter(Boolean) - .join(' '); - if (meta) process.stderr.write(`${meta}\n`); - if (Array.isArray(data?.notes)) { - for (const note of data.notes) { - if (typeof note === 'string' && note.length > 0) process.stderr.write(`${note}\n`); - } - } -} - -function readCommandSuccessLines(command: string, data: Record): string[] { - if (command !== 'scrollintoview') { - return []; - } - const ref = typeof data.ref === 'string' ? data.ref : ''; - const currentRef = typeof data.currentRef === 'string' ? data.currentRef : ''; - return currentRef && currentRef !== ref ? [`Current ref: @${currentRef}`] : []; -} - function readBatchSteps(flags: ReturnType['flags']): BatchStep[] { let raw = ''; if (flags.steps) { diff --git a/src/cli/commands/apps.ts b/src/cli/commands/apps.ts new file mode 100644 index 000000000..220479304 --- /dev/null +++ b/src/cli/commands/apps.ts @@ -0,0 +1,12 @@ +import { buildSelectionOptions, writeCommandOutput } from './shared.ts'; +import type { ClientCommandHandler } from './router.ts'; + +export const appsCommand: ClientCommandHandler = async ({ flags, client }) => { + const apps = await client.apps.list({ + ...buildSelectionOptions(flags), + appsFilter: flags.appsFilter, + }); + const data = { apps }; + writeCommandOutput(flags, data, () => apps.join('\n')); + return true; +}; diff --git a/src/cli/commands/client-command.ts b/src/cli/commands/client-command.ts new file mode 100644 index 000000000..c0f27e5ba --- /dev/null +++ b/src/cli/commands/client-command.ts @@ -0,0 +1,215 @@ +import type { + AlertCommandOptions, + AppStateCommandResult, + ClipboardCommandOptions, + ClipboardCommandResult, + KeyboardCommandOptions, + RotateCommandOptions, + WaitCommandOptions, +} from '../../client.ts'; +import { CLIENT_COMMANDS } from '../../client-command-registry.ts'; +import type { CliFlags } from '../../utils/command-schema.ts'; +import { AppError } from '../../utils/errors.ts'; +import { parseWaitArgs } from '../../daemon/handlers/snapshot.ts'; +import { parseDeviceRotation } from '../../core/device-rotation.ts'; +import { buildSelectionOptions, writeCommandMessage, writeCommandOutput } from './shared.ts'; +import type { ClientCommandHandlerMap } from './router.ts'; + +export const clientCommandMethodHandlers = { + [CLIENT_COMMANDS.wait]: async ({ positionals, flags, client }) => { + writeCommandMessage(flags, await client.command.wait(readWaitOptions(positionals, flags))); + return true; + }, + [CLIENT_COMMANDS.alert]: async ({ positionals, flags, client }) => { + writeCommandMessage(flags, await client.command.alert(readAlertOptions(positionals, flags))); + return true; + }, + [CLIENT_COMMANDS.appState]: async ({ flags, client }) => { + const result = await client.command.appState(buildSelectionOptions(flags)); + writeCommandOutput(flags, result, () => formatAppState(result)); + return true; + }, + [CLIENT_COMMANDS.back]: async ({ flags, client }) => { + writeCommandMessage( + flags, + await client.command.back({ ...buildSelectionOptions(flags), mode: flags.backMode }), + ); + return true; + }, + [CLIENT_COMMANDS.home]: async ({ flags, client }) => { + writeCommandMessage(flags, await client.command.home(buildSelectionOptions(flags))); + return true; + }, + [CLIENT_COMMANDS.rotate]: async ({ positionals, flags, client }) => { + writeCommandMessage(flags, await client.command.rotate(readRotateOptions(positionals, flags))); + return true; + }, + [CLIENT_COMMANDS.appSwitcher]: async ({ flags, client }) => { + writeCommandMessage(flags, await client.command.appSwitcher(buildSelectionOptions(flags))); + return true; + }, + [CLIENT_COMMANDS.keyboard]: async ({ positionals, flags, client }) => { + writeCommandMessage( + flags, + await client.command.keyboard(readKeyboardOptions(positionals, flags)), + ); + return true; + }, + [CLIENT_COMMANDS.clipboard]: async ({ positionals, flags, client }) => { + writeClipboardOutput( + flags, + await client.command.clipboard(readClipboardOptions(positionals, flags)), + ); + return true; + }, +} satisfies ClientCommandHandlerMap; + +function readWaitOptions(positionals: string[], flags: CliFlags): WaitCommandOptions { + const parsed = parseWaitArgs(positionals); + if (!parsed) { + throw new AppError( + 'INVALID_ARGS', + 'wait requires , text , @ref, or [timeoutMs].', + ); + } + + const base = { + ...buildSelectionOptions(flags), + depth: flags.snapshotDepth, + scope: flags.snapshotScope, + raw: flags.snapshotRaw, + }; + + 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), + }; +} + +function readTimeoutOption(timeoutMs: number | null): { timeoutMs?: number } { + return timeoutMs === null ? {} : { timeoutMs }; +} + +function readAlertOptions(positionals: string[], flags: CliFlags): AlertCommandOptions { + 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 { + ...buildSelectionOptions(flags), + ...(action ? { action } : {}), + ...(timeoutMs !== undefined ? { timeoutMs } : {}), + }; +} + +function readRotateOptions(positionals: string[], flags: CliFlags): RotateCommandOptions { + if (positionals.length > 1) { + throw new AppError('INVALID_ARGS', 'rotate accepts exactly one orientation argument.'); + } + return { + ...buildSelectionOptions(flags), + orientation: parseDeviceRotation(positionals[0]), + }; +} + +function readKeyboardOptions(positionals: string[], flags: CliFlags): KeyboardCommandOptions { + if (positionals.length > 1) { + throw new AppError('INVALID_ARGS', 'keyboard accepts at most one action argument.'); + } + const action = readKeyboardAction(positionals[0]); + return { + ...buildSelectionOptions(flags), + ...(action ? { action } : {}), + }; +} + +function readClipboardOptions(positionals: string[], flags: CliFlags): ClipboardCommandOptions { + const action = positionals[0]?.toLowerCase(); + if (action !== 'read' && action !== 'write') { + throw new AppError('INVALID_ARGS', 'clipboard requires a subcommand: read or write.'); + } + const base = buildSelectionOptions(flags); + if (action === 'read') { + if (positionals.length !== 1) { + throw new AppError('INVALID_ARGS', 'clipboard read does not accept additional arguments.'); + } + return { ...base, action }; + } + if (positionals.length < 2) { + throw new AppError('INVALID_ARGS', 'clipboard write requires text.'); + } + return { + ...base, + action, + text: positionals.slice(1).join(' '), + }; +} + +function readAlertAction(value: string | undefined): AlertCommandOptions['action'] | 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 readKeyboardAction( + value: string | undefined, +): KeyboardCommandOptions['action'] | undefined { + const action = value?.toLowerCase(); + if (action === 'get') return 'status'; + if (action === undefined || action === 'status' || action === 'dismiss') { + return action; + } + throw new AppError('INVALID_ARGS', 'keyboard action must be status, get, or dismiss.'); +} + +function readFiniteNumber(value: string | undefined, label: string): number | undefined { + if (value === undefined) return undefined; + const parsed = Number(value); + if (Number.isFinite(parsed)) return parsed; + throw new AppError('INVALID_ARGS', `${label} must be a finite number.`); +} + +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 writeClipboardOutput(flags: CliFlags, result: ClipboardCommandResult): void { + if (flags.json) { + writeCommandOutput(flags, result); + return; + } + if (result.action === 'read') { + process.stdout.write(`${result.text}\n`); + return; + } + writeCommandMessage(flags, result); +} diff --git a/src/cli/commands/generic.ts b/src/cli/commands/generic.ts new file mode 100644 index 000000000..a475e6113 --- /dev/null +++ b/src/cli/commands/generic.ts @@ -0,0 +1,548 @@ +import type { AgentDeviceClient, CommandRequestResult } from '../../client.ts'; +import { CLIENT_COMMANDS } from '../../client-command-registry.ts'; +import type { + FindLocator, + FindOptions, + IsOptions, + PermissionTarget, + SettingsUpdateOptions, +} from '../../client-types.ts'; +import { announceReplayTestRun } from '../../cli-test.ts'; +import { splitSelectorFromArgs } from '../../daemon/selectors.ts'; +import { AppError } from '../../utils/errors.ts'; +import type { CliFlags } from '../../utils/command-schema.ts'; +import { buildSelectionOptions } from './shared.ts'; +import { writeCommandCliOutput } from './output.ts'; +import type { ClientCommandHandler, ClientCommandHandlerMap } from './router.ts'; + +type GenericClientCommandRunner = (params: { + client: AgentDeviceClient; + positionals: string[]; + flags: CliFlags; +}) => Promise; + +export const genericClientCommandHandlers = { + [CLIENT_COMMANDS.boot]: createGenericClientCommandHandler( + CLIENT_COMMANDS.boot, + ({ client, flags }) => + client.devices.boot({ ...buildSelectionOptions(flags), headless: flags.headless }), + ), + [CLIENT_COMMANDS.push]: createGenericClientCommandHandler( + CLIENT_COMMANDS.push, + ({ client, positionals, flags }) => + client.apps.push({ + ...buildSelectionOptions(flags), + app: required(positionals[0], 'push requires bundleOrPackage'), + payload: required(positionals[1], 'push requires payloadOrJson'), + }), + ), + [CLIENT_COMMANDS.perf]: createGenericClientCommandHandler( + CLIENT_COMMANDS.perf, + ({ client, flags }) => client.observability.perf(buildSelectionOptions(flags)), + ), + [CLIENT_COMMANDS.click]: createGenericClientCommandHandler( + CLIENT_COMMANDS.click, + ({ client, positionals, flags }) => + client.interactions.click({ + ...readInteractionTarget(positionals), + ...readSelectorSnapshotOptions(flags), + ...buildSelectionOptions(flags), + count: flags.count, + intervalMs: flags.intervalMs, + holdMs: flags.holdMs, + jitterPx: flags.jitterPx, + doubleTap: flags.doubleTap, + button: flags.clickButton, + }), + ), + [CLIENT_COMMANDS.get]: createGenericClientCommandHandler( + CLIENT_COMMANDS.get, + ({ client, positionals, flags }) => + client.interactions.get({ + ...readElementTarget(positionals.slice(1)), + ...readSelectorSnapshotOptions(flags), + ...buildSelectionOptions(flags), + format: readGetFormat(positionals[0]), + }), + ), + [CLIENT_COMMANDS.replay]: createGenericClientCommandHandler( + CLIENT_COMMANDS.replay, + ({ client, positionals, flags }) => + client.replay.run({ + ...buildSelectionOptions(flags), + path: required(positionals[0], 'replay requires path'), + update: flags.replayUpdate, + }), + ), + [CLIENT_COMMANDS.test]: createGenericClientCommandHandler( + CLIENT_COMMANDS.test, + ({ client, positionals, flags }) => { + announceReplayTestRun({ json: flags.json }); + return client.replay.test({ + ...buildSelectionOptions(flags), + paths: positionals, + update: flags.replayUpdate, + failFast: flags.failFast, + timeoutMs: flags.timeoutMs, + retries: flags.retries, + artifactsDir: flags.artifactsDir, + reportJunit: flags.reportJunit, + }); + }, + ), + [CLIENT_COMMANDS.batch]: createGenericClientCommandHandler( + CLIENT_COMMANDS.batch, + ({ client, flags }) => + client.batch.run({ + ...buildSelectionOptions(flags), + steps: flags.batchSteps ?? [], + onError: flags.batchOnError, + maxSteps: flags.batchMaxSteps, + out: flags.out, + }), + ), + [CLIENT_COMMANDS.press]: createGenericClientCommandHandler( + CLIENT_COMMANDS.press, + ({ client, positionals, flags }) => + client.interactions.press({ + ...readInteractionTarget(positionals), + ...readSelectorSnapshotOptions(flags), + ...buildSelectionOptions(flags), + count: flags.count, + intervalMs: flags.intervalMs, + holdMs: flags.holdMs, + jitterPx: flags.jitterPx, + doubleTap: flags.doubleTap, + }), + ), + [CLIENT_COMMANDS.longPress]: createGenericClientCommandHandler( + CLIENT_COMMANDS.longPress, + ({ client, positionals, flags }) => + client.interactions.longPress({ + ...buildSelectionOptions(flags), + x: Number(positionals[0]), + y: Number(positionals[1]), + durationMs: optionalNumber(positionals[2]), + }), + ), + [CLIENT_COMMANDS.swipe]: createGenericClientCommandHandler( + CLIENT_COMMANDS.swipe, + ({ client, positionals, flags }) => + client.interactions.swipe({ + ...buildSelectionOptions(flags), + from: { x: Number(positionals[0]), y: Number(positionals[1]) }, + to: { x: Number(positionals[2]), y: Number(positionals[3]) }, + durationMs: optionalNumber(positionals[4]), + count: flags.count, + pauseMs: flags.pauseMs, + pattern: flags.pattern, + }), + ), + [CLIENT_COMMANDS.focus]: createGenericClientCommandHandler( + CLIENT_COMMANDS.focus, + ({ client, positionals, flags }) => + client.interactions.focus({ + ...buildSelectionOptions(flags), + x: Number(positionals[0]), + y: Number(positionals[1]), + }), + ), + [CLIENT_COMMANDS.type]: createGenericClientCommandHandler( + CLIENT_COMMANDS.type, + ({ client, positionals, flags }) => + client.interactions.type({ + ...buildSelectionOptions(flags), + text: positionals.join(' '), + delayMs: flags.delayMs, + }), + ), + [CLIENT_COMMANDS.fill]: createGenericClientCommandHandler( + CLIENT_COMMANDS.fill, + ({ client, positionals, flags }) => + client.interactions.fill({ + ...readFillTarget(positionals), + ...readSelectorSnapshotOptions(flags), + ...buildSelectionOptions(flags), + delayMs: flags.delayMs, + }), + ), + [CLIENT_COMMANDS.scroll]: createGenericClientCommandHandler( + CLIENT_COMMANDS.scroll, + ({ client, positionals, flags }) => + client.interactions.scroll({ + ...buildSelectionOptions(flags), + direction: readScrollDirection(positionals[0]), + amount: optionalNumber(positionals[1]), + pixels: flags.pixels, + }), + ), + [CLIENT_COMMANDS.scrollIntoView]: createGenericClientCommandHandler( + CLIENT_COMMANDS.scrollIntoView, + ({ client, positionals, flags }) => + client.interactions.scrollIntoView({ + ...buildSelectionOptions(flags), + ...(positionals[0]?.startsWith('@') + ? { ref: positionals[0], label: positionals.slice(1).join(' ') || undefined } + : { text: positionals.join(' ') }), + maxScrolls: flags.maxScrolls, + }), + ), + [CLIENT_COMMANDS.pinch]: createGenericClientCommandHandler( + CLIENT_COMMANDS.pinch, + ({ client, positionals, flags }) => + client.interactions.pinch({ + ...buildSelectionOptions(flags), + scale: Number(positionals[0]), + x: optionalNumber(positionals[1]), + y: optionalNumber(positionals[2]), + }), + ), + [CLIENT_COMMANDS.triggerAppEvent]: createGenericClientCommandHandler( + CLIENT_COMMANDS.triggerAppEvent, + ({ client, positionals, flags }) => + client.apps.triggerEvent({ + ...buildSelectionOptions(flags), + event: required(positionals[0], 'trigger-app-event requires event'), + payload: positionals[1] + ? readJsonObject(positionals[1], 'trigger-app-event payload') + : undefined, + }), + ), + [CLIENT_COMMANDS.record]: createGenericClientCommandHandler( + CLIENT_COMMANDS.record, + ({ client, positionals, flags }) => + client.recording.record({ + ...buildSelectionOptions(flags), + action: readStartStop(positionals[0], 'record'), + path: positionals[1], + fps: flags.fps, + hideTouches: flags.hideTouches, + }), + ), + [CLIENT_COMMANDS.trace]: createGenericClientCommandHandler( + CLIENT_COMMANDS.trace, + ({ client, positionals, flags }) => + client.recording.trace({ + ...buildSelectionOptions(flags), + action: readStartStop(positionals[0], 'trace'), + path: positionals[1], + }), + ), + [CLIENT_COMMANDS.logs]: createGenericClientCommandHandler( + CLIENT_COMMANDS.logs, + ({ client, positionals, flags }) => + client.observability.logs({ + ...buildSelectionOptions(flags), + action: readLogsAction(positionals[0]), + message: positionals.slice(1).join(' ') || undefined, + restart: flags.restart, + }), + ), + [CLIENT_COMMANDS.network]: createGenericClientCommandHandler( + CLIENT_COMMANDS.network, + ({ client, positionals, flags }) => + client.observability.network({ + ...buildSelectionOptions(flags), + action: readNetworkAction(positionals[0]), + limit: optionalNumber(positionals[1]), + include: flags.networkInclude ?? readNetworkInclude(positionals[2]), + }), + ), + [CLIENT_COMMANDS.find]: createGenericClientCommandHandler( + CLIENT_COMMANDS.find, + ({ client, positionals, flags }) => + client.interactions.find({ + ...readFindOptions(positionals), + ...readFindSnapshotOptions(flags), + ...buildSelectionOptions(flags), + first: flags.findFirst, + last: flags.findLast, + }), + ), + [CLIENT_COMMANDS.is]: createGenericClientCommandHandler( + CLIENT_COMMANDS.is, + ({ client, positionals, flags }) => + client.interactions.is({ + ...readIsOptions(positionals), + ...readSelectorSnapshotOptions(flags), + ...buildSelectionOptions(flags), + }), + ), + [CLIENT_COMMANDS.settings]: createGenericClientCommandHandler( + CLIENT_COMMANDS.settings, + ({ client, positionals, flags }) => + client.settings.update(readSettingsOptions(positionals, flags)), + ), +} satisfies ClientCommandHandlerMap; + +function createGenericClientCommandHandler( + command: string, + run: GenericClientCommandRunner, +): ClientCommandHandler { + return async ({ positionals, flags, client }) => { + const data = await run({ client, positionals, flags }); + const exitCode = writeCommandCliOutput(command, positionals, flags, data); + if (exitCode !== 0) { + process.exit(exitCode); + } + return true; + }; +} + +function readSelectorSnapshotOptions(flags: CliFlags) { + return { + depth: flags.snapshotDepth, + scope: flags.snapshotScope, + raw: flags.snapshotRaw, + }; +} + +function readFindSnapshotOptions(flags: CliFlags) { + return { + depth: flags.snapshotDepth, + raw: flags.snapshotRaw, + }; +} + +function readInteractionTarget(positionals: string[]) { + if (positionals[0]?.startsWith('@')) { + return { ref: positionals[0], label: positionals.slice(1).join(' ') || undefined }; + } + const selectorArgs = splitSelectorFromArgs(positionals); + if (selectorArgs) return { selector: selectorArgs.selectorExpression }; + return { x: Number(positionals[0]), y: Number(positionals[1]) }; +} + +function readElementTarget(positionals: string[]) { + if (positionals[0]?.startsWith('@')) { + return { ref: positionals[0], label: positionals.slice(1).join(' ') || undefined }; + } + const selector = positionals.join(' ').trim(); + if (!selector) throw new AppError('INVALID_ARGS', 'get requires @ref or selector expression'); + return { selector }; +} + +function readFillTarget(positionals: string[]) { + if (positionals[0]?.startsWith('@')) { + const text = + positionals.length >= 3 ? positionals.slice(2).join(' ') : positionals.slice(1).join(' '); + return { + ref: positionals[0], + label: positionals.length >= 3 ? positionals[1] : undefined, + text, + }; + } + const selectorArgs = splitSelectorFromArgs(positionals, { preferTrailingValue: true }); + if (selectorArgs) + return { selector: selectorArgs.selectorExpression, text: selectorArgs.rest.join(' ') }; + return { + x: Number(positionals[0]), + y: Number(positionals[1]), + text: positionals.slice(2).join(' '), + }; +} + +function readGetFormat(value: string | undefined): 'text' | 'attrs' { + if (value === 'text' || value === 'attrs') return value; + throw new AppError('INVALID_ARGS', 'get only supports text or attrs'); +} + +function readScrollDirection(value: string | undefined): 'up' | 'down' | 'left' | 'right' { + if (value === 'up' || value === 'down' || value === 'left' || value === 'right') return value; + throw new AppError('INVALID_ARGS', `Unknown direction: ${String(value)}`); +} + +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 readLogsAction( + value: string | undefined, +): 'path' | 'start' | 'stop' | 'doctor' | 'mark' | 'clear' | undefined { + if (value === undefined) return undefined; + if ( + value === 'path' || + value === 'start' || + value === 'stop' || + value === 'doctor' || + value === 'mark' || + value === 'clear' + ) { + return value; + } + throw new AppError('INVALID_ARGS', '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, +): 'summary' | 'headers' | 'body' | 'all' | undefined { + if (value === undefined) return undefined; + if (value === 'summary' || value === 'headers' || value === 'body' || value === 'all') + return value; + throw new AppError('INVALID_ARGS', 'network include mode must be summary, headers, body, or all'); +} + +function readFindOptions(positionals: string[]): FindOptions { + const locator = readFindLocator(positionals[0]); + const hasExplicitLocator = locator !== undefined; + const query = hasExplicitLocator ? positionals[1] : positionals[0]; + const actionOffset = hasExplicitLocator ? 2 : 1; + const action = positionals[actionOffset]; + if (action === undefined) return { locator, query: required(query, 'find requires query') }; + if (action === 'get') { + const subcommand = positionals[actionOffset + 1]; + if (subcommand === 'text') { + return { locator, query: required(query, 'find requires query'), action: 'getText' }; + } + if (subcommand === 'attrs') { + return { locator, query: required(query, 'find requires query'), action: 'getAttrs' }; + } + throw new AppError('INVALID_ARGS', 'find get only supports text or attrs'); + } + if (action === 'wait') { + return { + locator, + query: required(query, 'find requires query'), + action: 'wait', + timeoutMs: optionalNumber(positionals[actionOffset + 1]), + }; + } + if (action === 'fill' || action === 'type') { + return { + locator, + query: required(query, 'find requires query'), + action, + value: positionals.slice(actionOffset + 1).join(' '), + }; + } + if (action === 'click' || action === 'focus' || action === 'exists') { + return { locator, query: required(query, 'find requires query'), action }; + } + throw new AppError('INVALID_ARGS', `Unsupported find action: ${action}`); +} + +function readFindLocator(value: string | undefined): FindLocator | undefined { + if ( + value === 'text' || + value === 'label' || + value === 'value' || + value === 'role' || + value === 'id' + ) { + return value; + } + return undefined; +} + +function readIsOptions(positionals: string[]): IsOptions { + const predicate = positionals[0]; + const split = splitSelectorFromArgs(positionals.slice(1), { + preferTrailingValue: predicate === 'text', + }); + if (!split) throw new AppError('INVALID_ARGS', 'is requires a selector expression'); + if (predicate === 'text') { + return { predicate, selector: split.selectorExpression, value: split.rest.join(' ') }; + } + if ( + predicate === 'visible' || + predicate === 'hidden' || + predicate === 'exists' || + predicate === 'editable' || + predicate === 'selected' + ) { + return { predicate, selector: split.selectorExpression }; + } + throw new AppError( + 'INVALID_ARGS', + 'is requires predicate: visible|hidden|exists|editable|selected|text', + ); +} + +function readSettingsOptions(positionals: string[], flags: CliFlags): SettingsUpdateOptions { + const base = buildSelectionOptions(flags); + const setting = positionals[0]; + const state = positionals[1]; + if ( + (setting === 'wifi' || setting === 'airplane' || setting === 'location') && + (state === 'on' || state === 'off') + ) { + return { ...base, setting, state }; + } + if (setting === 'appearance' && (state === 'light' || state === 'dark' || state === 'toggle')) { + return { ...base, setting, state }; + } + if ( + (setting === 'faceid' || setting === 'touchid') && + (state === 'match' || state === 'nonmatch' || state === 'enroll' || state === 'unenroll') + ) { + return { ...base, setting, state }; + } + if (setting === 'fingerprint' && (state === 'match' || state === 'nonmatch')) { + return { ...base, setting, state }; + } + if (setting === 'permission' && (state === 'grant' || state === 'deny' || state === 'reset')) { + return { + ...base, + setting, + state, + permission: readPermission(positionals[2]), + mode: readPermissionMode(positionals[3]), + }; + } + throw new AppError('INVALID_ARGS', 'Invalid settings arguments.'); +} + +function readPermission(value: string | undefined): PermissionTarget { + switch (value) { + case 'camera': + case 'microphone': + case 'photos': + case 'contacts': + case 'contacts-limited': + case 'notifications': + case 'calendar': + case 'location': + case 'location-always': + case 'media-library': + case 'motion': + case 'reminders': + case 'siri': + case 'accessibility': + case 'screen-recording': + case 'input-monitoring': + return value; + default: + 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.'); +} + +function readJsonObject(value: string, label: string): Record { + try { + const parsed = JSON.parse(value) as unknown; + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed as Record; + } + } catch {} + throw new AppError('INVALID_ARGS', `${label} must be a JSON object`); +} + +function required(value: string | undefined, message: string): string { + if (value === undefined || value === '') throw new AppError('INVALID_ARGS', message); + return value; +} + +function optionalNumber(value: string | undefined): number | undefined { + return value === undefined ? undefined : Number(value); +} diff --git a/src/cli/commands/open.ts b/src/cli/commands/open.ts index dbbae51fc..516730321 100644 --- a/src/cli/commands/open.ts +++ b/src/cli/commands/open.ts @@ -7,9 +7,6 @@ import type { StopMetroTunnelOptions } from '../../metro.ts'; import type { ClientCommandHandler } from './router.ts'; export const openCommand: ClientCommandHandler = async ({ positionals, flags, client }) => { - if (!positionals[0]) { - return false; - } const runtime = await resolveRemoteOpenRuntime(flags, client); const result = await client.apps.open({ app: positionals[0], diff --git a/src/cli/commands/output.ts b/src/cli/commands/output.ts new file mode 100644 index 000000000..29e45390a --- /dev/null +++ b/src/cli/commands/output.ts @@ -0,0 +1,227 @@ +import type { CliFlags } from '../../utils/command-schema.ts'; +import { CLIENT_COMMANDS } from '../../client-command-registry.ts'; +import { readCommandMessage } from '../../utils/success-text.ts'; +import { printJson } from '../../utils/output.ts'; +import { renderReplayTestResponse } from '../../cli-test.ts'; +import type { ReplaySuiteResult } from '../../daemon/types.ts'; + +export function renderBatchSummary(data: Record): void { + 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; + process.stdout.write( + `Batch completed: ${executed}/${total} steps${durationMs !== undefined ? ` in ${durationMs}ms` : ''}\n`, + ); + const results = Array.isArray(data.results) ? data.results : []; + for (const entry of results) { + if (!entry || typeof entry !== 'object') continue; + const result = entry as Record; + const step = typeof result.step === 'number' ? result.step : undefined; + const command = typeof result.command === 'string' ? result.command : 'step'; + const stepOk = result.ok !== false; + const stepDurationMs = typeof result.durationMs === 'number' ? result.durationMs : undefined; + const stepData = + result.data && typeof result.data === 'object' + ? (result.data as Record) + : undefined; + const stepError = + result.error && typeof result.error === 'object' + ? (result.error as Record) + : undefined; + const description = stepOk + ? (readCommandMessage(stepData) ?? command) + : (readBatchStepFailure(stepError) ?? command); + const prefix = step !== undefined ? `${step}. ` : '- '; + const durationSuffix = stepDurationMs !== undefined ? ` (${stepDurationMs}ms)` : ''; + process.stdout.write(`${prefix}${stepOk ? 'OK' : 'FAILED'} ${description}${durationSuffix}\n`); + } +} + +export function writeCommandCliOutput( + command: string, + positionals: string[], + flags: Pick, + data: Record, +): number { + if (flags.json) { + if (command === CLIENT_COMMANDS.test) { + return renderReplayTestResponse({ + suite: data as ReplaySuiteResult, + json: true, + reportJunit: flags.reportJunit, + }); + } + printJson({ success: true, data }); + return 0; + } + + if (command === CLIENT_COMMANDS.test) { + return renderReplayTestResponse({ + suite: data as ReplaySuiteResult, + verbose: flags.verbose, + reportJunit: flags.reportJunit, + }); + } + if (command === CLIENT_COMMANDS.batch) { + renderBatchSummary(data); + return 0; + } + if (command === CLIENT_COMMANDS.get) { + const sub = positionals[0]; + if (sub === 'text') { + process.stdout.write(`${typeof data.text === 'string' ? data.text : ''}\n`); + return 0; + } + if (sub === 'attrs') { + process.stdout.write(`${JSON.stringify(data.node ?? {}, null, 2)}\n`); + return 0; + } + } + if (command === CLIENT_COMMANDS.find) { + if (typeof data.text === 'string') { + process.stdout.write(`${data.text}\n`); + return 0; + } + if (typeof data.found === 'boolean') { + process.stdout.write(`Found: ${data.found}\n`); + return 0; + } + if (data.node) { + process.stdout.write(`${JSON.stringify(data.node, null, 2)}\n`); + return 0; + } + } + if (command === CLIENT_COMMANDS.is) { + process.stdout.write(`Passed: is ${data.predicate ?? 'assertion'}\n`); + return 0; + } + if (command === CLIENT_COMMANDS.boot) { + const platform = data.platform ?? 'unknown'; + const device = data.device ?? data.id ?? 'unknown'; + process.stdout.write(`Boot ready: ${device} (${platform})\n`); + return 0; + } + if (command === CLIENT_COMMANDS.record) { + const outPath = typeof data.outPath === 'string' ? data.outPath : ''; + if (outPath) process.stdout.write(`${outPath}\n`); + return 0; + } + if (command === CLIENT_COMMANDS.logs) { + writeLogsCliOutput(data, flags); + return 0; + } + if (command === CLIENT_COMMANDS.network) { + writeNetworkCliOutput(data); + return 0; + } + if (command === CLIENT_COMMANDS.click || command === CLIENT_COMMANDS.press) { + const ref = data.ref ?? ''; + const x = data.x; + const y = data.y; + if (ref && typeof x === 'number' && typeof y === 'number') { + process.stdout.write(`Tapped @${ref} (${x}, ${y})\n`); + return 0; + } + } + if (command === CLIENT_COMMANDS.perf) { + process.stdout.write(`${JSON.stringify(data, null, 2)}\n`); + return 0; + } + const successText = readCommandMessage(data); + if (successText) { + process.stdout.write(`${successText}\n`); + for (const extraLine of readCommandSuccessLines(command, data)) { + process.stdout.write(`${extraLine}\n`); + } + } + return 0; +} + +function readBatchStepFailure(error: Record | undefined): string | null { + return typeof error?.message === 'string' && error.message.length > 0 ? error.message : null; +} + +function writeLogsCliOutput(data: Record, flags: { json?: boolean }): void { + const pathOut = typeof data.path === 'string' ? data.path : ''; + if (!pathOut) return; + process.stdout.write(`${pathOut}\n`); + const metaFields = ['active', 'state', 'backend', 'sizeBytes'] as const; + const meta = metaFields + .map((key) => (data[key] !== undefined && data[key] !== null ? `${key}=${data[key]}` : '')) + .filter(Boolean) + .join(' '); + if (meta && !flags.json) process.stderr.write(`${meta}\n`); + const actionFields = [ + 'started', + 'stopped', + 'marked', + 'cleared', + 'restarted', + 'removedRotatedFiles', + ] as const; + const actionMeta = actionFields + .map((key) => { + const value = data[key]; + return value === true ? `${key}=true` : typeof value === 'number' ? `${key}=${value}` : ''; + }) + .filter(Boolean) + .join(' '); + if (actionMeta && !flags.json) process.stderr.write(`${actionMeta}\n`); + if (data.hint && !flags.json) process.stderr.write(`${data.hint}\n`); + if (Array.isArray(data.notes) && !flags.json) { + for (const note of data.notes) { + if (typeof note === 'string' && note.length > 0) process.stderr.write(`${note}\n`); + } + } +} + +function writeNetworkCliOutput(data: Record): void { + const pathOut = typeof data.path === 'string' ? data.path : ''; + if (pathOut) process.stdout.write(`${pathOut}\n`); + const entries = Array.isArray(data.entries) ? data.entries : []; + if (entries.length === 0) { + process.stdout.write('No recent HTTP(s) entries found.\n'); + } else { + for (const entry of entries as Array>) { + const method = typeof entry.method === 'string' ? entry.method : 'HTTP'; + const url = typeof entry.url === 'string' ? entry.url : ''; + const status = typeof entry.status === 'number' ? ` status=${entry.status}` : ''; + const timestamp = typeof entry.timestamp === 'string' ? `${entry.timestamp} ` : ''; + const durationMs = + typeof entry.durationMs === 'number' ? ` durationMs=${entry.durationMs}` : ''; + process.stdout.write(`${timestamp}${method} ${url}${status}${durationMs}\n`); + if (typeof entry.headers === 'string') process.stdout.write(` headers: ${entry.headers}\n`); + if (typeof entry.requestBody === 'string') + process.stdout.write(` request: ${entry.requestBody}\n`); + if (typeof entry.responseBody === 'string') + process.stdout.write(` response: ${entry.responseBody}\n`); + } + } + const networkMetaFields = [ + 'active', + 'state', + 'backend', + 'include', + 'scannedLines', + 'matchedLines', + ] as const; + const meta = networkMetaFields + .map((key) => (data[key] !== undefined && data[key] !== null ? `${key}=${data[key]}` : '')) + .filter(Boolean) + .join(' '); + if (meta) process.stderr.write(`${meta}\n`); + if (Array.isArray(data.notes)) { + for (const note of data.notes) { + if (typeof note === 'string' && note.length > 0) process.stderr.write(`${note}\n`); + } + } +} + +function readCommandSuccessLines(command: string, data: Record): string[] { + if (command !== CLIENT_COMMANDS.scrollIntoView) { + return []; + } + const ref = typeof data.ref === 'string' ? data.ref : ''; + const currentRef = typeof data.currentRef === 'string' ? data.currentRef : ''; + return currentRef && currentRef !== ref ? [`Current ref: @${currentRef}`] : []; +} diff --git a/src/cli/commands/router.ts b/src/cli/commands/router.ts index a2a3aff44..683e6f3ab 100644 --- a/src/cli/commands/router.ts +++ b/src/cli/commands/router.ts @@ -1,13 +1,17 @@ import type { CliFlags } from '../../utils/command-schema.ts'; import type { AgentDeviceClient } from '../../client.ts'; +import { CLIENT_COMMANDS, type ClientCommandName } from '../../client-command-registry.ts'; import { sessionCommand } from './session.ts'; import { devicesCommand } from './devices.ts'; import { ensureSimulatorCommand } from './ensure-simulator.ts'; import { metroCommand } from './metro.ts'; +import { appsCommand } from './apps.ts'; import { installCommand, reinstallCommand, installFromSourceCommand } from './install.ts'; import { openCommand, closeCommand } from './open.ts'; import { snapshotCommand } from './snapshot.ts'; import { screenshotCommand, diffCommand } from './screenshot.ts'; +import { clientCommandMethodHandlers } from './client-command.ts'; +import { genericClientCommandHandlers } from './generic.ts'; export type ClientCommandParams = { positionals: string[]; @@ -16,10 +20,12 @@ export type ClientCommandParams = { }; export type ClientCommandHandler = (params: ClientCommandParams) => Promise; +export type ClientCommandHandlerMap = Partial>; -const clientCommandHandlers: Partial> = { +const dedicatedClientApiHandlers = { session: sessionCommand, - devices: devicesCommand, + [CLIENT_COMMANDS.devices]: devicesCommand, + [CLIENT_COMMANDS.apps]: appsCommand, 'ensure-simulator': ensureSimulatorCommand, metro: metroCommand, install: installCommand, @@ -27,9 +33,16 @@ const clientCommandHandlers: Partial> = { 'install-from-source': installFromSourceCommand, open: openCommand, close: closeCommand, - snapshot: snapshotCommand, - screenshot: screenshotCommand, - diff: diffCommand, + [CLIENT_COMMANDS.snapshot]: snapshotCommand, + [CLIENT_COMMANDS.screenshot]: screenshotCommand, + [CLIENT_COMMANDS.diff]: diffCommand, +} satisfies ClientCommandHandlerMap; + +const clientCommandHandlers: ClientCommandHandlerMap & + Record = { + ...dedicatedClientApiHandlers, + ...clientCommandMethodHandlers, + ...genericClientCommandHandlers, }; export async function tryRunClientBackedCommand(params: { diff --git a/src/cli/commands/screenshot.ts b/src/cli/commands/screenshot.ts index 15975034b..d7cd01c7f 100644 --- a/src/cli/commands/screenshot.ts +++ b/src/cli/commands/screenshot.ts @@ -1,17 +1,18 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { formatScreenshotDiffText } from '../../utils/output.ts'; +import { formatScreenshotDiffText, formatSnapshotDiffText } from '../../utils/output.ts'; import { AppError } from '../../utils/errors.ts'; import { compareScreenshots, type ScreenshotDiffResult } from '../../utils/screenshot-diff.ts'; import { resolveUserPath } from '../../utils/path-resolution.ts'; -import { writeCommandOutput } from './shared.ts'; +import { buildSelectionOptions, writeCommandOutput } from './shared.ts'; import type { ClientCommandHandler } from './router.ts'; export const screenshotCommand: ClientCommandHandler = async ({ positionals, flags, client }) => { const result = await client.capture.screenshot({ path: positionals[0] ?? flags.out, overlayRefs: flags.overlayRefs, + ...(flags.screenshotFullscreen !== undefined ? { fullscreen: flags.screenshotFullscreen } : {}), }); const data = { path: result.path, @@ -26,6 +27,21 @@ export const screenshotCommand: ClientCommandHandler = async ({ positionals, fla }; export const diffCommand: ClientCommandHandler = async ({ positionals, flags, client }) => { + if (positionals[0] === 'snapshot') { + const result = await client.capture.diff({ + ...buildSelectionOptions(flags), + kind: 'snapshot', + out: flags.out, + interactiveOnly: flags.snapshotInteractiveOnly, + compact: flags.snapshotCompact, + depth: flags.snapshotDepth, + scope: flags.snapshotScope, + raw: flags.snapshotRaw, + }); + writeCommandOutput(flags, result, () => formatSnapshotDiffText(result)); + return true; + } + if (positionals[0] !== 'screenshot') return false; const baselineRaw = flags.baseline; diff --git a/src/client-command-registry.ts b/src/client-command-registry.ts new file mode 100644 index 000000000..c9d791bb5 --- /dev/null +++ b/src/client-command-registry.ts @@ -0,0 +1,43 @@ +export const CLIENT_COMMANDS = { + alert: 'alert', + appState: 'appstate', + appSwitcher: 'app-switcher', + apps: 'apps', + back: 'back', + batch: 'batch', + boot: 'boot', + click: 'click', + clipboard: 'clipboard', + devices: 'devices', + diff: 'diff', + fill: 'fill', + find: 'find', + focus: 'focus', + get: 'get', + home: 'home', + is: 'is', + keyboard: 'keyboard', + logs: 'logs', + longPress: 'longpress', + network: 'network', + perf: 'perf', + pinch: 'pinch', + press: 'press', + push: 'push', + record: 'record', + replay: 'replay', + rotate: 'rotate', + scroll: 'scroll', + scrollIntoView: 'scrollintoview', + screenshot: 'screenshot', + settings: 'settings', + snapshot: 'snapshot', + swipe: 'swipe', + test: 'test', + trace: 'trace', + triggerAppEvent: 'trigger-app-event', + type: 'type', + wait: 'wait', +} as const; + +export type ClientCommandName = (typeof CLIENT_COMMANDS)[keyof typeof CLIENT_COMMANDS]; diff --git a/src/client-commands.ts b/src/client-commands.ts new file mode 100644 index 000000000..56ed2fad2 --- /dev/null +++ b/src/client-commands.ts @@ -0,0 +1,137 @@ +import { AppError } from './utils/errors.ts'; +import { tryParseSelectorChain } from './daemon/selectors.ts'; +import { CLIENT_COMMANDS } from './client-command-registry.ts'; +import type { AgentDeviceCommandClient, InternalRequestOptions } from './client-types.ts'; + +export type PreparedClientCommand = { + command: string; + positionals: string[]; + options: InternalRequestOptions; +}; + +type ExecutePreparedCommand = (prepared: PreparedClientCommand) => Promise; +type CommandOptions = NonNullable< + Parameters[0] +>; +type CommandResult = Awaited< + ReturnType +>; + +export function createAgentDeviceCommandClient( + executePreparedCommand: ExecutePreparedCommand, +): AgentDeviceCommandClient { + const run = async ( + prepared: PreparedClientCommand, + ): Promise> => await executePreparedCommand>(prepared); + + return { + wait: async (options) => await run<'wait'>(prepareWaitCommand(options)), + alert: async (options = {}) => await run<'alert'>(prepareAlertCommand(options)), + appState: async (options = {}) => + await run<'appState'>({ + command: CLIENT_COMMANDS.appState, + positionals: [], + options, + }), + back: async (options = {}) => + await run<'back'>({ + command: CLIENT_COMMANDS.back, + positionals: [], + options: { + ...options, + backMode: options.mode, + }, + }), + home: async (options = {}) => + await run<'home'>({ + command: CLIENT_COMMANDS.home, + positionals: [], + options, + }), + rotate: async (options) => + await run<'rotate'>({ + command: CLIENT_COMMANDS.rotate, + positionals: [options.orientation], + options, + }), + appSwitcher: async (options = {}) => + await run<'appSwitcher'>({ + command: CLIENT_COMMANDS.appSwitcher, + positionals: [], + options, + }), + keyboard: async (options = {}) => + await run<'keyboard'>({ + command: CLIENT_COMMANDS.keyboard, + positionals: options.action ? [options.action] : [], + options, + }), + clipboard: async (options) => await run<'clipboard'>(prepareClipboardCommand(options)), + }; +} + +function prepareWaitCommand(options: CommandOptions<'wait'>): PreparedClientCommand { + 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 { command: CLIENT_COMMANDS.wait, positionals: [String(options.durationMs)], options }; + } + + const timeout = options.timeoutMs !== undefined ? [String(options.timeoutMs)] : []; + if (options.text !== undefined) { + return { + command: CLIENT_COMMANDS.wait, + positionals: ['text', options.text, ...timeout], + options, + }; + } + if (options.ref !== undefined) { + return { + command: CLIENT_COMMANDS.wait, + positionals: [options.ref, ...timeout], + options, + }; + } + const selector = options.selector!; + assertValidSelector(selector); + return { + command: CLIENT_COMMANDS.wait, + positionals: [selector, ...timeout], + options, + }; +} + +function assertValidSelector(selector: string): void { + if (tryParseSelectorChain(selector)) return; + throw new AppError('INVALID_ARGS', `Invalid wait selector: ${selector}`); +} + +function prepareAlertCommand(options: CommandOptions<'alert'>): PreparedClientCommand { + const action = options.action ?? 'get'; + return { + command: CLIENT_COMMANDS.alert, + positionals: [action, ...(options.timeoutMs !== undefined ? [String(options.timeoutMs)] : [])], + options, + }; +} + +function prepareClipboardCommand(options: CommandOptions<'clipboard'>): PreparedClientCommand { + if (options.action === 'read') { + return { command: CLIENT_COMMANDS.clipboard, positionals: ['read'], options }; + } + return { + command: CLIENT_COMMANDS.clipboard, + positionals: ['write', options.text], + options, + }; +} diff --git a/src/client-normalizers.ts b/src/client-normalizers.ts index 2b67b4fdd..3a6366640 100644 --- a/src/client-normalizers.ts +++ b/src/client-normalizers.ts @@ -257,6 +257,7 @@ export function buildFlags(options: InternalRequestOptions): CommandFlags { shutdown: options.shutdown, saveScript: options.saveScript, noRecord: options.noRecord, + backMode: options.backMode, metroHost: options.metroHost, metroPort: options.metroPort, bundleUrl: options.bundleUrl, @@ -266,8 +267,37 @@ export function buildFlags(options: InternalRequestOptions): CommandFlags { snapshotDepth: options.depth, snapshotScope: options.scope, snapshotRaw: options.raw, + screenshotFullscreen: options.screenshotFullscreen, overlayRefs: options.overlayRefs, appsFilter: options.appsFilter, + out: options.out, + count: options.count, + fps: options.fps, + hideTouches: options.hideTouches, + intervalMs: options.intervalMs, + delayMs: options.delayMs, + holdMs: options.holdMs, + jitterPx: options.jitterPx, + pixels: options.pixels, + doubleTap: options.doubleTap, + clickButton: options.clickButton, + pauseMs: options.pauseMs, + pattern: options.pattern, + maxScrolls: options.maxScrolls, + headless: options.headless, + restart: options.restart, + replayUpdate: options.replayUpdate, + failFast: options.failFast, + timeoutMs: options.timeoutMs, + retries: options.retries, + artifactsDir: options.artifactsDir, + reportJunit: options.reportJunit, + findFirst: options.findFirst, + findLast: options.findLast, + networkInclude: options.networkInclude, + batchOnError: options.batchOnError, + batchMaxSteps: options.batchMaxSteps, + batchSteps: options.batchSteps, verbose: options.debug, }) as CommandFlags; } @@ -290,9 +320,6 @@ export function buildMeta(options: InternalRequestOptions): DaemonRequest['meta' }); } -export function resolveSessionName( - defaultSession: string | undefined, - session: string | undefined, -): string { - return session ?? defaultSession ?? DEFAULT_SESSION_NAME; +export function resolveSessionName(session: string | undefined): string { + return session ?? DEFAULT_SESSION_NAME; } diff --git a/src/client-types.ts b/src/client-types.ts index d92079268..79d91efc4 100644 --- a/src/client-types.ts +++ b/src/client-types.ts @@ -1,4 +1,5 @@ import type { + DaemonResponseData, DaemonInstallSource, DaemonLockPolicy, DaemonRequest, @@ -22,6 +23,7 @@ export type AgentDeviceClientConfig = { lockPolicy?: DaemonLockPolicy; lockPlatform?: PlatformSelector; requestId?: string; + remoteConfig?: string; stateDir?: string; daemonBaseUrl?: string; daemonAuthToken?: string; @@ -158,7 +160,7 @@ export type AppDeployResult = { export type AppOpenOptions = AgentDeviceRequestOverrides & AgentDeviceSelectionOptions & { - app: string; + app?: string; url?: string; surface?: 'app' | 'frontmost-app' | 'desktop' | 'menubar'; activity?: string; @@ -270,6 +272,7 @@ export type CaptureSnapshotResult = { export type CaptureScreenshotOptions = AgentDeviceRequestOverrides & { path?: string; overlayRefs?: boolean; + fullscreen?: boolean; }; export type CaptureScreenshotResult = { @@ -278,8 +281,453 @@ export type CaptureScreenshotResult = { identifiers: AgentDeviceIdentifiers; }; -export type InternalRequestOptions = AgentDeviceClientConfig & +export type DeviceCommandBaseOptions = AgentDeviceRequestOverrides & AgentDeviceSelectionOptions; + +type WaitSnapshotOptions = Pick; + +type WaitCommandTarget = + | { + durationMs: number; + text?: never; + ref?: never; + selector?: never; + timeoutMs?: never; + } + | (WaitSnapshotOptions & { + text: string; + durationMs?: never; + ref?: never; + selector?: never; + timeoutMs?: number; + }) + | (WaitSnapshotOptions & { + ref: string; + durationMs?: never; + text?: never; + selector?: never; + timeoutMs?: number; + }) + | (WaitSnapshotOptions & { + selector: string; + durationMs?: never; + text?: never; + ref?: never; + timeoutMs?: number; + }); + +export type WaitCommandOptions = DeviceCommandBaseOptions & WaitCommandTarget; + +export type AlertCommandOptions = DeviceCommandBaseOptions & { + action?: 'get' | 'accept' | 'dismiss' | 'wait'; + timeoutMs?: number; +}; + +export type AppStateCommandOptions = DeviceCommandBaseOptions; + +export type BackCommandOptions = DeviceCommandBaseOptions & { + mode?: 'in-app' | 'system'; +}; + +export type HomeCommandOptions = DeviceCommandBaseOptions; + +export type RotateCommandOptions = DeviceCommandBaseOptions & { + orientation: 'portrait' | 'portrait-upside-down' | 'landscape-left' | 'landscape-right'; +}; + +export type AppSwitcherCommandOptions = DeviceCommandBaseOptions; + +export type KeyboardCommandOptions = DeviceCommandBaseOptions & { + action?: 'status' | 'dismiss'; +}; + +export type ClipboardCommandOptions = + | (DeviceCommandBaseOptions & { + action: 'read'; + }) + | (DeviceCommandBaseOptions & { + action: 'write'; + text: string; + }); + +export type WaitCommandResult = DaemonResponseData & { + waitedMs?: number; + text?: string; + selector?: string; +}; + +export type AlertCommandResult = DaemonResponseData; + +type CommandActionResult = DaemonResponseData & { + action?: T; +}; + +export type AppStateCommandResult = DaemonResponseData & { + platform?: Platform; + appName?: string; + appBundleId?: string; + package?: string; + activity?: string; + source?: 'session'; + surface?: 'app' | 'frontmost-app' | 'desktop' | 'menubar'; +}; + +export type BackCommandResult = CommandActionResult<'back'> & { + mode?: 'in-app' | 'system'; +}; + +export type HomeCommandResult = CommandActionResult<'home'>; + +export type RotateCommandResult = CommandActionResult<'rotate'> & { + orientation?: RotateCommandOptions['orientation']; +}; + +export type AppSwitcherCommandResult = CommandActionResult<'app-switcher'>; + +export type KeyboardCommandResult = DaemonResponseData & { + platform?: 'android' | 'ios'; + action?: 'status' | 'dismiss'; + visible?: boolean; + inputType?: string | null; + type?: string | null; + wasVisible?: boolean; + dismissed?: boolean; + attempts?: number; +}; + +export type ClipboardCommandResult = + | (DaemonResponseData & { + action: 'read'; + text: string; + }) + | (DaemonResponseData & { + action: 'write'; + textLength: number; + }); + +export type AgentDeviceCommandClient = { + wait: (options: WaitCommandOptions) => Promise; + alert: (options?: AlertCommandOptions) => Promise; + appState: (options?: AppStateCommandOptions) => Promise; + back: (options?: BackCommandOptions) => Promise; + home: (options?: HomeCommandOptions) => Promise; + rotate: (options: RotateCommandOptions) => Promise; + appSwitcher: (options?: AppSwitcherCommandOptions) => Promise; + keyboard: (options?: KeyboardCommandOptions) => Promise; + clipboard: (options: ClipboardCommandOptions) => Promise; +}; + +type SelectorSnapshotCommandOptions = Pick; +type FindSnapshotCommandOptions = Pick; + +type ClientCommandBaseOptions = AgentDeviceRequestOverrides & AgentDeviceSelectionOptions; + +type PointTarget = { + x: number; + y: number; + ref?: never; + selector?: never; + label?: never; +}; + +type RefTarget = { + ref: string; + label?: string; + x?: never; + y?: never; + selector?: never; +}; + +type SelectorTarget = { + selector: string; + x?: never; + y?: never; + ref?: never; + label?: never; +}; + +export type InteractionTarget = PointTarget | RefTarget | SelectorTarget; + +export type ElementTarget = RefTarget | SelectorTarget; + +type RepeatedPressOptions = { + count?: number; + intervalMs?: number; + holdMs?: number; + jitterPx?: number; + doubleTap?: boolean; +}; + +export type DeviceBootOptions = ClientCommandBaseOptions & { + headless?: boolean; +}; + +export type AppPushOptions = ClientCommandBaseOptions & { + app: string; + payload: string | Record; +}; + +export type AppTriggerEventOptions = ClientCommandBaseOptions & { + event: string; + payload?: Record; +}; + +export type CaptureDiffOptions = ClientCommandBaseOptions & + Pick & { + kind: 'snapshot'; + out?: string; + }; + +export type ClickOptions = ClientCommandBaseOptions & + SelectorSnapshotCommandOptions & + InteractionTarget & + RepeatedPressOptions & { + button?: 'primary' | 'secondary' | 'middle'; + }; + +export type PressOptions = ClientCommandBaseOptions & + SelectorSnapshotCommandOptions & + InteractionTarget & + RepeatedPressOptions; + +export type LongPressOptions = ClientCommandBaseOptions & { + x: number; + y: number; + durationMs?: number; +}; + +export type SwipeOptions = ClientCommandBaseOptions & { + from: { x: number; y: number }; + to: { x: number; y: number }; + durationMs?: number; + count?: number; + pauseMs?: number; + pattern?: 'one-way' | 'ping-pong'; +}; + +export type FocusOptions = ClientCommandBaseOptions & { + x: number; + y: number; +}; + +export type TypeTextOptions = ClientCommandBaseOptions & { + text: string; + delayMs?: number; +}; + +export type FillOptions = ClientCommandBaseOptions & + SelectorSnapshotCommandOptions & + InteractionTarget & { + text: string; + delayMs?: number; + }; + +export type ScrollOptions = ClientCommandBaseOptions & { + direction: 'up' | 'down' | 'left' | 'right'; + amount?: number; + pixels?: number; +}; + +export type ScrollIntoViewOptions = ClientCommandBaseOptions & + ( + | { + text: string; + ref?: never; + label?: never; + } + | { + ref: string; + label?: string; + text?: never; + } + ) & { + maxScrolls?: number; + }; + +export type PinchOptions = ClientCommandBaseOptions & { + scale: number; + x?: number; + y?: number; +}; + +export type GetOptions = ClientCommandBaseOptions & + SelectorSnapshotCommandOptions & + ElementTarget & { + format: 'text' | 'attrs'; + }; + +type IsTextPredicateOptions = ClientCommandBaseOptions & + SelectorSnapshotCommandOptions & { + predicate: 'text'; + selector: string; + value: string; + }; + +type IsStatePredicateOptions = ClientCommandBaseOptions & + SelectorSnapshotCommandOptions & { + predicate: 'visible' | 'hidden' | 'exists' | 'editable' | 'selected'; + selector: string; + value?: never; + }; + +export type IsOptions = IsTextPredicateOptions | IsStatePredicateOptions; + +export type FindLocator = 'any' | 'text' | 'label' | 'value' | 'role' | 'id'; + +type FindBaseOptions = ClientCommandBaseOptions & + FindSnapshotCommandOptions & { + locator?: FindLocator; + query: string; + first?: boolean; + last?: boolean; + }; + +export type FindOptions = + | (FindBaseOptions & { action?: 'click' | 'focus' | 'exists' | 'getText' | 'getAttrs' }) + | (FindBaseOptions & { action: 'wait'; timeoutMs?: number }) + | (FindBaseOptions & { action: 'fill' | 'type'; value: string }); + +export type ReplayRunOptions = AgentDeviceRequestOverrides & { + path: string; + update?: boolean; +}; + +export type ReplayTestOptions = AgentDeviceRequestOverrides & AgentDeviceSelectionOptions & { + paths: string[]; + update?: boolean; + failFast?: boolean; + timeoutMs?: number; + retries?: number; + artifactsDir?: string; + reportJunit?: string; + }; + +export type BatchStep = { + command: string; + positionals?: string[]; + flags?: Record; +}; + +export type BatchRunOptions = AgentDeviceRequestOverrides & { + steps: BatchStep[]; + onError?: 'stop'; + maxSteps?: number; + out?: string; +}; + +export type PerfOptions = ClientCommandBaseOptions; + +export type LogsOptions = AgentDeviceRequestOverrides & { + action?: 'path' | 'start' | 'stop' | 'doctor' | 'mark' | 'clear'; + message?: string; + restart?: boolean; +}; + +export type NetworkOptions = AgentDeviceRequestOverrides & { + action?: 'dump' | 'log'; + limit?: number; + include?: 'summary' | 'headers' | 'body' | 'all'; +}; + +export type RecordOptions = AgentDeviceRequestOverrides & { + action: 'start' | 'stop'; + path?: string; + fps?: number; + hideTouches?: boolean; +}; + +export type TraceOptions = AgentDeviceRequestOverrides & { + action: 'start' | 'stop'; + path?: string; +}; + +export type PermissionTarget = + | 'camera' + | 'microphone' + | 'photos' + | 'contacts' + | 'contacts-limited' + | 'notifications' + | 'calendar' + | 'location' + | 'location-always' + | 'media-library' + | 'motion' + | 'reminders' + | 'siri' + | 'accessibility' + | 'screen-recording' + | 'input-monitoring'; + +export type SettingsUpdateOptions = + | (ClientCommandBaseOptions & { + setting: 'wifi' | 'airplane' | 'location'; + state: 'on' | 'off'; + }) + | (ClientCommandBaseOptions & { + setting: 'appearance'; + state: 'light' | 'dark' | 'toggle'; + }) + | (ClientCommandBaseOptions & { + setting: 'faceid' | 'touchid'; + state: 'match' | 'nonmatch' | 'enroll' | 'unenroll'; + }) + | (ClientCommandBaseOptions & { + setting: 'fingerprint'; + state: 'match' | 'nonmatch'; + }) + | (ClientCommandBaseOptions & { + setting: 'permission'; + state: 'grant' | 'deny' | 'reset'; + permission: PermissionTarget; + mode?: 'full' | 'limited'; + }); + +type CommandExecutionOptions = { + positionals?: string[]; + out?: string; + interactiveOnly?: boolean; + compact?: boolean; + depth?: number; + scope?: string; + raw?: boolean; + screenshotFullscreen?: boolean; + count?: number; + fps?: number; + hideTouches?: boolean; + intervalMs?: number; + delayMs?: number; + holdMs?: number; + jitterPx?: number; + pixels?: number; + doubleTap?: boolean; + clickButton?: 'primary' | 'secondary' | 'middle'; + pauseMs?: number; + pattern?: 'one-way' | 'ping-pong'; + maxScrolls?: number; + headless?: boolean; + restart?: boolean; + replayUpdate?: boolean; + failFast?: boolean; + timeoutMs?: number; + retries?: number; + artifactsDir?: string; + reportJunit?: string; + findFirst?: boolean; + findLast?: boolean; + networkInclude?: 'summary' | 'headers' | 'body' | 'all'; + batchOnError?: 'stop'; + batchMaxSteps?: number; + batchSteps?: Array<{ + command: string; + positionals?: string[]; + flags?: Record; + }>; +}; + +export type InternalRequestOptions = AgentDeviceClientConfig & + AgentDeviceSelectionOptions & + CommandExecutionOptions & { simulatorRuntimeId?: string; runtime?: SessionRuntimeHints; overlayRefs?: boolean; @@ -291,15 +739,11 @@ export type InternalRequestOptions = AgentDeviceClientConfig & shutdown?: boolean; saveScript?: boolean | string; noRecord?: boolean; + backMode?: 'in-app' | 'system'; metroHost?: string; metroPort?: number; bundleUrl?: string; launchUrl?: string; - interactiveOnly?: boolean; - compact?: boolean; - depth?: number; - scope?: string; - raw?: boolean; appsFilter?: 'all' | 'user-installed'; installSource?: DaemonInstallSource; retainMaterializedPaths?: boolean; @@ -307,11 +751,15 @@ export type InternalRequestOptions = AgentDeviceClientConfig & materializationId?: string; }; +export type CommandRequestResult = DaemonResponseData; + export type AgentDeviceClient = { + command: AgentDeviceCommandClient; devices: { list: ( options?: AgentDeviceRequestOverrides & AgentDeviceSelectionOptions, ) => Promise; + boot: (options?: DeviceBootOptions) => Promise; }; sessions: { list: (options?: AgentDeviceRequestOverrides) => Promise; @@ -331,6 +779,8 @@ export type AgentDeviceClient = { list: (options?: AppListOptions) => Promise; open: (options: AppOpenOptions) => Promise; close: (options?: AppCloseOptions) => Promise; + push: (options: AppPushOptions) => Promise; + triggerEvent: (options: AppTriggerEventOptions) => Promise; }; materializations: { release: (options: MaterializationReleaseOptions) => Promise; @@ -341,5 +791,40 @@ export type AgentDeviceClient = { capture: { snapshot: (options?: CaptureSnapshotOptions) => Promise; screenshot: (options?: CaptureScreenshotOptions) => Promise; + diff: (options: CaptureDiffOptions) => Promise; + }; + interactions: { + click: (options: ClickOptions) => Promise; + press: (options: PressOptions) => Promise; + longPress: (options: LongPressOptions) => Promise; + swipe: (options: SwipeOptions) => Promise; + focus: (options: FocusOptions) => Promise; + type: (options: TypeTextOptions) => Promise; + fill: (options: FillOptions) => Promise; + scroll: (options: ScrollOptions) => Promise; + scrollIntoView: (options: ScrollIntoViewOptions) => Promise; + pinch: (options: PinchOptions) => Promise; + get: (options: GetOptions) => Promise; + is: (options: IsOptions) => Promise; + find: (options: FindOptions) => Promise; + }; + replay: { + run: (options: ReplayRunOptions) => Promise; + test: (options: ReplayTestOptions) => Promise; + }; + batch: { + run: (options: BatchRunOptions) => Promise; + }; + observability: { + perf: (options?: PerfOptions) => Promise; + logs: (options?: LogsOptions) => Promise; + network: (options?: NetworkOptions) => Promise; + }; + recording: { + record: (options: RecordOptions) => Promise; + trace: (options: TraceOptions) => Promise; + }; + settings: { + update: (options: SettingsUpdateOptions) => Promise; }; }; diff --git a/src/client.ts b/src/client.ts index 10a2a0142..85750a934 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,6 +1,9 @@ import { sendToDaemon } from './daemon-client.ts'; import { prepareMetroRuntime } from './client-metro.ts'; +import { CLIENT_COMMANDS } from './client-command-registry.ts'; +import { createAgentDeviceCommandClient, type PreparedClientCommand } from './client-commands.ts'; import { throwDaemonError } from './daemon-error.ts'; +import { resolveRemoteConfigDefaults } from './utils/remote-config.ts'; import { buildFlags, buildMeta, @@ -23,6 +26,8 @@ import type { AgentDeviceClient, AgentDeviceClientConfig, AgentDeviceDaemonTransport, + AppPushOptions, + AppTriggerEventOptions, AppCloseOptions, AppDeployOptions, AppInstallFromSourceOptions, @@ -31,10 +36,15 @@ import type { CaptureScreenshotOptions, CaptureSnapshotOptions, CaptureSnapshotResult, + CommandRequestResult, + ElementTarget, EnsureSimulatorOptions, + FindOptions, + InteractionTarget, InternalRequestOptions, MaterializationReleaseOptions, MetroPrepareOptions, + NetworkOptions, } from './client-types.ts'; export function createAgentDeviceClient( @@ -42,15 +52,16 @@ export function createAgentDeviceClient( deps: { transport?: AgentDeviceDaemonTransport } = {}, ): AgentDeviceClient { const transport = deps.transport ?? sendToDaemon; + const remoteConfigDefaults = resolveClientRemoteConfigDefaults(config); const execute = async ( command: string, positionals: string[] = [], options: InternalRequestOptions = {}, ): Promise> => { - const merged = { ...config, ...options }; + const merged = mergeClientOptions(config, remoteConfigDefaults, options); const response = await transport({ - session: resolveSessionName(config.session, options.session), + session: resolveSessionName(merged.session), command, positionals, flags: buildFlags(merged), @@ -69,18 +80,33 @@ export function createAgentDeviceClient( return sessions.map(normalizeSession); }; + const executePreparedCommand = async (prepared: PreparedClientCommand): Promise => + (await execute(prepared.command, prepared.positionals, prepared.options)) as T; + + const executeCommandRequest = async ( + command: string, + positionals: string[] = [], + options: InternalRequestOptions = {}, + ): Promise => + (await execute(command, positionals, options)) as CommandRequestResult; + + const resolveRequestSession = (options: InternalRequestOptions = {}) => + resolveSessionName(mergeClientOptions(config, remoteConfigDefaults, options).session); + return { + command: createAgentDeviceCommandClient(executePreparedCommand), devices: { list: async (options = {}) => { - const data = await execute('devices', [], options); + const data = await execute(CLIENT_COMMANDS.devices, [], options); const devices = Array.isArray(data.devices) ? data.devices : []; return devices.map(normalizeDevice); }, + boot: async (options = {}) => await executeCommandRequest(CLIENT_COMMANDS.boot, [], options), }, sessions: { list: async (options = {}) => await listSessions(options), close: async (options = {}) => { - const session = resolveSessionName(config.session, options.session); + const session = resolveRequestSession(options); const data = await execute('close', [], options); const shutdown = data.shutdown; return { @@ -121,12 +147,12 @@ export function createAgentDeviceClient( install: async (options: AppDeployOptions) => normalizeDeployResult( await execute('install', [options.app, options.appPath], options), - resolveSessionName(config.session, options.session), + resolveRequestSession(options), ), reinstall: async (options: AppDeployOptions) => normalizeDeployResult( await execute('reinstall', [options.app, options.appPath], options), - resolveSessionName(config.session, options.session), + resolveRequestSession(options), ), installFromSource: async (options: AppInstallFromSourceOptions) => normalizeInstallFromSourceResult( @@ -136,17 +162,21 @@ export function createAgentDeviceClient( retainMaterializedPaths: options.retainPaths, materializedPathRetentionMs: options.retentionMs, }), - resolveSessionName(config.session, options.session), + resolveRequestSession(options), ), list: async (options: AppListOptions = {}) => { - const data = await execute('apps', [], options); + const data = await execute(CLIENT_COMMANDS.apps, [], options); return Array.isArray(data.apps) ? data.apps.filter((app): app is string => typeof app === 'string') : []; }, open: async (options: AppOpenOptions) => { - const session = resolveSessionName(config.session, options.session); - const positionals = options.url ? [options.app, options.url] : [options.app]; + const session = resolveRequestSession(options); + const positionals = options.app + ? options.url + ? [options.app, options.url] + : [options.app] + : []; const data = await execute('open', positionals, options); const device = normalizeOpenDevice(data); const appBundleId = readOptionalString(data, 'appBundleId'); @@ -171,7 +201,7 @@ export function createAgentDeviceClient( }; }, close: async (options: AppCloseOptions = {}) => { - const session = resolveSessionName(config.session, options.session); + const session = resolveRequestSession(options); const data = await execute('close', options.app ? [options.app] : [], options); const shutdown = data.shutdown; return { @@ -184,6 +214,18 @@ export function createAgentDeviceClient( identifiers: { session }, }; }, + push: async (options) => + await executeCommandRequest( + CLIENT_COMMANDS.push, + [options.app, stringifyPayload(options.payload)], + options, + ), + triggerEvent: async (options) => + await executeCommandRequest( + CLIENT_COMMANDS.triggerAppEvent, + triggerEventPositionals(options), + options, + ), }, materializations: { release: async (options: MaterializationReleaseOptions) => @@ -218,8 +260,8 @@ export function createAgentDeviceClient( }, capture: { snapshot: async (options: CaptureSnapshotOptions = {}) => { - const session = resolveSessionName(config.session, options.session); - const data = await execute('snapshot', [], options); + const session = resolveRequestSession(options); + const data = await execute(CLIENT_COMMANDS.snapshot, [], options); const appBundleId = readOptionalString(data, 'appBundleId'); const visibility = typeof data.visibility === 'object' && data.visibility !== null @@ -242,21 +284,269 @@ export function createAgentDeviceClient( }; }, screenshot: async (options: CaptureScreenshotOptions = {}) => { - const session = resolveSessionName(config.session, options.session); - const data = await execute('screenshot', options.path ? [options.path] : [], options); + const session = resolveRequestSession(options); + const data = await execute(CLIENT_COMMANDS.screenshot, options.path ? [options.path] : [], { + ...options, + screenshotFullscreen: options.fullscreen, + }); return { path: readRequiredString(data, 'path'), overlayRefs: readScreenshotOverlayRefs(data), identifiers: { session }, }; }, + diff: async (options) => + await executeCommandRequest(CLIENT_COMMANDS.diff, [options.kind], { + ...options, + interactiveOnly: options.interactiveOnly, + compact: options.compact, + depth: options.depth, + scope: options.scope, + raw: options.raw, + }), + }, + interactions: { + click: async (options) => + await executeCommandRequest(CLIENT_COMMANDS.click, targetPositionals(options), { + ...options, + clickButton: options.button, + }), + press: async (options) => + await executeCommandRequest(CLIENT_COMMANDS.press, targetPositionals(options), options), + longPress: async (options) => + await executeCommandRequest( + CLIENT_COMMANDS.longPress, + [String(options.x), String(options.y), ...optionalNumber(options.durationMs)], + options, + ), + swipe: async (options) => + await executeCommandRequest( + CLIENT_COMMANDS.swipe, + [ + String(options.from.x), + String(options.from.y), + String(options.to.x), + String(options.to.y), + ...optionalNumber(options.durationMs), + ], + options, + ), + focus: async (options) => + await executeCommandRequest( + CLIENT_COMMANDS.focus, + [String(options.x), String(options.y)], + options, + ), + type: async (options) => + await executeCommandRequest(CLIENT_COMMANDS.type, [options.text], options), + fill: async (options) => + await executeCommandRequest( + CLIENT_COMMANDS.fill, + [...targetPositionals(options), options.text], + options, + ), + scroll: async (options) => + await executeCommandRequest( + CLIENT_COMMANDS.scroll, + [options.direction, ...optionalNumber(options.amount)], + options, + ), + scrollIntoView: async (options) => + await executeCommandRequest( + CLIENT_COMMANDS.scrollIntoView, + scrollIntoViewPositionals(options), + options, + ), + pinch: async (options) => + await executeCommandRequest( + CLIENT_COMMANDS.pinch, + [String(options.scale), ...optionalNumber(options.x), ...optionalNumber(options.y)], + options, + ), + get: async (options) => + await executeCommandRequest( + CLIENT_COMMANDS.get, + [options.format, ...elementPositionals(options)], + options, + ), + is: async (options) => + await executeCommandRequest( + CLIENT_COMMANDS.is, + [ + options.predicate, + options.selector, + ...(options.predicate === 'text' ? [options.value] : []), + ], + options, + ), + find: async (options) => + await executeCommandRequest(CLIENT_COMMANDS.find, findPositionals(options), { + ...options, + findFirst: options.first, + findLast: options.last, + }), + }, + replay: { + run: async (options) => + await executeCommandRequest(CLIENT_COMMANDS.replay, [options.path], { + ...options, + replayUpdate: options.update, + }), + test: async (options) => + await executeCommandRequest(CLIENT_COMMANDS.test, options.paths, { + ...options, + replayUpdate: options.update, + }), + }, + batch: { + run: async (options) => + await executeCommandRequest(CLIENT_COMMANDS.batch, [], { + ...options, + batchSteps: options.steps, + batchOnError: options.onError, + batchMaxSteps: options.maxSteps, + }), + }, + observability: { + perf: async (options = {}) => await executeCommandRequest(CLIENT_COMMANDS.perf, [], options), + logs: async (options = {}) => + await executeCommandRequest(CLIENT_COMMANDS.logs, logsPositionals(options), options), + network: async (options = {}) => + await executeCommandRequest(CLIENT_COMMANDS.network, networkPositionals(options), { + ...options, + networkInclude: options.include, + }), + }, + recording: { + record: async (options) => + await executeCommandRequest( + CLIENT_COMMANDS.record, + [options.action, ...optionalString(options.path)], + options, + ), + trace: async (options) => + await executeCommandRequest( + CLIENT_COMMANDS.trace, + [options.action, ...optionalString(options.path)], + options, + ), + }, + settings: { + update: async (options) => + await executeCommandRequest( + CLIENT_COMMANDS.settings, + [ + options.setting, + options.state, + ...('permission' in options ? [options.permission] : []), + ...('mode' in options && options.mode ? [options.mode] : []), + ], + options, + ), }, }; } +function targetPositionals(options: InteractionTarget): string[] { + if (options.ref !== undefined) return [options.ref, ...optionalString(options.label)]; + if (options.selector !== undefined) return [options.selector]; + return [String(options.x), String(options.y)]; +} + +function elementPositionals(options: ElementTarget): string[] { + if (options.ref !== undefined) return [options.ref, ...optionalString(options.label)]; + return [options.selector]; +} + +function scrollIntoViewPositionals(options: { + text?: string; + ref?: string; + label?: string; +}): string[] { + if (options.ref !== undefined) return [options.ref, ...optionalString(options.label)]; + return [options.text ?? '']; +} + +function stringifyPayload(payload: AppPushOptions['payload']): string { + return typeof payload === 'string' ? payload : JSON.stringify(payload); +} + +function triggerEventPositionals(options: AppTriggerEventOptions): string[] { + return [options.event, ...(options.payload ? [JSON.stringify(options.payload)] : [])]; +} + +function findPositionals(options: FindOptions): string[] { + const args = + options.locator && options.locator !== 'any' + ? [options.locator, options.query] + : [options.query]; + switch (options.action) { + case undefined: + case 'click': + case 'focus': + case 'exists': + return options.action ? [...args, options.action] : args; + case 'getText': + return [...args, 'get', 'text']; + case 'getAttrs': + return [...args, 'get', 'attrs']; + case 'wait': + return [...args, 'wait', ...optionalNumber(options.timeoutMs)]; + case 'fill': + case 'type': + return [...args, options.action, options.value]; + } +} + +function logsPositionals(options: { action?: string; message?: string }): string[] { + return [options.action ?? 'path', ...optionalString(options.message)]; +} + +function networkPositionals(options: NetworkOptions): string[] { + return [...(options.action ? [options.action] : []), ...optionalNumber(options.limit)]; +} + +function optionalString(value: string | undefined): string[] { + return value === undefined ? [] : [value]; +} + +function optionalNumber(value: number | undefined): string[] { + return value === undefined ? [] : [String(value)]; +} + +function mergeClientOptions( + config: AgentDeviceClientConfig, + remoteConfigDefaults: InternalRequestOptions, + options: InternalRequestOptions, +): InternalRequestOptions { + if (options.remoteConfig && options.remoteConfig !== config.remoteConfig) { + return { + ...resolveClientRemoteConfigDefaults({ ...config, ...options }), + ...config, + ...options, + }; + } + return { ...remoteConfigDefaults, ...config, ...options }; +} + +function resolveClientRemoteConfigDefaults( + config: AgentDeviceClientConfig, +): InternalRequestOptions { + if (!config.remoteConfig) return {}; + + const remoteDefaults = resolveRemoteConfigDefaults({ + remoteConfig: config.remoteConfig, + cwd: config.cwd ?? process.cwd(), + env: process.env, + }); + const { runtime: _cliRuntime, ...clientDefaults } = remoteDefaults; + return clientDefaults; +} + export type { AgentDeviceClient, AgentDeviceClientConfig, + AgentDeviceCommandClient, AgentDeviceDaemonTransport, AgentDeviceDevice, AgentDeviceIdentifiers, @@ -264,6 +554,12 @@ export type { AgentDeviceSelectionOptions, AgentDeviceSession, AgentDeviceSessionDevice, + AlertCommandOptions, + AlertCommandResult, + AppPushOptions, + AppStateCommandOptions, + AppStateCommandResult, + AppTriggerEventOptions, AppCloseOptions, AppCloseResult, AppDeployOptions, @@ -273,16 +569,60 @@ export type { AppListOptions, AppOpenOptions, AppOpenResult, + AppSwitcherCommandOptions, + AppSwitcherCommandResult, + BackCommandOptions, + BackCommandResult, CaptureScreenshotOptions, CaptureScreenshotResult, CaptureSnapshotOptions, CaptureSnapshotResult, + CaptureDiffOptions, + ClipboardCommandOptions, + ClipboardCommandResult, + CommandRequestResult, + BatchRunOptions, + BatchStep, + ClickOptions, + ElementTarget, + DeviceBootOptions, EnsureSimulatorOptions, EnsureSimulatorResult, + FillOptions, + FindLocator, + FindOptions, + FocusOptions, + GetOptions, + HomeCommandOptions, + HomeCommandResult, + IsOptions, + InteractionTarget, + KeyboardCommandOptions, + KeyboardCommandResult, + LogsOptions, + LongPressOptions, MaterializationReleaseOptions, MaterializationReleaseResult, MetroPrepareOptions, MetroPrepareResult, + NetworkOptions, + PerfOptions, + PermissionTarget, + PinchOptions, + PressOptions, + RecordOptions, + ReplayRunOptions, + ReplayTestOptions, + RotateCommandOptions, + RotateCommandResult, + ScrollIntoViewOptions, + ScrollOptions, SessionCloseResult, + SettingsUpdateOptions, StartupPerfSample, + SwipeOptions, + TraceOptions, + TypeTextOptions, + WaitCommandOptions, + WaitCommandResult, } from './client-types.ts'; diff --git a/src/index.ts b/src/index.ts index ffbaa7a4e..2dda30780 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,95 @@ export { createAgentDeviceClient } from './client.ts'; export { AppError } from './utils/errors.ts'; + +export type { + AgentDeviceClient, + AgentDeviceClientConfig, + AgentDeviceCommandClient, + AgentDeviceDaemonTransport, + AgentDeviceDevice, + AgentDeviceIdentifiers, + AgentDeviceRequestOverrides, + AgentDeviceSelectionOptions, + AgentDeviceSession, + AgentDeviceSessionDevice, + AlertCommandOptions, + AlertCommandResult, + AppCloseOptions, + AppCloseResult, + AppDeployOptions, + AppDeployResult, + AppInstallFromSourceOptions, + AppInstallFromSourceResult, + AppListOptions, + AppOpenOptions, + AppOpenResult, + AppPushOptions, + AppStateCommandOptions, + AppStateCommandResult, + AppSwitcherCommandOptions, + AppSwitcherCommandResult, + AppTriggerEventOptions, + BackCommandOptions, + BackCommandResult, + BatchRunOptions, + BatchStep, + CaptureDiffOptions, + CaptureScreenshotOptions, + CaptureScreenshotResult, + CaptureSnapshotOptions, + CaptureSnapshotResult, + ClickOptions, + ClipboardCommandOptions, + ClipboardCommandResult, + CommandRequestResult, + DeviceBootOptions, + ElementTarget, + EnsureSimulatorOptions, + EnsureSimulatorResult, + FillOptions, + FindLocator, + FindOptions, + FocusOptions, + GetOptions, + HomeCommandOptions, + HomeCommandResult, + InteractionTarget, + IsOptions, + KeyboardCommandOptions, + KeyboardCommandResult, + LogsOptions, + LongPressOptions, + MaterializationReleaseOptions, + MaterializationReleaseResult, + MetroPrepareOptions, + MetroPrepareResult, + NetworkOptions, + PerfOptions, + PermissionTarget, + PinchOptions, + PressOptions, + RecordOptions, + ReplayRunOptions, + ReplayTestOptions, + RotateCommandOptions, + RotateCommandResult, + ScrollIntoViewOptions, + ScrollOptions, + SessionCloseResult, + SettingsUpdateOptions, + StartupPerfSample, + SwipeOptions, + TraceOptions, + TypeTextOptions, + WaitCommandOptions, + WaitCommandResult, +} from './client.ts'; + +export type { + Point, + Rect, + ScreenshotOverlayRef, + SnapshotNode, + SnapshotVisibility, + SnapshotVisibilityReason, +} from './utils/snapshot.ts'; diff --git a/src/utils/__tests__/args.test.ts b/src/utils/__tests__/args.test.ts index d17ee3b96..89a920279 100644 --- a/src/utils/__tests__/args.test.ts +++ b/src/utils/__tests__/args.test.ts @@ -1,6 +1,6 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; -import { parseArgs, toDaemonFlags, usage, usageForCommand } from '../args.ts'; +import { parseArgs, usage, usageForCommand } from '../args.ts'; import { AppError } from '../errors.ts'; import { getCliCommandNames, getSchemaCapabilityKeys } from '../command-schema.ts'; import { listCapabilityCommands } from '../../core/capabilities.ts'; @@ -375,17 +375,6 @@ test('batch requires exactly one step source', () => { ); }); -test('toDaemonFlags strips CLI-only flags', () => { - const parsed = parseArgs(['open', 'settings', '--json', '--session-lock', 'strip']); - const daemonFlags = toDaemonFlags(parsed.flags); - assert.equal(Object.hasOwn(daemonFlags, 'json'), false); - assert.equal(Object.hasOwn(daemonFlags, 'help'), false); - assert.equal(Object.hasOwn(daemonFlags, 'version'), false); - assert.equal(Object.hasOwn(daemonFlags, 'sessionLock'), false); - assert.equal(Object.hasOwn(daemonFlags, 'sessionLocked'), false); - assert.equal(Object.hasOwn(daemonFlags, 'sessionLockConflicts'), false); -}); - test('parseArgs accepts --save-script with optional path value', () => { const withoutPath = parseArgs(['open', 'settings', '--save-script']); assert.equal(withoutPath.command, 'open'); diff --git a/src/utils/args.ts b/src/utils/args.ts index a64e65c7d..b58f929ea 100644 --- a/src/utils/args.ts +++ b/src/utils/args.ts @@ -313,23 +313,6 @@ function formatUnsupportedFlagMessage(command: string | null, unsupported: strin : `Flags ${unsupported.join(', ')} are not supported for command ${command}.`; } -export function toDaemonFlags( - flags: CliFlags, -): Omit { - const { - json: _json, - config: _config, - remoteConfig: _remoteConfig, - help: _help, - version: _version, - sessionLock: _sessionLock, - sessionLocked: _sessionLocked, - sessionLockConflicts: _sessionLockConflicts, - ...daemonFlags - } = flags; - return daemonFlags; -} - export function usage(): string { return buildUsageText(); } diff --git a/src/utils/cli-options.ts b/src/utils/cli-options.ts index 3089cdf60..17efd8728 100644 --- a/src/utils/cli-options.ts +++ b/src/utils/cli-options.ts @@ -18,7 +18,7 @@ export function resolveCliOptions( const env = options?.env ?? process.env; const cwd = options?.cwd ?? process.cwd(); const remoteConfigDefaults = resolveRemoteConfigDefaults({ - cliFlags: rawParsed.flags as CliFlags, + remoteConfig: rawParsed.flags.remoteConfig, cwd, env, }); diff --git a/src/utils/remote-config.ts b/src/utils/remote-config.ts index c5534e1f9..95d4e8619 100644 --- a/src/utils/remote-config.ts +++ b/src/utils/remote-config.ts @@ -31,22 +31,22 @@ function profileToCliFlags(profile: RemoteConfigProfile): Partial { } export function resolveRemoteConfigDefaults(options: { - cliFlags: CliFlags; + remoteConfig?: string; cwd: string; env: Record; }): Partial { - if (!options.cliFlags.remoteConfig) { + if (!options.remoteConfig) { return {}; } const resolved = resolveRemoteConfigProfile({ - configPath: options.cliFlags.remoteConfig, + configPath: options.remoteConfig, cwd: options.cwd, env: options.env, }); return { ...profileToCliFlags(resolved.profile), - remoteConfig: options.cliFlags.remoteConfig, + remoteConfig: options.remoteConfig, }; } diff --git a/website/docs/docs/client-api.md b/website/docs/docs/client-api.md index 06c4496a2..510512f46 100644 --- a/website/docs/docs/client-api.md +++ b/website/docs/docs/client-api.md @@ -33,6 +33,8 @@ const client = createAgentDeviceClient({ session: 'qa-ios', lockPolicy: 'reject', lockPlatform: 'ios', + // Optional: loads profile defaults for daemon-backed requests. Per-call options override it. + remoteConfig: './agent-device.remote.json', }); const devices = await client.devices.list({ platform: 'ios' }); @@ -57,6 +59,59 @@ const snapshot = await client.capture.snapshot({ interactiveOnly: true }); await client.sessions.close(); ``` +## Command methods + +Use `client.command.()` for command-level device actions. It uses the same daemon transport path as the higher-level client methods, including session metadata, tenant/run/lease fields, client-level remote config defaults, normalized daemon errors, and remote artifact handling. + +Results are daemon-shaped objects with typed known fields, so command semantics stay aligned with the CLI. + +```ts +await client.command.wait({ + text: 'Continue', + timeoutMs: 5_000, +}); + +await client.command.keyboard({ + action: 'dismiss', +}); + +await client.command.clipboard({ + action: 'write', + text: 'hello from Node', +}); + +await client.command.back({ + mode: 'system', +}); + +await client.command.appSwitcher(); +``` + +Supported command methods: + +- `wait` +- `appState` +- `back` +- `home` +- `rotate` +- `appSwitcher` +- `keyboard` +- `clipboard` +- `alert` + +Additional CLI-backed methods are exposed on their domain groups with typed option objects so Node consumers do not need to build raw daemon requests: + +- `client.devices.boot()` +- `client.apps.push()` +- `client.apps.triggerEvent()` +- `client.capture.diff()` +- `client.interactions.click()`, `press()`, `longPress()`, `swipe()`, `focus()`, `type()`, `fill()`, `scroll()`, `scrollIntoView()`, `pinch()`, `get()`, `is()`, `find()` +- `client.replay.run()` and `client.replay.test()` +- `client.batch.run()` +- `client.observability.perf()`, `logs()`, and `network()` +- `client.recording.record()` and `client.recording.trace()` +- `client.settings.update()` + ## Android `installFromSource()` ```ts