diff --git a/.fallowrc.json b/.fallowrc.json index 75c27d96f..23a5a9c1d 100644 --- a/.fallowrc.json +++ b/.fallowrc.json @@ -19,6 +19,10 @@ "scripts/patch-xcuitest-runner-icon.ts", "scripts/runner-request-count/run.ts", "src/utils/update-check-entry.ts", + "examples/sdk/client-session.ts", + "examples/sdk/metro-runtime.ts", + "examples/sdk/contracts-result.ts", + "examples/sdk/batch-orchestration.ts", "test/scripts/metro-prepare-packaged-smoke.mjs", "test/integration/*.test.ts", "website/docs/404.mdx", diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 000000000..271fa4bfd --- /dev/null +++ b/examples/README.md @@ -0,0 +1,38 @@ +# Examples + +Runnable, typechecked Node.js examples for the `agent-device` SDK surface exposed to Node +consumers. Source of truth for the API itself is +[Typed Client](../website/docs/docs/client-api.md). Two guards keep these files in sync with that +doc: `src/__tests__/client-api-examples-drift.test.ts` checks the doc's subpath API manifest against +what these examples import, and `test/integration/client-api-doc-snippets.test.ts` compiles every +fenced TypeScript code block in the doc itself against the real `agent-device/*` sources. + +## sdk/ + +Standalone scripts under [`sdk/`](./sdk) exercise the published `agent-device` export map — +`agent-device`, `agent-device/metro`, `agent-device/contracts`, and `agent-device/batch` — the same +way a Node consumer or the agent-device-cloud bridge would. Each file: + +- has a top comment stating what it demonstrates and its prerequisites (daemon running, + device/simulator available); +- typechecks without live hardware — `pnpm typecheck` resolves the `agent-device/...` imports + against `src/sdk/` via [`examples/sdk/tsconfig.json`](./sdk/tsconfig.json)'s `paths`, so CI checks + them without a build or publish step; +- imports the package by name (`agent-device/...`), not a relative `src/` path, so it exercises the + same surface a real consumer sees; +- is runnable on its own with `node --experimental-strip-types` (repo Node is >=22.12) once the + package is built (`pnpm build`) — running for real resolves `agent-device` as a self-referencing + package, the same way an installed consumer would. + +| Example | Subpath | Demonstrates | +| --- | --- | --- | +| [`client-session.ts`](./sdk/client-session.ts) | `agent-device` | `createAgentDeviceClient` → open → snapshot/tap → close, with typed error handling | +| [`metro-runtime.ts`](./sdk/metro-runtime.ts) | `agent-device/metro` | `normalizeBaseUrl`, `resolveRuntimeTransport` | +| [`contracts-result.ts`](./sdk/contracts-result.ts) | `agent-device/contracts` | typed result consumption via `centerOfRect` | +| [`batch-orchestration.ts`](./sdk/batch-orchestration.ts) | `agent-device/batch` | `runBatch` for a custom transport | + +## test-app/ + +[`test-app/`](./test-app) is the Expo dogfood fixture used for `agent-device` and `skillgym` +experiments (see its own [README](./test-app/README.md)) — it is a test fixture, not an SDK usage +example. diff --git a/examples/sdk/batch-orchestration.ts b/examples/sdk/batch-orchestration.ts new file mode 100644 index 000000000..56ad162ec --- /dev/null +++ b/examples/sdk/batch-orchestration.ts @@ -0,0 +1,61 @@ +/** + * Batch orchestration for a custom transport: `runBatch` keeps step + * validation, inherited flags, serial execution, partial results, and + * daemon-shaped error envelopes aligned with the CLI's `batch` command, so a + * bridge that owns command dispatch itself does not have to reimplement them. + * + * Demonstrates: `runBatch` from `agent-device/batch`, consumed against the + * `DaemonResponse` result type from `agent-device/contracts`. + * + * Prerequisites: none — `dispatch` below is a stub; a real integration would + * replace it with a call into the bridge's own command dispatcher. + * + * Run: node --experimental-strip-types examples/sdk/batch-orchestration.ts + */ +import { runBatch } from 'agent-device/batch'; +import type { DaemonResponse } from 'agent-device/contracts'; + +type BatchRequest = Parameters[0]; + +async function dispatch(stepReq: unknown): Promise> { + console.log('dispatching step', stepReq); + return { handled: true }; +} + +function bridgeErrorToDaemonResponse(error: unknown): Extract { + return { + ok: false, + error: { + code: 'COMMAND_FAILED', + message: error instanceof Error ? error.message : 'Unknown bridge error', + }, + }; +} + +async function handleBatch(req: BatchRequest): Promise { + return await runBatch(req, req.session ?? 'default', async (stepReq) => { + try { + return { ok: true, data: await dispatch(stepReq) }; + } catch (error) { + return bridgeErrorToDaemonResponse(error); + } + }); +} + +const result = await handleBatch({ + command: 'batch', + positionals: [], + flags: { + batchSteps: [ + { command: 'wait', input: { text: 'Welcome' } }, + { command: 'back', input: {} }, + ], + }, +}); + +if (result.ok) { + console.log(`batch completed: ${JSON.stringify(result.data)}`); +} else { + console.error(`batch failed [${result.error.code}]: ${result.error.message}`); + process.exitCode = 1; +} diff --git a/examples/sdk/client-session.ts b/examples/sdk/client-session.ts new file mode 100644 index 000000000..7bc4ffd4f --- /dev/null +++ b/examples/sdk/client-session.ts @@ -0,0 +1,75 @@ +/** + * Root client session: create a client, open an app, capture a snapshot, tap + * a node, then close the session — with typed error handling via the + * exported error helpers. + * + * Demonstrates: `createAgentDeviceClient`, `AppError`, `isAgentDeviceError`, + * and `normalizeAgentDeviceError` from the `agent-device` root export. + * + * Prerequisites: an `agent-device` daemon target (a booted iOS simulator). + * This file typechecks without one; running it for real also requires + * `pnpm build` first, so the package resolves at runtime. + * + * Run: node --experimental-strip-types examples/sdk/client-session.ts + */ +import { + AppError, + createAgentDeviceClient, + isAgentDeviceError, + normalizeAgentDeviceError, +} from 'agent-device'; + +async function resolveSnapshotCapableIosDevice(client: ReturnType) { + const devices = await client.devices.list({ platform: 'ios' }); + const device = devices[0]; + if (!device) { + throw new AppError('DEVICE_NOT_FOUND', 'No iOS device available'); + } + + const capabilities = await client.devices.capabilities({ platform: 'ios' }); + if (!capabilities.availableCommands.includes('snapshot')) { + throw new AppError('UNSUPPORTED_OPERATION', 'Selected target does not support snapshots'); + } + + return device; +} + +function reportAgentDeviceError(error: unknown): void { + const normalized = normalizeAgentDeviceError(error); + console.error(`agent-device error [${normalized.code}]: ${normalized.message}`); + if (normalized.hint) { + console.error(`hint: ${normalized.hint}`); + } + process.exitCode = 1; +} + +async function main(): Promise { + const client = createAgentDeviceClient({ + session: 'sdk-example', + lockPolicy: 'reject', + lockPlatform: 'ios', + }); + + try { + const device = await resolveSnapshotCapableIosDevice(client); + + await client.apps.open({ + app: 'com.apple.Preferences', + platform: 'ios', + udid: device.id, + }); + + const snapshot = await client.capture.snapshot({ interactiveOnly: true }); + const target = snapshot.nodes.find((node) => node.role === 'button'); + if (target) { + await client.interactions.press({ ref: target.ref }); + } + } catch (error) { + if (!isAgentDeviceError(error)) throw error; + reportAgentDeviceError(error); + } finally { + await client.sessions.close(); + } +} + +await main(); diff --git a/examples/sdk/contracts-result.ts b/examples/sdk/contracts-result.ts new file mode 100644 index 000000000..ef3d28594 --- /dev/null +++ b/examples/sdk/contracts-result.ts @@ -0,0 +1,42 @@ +/** + * Typed result consumption from `agent-device/contracts`: compute the center + * point of a snapshot node's rect, using the same `centerOfRect` helper the + * daemon and CLI use internally. + * + * Demonstrates: `centerOfRect` from `agent-device/contracts`, consumed + * against the `CaptureSnapshotResult` shape returned by the root client. + * + * Prerequisites: an `agent-device` daemon target with an app already open. + * This file typechecks without one. + * + * Run: node --experimental-strip-types examples/sdk/contracts-result.ts + */ +import { createAgentDeviceClient } from 'agent-device'; +import { centerOfRect } from 'agent-device/contracts'; + +function assertHasRect( + node: T | undefined, +): asserts node is T & { rect: NonNullable } { + if (!node?.rect) { + throw new Error('No visible node with a rect in the snapshot'); + } +} + +async function main(): Promise { + const client = createAgentDeviceClient({ session: 'sdk-example' }); + + try { + const snapshot = await client.capture.snapshot({ interactiveOnly: true }); + const node = snapshot.nodes.find((candidate) => candidate.rect !== undefined); + assertHasRect(node); + + const center = centerOfRect(node.rect); + console.log( + `center of ${node.role ?? 'node'} "${node.label ?? node.ref}": (${center.x}, ${center.y})`, + ); + } finally { + await client.sessions.close(); + } +} + +await main(); diff --git a/examples/sdk/metro-runtime.ts b/examples/sdk/metro-runtime.ts new file mode 100644 index 000000000..3286cbe33 --- /dev/null +++ b/examples/sdk/metro-runtime.ts @@ -0,0 +1,31 @@ +/** + * Metro runtime helpers: normalize a Metro base URL and resolve the + * host/port/scheme a client should connect through, given the runtime hints + * a daemon response (or `AppOpenOptions.runtime`) carries. + * + * Demonstrates: `normalizeBaseUrl` and `resolveRuntimeTransport` from + * `agent-device/metro`. + * + * Prerequisites: none — both functions are pure and need no daemon, device, + * or running Metro bundler. + * + * Run: node --experimental-strip-types examples/sdk/metro-runtime.ts + */ +import { normalizeBaseUrl, resolveRuntimeTransport } from 'agent-device/metro'; +import type { SessionRuntimeHints } from 'agent-device/contracts'; + +const baseUrl = normalizeBaseUrl('https://metro.example.dev///'); +console.log(`normalized base URL: ${baseUrl}`); + +const hints: SessionRuntimeHints = { + platform: 'ios', + metroHost: 'metro.example.dev', + metroPort: 443, +}; + +const transport = resolveRuntimeTransport(hints); +if (!transport) { + throw new Error('Unable to resolve a Metro runtime transport from the provided hints'); +} + +console.log(`resolved transport: ${transport.scheme}://${transport.host}:${transport.port}`); diff --git a/examples/sdk/tsconfig.json b/examples/sdk/tsconfig.json new file mode 100644 index 000000000..c343c29a8 --- /dev/null +++ b/examples/sdk/tsconfig.json @@ -0,0 +1,25 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "paths": { + "agent-device": ["../../src/sdk/index.ts"], + "agent-device/io": ["../../src/sdk/io.ts"], + "agent-device/artifacts": ["../../src/sdk/artifacts.ts"], + "agent-device/metro": ["../../src/sdk/metro.ts"], + "agent-device/batch": ["../../src/sdk/batch.ts"], + "agent-device/remote-config": ["../../src/sdk/remote-config.ts"], + "agent-device/install-source": ["../../src/sdk/install-source.ts"], + "agent-device/android-adb": ["../../src/sdk/android-adb.ts"], + "agent-device/contracts": ["../../src/sdk/contracts.ts"], + "agent-device/selectors": ["../../src/sdk/selectors.ts"], + "agent-device/finders": ["../../src/sdk/finders.ts"] + } + }, + // Scoped to this directory only: these examples import `agent-device/...` + // by package name (the published export map), and `paths` above resolves + // those specifiers straight to `src/sdk/` so CI typechecks them without a + // prior build, workspace link, or publish step. Running an example for real + // still resolves `agent-device` as a self-referencing package (needs + // `pnpm build`), matching how a real consumer would import it. + "include": [".", "../../src/global.d.ts"] +} diff --git a/package.json b/package.json index 6602bdde2..cf710a373 100644 --- a/package.json +++ b/package.json @@ -146,7 +146,7 @@ "check:unit": "pnpm test:unit && pnpm test:smoke", "check": "pnpm check:tooling && pnpm check:fallow && pnpm check:unit", "prepack": "pnpm check:mcp-metadata && pnpm build:all && pnpm package:apple-runner:npm && pnpm package:android-snapshot-helper:npm && pnpm package:android-ime-helper:npm", - "typecheck": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json && tsc -p examples/sdk/tsconfig.json", "test-app:install": "pnpm install --dir examples/test-app", "test-app:start": "pnpm --dir examples/test-app start", "test-app:ios": "pnpm --dir examples/test-app ios", diff --git a/src/__tests__/client-api-examples-drift.test.ts b/src/__tests__/client-api-examples-drift.test.ts new file mode 100644 index 000000000..61218967d --- /dev/null +++ b/src/__tests__/client-api-examples-drift.test.ts @@ -0,0 +1,151 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import assert from 'node:assert/strict'; +import { describe, test } from 'vitest'; + +// Guards `examples/sdk/*.ts` against drifting away from the subpath API +// manifest documented in `website/docs/docs/client-api.md` (the "Public +// subpath API exposed for Node consumers" bullet list), in both directions: +// forward — every symbol an example imports from an `agent-device/...` +// subpath is one the doc's manifest lists for that subpath, so an +// example cannot start exercising undocumented API unnoticed. +// reverse — the handful of symbols the issue calls out as the minimum +// example surface (createAgentDeviceClient, normalizeBaseUrl, +// resolveRuntimeTransport, centerOfRect, runBatch) each still +// appear in some example's imports, so an example cannot be +// quietly deleted or renamed away from the symbol it exists to +// demonstrate. +// This mirrors command-doc-coverage.test.ts's markdown-scanning approach +// rather than compiling snippets, since the manifest list is already a +// structured, parseable statement of the subpath surface. The doc's own +// fenced ```ts snippets are compiled separately, in the Node integration lane +// (test/integration/client-api-doc-snippets.test.ts) — that check spawns a +// real tsc Program and doesn't fit the unit suite's wall-clock budget. + +const CLIENT_API_DOC_PATH = 'website/docs/docs/client-api.md'; +const EXAMPLES_SDK_DIR = 'examples/sdk'; + +type SubpathManifest = ReadonlyMap>; + +// Symbols the acceptance criteria for #1463 requires an example to exercise. +// Each must be imported by at least one file in examples/sdk/. +const REQUIRED_EXAMPLE_SYMBOLS: readonly { subpath: string; symbol: string }[] = [ + { subpath: 'agent-device', symbol: 'createAgentDeviceClient' }, + { subpath: 'agent-device/metro', symbol: 'normalizeBaseUrl' }, + { subpath: 'agent-device/metro', symbol: 'resolveRuntimeTransport' }, + { subpath: 'agent-device/contracts', symbol: 'centerOfRect' }, + { subpath: 'agent-device/batch', symbol: 'runBatch' }, +]; + +// Parses the "Public subpath API" bullet list: a top-level `- \`agent-device...\`` +// bullet starts a subpath section; backtick-quoted identifiers on its nested +// bullet lines (stripping a trailing `(...)` call signature) are that +// subpath's documented symbols, until the next top-level bullet. +function matchTopLevelSubpathBullet(line: string): string | null { + return /^- `(agent-device[a-z0-9/-]*)`$/.exec(line)?.[1] ?? null; +} + +function extractBacktickedNames(line: string): string[] { + return [...line.matchAll(/`([A-Za-z0-9_]+)(?:\([^)]*\))?`/g)] + .map((match) => match[1]) + .filter((name): name is string => name !== undefined); +} + +function parseSubpathManifest(markdown: string): SubpathManifest { + const manifest = new Map>(); + let currentSubpath: string | null = null; + for (const line of markdown.split('\n')) { + const subpath = matchTopLevelSubpathBullet(line); + if (subpath) { + currentSubpath = subpath; + if (!manifest.has(subpath)) manifest.set(subpath, new Set()); + continue; + } + if (!currentSubpath || !/^\s+- /.test(line)) continue; + const symbols = manifest.get(currentSubpath); + if (!symbols) continue; + for (const name of extractBacktickedNames(line)) symbols.add(name); + } + return manifest; +} + +// Extracts `import { a, b } from 'agent-device...'` / `import type {...}` +// bindings per subpath from a single example file. +function extractImportedSymbols(source: string): Map> { + const bySubpath = new Map>(); + const importPattern = /import\s+(?:type\s+)?\{([^}]+)\}\s+from\s+'(agent-device[^']*)'/g; + for (const match of source.matchAll(importPattern)) { + const [, bindings, subpath] = match; + if (!bindings || !subpath) continue; + const symbols = bySubpath.get(subpath) ?? new Set(); + bySubpath.set(subpath, symbols); + for (const binding of bindings.split(',')) { + const name = binding + .replace(/^type\s+/, '') + .split(/\s+as\s+/)[0] + ?.trim(); + if (name) symbols.add(name); + } + } + return bySubpath; +} + +function listExampleFiles(dir: string): string[] { + return fs + .readdirSync(dir) + .filter((entry) => entry.endsWith('.ts')) + .sort() + .map((entry) => path.join(dir, entry)); +} + +const manifest = parseSubpathManifest(fs.readFileSync(CLIENT_API_DOC_PATH, 'utf8')); +const exampleFiles = listExampleFiles(EXAMPLES_SDK_DIR); +const importsByFile = new Map( + exampleFiles.map((file) => [file, extractImportedSymbols(fs.readFileSync(file, 'utf8'))]), +); + +describe('examples/sdk vs client-api.md drift guard', () => { + test('client-api.md documents a subpath API manifest to check examples against', () => { + assert.ok( + manifest.size > 0, + `${CLIENT_API_DOC_PATH} did not yield a parseable subpath API manifest; ` + + 'has the "Public subpath API exposed for Node consumers" list moved or changed format?', + ); + }); + + test('every symbol an example imports from agent-device is documented in client-api.md', () => { + const undocumented: string[] = []; + for (const [file, importsBySubpath] of importsByFile) { + for (const [subpath, symbols] of importsBySubpath) { + const documented = manifest.get(subpath); + for (const symbol of symbols) { + if (!documented || !documented.has(symbol)) { + undocumented.push(`${file}: \`${symbol}\` from \`${subpath}\``); + } + } + } + } + assert.deepEqual( + undocumented, + [], + `Example(s) import symbols not listed in ${CLIENT_API_DOC_PATH}'s subpath API manifest: ` + + `${undocumented.join(', ')}. Update the doc's manifest, or fix the example if this was a typo.`, + ); + }); + + test('the minimum example surface required by #1463 is still exercised', () => { + const missing = REQUIRED_EXAMPLE_SYMBOLS.filter( + ({ subpath, symbol }) => + ![...importsByFile.values()].some((importsBySubpath) => + importsBySubpath.get(subpath)?.has(symbol), + ), + ).map(({ subpath, symbol }) => `\`${symbol}\` from \`${subpath}\``); + assert.deepEqual( + missing, + [], + `No example in ${EXAMPLES_SDK_DIR} imports: ${missing.join(', ')}. ` + + 'Restore the example that demonstrates it, or update this test if the ' + + 'requirement in issue #1463 has intentionally changed.', + ); + }); +}); diff --git a/src/contracts/client-observability.ts b/src/contracts/client-observability.ts index cc4c8f6e2..780eafa85 100644 --- a/src/contracts/client-observability.ts +++ b/src/contracts/client-observability.ts @@ -28,13 +28,13 @@ export type EventsOptions = AgentDeviceRequestOverrides & { limit?: number; }; -export type NetworkOptions = AgentDeviceRequestOverrides & { +export type NetworkOptions = DeviceCommandBaseOptions & { action?: 'dump' | 'log'; limit?: number; include?: NetworkIncludeMode; }; -export type AudioOptions = AgentDeviceRequestOverrides & { +export type AudioOptions = DeviceCommandBaseOptions & { action?: 'probe'; probeAction?: 'start' | 'status' | 'stop'; durationMs?: number; diff --git a/src/sdk/metro.ts b/src/sdk/metro.ts index ca63b1f2f..b133d4118 100644 --- a/src/sdk/metro.ts +++ b/src/sdk/metro.ts @@ -4,4 +4,5 @@ export type { MetroTunnelRequestMessage, MetroTunnelResponseMessage, } from '../metro/metro.ts'; -export { resolveRuntimeTransport } from '../metro/metro.ts'; +export { resolveRuntimeTransport, stopMetroTunnel } from '../metro/metro.ts'; +export { prepareMetroRuntime, reloadMetro } from '../metro/client-metro.ts'; diff --git a/src/sdk/remote-config.ts b/src/sdk/remote-config.ts index 38c618345..f4a8be73b 100644 --- a/src/sdk/remote-config.ts +++ b/src/sdk/remote-config.ts @@ -1 +1,2 @@ export type { RemoteConfigProfile } from '../remote/remote-config-schema.ts'; +export { resolveRemoteConfigProfile } from '../remote/remote-config.ts'; diff --git a/test/integration/client-api-doc-snippets.test.ts b/test/integration/client-api-doc-snippets.test.ts new file mode 100644 index 000000000..b4069a7de --- /dev/null +++ b/test/integration/client-api-doc-snippets.test.ts @@ -0,0 +1,194 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { runCmdSync } from '../../src/utils/exec.ts'; + +// Compiles every fenced ```ts snippet in website/docs/docs/client-api.md against +// agent-device's real `agent-device/*` subpath sources, so a doc snippet that no +// longer compiles — wrong property, renamed export, changed signature — fails +// CI instead of only being caught if an examples/sdk/*.ts file happens to drift +// the same way. src/__tests__/client-api-examples-drift.test.ts covers the +// doc's bullet-list API manifest against examples/sdk/*.ts; this is the +// complementary check the reviewer asked for on #1463's drift guard (checking +// the doc's actual code, not just its symbol-name manifest). +// +// Lives in the Node integration lane, not vitest's unit-core: it spawns a real +// tsc Program over a chunk of src/, which comfortably exceeds the unit suite's +// 2.5s slow-test budget (see docs/agents/testing.md). + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); +const CLIENT_API_DOC_PATH = path.join(repoRoot, 'website/docs/docs/client-api.md'); +const EXAMPLES_SDK_TSCONFIG = path.join(repoRoot, 'examples/sdk/tsconfig.json'); +const TSC_BIN = path.join(repoRoot, 'node_modules/.bin/tsc'); + +// Extracts every fenced ```ts code block from the doc, in document order. Each +// block is compiled standalone. +function extractTsSnippets(markdown: string): string[] { + return [...markdown.matchAll(/```ts\n([\s\S]*?)```/g)].map((match) => match[1] ?? ''); +} + +// examples/sdk/tsconfig.json's `paths` is the existing, CI-proven mechanism that +// resolves `agent-device/...` specifiers straight to `src/sdk/*.ts` so examples +// typecheck without a build (see that file's own comment). `tsc --showConfig` +// returns it fully resolved and comment-free, so this reads the one true copy of +// the subpath map instead of keeping a second, driftable one here. +function resolveExamplesSdkPaths(): Record { + const raw = runCmdSync(TSC_BIN, ['-p', EXAMPLES_SDK_TSCONFIG, '--showConfig']).stdout; + const configDir = path.dirname(EXAMPLES_SDK_TSCONFIG); + const paths: Record = {}; + for (const [specifier, targets] of Object.entries( + JSON.parse(raw).compilerOptions.paths as Record, + )) { + paths[specifier] = targets.map((target) => path.resolve(configDir, target)); + } + return paths; +} + +// Free identifiers a snippet references without declaring, because the doc's +// prose treats them as continuing from an earlier snippet (`client`, +// `androidClient`, `snapshot` — typed against the real SDK return type, not +// `any`, so continuation snippets still get meaningful checking) or as +// illustrative host-glue the doc invents on purpose (a bridge's own transport +// function, never part of the SDK). This is a closed, explicit allowlist, not +// a catch-all: any free identifier NOT listed here — including a typo of one +// that IS, like `cliet` for `client` — is left as a real "Cannot find name" +// failure. See the "rejects an unrecognized free identifier" test below for +// the regression this guards against. +const KNOWN_FREE_NAME_STUB_TYPES: Record = { + client: `ReturnType`, + androidClient: `ReturnType`, + snapshot: `Awaited['capture']['snapshot']>>`, + // "Android ADB providers": the doc's own invented remote-transport glue, not + // part of agent-device — typed as the real `AndroidAdbExecutor` function + // shape so the snippet's `exec: async (args, options) => ...` still has to + // return something assignable to it. + runAdbThroughRemoteTunnel: `import('agent-device/android-adb').AndroidAdbExecutor`, + // "Batch orchestration for custom transports": the doc's own invented + // command dispatcher and error mapper. + dispatch: `(stepReq: unknown) => Promise`, + bridgeErrorToDaemonResponse: `(error: unknown) => import('agent-device/contracts').DaemonResponse`, +}; + +function writeSnippetProgram(snippets: string[]): string { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'client-api-doc-snippets-')); + fs.writeFileSync(path.join(tmpDir, 'package.json'), '{"type":"module"}\n'); + fs.writeFileSync( + path.join(tmpDir, 'tsconfig.json'), + JSON.stringify({ + extends: path.join(repoRoot, 'tsconfig.json'), + compilerOptions: { + typeRoots: [path.join(repoRoot, 'node_modules/@types')], + // Doc snippets are illustrative fragments, not production code: an + // unused destructured result is normal and not a real defect here. + noUnusedLocals: false, + noUnusedParameters: false, + paths: resolveExamplesSdkPaths(), + }, + // src/global.d.ts declares build-time ambient globals (e.g. __OWNER_FILES__) + // that src/ files reference; examples/sdk/tsconfig.json includes it for the + // same reason. + include: ['*.ts', path.join(repoRoot, 'src/global.d.ts')], + }), + ); + snippets.forEach((code, index) => { + // `export {}` forces module scope so each snippet's declarations (and any + // stubs injected below) can't collide with another snippet's globals. + fs.writeFileSync(path.join(tmpDir, `snippet-${index}.ts`), `export {};\n${code}`); + }); + return tmpDir; +} + +function runTsc(tmpDir: string): string { + const result = runCmdSync( + TSC_BIN, + ['--noEmit', '-p', path.join(tmpDir, 'tsconfig.json'), '--pretty', 'false'], + { cwd: tmpDir, allowFailure: true }, + ); + return `${result.stdout}${result.stderr}`; +} + +// Finds every "Cannot find name 'X'" diagnostic where `X` is in +// KNOWN_FREE_NAME_STUB_TYPES, grouped by snippet file. Any other free name — +// anything not in that explicit allowlist — is deliberately left out here, so +// its diagnostic survives untouched into the final output and fails the +// check. A missing/renamed *import* binding is a different diagnostic code +// and is never matched by this regex either way. +function findKnownFreeNamesByFile(tscOutput: string): Map> { + const freeNamesByFile = new Map>(); + for (const line of tscOutput.split('\n')) { + const match = /^(snippet-\d+\.ts)\(\d+,\d+\): error TS2304: Cannot find name '([^']+)'/.exec( + line, + ); + const name = match?.[2]; + if (match?.[1] && name && name in KNOWN_FREE_NAME_STUB_TYPES) { + const names = freeNamesByFile.get(match[1]) ?? new Set(); + names.add(name); + freeNamesByFile.set(match[1], names); + } + } + return freeNamesByFile; +} + +// One auto-stub pass: declare each known free name at the top of its snippet +// file (typed per KNOWN_FREE_NAME_STUB_TYPES) and recompile. +function stubFreeNamesAndRecompile(tmpDir: string, firstPassOutput: string): string { + const freeNamesByFile = findKnownFreeNamesByFile(firstPassOutput); + if (freeNamesByFile.size === 0) return firstPassOutput; + + for (const [fileName, names] of freeNamesByFile) { + const filePath = path.join(tmpDir, fileName); + const stubs = [...names] + .map((name) => `declare const ${name}: ${KNOWN_FREE_NAME_STUB_TYPES[name]};`) + .join('\n'); + fs.writeFileSync( + filePath, + fs.readFileSync(filePath, 'utf8').replace('export {};\n', `export {};\n${stubs}\n`), + ); + } + return runTsc(tmpDir); +} + +function compileDocSnippets(snippets: string[]): string[] { + const tmpDir = writeSnippetProgram(snippets); + try { + const output = stubFreeNamesAndRecompile(tmpDir, runTsc(tmpDir)); + return output.split('\n').filter((line) => /error TS\d+:/.test(line)); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +} + +test("every fenced ```ts snippet in client-api.md compiles against agent-device's real exports", () => { + const snippets = extractTsSnippets(fs.readFileSync(CLIENT_API_DOC_PATH, 'utf8')); + assert.ok( + snippets.length > 0, + `${CLIENT_API_DOC_PATH} has no fenced \`\`\`ts snippets to check.`, + ); + + const failures = compileDocSnippets(snippets); + assert.deepEqual( + failures, + [], + `A \`\`\`ts snippet in ${CLIENT_API_DOC_PATH} no longer compiles against agent-device's real ` + + `exports:\n${failures.join('\n')}\n` + + 'Fix the snippet to match the current API, or fix the actual exported contract if the doc ' + + 'was right and the API regressed.', + ); +}); + +test('rejects an unrecognized free identifier instead of silently stubbing it (e.g. a typo of `client`)', () => { + const failures = compileDocSnippets([ + "import { createAgentDeviceClient } from 'agent-device';\n\n" + + "const client = createAgentDeviceClient({ session: 'qa-ios' });\n" + + "await cliet.apps.open({ app: 'com.example.app', platform: 'ios' });\n", + ]); + assert.ok( + failures.some((line) => line.includes("Cannot find name 'cliet'")), + `Expected a "Cannot find name 'cliet'" failure for an unrecognized free identifier, got: ` + + `${JSON.stringify(failures)}. If this fails, stubFreeNamesAndRecompile is stubbing names ` + + 'outside its explicit allowlist again.', + ); +}); diff --git a/website/docs/docs/client-api.md b/website/docs/docs/client-api.md index cc857ed49..33aee1dfc 100644 --- a/website/docs/docs/client-api.md +++ b/website/docs/docs/client-api.md @@ -22,10 +22,12 @@ Public subpath API exposed for Node consumers: - `buildBundleUrl(baseUrl, platform)` - `normalizeBaseUrl(baseUrl)` - `resolveRuntimeTransport(runtime)` + - `prepareMetroRuntime(options?)`, `reloadMetro(options?)`, `stopMetroTunnel(options)` - types: `MetroBridgeDescriptor`, `MetroTunnelRequestMessage`, `MetroTunnelResponseMessage` - `agent-device/batch` - `runBatch(req, sessionName, invoke)` - `agent-device/remote-config` + - `resolveRemoteConfigProfile(options)` - types: `RemoteConfigProfile` - `agent-device/contracts` - `centerOfRect(rect)` @@ -124,8 +126,10 @@ const result = await client.sessions.artifacts({ providerSessionId: 'arn:aws:devicefarm:us-west-2:123:session/project/session/00000', }); -for (const artifact of result.cloudArtifacts) { - console.log(artifact.kind, artifact.name, artifact.url); +if ('cloudArtifacts' in result) { + for (const artifact of result.cloudArtifacts) { + console.log(artifact.kind, artifact.name, artifact.url); + } } ``` @@ -138,13 +142,11 @@ import { createAgentDeviceClient } from 'agent-device'; const client = createAgentDeviceClient({ leaseProvider: 'browserstack', - platform: 'android', - device: 'Google Pixel 8', providerOsVersion: '14.0', providerApp: 'bs://app-id', }); -await client.apps.open({ app: 'com.example.app' }); +await client.apps.open({ app: 'com.example.app', platform: 'android', device: 'Google Pixel 8' }); await client.capture.snapshot({ interactiveOnly: true }); const closed = await client.sessions.close(); ``` @@ -197,9 +199,11 @@ idempotent for the same owner and rejects conflicting owners for the same local ```ts import { getAndroidAppStateWithAdb, listAndroidAppsWithAdb } from 'agent-device/android-adb'; +import type { AndroidAdbExecutorOptions } from 'agent-device/android-adb'; const provider = { - exec: async (args, options) => await runAdbThroughRemoteTunnel(args, options), + exec: async (args: string[], options?: AndroidAdbExecutorOptions) => + await runAdbThroughRemoteTunnel(args, options), }; const apps = await listAndroidAppsWithAdb(provider.exec); // user-installed apps by default @@ -343,6 +347,8 @@ async function handleBatch(req: BatchRequest): Promise { ## Android `installFromSource()` ```ts +import { createAgentDeviceClient } from 'agent-device'; + const androidClient = createAgentDeviceClient({ session: 'qa-android' }); const installed = await androidClient.apps.installFromSource({ @@ -404,7 +410,7 @@ Direct Android `.apk` and `.aab` URL sources can still resolve package identity ## Remote Metro helpers ```ts -import { prepareRemoteMetro, reloadRemoteMetro, stopMetroTunnel } from 'agent-device/metro'; +import { prepareMetroRuntime, reloadMetro, stopMetroTunnel } from 'agent-device/metro'; import { resolveRemoteConfigProfile } from 'agent-device/remote-config'; const remoteConfig = resolveRemoteConfigProfile({ @@ -412,7 +418,7 @@ const remoteConfig = resolveRemoteConfigProfile({ cwd: process.cwd(), }); -const prepared = await prepareRemoteMetro({ +const prepared = await prepareMetroRuntime({ projectRoot: remoteConfig.profile.metroProjectRoot!, kind: remoteConfig.profile.metroKind ?? 'auto', proxyBaseUrl: remoteConfig.profile.metroProxyBaseUrl, @@ -422,12 +428,12 @@ const prepared = await prepareRemoteMetro({ runId: remoteConfig.profile.runId!, leaseId: remoteConfig.profile.leaseId!, }, - profileKey: remoteConfig.resolvedPath, + companionProfileKey: remoteConfig.resolvedPath, }); console.log(prepared.iosRuntime, prepared.androidRuntime); -await reloadRemoteMetro({ +await reloadMetro({ runtime: prepared.iosRuntime, }); @@ -437,7 +443,7 @@ await stopMetroTunnel({ }); ``` -Use `agent-device/remote-config` for profile loading and path resolution, `agent-device/metro` for Metro preparation, reload, and tunnel lifecycle, and `agent-device/contracts` when a server consumer needs daemon request or runtime contract types. For bridged remote Metro, `proxyBaseUrl` is the bridge origin and `publicBaseUrl` is optional; the bridge descriptor supplies cloud iOS wildcard HTTPS hints and Android runtime-route hints. `reloadRemoteMetro()` calls Metro's `/reload` endpoint, matching the terminal `r` reload path for connected React Native apps. +Use `agent-device/remote-config` for profile loading and path resolution, `agent-device/metro` for Metro preparation, reload, and tunnel lifecycle, and `agent-device/contracts` when a server consumer needs daemon request or runtime contract types. For bridged remote Metro, `proxyBaseUrl` is the bridge origin and `publicBaseUrl` is optional; the bridge descriptor supplies cloud iOS wildcard HTTPS hints and Android runtime-route hints. `reloadMetro()` calls Metro's `/reload` endpoint, matching the terminal `r` reload path for connected React Native apps. ## Selector helpers