diff --git a/README.md b/README.md index a21d1233..6cc3820a 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,48 @@ Or use it without installing: npx agent-device open SampleApp ``` +Use the typed daemon client from application code: + +```ts +import { createAgentDeviceClient } from 'agent-device'; + +const client = createAgentDeviceClient({ session: 'qa-ios' }); + +const devices = await client.devices.list({ platform: 'ios' }); +const ensured = await client.simulators.ensure({ + device: 'iPhone 16', + boot: true, +}); + +await client.apps.open({ + app: 'com.apple.Preferences', + platform: 'ios', + udid: ensured.udid, + runtime: { + metroHost: '127.0.0.1', + metroPort: 8081, + }, +}); + +const snapshot = await client.capture.snapshot({ interactiveOnly: true }); +const androidClient = createAgentDeviceClient({ session: 'qa-android' }); +const installed = await androidClient.apps.installFromSource({ + platform: 'android', + retainPaths: true, + retentionMs: 60_000, + source: { kind: 'url', url: 'https://example.com/app.apk' }, +}); +await androidClient.apps.open({ app: installed.launchTarget, platform: 'android' }); +console.log(installed.installablePath, installed.materializationId); +if (installed.materializationId) { + await androidClient.materializations.release({ + materializationId: installed.materializationId, + }); +} +await client.sessions.close(); +await androidClient.sessions.close(); +``` + The skill is also accessible on [ClawHub](https://clawhub.ai/okwasniewski/agent-device). For structured exploratory QA workflows, use the dogfood skill at [skills/dogfood/SKILL.md](skills/dogfood/SKILL.md). @@ -314,7 +356,7 @@ Navigation helpers: - `reinstall ` uninstalls and installs the app binary in one command (Android + iOS simulator/device). - `install`/`reinstall` accept package/bundle id style app names and support `~` in paths. - When `AGENT_DEVICE_DAEMON_BASE_URL` targets a remote daemon, local `.apk`/`.aab`/`.ipa` files and `.app` bundles are uploaded automatically before `install`/`reinstall`. -- Remote daemon clients can persist session-scoped runtime hints with `runtime set` before `open`, or pass a typed `runtime` object on the `open` daemon request to replace the session runtime atomically for that launch. Android launches write React Native dev prefs, and iOS simulator launches write React Native bundle defaults before app start. CLI example: `agent-device runtime set --session my-session --platform android --metro-host 10.0.0.10 --metro-port 8081 --launch-url "myapp://dev"`. +- Remote daemon clients can persist session-scoped runtime hints with `runtime set` before `open`; Android launches write React Native dev prefs, and iOS simulator launches write React Native bundle defaults before app start. Example: `agent-device runtime set --session my-session --platform android --metro-host 10.0.0.10 --metro-port 8081 --launch-url "myapp://dev"`. - Remote daemon screenshots and recordings are materialized back to the caller path instead of returning host-local daemon paths. - To force a daemon-side path instead of uploading a local file, prefix it with `remote:`, for example `remote:/srv/builds/MyApp.app`. - Supported binary formats for `install`/`reinstall`: Android `.apk` and `.aab`, iOS `.app` and `.ipa`. @@ -522,11 +564,6 @@ Environment selectors: - `AGENT_DEVICE_STATE_DIR=` override daemon state directory (metadata, logs, session artifacts). - `AGENT_DEVICE_DAEMON_BASE_URL=http(s)://host:port[/base-path]` connect directly to a remote HTTP daemon and skip local daemon metadata/startup. - Remote daemon installs upload local artifacts through `POST /upload`; use a `remote:` path prefix when you need the daemon to read an existing server-side artifact path as-is. -- HTTP JSON-RPC also exposes `agent_device.install_from_source` for typed host-side download/materialize/install flows. It accepts `{ platform, source: { kind: "url" | "path", ... }, session?, requestId?, retainPaths?, retentionMs? }` and returns normalized app identity (`packageName`/`bundleId`, `launchTarget`). -- Set `retainPaths: true` to opt into daemon-managed retained materialization. In that mode the response also includes `installablePath`, optional `archivePath`, `materializationId`, and `materializationExpiresAt`. -- Retained paths are server-side paths intended for later daemon-side reuse. They are cleaned automatically on TTL expiry, on session close when bound to a session, or immediately via `agent_device.release_materialized_paths({ materializationId, session?, requestId? })`. -- `AGENT_DEVICE_SOURCE_DOWNLOAD_TIMEOUT_MS=` timeout for host-side `install_from_source` URL downloads (default: `120000`). -- `AGENT_DEVICE_INSTALL_SOURCE_RETAIN_TTL_MS=` default retention TTL for `install_from_source retainPaths:true` materializations (default: `900000`). - `AGENT_DEVICE_DAEMON_AUTH_TOKEN=` auth token for remote HTTP daemon mode; sent in both the JSON-RPC request token and HTTP auth headers (`Authorization: Bearer` and `x-agent-device-token`). - `AGENT_DEVICE_DAEMON_SERVER_MODE=socket|http|dual` daemon server mode. `http` and `dual` expose JSON-RPC 2.0 at `POST /rpc` (`GET /health` available for liveness). - `AGENT_DEVICE_DAEMON_TRANSPORT=auto|socket|http` client preference when connecting to daemon metadata. diff --git a/package.json b/package.json index d53cf0d8..2b7c6cc1 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,14 @@ "license": "MIT", "author": "Callstack", "type": "module", + "main": "dist/src/index.js", + "types": "dist/src/index.d.ts", + "exports": { + ".": { + "import": "./dist/src/index.js", + "types": "./dist/src/index.d.ts" + } + }, "engines": { "node": ">=22" }, diff --git a/rslib.config.ts b/rslib.config.ts index 6927cf76..8a00dbd5 100644 --- a/rslib.config.ts +++ b/rslib.config.ts @@ -6,7 +6,7 @@ export default defineConfig({ { format: 'esm', syntax: 'esnext', - dts: false, + dts: true, shims: { esm: { __filename: true, @@ -14,6 +14,7 @@ export default defineConfig({ }, source: { entry: { + index: 'src/index.ts', bin: 'src/bin.ts', daemon: 'src/daemon.ts', }, diff --git a/src/__tests__/cli-diagnostics.test.ts b/src/__tests__/cli-diagnostics.test.ts index 4ecacc8b..0cc30014 100644 --- a/src/__tests__/cli-diagnostics.test.ts +++ b/src/__tests__/cli-diagnostics.test.ts @@ -70,10 +70,17 @@ async function runCliCapture( test('cli forwards --debug as verbose/debug metadata', async () => { const result = await runCliCapture(['open', 'settings', '--debug', '--json'], async () => ({ ok: true, - data: { app: 'settings' }, + data: { + app: 'settings', + platform: 'ios', + target: 'mobile', + device: 'iPhone 16', + id: 'SIM-001', + }, })); assert.equal(result.code, null); assert.equal(result.calls.length, 1); + assert.equal(result.calls[0]?.command, 'open'); assert.equal(result.calls[0]?.flags?.verbose, true); assert.equal(result.calls[0]?.meta?.debug, true); assert.equal(result.calls[0]?.meta?.cwd, process.cwd()); @@ -149,3 +156,32 @@ test('cli parse failures include diagnostic references in JSON mode', async () = process.env.HOME = previousHome; } }); + +test('cli forwards save-script and no-record flags for client-backed open', async () => { + const result = await runCliCapture(['open', 'settings', '--save-script', '--no-record', '--json'], async () => ({ + ok: true, + data: { + app: 'settings', + platform: 'ios', + target: 'mobile', + device: 'iPhone 16', + id: 'SIM-001', + }, + })); + assert.equal(result.code, null); + assert.equal(result.calls.length, 1); + assert.equal(result.calls[0]?.command, 'open'); + assert.equal(result.calls[0]?.flags?.saveScript, true); + assert.equal(result.calls[0]?.flags?.noRecord, true); +}); + +test('cli preserves --out for client-backed screenshot', async () => { + const result = await runCliCapture(['screenshot', '--out', '/tmp/shot.png', '--json'], async () => ({ + ok: true, + data: { path: '/tmp/shot.png' }, + })); + assert.equal(result.code, null); + assert.equal(result.calls.length, 1); + assert.equal(result.calls[0]?.command, 'screenshot'); + assert.deepEqual(result.calls[0]?.positionals, ['/tmp/shot.png']); +}); diff --git a/src/__tests__/client.test.ts b/src/__tests__/client.test.ts new file mode 100644 index 00000000..39c30e80 --- /dev/null +++ b/src/__tests__/client.test.ts @@ -0,0 +1,287 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + createAgentDeviceClient, + type AgentDeviceClientConfig, +} from '../client.ts'; +import type { DaemonRequest, DaemonResponse } from '../daemon/types.ts'; +import { AppError } from '../utils/errors.ts'; + +function createTransport( + handler: (req: Omit) => Promise | DaemonResponse, +): { + calls: Array>; + config: AgentDeviceClientConfig; + transport: (req: Omit) => Promise; +} { + const calls: Array> = []; + const config: AgentDeviceClientConfig = { + session: 'qa', + cwd: '/tmp/agent-device', + debug: true, + daemonBaseUrl: 'http://daemon.example.test', + daemonAuthToken: 'secret', + daemonTransport: 'http', + tenant: 'acme', + sessionIsolation: 'tenant', + runId: 'run-123', + leaseId: 'lease-123', + }; + return { + calls, + config, + transport: async (req) => { + calls.push(req); + return await handler(req); + }, + }; +} + +test('devices.list maps daemon devices into normalized identifiers', async () => { + const setup = createTransport(async () => ({ + ok: true, + data: { + devices: [ + { + platform: 'ios', + id: 'SIM-001', + name: 'iPhone 16', + kind: 'simulator', + target: 'mobile', + booted: true, + }, + ], + }, + })); + const client = createAgentDeviceClient(setup.config, { transport: setup.transport }); + + const devices = await client.devices.list({ + platform: 'ios', + iosSimulatorDeviceSet: '/tmp/sim-set', + }); + + assert.equal(setup.calls.length, 1); + assert.equal(setup.calls[0]?.command, 'devices'); + assert.deepEqual(setup.calls[0]?.flags, { + daemonBaseUrl: 'http://daemon.example.test', + daemonAuthToken: 'secret', + daemonTransport: 'http', + tenant: 'acme', + sessionIsolation: 'tenant', + runId: 'run-123', + leaseId: 'lease-123', + platform: 'ios', + iosSimulatorDeviceSet: '/tmp/sim-set', + verbose: true, + }); + assert.deepEqual(devices, [ + { + platform: 'ios', + target: 'mobile', + kind: 'simulator', + id: 'SIM-001', + name: 'iPhone 16', + booted: true, + identifiers: { + deviceId: 'SIM-001', + deviceName: 'iPhone 16', + udid: 'SIM-001', + }, + ios: { + udid: 'SIM-001', + }, + android: undefined, + }, + ]); +}); + +test('apps.open resolves session device identifiers from open response', async () => { + const setup = createTransport(async (req) => { + if (req.command === 'open') { + return { + ok: true, + data: { + session: 'qa', + appName: 'Settings', + appBundleId: 'com.apple.Preferences', + platform: 'ios', + target: 'mobile', + device: 'iPhone 16', + id: 'SIM-001', + kind: 'simulator', + device_udid: 'SIM-001', + ios_simulator_device_set: '/tmp/sim-set', + startup: { + durationMs: 1234, + measuredAt: '2026-03-13T10:00:00.000Z', + method: 'open-command-roundtrip', + }, + }, + }; + } + throw new Error(`Unexpected command: ${req.command}`); + }); + const client = createAgentDeviceClient(setup.config, { transport: setup.transport }); + + const result = await client.apps.open({ + app: 'Settings', + platform: 'ios', + relaunch: true, + }); + + assert.equal(setup.calls.length, 1); + assert.equal(setup.calls[0]?.command, 'open'); + assert.deepEqual(setup.calls[0]?.positionals, ['Settings']); + assert.equal(result.identifiers.session, 'qa'); + assert.equal(result.identifiers.deviceId, 'SIM-001'); + assert.equal(result.identifiers.udid, 'SIM-001'); + assert.equal(result.identifiers.appId, 'com.apple.Preferences'); + assert.equal(result.device?.name, 'iPhone 16'); + assert.equal(result.device?.ios?.simulatorSetPath, '/tmp/sim-set'); +}); + +test('apps.open forwards explicit runtime hints through the daemon request', async () => { + const setup = createTransport(async () => ({ + ok: true, + data: { + session: 'qa', + appName: 'Demo', + appBundleId: 'com.example.demo', + runtime: { + platform: 'ios', + metroHost: '127.0.0.1', + metroPort: 8081, + }, + }, + })); + const client = createAgentDeviceClient(setup.config, { transport: setup.transport }); + + const result = await client.apps.open({ + app: 'Demo', + platform: 'ios', + runtime: { + metroHost: '127.0.0.1', + metroPort: 8081, + }, + }); + + assert.equal(setup.calls.length, 1); + assert.deepEqual(setup.calls[0]?.runtime, { + metroHost: '127.0.0.1', + metroPort: 8081, + }); + assert.deepEqual(result.runtime, { + platform: 'ios', + metroHost: '127.0.0.1', + metroPort: 8081, + bundleUrl: undefined, + launchUrl: undefined, + }); +}); + +test('apps.installFromSource forwards source payload and normalizes launch identity', async () => { + const setup = createTransport(async () => ({ + ok: true, + data: { + packageName: 'com.example.demo', + appName: 'Demo', + launchTarget: 'com.example.demo', + installablePath: '/tmp/materialized/installable/demo.apk', + archivePath: '/tmp/materialized/archive/demo.zip', + materializationId: 'materialized-123', + materializationExpiresAt: '2026-03-13T12:00:00.000Z', + }, + })); + const client = createAgentDeviceClient(setup.config, { transport: setup.transport }); + + const result = await client.apps.installFromSource({ + platform: 'android', + retainPaths: true, + retentionMs: 60_000, + source: { + kind: 'url', + url: 'https://example.com/demo.apk', + headers: { authorization: 'Bearer token' }, + }, + }); + + assert.equal(setup.calls.length, 1); + assert.equal(setup.calls[0]?.command, 'install_source'); + assert.deepEqual(setup.calls[0]?.meta?.installSource, { + kind: 'url', + url: 'https://example.com/demo.apk', + headers: { authorization: 'Bearer token' }, + }); + assert.equal(setup.calls[0]?.meta?.retainMaterializedPaths, true); + assert.equal(setup.calls[0]?.meta?.materializedPathRetentionMs, 60_000); + assert.deepEqual(result, { + appName: 'Demo', + appId: 'com.example.demo', + bundleId: undefined, + packageName: 'com.example.demo', + launchTarget: 'com.example.demo', + installablePath: '/tmp/materialized/installable/demo.apk', + archivePath: '/tmp/materialized/archive/demo.zip', + materializationId: 'materialized-123', + materializationExpiresAt: '2026-03-13T12:00:00.000Z', + identifiers: { + session: 'qa', + appId: 'com.example.demo', + appBundleId: undefined, + package: 'com.example.demo', + }, + }); +}); + +test('materializations.release forwards materialization identity through the daemon request', async () => { + const setup = createTransport(async () => ({ + ok: true, + data: { + released: true, + materializationId: 'materialized-123', + }, + })); + const client = createAgentDeviceClient(setup.config, { transport: setup.transport }); + + const result = await client.materializations.release({ + materializationId: 'materialized-123', + }); + + assert.equal(setup.calls.length, 1); + assert.equal(setup.calls[0]?.command, 'release_materialized_paths'); + assert.equal(setup.calls[0]?.meta?.materializationId, 'materialized-123'); + assert.deepEqual(result, { + released: true, + materializationId: 'materialized-123', + identifiers: {}, + }); +}); + +test('client throws AppError for daemon failures', async () => { + const setup = createTransport(async () => ({ + ok: false, + error: { + code: 'SESSION_NOT_FOUND', + message: 'No active session', + hint: 'Run open first.', + diagnosticId: 'diag-1', + logPath: '/tmp/daemon.log', + details: { session: 'qa' }, + }, + })); + const client = createAgentDeviceClient(setup.config, { transport: setup.transport }); + + await assert.rejects( + async () => await client.capture.snapshot(), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.code, 'SESSION_NOT_FOUND'); + assert.equal(error.message, 'No active session'); + assert.equal(error.details?.hint, 'Run open first.'); + assert.equal(error.details?.diagnosticId, 'diag-1'); + assert.equal(error.details?.logPath, '/tmp/daemon.log'); + assert.deepEqual(error.details?.session, 'qa'); + return true; + }, + ); +}); diff --git a/src/cli-client-commands.ts b/src/cli-client-commands.ts new file mode 100644 index 00000000..81e6b366 --- /dev/null +++ b/src/cli-client-commands.ts @@ -0,0 +1,319 @@ +import type { CliFlags } from './utils/command-schema.ts'; +import { formatSnapshotText, printJson } from './utils/output.ts'; +import { AppError } from './utils/errors.ts'; +import type { + AgentDeviceClient, + AgentDeviceDevice, + AgentDeviceSession, + AppCloseResult, + AppDeployResult, + AppOpenResult, + CaptureSnapshotResult, + EnsureSimulatorResult, + RuntimeResult, + SessionCloseResult, +} from './client.ts'; + +export async function tryRunClientBackedCommand(params: { + command: string; + positionals: string[]; + flags: CliFlags; + client: AgentDeviceClient; +}): Promise { + const handler = clientCommandHandlers[params.command]; + return handler ? await handler(params) : false; +} + +type ClientCommandParams = { + positionals: string[]; + flags: CliFlags; + client: AgentDeviceClient; +}; + +type ClientCommandHandler = (params: ClientCommandParams) => Promise; + +const clientCommandHandlers: Partial> = { + session: async ({ positionals, flags, client }) => { + const sub = positionals[0] ?? 'list'; + if (sub !== 'list') { + throw new AppError('INVALID_ARGS', 'session only supports list'); + } + const sessions = await client.sessions.list(); + const data = { sessions: sessions.map(serializeSessionListEntry) }; + if (flags.json) printJson({ success: true, data }); + else process.stdout.write(`${JSON.stringify(data, null, 2)}\n`); + return true; + }, + devices: async ({ flags, client }) => { + const devices = await client.devices.list(buildSelectionOptions(flags)); + const data = { devices: devices.map(serializeDevice) }; + if (flags.json) printJson({ success: true, data }); + else process.stdout.write(`${devices.map(formatDeviceLine).join('\n')}\n`); + return true; + }, + 'ensure-simulator': async ({ flags, client }) => { + if (!flags.device) { + throw new AppError('INVALID_ARGS', 'ensure-simulator requires --device '); + } + const result = await client.simulators.ensure({ + device: flags.device, + runtime: flags.runtime, + boot: flags.boot, + reuseExisting: flags.reuseExisting, + iosSimulatorDeviceSet: flags.iosSimulatorDeviceSet, + }); + const data = serializeEnsureSimulatorResult(result); + if (flags.json) { + printJson({ success: true, data }); + } else { + const action = result.created ? 'Created' : 'Reused'; + const bootedSuffix = result.booted ? ' (booted)' : ''; + process.stdout.write(`${action}: ${result.device} ${result.udid}${bootedSuffix}\n`); + if (result.runtime) process.stdout.write(`Runtime: ${result.runtime}\n`); + } + return true; + }, + runtime: async ({ positionals, flags, client }) => { + const action = (positionals[0] ?? 'show').toLowerCase(); + if (action === 'set') { + writeRuntimeResult(await client.runtime.set({ + platform: flags.platform, + metroHost: flags.metroHost, + metroPort: flags.metroPort, + bundleUrl: flags.bundleUrl, + launchUrl: flags.launchUrl, + }), flags); + return true; + } + if (action === 'show') { + writeRuntimeResult(await client.runtime.show(), flags); + return true; + } + return false; + }, + install: async ({ positionals, flags, client }) => { + const result = await runDeployCommand('install', positionals, flags, client); + if (flags.json) printJson({ success: true, data: serializeDeployResult(result) }); + return true; + }, + reinstall: async ({ positionals, flags, client }) => { + const result = await runDeployCommand('reinstall', positionals, flags, client); + if (flags.json) printJson({ success: true, data: serializeDeployResult(result) }); + return true; + }, + open: async ({ positionals, flags, client }) => { + if (!positionals[0]) { + return false; + } + const result = await client.apps.open({ + app: positionals[0], + url: positionals[1], + activity: flags.activity, + relaunch: flags.relaunch, + saveScript: flags.saveScript, + noRecord: flags.noRecord, + ...buildSelectionOptions(flags), + }); + if (flags.json) printJson({ success: true, data: serializeOpenResult(result) }); + return true; + }, + close: async ({ positionals, flags, client }) => { + const result = positionals[0] + ? await client.apps.close({ app: positionals[0], shutdown: flags.shutdown }) + : await client.sessions.close({ shutdown: flags.shutdown }); + if (flags.json) { + printJson({ success: true, data: serializeCloseResult(result) }); + } + return true; + }, + snapshot: async ({ flags, client }) => { + const result = await client.capture.snapshot({ + ...buildSelectionOptions(flags), + interactiveOnly: flags.snapshotInteractiveOnly, + compact: flags.snapshotCompact, + depth: flags.snapshotDepth, + scope: flags.snapshotScope, + raw: flags.snapshotRaw, + }); + const data = serializeSnapshotResult(result); + if (flags.json) { + printJson({ success: true, data }); + } else { + process.stdout.write( + formatSnapshotText(data, { + raw: flags.snapshotRaw, + flatten: flags.snapshotInteractiveOnly, + }), + ); + } + return true; + }, + screenshot: async ({ positionals, flags, client }) => { + const result = await client.capture.screenshot({ path: positionals[0] ?? flags.out }); + const data = { path: result.path }; + if (flags.json) printJson({ success: true, data }); + else process.stdout.write(`${result.path}\n`); + return true; + }, +}; + +async function runDeployCommand( + command: 'install' | 'reinstall', + positionals: string[], + flags: CliFlags, + client: AgentDeviceClient, +): Promise { + const app = positionals[0]; + const appPath = positionals[1]; + if (!app || !appPath) { + throw new AppError('INVALID_ARGS', `${command} requires: ${command} `); + } + const options = { + app, + appPath, + ...buildSelectionOptions(flags), + }; + return command === 'install' + ? await client.apps.install(options) + : await client.apps.reinstall(options); +} + +function writeRuntimeResult(result: RuntimeResult, flags: CliFlags): void { + const data = serializeRuntimeResult(result); + if (flags.json) { + printJson({ success: true, data }); + } else if (!result.configured) { + process.stdout.write('No runtime hints configured\n'); + } else { + process.stdout.write(`${JSON.stringify(result.runtime ?? {}, null, 2)}\n`); + } +} + +function buildSelectionOptions(flags: CliFlags): { + platform?: CliFlags['platform']; + target?: CliFlags['target']; + device?: string; + udid?: string; + serial?: string; + iosSimulatorDeviceSet?: string; + androidDeviceAllowlist?: string; +} { + return { + platform: flags.platform, + target: flags.target, + device: flags.device, + udid: flags.udid, + serial: flags.serial, + iosSimulatorDeviceSet: flags.iosSimulatorDeviceSet, + androidDeviceAllowlist: flags.androidDeviceAllowlist, + }; +} + +function serializeSessionListEntry(session: AgentDeviceSession): Record { + return { + name: session.name, + platform: session.device.platform, + target: session.device.target, + device: session.device.name, + id: session.device.id, + createdAt: session.createdAt, + ...(session.device.platform === 'ios' && { + device_udid: session.device.ios?.udid ?? session.device.id, + ios_simulator_device_set: session.device.ios?.simulatorSetPath ?? null, + }), + }; +} + +function serializeDevice(device: AgentDeviceDevice): Record { + return { + platform: device.platform, + id: device.id, + name: device.name, + kind: device.kind, + target: device.target, + ...(typeof device.booted === 'boolean' ? { booted: device.booted } : {}), + }; +} + +function formatDeviceLine(device: AgentDeviceDevice): string { + const kind = device.kind ? ` ${device.kind}` : ''; + const target = device.target ? ` target=${device.target}` : ''; + const booted = typeof device.booted === 'boolean' ? ` booted=${device.booted}` : ''; + return `${device.name} (${device.platform}${kind}${target})${booted}`; +} + +function serializeEnsureSimulatorResult(result: EnsureSimulatorResult): Record { + return { + udid: result.udid, + device: result.device, + runtime: result.runtime, + ios_simulator_device_set: result.iosSimulatorDeviceSet ?? null, + created: result.created, + booted: result.booted, + }; +} + +function serializeRuntimeResult(result: RuntimeResult): Record { + return { + session: result.session, + configured: result.configured, + ...(result.cleared ? { cleared: true } : {}), + ...(result.runtime ? { runtime: result.runtime } : {}), + }; +} + +function serializeDeployResult(result: AppDeployResult): Record { + return { + app: result.app, + appPath: result.appPath, + platform: result.platform, + ...(result.appId ? { appId: result.appId } : {}), + ...(result.bundleId ? { bundleId: result.bundleId } : {}), + ...(result.package ? { package: result.package } : {}), + }; +} + +function serializeOpenResult(result: AppOpenResult): Record { + return { + session: result.session, + ...(result.appName ? { appName: result.appName } : {}), + ...(result.appBundleId ? { appBundleId: result.appBundleId } : {}), + ...(result.startup ? { startup: result.startup } : {}), + ...(result.runtime ? { runtime: result.runtime } : {}), + ...(result.device + ? { + platform: result.device.platform, + target: result.device.target, + device: result.device.name, + id: result.device.id, + } + : {}), + ...(result.device?.platform === 'ios' + ? { + device_udid: result.device.ios?.udid ?? result.device.id, + ios_simulator_device_set: result.device.ios?.simulatorSetPath ?? null, + } + : {}), + ...(result.device?.platform === 'android' + ? { + serial: result.device.android?.serial ?? result.device.id, + } + : {}), + }; +} + +function serializeCloseResult(result: SessionCloseResult | AppCloseResult): Record { + return { + session: result.session, + ...(result.shutdown ? { shutdown: result.shutdown } : {}), + }; +} + +function serializeSnapshotResult(result: CaptureSnapshotResult): Record { + return { + nodes: result.nodes, + truncated: result.truncated, + ...(result.appName ? { appName: result.appName } : {}), + ...(result.appBundleId ? { appBundleId: result.appBundleId } : {}), + }; +} diff --git a/src/cli.ts b/src/cli.ts index 1612440c..b03dab1d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -8,6 +8,8 @@ import fs from 'node:fs'; import path from 'node:path'; import type { BatchStep } from './core/dispatch.ts'; import { parseBatchStepsJson } from './core/batch.ts'; +import { createAgentDeviceClient, type AgentDeviceClientConfig } from './client.ts'; +import { tryRunClientBackedCommand } from './cli-client-commands.ts'; import { createRequestId, emitDiagnostic, flushDiagnosticsToSessionFile, getDiagnosticsMeta, withDiagnosticsScope } from './utils/diagnostics.ts'; import { resolveDaemonPaths } from './daemon/config.ts'; @@ -103,6 +105,22 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): const logTailStopper = flags.verbose && !flags.json && !remoteDaemonBaseUrl ? startDaemonLogTail(daemonPaths.logPath) : null; + const clientConfig: AgentDeviceClientConfig = { + session: sessionName, + requestId, + stateDir: flags.stateDir, + daemonBaseUrl: flags.daemonBaseUrl, + daemonAuthToken: flags.daemonAuthToken, + daemonTransport: flags.daemonTransport, + daemonServerMode: flags.daemonServerMode, + tenant: flags.tenant, + sessionIsolation: flags.sessionIsolation, + runId: flags.runId, + leaseId: flags.leaseId, + cwd: process.cwd(), + 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, @@ -151,26 +169,7 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): return; } - if (command === 'session') { - const sub = positionals[0] ?? 'list'; - if (sub !== 'list') { - throw new AppError('INVALID_ARGS', 'session only supports list'); - } - const response = await sendDaemonRequest({ - command: 'session_list', - positionals: [], - flags: daemonFlags, - }); - if (!response.ok) { - throw new AppError(response.error.code as any, response.error.message, { - ...(response.error.details ?? {}), - hint: response.error.hint, - diagnosticId: response.error.diagnosticId, - logPath: response.error.logPath, - }); - } - if (flags.json) printJson({ success: true, data: response.data ?? {} }); - else process.stdout.write(`${JSON.stringify(response.data ?? {}, null, 2)}\n`); + if (await tryRunClientBackedCommand({ command, positionals, flags, client })) { if (logTailStopper) logTailStopper(); return; } diff --git a/src/client-normalizers.ts b/src/client-normalizers.ts new file mode 100644 index 00000000..da4aefe6 --- /dev/null +++ b/src/client-normalizers.ts @@ -0,0 +1,337 @@ +import type { CommandFlags } from './core/dispatch.ts'; +import type { DaemonRequest, SessionRuntimeHints } from './daemon/types.ts'; +import { AppError } from './utils/errors.ts'; +import type { DeviceKind, DeviceTarget, Platform } from './utils/device.ts'; +import type { SnapshotNode } from './utils/snapshot.ts'; +import type { + AgentDeviceDevice, + AgentDeviceSession, + AgentDeviceSessionDevice, + AppDeployResult, + AppInstallFromSourceResult, + InternalRequestOptions, + MaterializationReleaseResult, + RuntimeResult, + StartupPerfSample, +} from './client-types.ts'; + +export const DEFAULT_SESSION_NAME = 'default'; + +export function normalizeDeployResult( + data: Record, + session?: string, +): AppDeployResult { + const bundleId = readOptionalString(data, 'bundleId'); + const pkg = readOptionalString(data, 'package'); + const appId = bundleId ?? pkg; + return { + app: readRequiredString(data, 'app'), + appPath: readRequiredString(data, 'appPath'), + platform: readRequiredPlatform(data, 'platform'), + appId, + bundleId, + package: pkg, + identifiers: { + session, + appId, + appBundleId: bundleId, + package: pkg, + }, + }; +} + +export function normalizeRuntimeResult( + data: Record, + session: string, +): RuntimeResult { + return { + session, + configured: data.configured === true, + cleared: data.cleared === true ? true : undefined, + runtime: normalizeRuntimeHints(data.runtime), + identifiers: { session }, + }; +} + +export function normalizeInstallFromSourceResult( + data: Record, + session?: string, +): AppInstallFromSourceResult { + const bundleId = readOptionalString(data, 'bundleId'); + const packageName = readOptionalString(data, 'packageName'); + const appId = bundleId ?? packageName; + return { + appName: readOptionalString(data, 'appName'), + appId, + bundleId, + packageName, + launchTarget: readRequiredString(data, 'launchTarget'), + installablePath: readOptionalString(data, 'installablePath'), + archivePath: readOptionalString(data, 'archivePath'), + materializationId: readOptionalString(data, 'materializationId'), + materializationExpiresAt: readOptionalString(data, 'materializationExpiresAt'), + identifiers: { + session, + appId, + appBundleId: bundleId, + package: packageName, + }, + }; +} + +export function normalizeMaterializationReleaseResult( + data: Record, +): MaterializationReleaseResult { + return { + released: data.released === true, + materializationId: readRequiredString(data, 'materializationId'), + identifiers: {}, + }; +} + +export function normalizeDevice(value: unknown): AgentDeviceDevice { + const record = asRecord(value); + const platform = readRequiredPlatform(record, 'platform'); + const id = readRequiredString(record, 'id'); + const target = readDeviceTarget(record, 'target'); + return { + platform, + target, + kind: readRequiredDeviceKind(record, 'kind'), + id, + name: readRequiredString(record, 'name'), + booted: typeof record.booted === 'boolean' ? record.booted : undefined, + identifiers: { + deviceId: id, + deviceName: readRequiredString(record, 'name'), + ...(platform === 'ios' ? { udid: id } : { serial: id }), + }, + ios: platform === 'ios' ? { udid: id } : undefined, + android: platform === 'android' ? { serial: id } : undefined, + }; +} + +export function normalizeSession(value: unknown): AgentDeviceSession { + const record = asRecord(value); + const platform = readRequiredPlatform(record, 'platform'); + const id = readRequiredString(record, 'id'); + const name = readRequiredString(record, 'name'); + const target = readDeviceTarget(record, 'target'); + const deviceName = readRequiredString(record, 'device'); + const identifiers = { + session: name, + deviceId: id, + deviceName, + ...(platform === 'ios' ? { udid: id } : { serial: id }), + }; + return { + name, + createdAt: readRequiredNumber(record, 'createdAt'), + device: { + platform, + target, + id, + name: deviceName, + identifiers, + ios: platform === 'ios' + ? { + udid: id, + simulatorSetPath: readNullableString(record, 'ios_simulator_device_set'), + } + : undefined, + android: platform === 'android' ? { serial: id } : undefined, + }, + identifiers, + }; +} + +export function normalizeRuntimeHints(value: unknown): SessionRuntimeHints | undefined { + if (!isRecord(value)) return undefined; + const platform = value.platform; + const metroHost = readOptionalString(value, 'metroHost'); + const metroPort = typeof value.metroPort === 'number' ? value.metroPort : undefined; + const bundleUrl = readOptionalString(value, 'bundleUrl'); + const launchUrl = readOptionalString(value, 'launchUrl'); + return { + platform: platform === 'ios' || platform === 'android' ? platform : undefined, + metroHost, + metroPort, + bundleUrl, + launchUrl, + }; +} + +export function normalizeOpenDevice(value: Record): AgentDeviceSessionDevice | undefined { + const platform = value.platform; + const id = readOptionalString(value, 'id'); + const name = readOptionalString(value, 'device'); + if ((platform !== 'ios' && platform !== 'android') || !id || !name) { + return undefined; + } + const target = readDeviceTarget(value, 'target'); + const identifiers = { + deviceId: id, + deviceName: name, + ...(platform === 'ios' ? { udid: id } : { serial: id }), + }; + return { + platform, + target, + id, + name, + identifiers, + ios: platform === 'ios' + ? { + udid: readOptionalString(value, 'device_udid') ?? id, + simulatorSetPath: readNullableString(value, 'ios_simulator_device_set'), + } + : undefined, + android: platform === 'android' + ? { serial: readOptionalString(value, 'serial') ?? id } + : undefined, + }; +} + +export function normalizeStartupSample(value: unknown): StartupPerfSample | undefined { + if (!isRecord(value)) return undefined; + if (typeof value.durationMs !== 'number' || typeof value.measuredAt !== 'string' || typeof value.method !== 'string') { + return undefined; + } + return { + durationMs: value.durationMs, + measuredAt: value.measuredAt, + method: value.method, + appTarget: readOptionalString(value, 'appTarget'), + appBundleId: readOptionalString(value, 'appBundleId'), + }; +} + +export function readSnapshotNodes(value: unknown): SnapshotNode[] { + // Snapshot nodes are produced by the daemon snapshot pipeline and treated as trusted here. + return Array.isArray(value) ? value as SnapshotNode[] : []; +} + +export function buildFlags(options: InternalRequestOptions): CommandFlags { + return stripUndefined({ + stateDir: options.stateDir, + daemonBaseUrl: options.daemonBaseUrl, + daemonAuthToken: options.daemonAuthToken, + daemonTransport: options.daemonTransport, + daemonServerMode: options.daemonServerMode, + tenant: options.tenant, + sessionIsolation: options.sessionIsolation, + runId: options.runId, + leaseId: options.leaseId, + platform: options.platform, + target: options.target, + device: options.device, + udid: options.udid, + serial: options.serial, + iosSimulatorDeviceSet: options.iosSimulatorDeviceSet, + androidDeviceAllowlist: options.androidDeviceAllowlist, + runtime: options.simulatorRuntimeId, + boot: options.boot, + reuseExisting: options.reuseExisting, + activity: options.activity, + relaunch: options.relaunch, + shutdown: options.shutdown, + saveScript: options.saveScript, + noRecord: options.noRecord, + metroHost: options.metroHost, + metroPort: options.metroPort, + bundleUrl: options.bundleUrl, + launchUrl: options.launchUrl, + snapshotInteractiveOnly: options.interactiveOnly, + snapshotCompact: options.compact, + snapshotDepth: options.depth, + snapshotScope: options.scope, + snapshotRaw: options.raw, + verbose: options.debug, + }) as CommandFlags; +} + +export function buildMeta(options: InternalRequestOptions): DaemonRequest['meta'] { + return stripUndefined({ + requestId: options.requestId, + cwd: options.cwd, + debug: options.debug, + tenantId: options.tenant, + runId: options.runId, + leaseId: options.leaseId, + sessionIsolation: options.sessionIsolation, + installSource: options.installSource, + retainMaterializedPaths: options.retainMaterializedPaths, + materializedPathRetentionMs: options.materializedPathRetentionMs, + materializationId: options.materializationId, + }); +} + +export function resolveSessionName(defaultSession: string | undefined, session: string | undefined): string { + return session ?? defaultSession ?? DEFAULT_SESSION_NAME; +} + +function stripUndefined>(value: T): T { + const output = {} as T; + for (const [key, current] of Object.entries(value)) { + if (current !== undefined) { + (output as Record)[key] = current; + } + } + return output; +} + +function asRecord(value: unknown): Record { + if (!isRecord(value)) { + throw new AppError('COMMAND_FAILED', 'Daemon returned an unexpected response shape.', { + value, + }); + } + return value; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +export function readRequiredString(record: Record, key: string): string { + const value = record[key]; + if (typeof value !== 'string' || value.length === 0) { + throw new AppError('COMMAND_FAILED', `Daemon response is missing "${key}".`, { response: record }); + } + return value; +} + +export function readOptionalString(record: Record, key: string): string | undefined { + const value = record[key]; + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +export function readNullableString(record: Record, key: string): string | null | undefined { + const value = record[key]; + if (value === null) return null; + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +function readRequiredNumber(record: Record, key: string): number { + const value = record[key]; + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new AppError('COMMAND_FAILED', `Daemon response is missing numeric "${key}".`, { response: record }); + } + return value; +} + +function readRequiredPlatform(record: Record, key: string): Platform { + const value = record[key]; + if (value === 'ios' || value === 'android') return value; + throw new AppError('COMMAND_FAILED', `Daemon response has invalid "${key}".`, { response: record }); +} + +function readRequiredDeviceKind(record: Record, key: string): DeviceKind { + const value = record[key]; + if (value === 'simulator' || value === 'emulator' || value === 'device') return value; + throw new AppError('COMMAND_FAILED', `Daemon response has invalid "${key}".`, { response: record }); +} + +function readDeviceTarget(record: Record, key: string): DeviceTarget { + return record[key] === 'tv' ? 'tv' : 'mobile'; +} diff --git a/src/client-types.ts b/src/client-types.ts new file mode 100644 index 00000000..e243d182 --- /dev/null +++ b/src/client-types.ts @@ -0,0 +1,297 @@ +import type { DaemonInstallSource, DaemonRequest, DaemonResponse, SessionRuntimeHints } from './daemon/types.ts'; +import type { DeviceKind, DeviceTarget, Platform, PlatformSelector } from './utils/device.ts'; +import type { SnapshotNode } from './utils/snapshot.ts'; + +type DaemonTransportMode = 'auto' | 'socket' | 'http'; +type DaemonServerMode = 'socket' | 'http' | 'dual'; +type SessionIsolationMode = 'none' | 'tenant'; + +export type AgentDeviceDaemonTransport = (req: Omit) => Promise; + +export type AgentDeviceClientConfig = { + session?: string; + requestId?: string; + stateDir?: string; + daemonBaseUrl?: string; + daemonAuthToken?: string; + daemonTransport?: DaemonTransportMode; + daemonServerMode?: DaemonServerMode; + tenant?: string; + sessionIsolation?: SessionIsolationMode; + runId?: string; + leaseId?: string; + cwd?: string; + debug?: boolean; +}; + +export type AgentDeviceRequestOverrides = Pick< + AgentDeviceClientConfig, + 'session' | 'requestId' | 'tenant' | 'sessionIsolation' | 'runId' | 'leaseId' | 'cwd' | 'debug' +>; + +export type AgentDeviceIdentifiers = { + session?: string; + deviceId?: string; + deviceName?: string; + udid?: string; + serial?: string; + appId?: string; + appBundleId?: string; + package?: string; +}; + +export type AgentDeviceSelectionOptions = { + platform?: PlatformSelector; + target?: DeviceTarget; + device?: string; + udid?: string; + serial?: string; + iosSimulatorDeviceSet?: string; + androidDeviceAllowlist?: string; +}; + +export type AgentDeviceDevice = { + platform: Platform; + target: DeviceTarget; + kind: DeviceKind; + id: string; + name: string; + booted?: boolean; + identifiers: AgentDeviceIdentifiers; + ios?: { + udid: string; + }; + android?: { + serial: string; + }; +}; + +export type AgentDeviceSessionDevice = { + platform: Platform; + target: DeviceTarget; + id: string; + name: string; + identifiers: AgentDeviceIdentifiers; + ios?: { + udid: string; + simulatorSetPath?: string | null; + }; + android?: { + serial: string; + }; +}; + +export type AgentDeviceSession = { + name: string; + createdAt: number; + device: AgentDeviceSessionDevice; + identifiers: AgentDeviceIdentifiers; +}; + +export type StartupPerfSample = { + durationMs: number; + measuredAt: string; + method: string; + appTarget?: string; + appBundleId?: string; +}; + +export type SessionCloseResult = { + session: string; + shutdown?: Record; + identifiers: AgentDeviceIdentifiers; +}; + +export type EnsureSimulatorOptions = AgentDeviceRequestOverrides & { + device: string; + runtime?: string; + boot?: boolean; + reuseExisting?: boolean; + iosSimulatorDeviceSet?: string; +}; + +export type EnsureSimulatorResult = { + udid: string; + device: string; + runtime: string; + created: boolean; + booted: boolean; + iosSimulatorDeviceSet?: string | null; + identifiers: AgentDeviceIdentifiers; +}; + +export type AppDeployOptions = AgentDeviceRequestOverrides & AgentDeviceSelectionOptions & { + app: string; + appPath: string; +}; + +export type AppDeployResult = { + app: string; + appPath: string; + platform: Platform; + appId?: string; + bundleId?: string; + package?: string; + identifiers: AgentDeviceIdentifiers; +}; + +export type AppOpenOptions = AgentDeviceRequestOverrides & AgentDeviceSelectionOptions & { + app: string; + url?: string; + activity?: string; + relaunch?: boolean; + saveScript?: boolean | string; + noRecord?: boolean; + runtime?: SessionRuntimeHints; +}; + +export type AppOpenResult = { + session: string; + appName?: string; + appBundleId?: string; + appId?: string; + startup?: StartupPerfSample; + runtime?: SessionRuntimeHints; + device?: AgentDeviceSessionDevice; + identifiers: AgentDeviceIdentifiers; +}; + +export type AppCloseOptions = AgentDeviceRequestOverrides & { + app?: string; + shutdown?: boolean; +}; + +export type AppCloseResult = { + session: string; + closedApp?: string; + shutdown?: Record; + identifiers: AgentDeviceIdentifiers; +}; + +export type AppInstallFromSourceOptions = AgentDeviceRequestOverrides & AgentDeviceSelectionOptions & { + source: DaemonInstallSource; + retainPaths?: boolean; + retentionMs?: number; +}; + +export type AppInstallFromSourceResult = { + appName?: string; + appId?: string; + bundleId?: string; + packageName?: string; + launchTarget: string; + installablePath?: string; + archivePath?: string; + materializationId?: string; + materializationExpiresAt?: string; + identifiers: AgentDeviceIdentifiers; +}; + +export type MaterializationReleaseOptions = AgentDeviceRequestOverrides & { + materializationId: string; +}; + +export type MaterializationReleaseResult = { + released: boolean; + materializationId: string; + identifiers: AgentDeviceIdentifiers; +}; + +export type RuntimeShowOptions = AgentDeviceRequestOverrides; + +export type RuntimeSetOptions = AgentDeviceRequestOverrides & { + platform?: PlatformSelector; + metroHost?: string; + metroPort?: number; + bundleUrl?: string; + launchUrl?: string; +}; + +export type RuntimeResult = { + session: string; + configured: boolean; + cleared?: boolean; + runtime?: SessionRuntimeHints; + identifiers: AgentDeviceIdentifiers; +}; + +export type CaptureSnapshotOptions = AgentDeviceRequestOverrides & AgentDeviceSelectionOptions & { + interactiveOnly?: boolean; + compact?: boolean; + depth?: number; + scope?: string; + raw?: boolean; +}; + +export type CaptureSnapshotResult = { + nodes: SnapshotNode[]; + truncated: boolean; + appName?: string; + appBundleId?: string; + identifiers: AgentDeviceIdentifiers; +}; + +export type CaptureScreenshotOptions = AgentDeviceRequestOverrides & { + path?: string; +}; + +export type CaptureScreenshotResult = { + path: string; + identifiers: AgentDeviceIdentifiers; +}; + +export type InternalRequestOptions = AgentDeviceClientConfig & AgentDeviceSelectionOptions & { + simulatorRuntimeId?: string; + runtime?: SessionRuntimeHints; + boot?: boolean; + reuseExisting?: boolean; + activity?: string; + relaunch?: boolean; + shutdown?: boolean; + saveScript?: boolean | string; + noRecord?: boolean; + metroHost?: string; + metroPort?: number; + bundleUrl?: string; + launchUrl?: string; + interactiveOnly?: boolean; + compact?: boolean; + depth?: number; + scope?: string; + raw?: boolean; + installSource?: DaemonInstallSource; + retainMaterializedPaths?: boolean; + materializedPathRetentionMs?: number; + materializationId?: string; +}; + +export type AgentDeviceClient = { + devices: { + list: (options?: AgentDeviceRequestOverrides & AgentDeviceSelectionOptions) => Promise; + }; + sessions: { + list: (options?: AgentDeviceRequestOverrides) => Promise; + close: (options?: AgentDeviceRequestOverrides & { shutdown?: boolean }) => Promise; + }; + simulators: { + ensure: (options: EnsureSimulatorOptions) => Promise; + }; + apps: { + install: (options: AppDeployOptions) => Promise; + reinstall: (options: AppDeployOptions) => Promise; + installFromSource: (options: AppInstallFromSourceOptions) => Promise; + open: (options: AppOpenOptions) => Promise; + close: (options?: AppCloseOptions) => Promise; + }; + materializations: { + release: (options: MaterializationReleaseOptions) => Promise; + }; + runtime: { + set: (options: RuntimeSetOptions) => Promise; + show: (options?: RuntimeShowOptions) => Promise; + }; + capture: { + snapshot: (options?: CaptureSnapshotOptions) => Promise; + screenshot: (options?: CaptureScreenshotOptions) => Promise; + }; +}; diff --git a/src/client.ts b/src/client.ts new file mode 100644 index 00000000..3649b6b2 --- /dev/null +++ b/src/client.ts @@ -0,0 +1,258 @@ +import { sendToDaemon } from './daemon-client.ts'; +import { AppError } from './utils/errors.ts'; +import { + buildFlags, + buildMeta, + normalizeDeployResult, + normalizeDevice, + normalizeInstallFromSourceResult, + normalizeMaterializationReleaseResult, + normalizeOpenDevice, + normalizeRuntimeHints, + normalizeRuntimeResult, + normalizeSession, + normalizeStartupSample, + readNullableString, + readOptionalString, + readRequiredString, + readSnapshotNodes, + resolveSessionName, +} from './client-normalizers.ts'; +import type { + AgentDeviceClient, + AgentDeviceClientConfig, + AgentDeviceDaemonTransport, + AppCloseOptions, + AppDeployOptions, + AppInstallFromSourceOptions, + AppOpenOptions, + CaptureScreenshotOptions, + CaptureSnapshotOptions, + EnsureSimulatorOptions, + InternalRequestOptions, + MaterializationReleaseOptions, + RuntimeSetOptions, + RuntimeShowOptions, +} from './client-types.ts'; + +export function createAgentDeviceClient( + config: AgentDeviceClientConfig = {}, + deps: { transport?: AgentDeviceDaemonTransport } = {}, +): AgentDeviceClient { + const transport = deps.transport ?? sendToDaemon; + + const execute = async ( + command: string, + positionals: string[] = [], + options: InternalRequestOptions = {}, + ): Promise> => { + const merged = { ...config, ...options }; + const response = await transport({ + session: resolveSessionName(config.session, options.session), + command, + positionals, + flags: buildFlags(merged), + runtime: merged.runtime, + meta: buildMeta(merged), + }); + if (!response.ok) { + throw new AppError(response.error.code as any, response.error.message, { + ...(response.error.details ?? {}), + hint: response.error.hint, + diagnosticId: response.error.diagnosticId, + logPath: response.error.logPath, + }); + } + return (response.data ?? {}) as Record; + }; + + const listSessions = async (options: RuntimeShowOptions = {}) => { + const data = await execute('session_list', [], options); + const sessions = Array.isArray(data.sessions) ? data.sessions : []; + return sessions.map(normalizeSession); + }; + + return { + devices: { + list: async (options = {}) => { + const data = await execute('devices', [], options); + const devices = Array.isArray(data.devices) ? data.devices : []; + return devices.map(normalizeDevice); + }, + }, + sessions: { + list: async (options = {}) => await listSessions(options), + close: async (options = {}) => { + const session = resolveSessionName(config.session, options.session); + const data = await execute('close', [], options); + const shutdown = data.shutdown; + return { + session, + shutdown: typeof shutdown === 'object' && shutdown !== null + ? shutdown as Record + : undefined, + identifiers: { session }, + }; + }, + }, + simulators: { + ensure: async (options: EnsureSimulatorOptions) => { + const { runtime, ...rest } = options; + const data = await execute('ensure-simulator', [], { + ...rest, + simulatorRuntimeId: runtime, + }); + const udid = readRequiredString(data, 'udid'); + const device = readRequiredString(data, 'device'); + return { + udid, + device, + runtime: readRequiredString(data, 'runtime'), + created: data.created === true, + booted: data.booted === true, + iosSimulatorDeviceSet: readNullableString(data, 'ios_simulator_device_set'), + identifiers: { + deviceId: udid, + deviceName: device, + udid, + }, + }; + }, + }, + apps: { + install: async (options: AppDeployOptions) => normalizeDeployResult( + await execute('install', [options.app, options.appPath], options), + resolveSessionName(config.session, options.session), + ), + reinstall: async (options: AppDeployOptions) => normalizeDeployResult( + await execute('reinstall', [options.app, options.appPath], options), + resolveSessionName(config.session, options.session), + ), + installFromSource: async (options: AppInstallFromSourceOptions) => normalizeInstallFromSourceResult( + await execute('install_source', [], { + ...options, + installSource: options.source, + retainMaterializedPaths: options.retainPaths, + materializedPathRetentionMs: options.retentionMs, + }), + resolveSessionName(config.session, options.session), + ), + open: async (options: AppOpenOptions) => { + const session = resolveSessionName(config.session, options.session); + const positionals = options.url ? [options.app, options.url] : [options.app]; + const data = await execute('open', positionals, options); + const device = normalizeOpenDevice(data); + const appBundleId = readOptionalString(data, 'appBundleId'); + const appId = appBundleId; + return { + session, + appName: readOptionalString(data, 'appName'), + appBundleId, + appId, + startup: normalizeStartupSample(data.startup), + runtime: normalizeRuntimeHints(data.runtime), + device, + identifiers: { + session, + deviceId: device?.id, + deviceName: device?.name, + udid: device?.ios?.udid, + serial: device?.android?.serial, + appId, + appBundleId, + }, + }; + }, + close: async (options: AppCloseOptions = {}) => { + const session = resolveSessionName(config.session, options.session); + const data = await execute('close', options.app ? [options.app] : [], options); + const shutdown = data.shutdown; + return { + session, + closedApp: options.app, + shutdown: typeof shutdown === 'object' && shutdown !== null + ? shutdown as Record + : undefined, + identifiers: { session }, + }; + }, + }, + materializations: { + release: async (options: MaterializationReleaseOptions) => normalizeMaterializationReleaseResult( + await execute('release_materialized_paths', [], { + ...options, + materializationId: options.materializationId, + }), + ), + }, + runtime: { + set: async (options: RuntimeSetOptions) => normalizeRuntimeResult( + await execute('runtime', ['set'], options), + resolveSessionName(config.session, options.session), + ), + show: async (options: RuntimeShowOptions = {}) => normalizeRuntimeResult( + await execute('runtime', ['show'], options), + resolveSessionName(config.session, options.session), + ), + }, + capture: { + snapshot: async (options: CaptureSnapshotOptions = {}) => { + const session = resolveSessionName(config.session, options.session); + const data = await execute('snapshot', [], options); + const appBundleId = readOptionalString(data, 'appBundleId'); + return { + nodes: readSnapshotNodes(data.nodes), + truncated: data.truncated === true, + appName: readOptionalString(data, 'appName'), + appBundleId, + identifiers: { + session, + appId: appBundleId, + appBundleId, + }, + }; + }, + screenshot: async (options: CaptureScreenshotOptions = {}) => { + const session = resolveSessionName(config.session, options.session); + const data = await execute('screenshot', options.path ? [options.path] : [], options); + return { + path: readRequiredString(data, 'path'), + identifiers: { session }, + }; + }, + }, + }; +} + +export type { + AgentDeviceClient, + AgentDeviceClientConfig, + AgentDeviceDaemonTransport, + AgentDeviceDevice, + AgentDeviceIdentifiers, + AgentDeviceRequestOverrides, + AgentDeviceSelectionOptions, + AgentDeviceSession, + AgentDeviceSessionDevice, + AppCloseOptions, + AppCloseResult, + AppDeployOptions, + AppDeployResult, + AppInstallFromSourceOptions, + AppInstallFromSourceResult, + AppOpenOptions, + AppOpenResult, + CaptureScreenshotOptions, + CaptureScreenshotResult, + CaptureSnapshotOptions, + CaptureSnapshotResult, + EnsureSimulatorOptions, + EnsureSimulatorResult, + MaterializationReleaseOptions, + MaterializationReleaseResult, + RuntimeResult, + RuntimeSetOptions, + RuntimeShowOptions, + SessionCloseResult, + StartupPerfSample, +} from './client-types.ts'; diff --git a/src/daemon/artifact-materialization.ts b/src/daemon/artifact-materialization.ts index b39e8a7d..0eaf4947 100644 --- a/src/daemon/artifact-materialization.ts +++ b/src/daemon/artifact-materialization.ts @@ -2,8 +2,8 @@ import fs from 'node:fs'; import path from 'node:path'; import { extractTarInstallableArtifact, readZipEntries, resolveTarArchiveRootName } from './artifact-archive.ts'; import { createArtifactTempDir, downloadArtifactToTempDir } from './artifact-download.ts'; +import { readInfoPlistString } from '../platforms/ios/plist.ts'; import { AppError } from '../utils/errors.ts'; -import { runCmd } from '../utils/exec.ts'; export type MaterializeArtifactParams = { platform: 'ios' | 'android'; @@ -165,18 +165,7 @@ async function detectIosAppMetadata(installablePath: string): Promise { const infoPlistPath = path.join(appBundlePath, 'Info.plist'); - try { - const result = await runCmd( - 'plutil', - ['-extract', key, 'raw', '-o', '-', infoPlistPath], - { allowFailure: true }, - ); - if (result.exitCode !== 0) return undefined; - const value = String(result.stdout ?? '').trim(); - return value.length > 0 ? value : undefined; - } catch { - return undefined; - } + return await readInfoPlistString(infoPlistPath, key); } function readBaseNameIfMeaningful(filePath: string, suffix?: string): string | undefined { diff --git a/src/daemon/handlers/__tests__/session.test.ts b/src/daemon/handlers/__tests__/session.test.ts index 3d8f6088..3f8cb964 100644 --- a/src/daemon/handlers/__tests__/session.test.ts +++ b/src/daemon/handlers/__tests__/session.test.ts @@ -433,6 +433,11 @@ test('open applies stored runtime launchUrl and reports runtime hints', async () { command: 'open', positionals: ['myapp://dev-client'] }, ]); if (response && response.ok) { + assert.equal(response.data?.platform, 'android'); + assert.equal(response.data?.target, 'mobile'); + assert.equal(response.data?.device, 'Pixel'); + assert.equal(response.data?.id, 'emulator-5554'); + assert.equal(response.data?.serial, 'emulator-5554'); assert.deepEqual(response.data?.runtime, { platform: 'android', metroHost: '10.0.0.10', diff --git a/src/daemon/handlers/session.ts b/src/daemon/handlers/session.ts index 52d5bb45..54dab594 100644 --- a/src/daemon/handlers/session.ts +++ b/src/daemon/handlers/session.ts @@ -161,6 +161,16 @@ function buildOpenResult(params: { if (runtime && countConfiguredRuntimeHints(runtime) > 0) { result.runtime = runtime; } + if (device) { + result.platform = device.platform; + result.target = device.target ?? 'mobile'; + result.device = device.name; + result.id = device.id; + result.kind = device.kind; + if (device.platform === 'android') { + result.serial = device.id; + } + } if (device?.platform === 'ios') { result.device_udid = device.id; result.ios_simulator_device_set = device.simulatorSetPath ?? null; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 00000000..28d975ac --- /dev/null +++ b/src/index.ts @@ -0,0 +1,34 @@ +export { + createAgentDeviceClient, + type AgentDeviceClient, + type AgentDeviceClientConfig, + type AgentDeviceDevice, + type AgentDeviceIdentifiers, + type AgentDeviceSelectionOptions, + type AgentDeviceSession, + type AgentDeviceSessionDevice, + type AppCloseOptions, + type AppCloseResult, + type AppDeployOptions, + type AppDeployResult, + type AppInstallFromSourceOptions, + type AppInstallFromSourceResult, + type AppOpenOptions, + type AppOpenResult, + type CaptureScreenshotOptions, + type CaptureScreenshotResult, + type CaptureSnapshotOptions, + type CaptureSnapshotResult, + type EnsureSimulatorOptions, + type EnsureSimulatorResult, + type MaterializationReleaseOptions, + type MaterializationReleaseResult, + type RuntimeResult, + type RuntimeSetOptions, + type RuntimeShowOptions, + type SessionCloseResult, + type StartupPerfSample, +} from './client.ts'; +export { AppError, type NormalizedError } from './utils/errors.ts'; +export type { SessionRuntimeHints } from './daemon/types.ts'; +export type { SnapshotNode } from './utils/snapshot.ts'; diff --git a/src/platforms/ios/install-artifact.ts b/src/platforms/ios/install-artifact.ts index 6c785cf9..ed7a37e5 100644 --- a/src/platforms/ios/install-artifact.ts +++ b/src/platforms/ios/install-artifact.ts @@ -1,6 +1,7 @@ import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { readInfoPlistString } from './plist.ts'; import { AppError } from '../../utils/errors.ts'; import { runCmd } from '../../utils/exec.ts'; import { materializeInstallablePath, type MaterializeInstallSource } from '../install-source.ts'; @@ -70,24 +71,6 @@ export async function readIosBundleInfo( }; } -async function readInfoPlistString( - infoPlistPath: string, - key: string, -): Promise { - try { - const result = await runCmd( - 'plutil', - ['-extract', key, 'raw', '-o', '-', infoPlistPath], - { allowFailure: true }, - ); - if (result.exitCode !== 0) return undefined; - const value = String(result.stdout ?? '').trim(); - return value.length > 0 ? value : undefined; - } catch { - return undefined; - } -} - async function resolveIosInstallablePath( appPath: string, options?: InstallIosArtifactOptions, diff --git a/src/platforms/ios/plist.ts b/src/platforms/ios/plist.ts new file mode 100644 index 00000000..316b6d2c --- /dev/null +++ b/src/platforms/ios/plist.ts @@ -0,0 +1,53 @@ +import { promises as fs } from 'node:fs'; +import { runCmd } from '../../utils/exec.ts'; + +export async function readInfoPlistString( + infoPlistPath: string, + key: string, +): Promise { + try { + const result = await runCmd( + 'plutil', + ['-extract', key, 'raw', '-o', '-', infoPlistPath], + { allowFailure: true }, + ); + if (result.exitCode === 0) { + const value = String(result.stdout ?? '').trim(); + if (value.length > 0) { + return value; + } + } + } catch { + // Fall through to XML parsing for non-Darwin environments without plutil. + } + + try { + const plist = await fs.readFile(infoPlistPath, 'utf8'); + return readXmlPlistString(plist, key); + } catch { + return undefined; + } +} + +function readXmlPlistString(plist: string, key: string): string | undefined { + const escapedKey = escapeRegExp(key); + const match = plist.match(new RegExp(`\\s*${escapedKey}\\s*<\\/key>\\s*([\\s\\S]*?)<\\/string>`, 'i')); + if (!match?.[1]) { + return undefined; + } + const value = decodeXmlEntities(match[1].trim()); + return value.length > 0 ? value : undefined; +} + +function decodeXmlEntities(value: string): string { + return value + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, '\'') + .replace(/&/g, '&'); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/tsconfig.lib.json b/tsconfig.lib.json index 505010ce..f7a43bb9 100644 --- a/tsconfig.lib.json +++ b/tsconfig.lib.json @@ -5,5 +5,6 @@ "declaration": false, "emitDeclarationOnly": false }, - "include": ["src"] + "include": ["src"], + "exclude": ["src/**/__tests__/**"] }