From 95a18303ae85fe86ffc49917c086e1a5e26437d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 15:05:01 +0000 Subject: [PATCH 1/5] feat(examples): add runnable Node.js SDK examples under examples/sdk/ examples/test-app is a fixture and the repo's only prior examples/ content; the real SDK usage patterns lived only in website/docs/docs/client-api.md with no runnable script anywhere. Adds four standalone, typechecked examples covering the minimum surface from #1463: root client session (create -> open -> snapshot/tap -> close with typed error handling), agent-device/metro (normalizeBaseUrl/resolveRuntimeTransport), agent-device/contracts (centerOfRect on a snapshot node), and agent-device/batch (runBatch for a custom transport). Each imports the published `agent-device/...` subpaths rather than relative src/ paths. examples/sdk/tsconfig.json path-maps those subpaths to src/sdk/ so `pnpm typecheck` (now also run against this tsconfig) checks the examples in CI without a prior build, workspace link, or publish step. Running an example for real still resolves `agent-device` as a self-referencing package after `pnpm build`. src/__tests__/client-api-examples-drift.test.ts guards the examples against drifting from client-api.md's subpath API manifest in both directions, picked up automatically by the existing unit-core vitest project (no new script or workflow needed). examples/README.md indexes the new examples and notes that test-app/ remains a fixture, not an example; it is not renamed or moved. Refs #1463 --- examples/README.md | 36 +++++ examples/sdk/batch-orchestration.ts | 61 ++++++++ examples/sdk/client-session.ts | 68 +++++++++ examples/sdk/contracts-result.ts | 36 +++++ examples/sdk/metro-runtime.ts | 31 ++++ examples/sdk/tsconfig.json | 25 +++ package.json | 2 +- .../client-api-examples-drift.test.ts | 143 ++++++++++++++++++ 8 files changed, 401 insertions(+), 1 deletion(-) create mode 100644 examples/README.md create mode 100644 examples/sdk/batch-orchestration.ts create mode 100644 examples/sdk/client-session.ts create mode 100644 examples/sdk/contracts-result.ts create mode 100644 examples/sdk/metro-runtime.ts create mode 100644 examples/sdk/tsconfig.json create mode 100644 src/__tests__/client-api-examples-drift.test.ts diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 000000000..35b066412 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,36 @@ +# 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); `src/__tests__/client-api-examples-drift.test.ts` +guards these files against drifting out of sync with that doc. + +## 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..33cc311dc --- /dev/null +++ b/examples/sdk/client-session.ts @@ -0,0 +1,68 @@ +/** + * 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 main(): Promise { + const client = createAgentDeviceClient({ + session: 'sdk-example', + lockPolicy: 'reject', + lockPlatform: 'ios', + }); + + try { + 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'); + } + + 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)) { + const normalized = normalizeAgentDeviceError(error); + console.error(`agent-device error [${normalized.code}]: ${normalized.message}`); + if (normalized.hint) { + console.error(`hint: ${normalized.hint}`); + } + process.exitCode = 1; + return; + } + throw 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..3b38ff167 --- /dev/null +++ b/examples/sdk/contracts-result.ts @@ -0,0 +1,36 @@ +/** + * 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'; + +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); + if (!node?.rect) { + throw new Error('No visible node with a rect in the snapshot'); + } + + 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..3551a2714 --- /dev/null +++ b/src/__tests__/client-api-examples-drift.test.ts @@ -0,0 +1,143 @@ +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. + +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 parseSubpathManifest(markdown: string): SubpathManifest { + const manifest = new Map>(); + let currentSubpath: string | null = null; + for (const line of markdown.split('\n')) { + const topLevelMatch = /^- `(agent-device[a-z0-9/-]*)`$/.exec(line); + if (topLevelMatch) { + currentSubpath = topLevelMatch[1] ?? null; + if (currentSubpath && !manifest.has(currentSubpath)) { + manifest.set(currentSubpath, new Set()); + } + continue; + } + if (currentSubpath === null || !/^\s+- /.test(line)) continue; + const symbols = manifest.get(currentSubpath); + if (!symbols) continue; + for (const match of line.matchAll(/`([A-Za-z0-9_]+)(?:\([^)]*\))?`/g)) { + const name = match[1]; + if (name) 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.', + ); + }); +}); From c1bac5a9e9f6644568c495b4008114b8012047ef Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 15:14:23 +0000 Subject: [PATCH 2/5] fix: address Fallow findings on the new SDK examples Fallow flagged the four examples/sdk/*.ts files as unused files (not reachable from any entry point) and three functions as high complexity. - Register the examples as manual entry points in .fallowrc.json, matching how other standalone scripts (scripts/patch-xcuitest-runner-icon.ts, scripts/runner-request-count/run.ts) are already declared. - Reduce complexity in client-session.ts and contracts-result.ts by extracting device-resolution/error-reporting and rect-assertion helpers out of main(). - Reduce complexity in the drift guard's parseSubpathManifest by splitting bullet-matching and backtick-name extraction into their own functions. Verified: pnpm check:fallow --base now reports no issues, and pnpm check:tooling / pnpm test:unit stay green. Refs #1463 --- .fallowrc.json | 4 ++ examples/sdk/client-session.ts | 47 +++++++++++-------- examples/sdk/contracts-result.ts | 12 +++-- .../client-api-examples-drift.test.ts | 27 ++++++----- 4 files changed, 56 insertions(+), 34 deletions(-) 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/sdk/client-session.ts b/examples/sdk/client-session.ts index 33cc311dc..7bc4ffd4f 100644 --- a/examples/sdk/client-session.ts +++ b/examples/sdk/client-session.ts @@ -19,6 +19,30 @@ import { 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', @@ -27,16 +51,7 @@ async function main(): Promise { }); try { - 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'); - } + const device = await resolveSnapshotCapableIosDevice(client); await client.apps.open({ app: 'com.apple.Preferences', @@ -50,16 +65,8 @@ async function main(): Promise { await client.interactions.press({ ref: target.ref }); } } catch (error) { - if (isAgentDeviceError(error)) { - const normalized = normalizeAgentDeviceError(error); - console.error(`agent-device error [${normalized.code}]: ${normalized.message}`); - if (normalized.hint) { - console.error(`hint: ${normalized.hint}`); - } - process.exitCode = 1; - return; - } - throw error; + if (!isAgentDeviceError(error)) throw error; + reportAgentDeviceError(error); } finally { await client.sessions.close(); } diff --git a/examples/sdk/contracts-result.ts b/examples/sdk/contracts-result.ts index 3b38ff167..ef3d28594 100644 --- a/examples/sdk/contracts-result.ts +++ b/examples/sdk/contracts-result.ts @@ -14,15 +14,21 @@ 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); - if (!node?.rect) { - throw new Error('No visible node with a rect in the snapshot'); - } + assertHasRect(node); const center = centerOfRect(node.rect); console.log( diff --git a/src/__tests__/client-api-examples-drift.test.ts b/src/__tests__/client-api-examples-drift.test.ts index 3551a2714..adde542a9 100644 --- a/src/__tests__/client-api-examples-drift.test.ts +++ b/src/__tests__/client-api-examples-drift.test.ts @@ -38,25 +38,30 @@ const REQUIRED_EXAMPLE_SYMBOLS: readonly { subpath: string; symbol: string }[] = // 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 topLevelMatch = /^- `(agent-device[a-z0-9/-]*)`$/.exec(line); - if (topLevelMatch) { - currentSubpath = topLevelMatch[1] ?? null; - if (currentSubpath && !manifest.has(currentSubpath)) { - manifest.set(currentSubpath, new Set()); - } + const subpath = matchTopLevelSubpathBullet(line); + if (subpath) { + currentSubpath = subpath; + if (!manifest.has(subpath)) manifest.set(subpath, new Set()); continue; } - if (currentSubpath === null || !/^\s+- /.test(line)) continue; + if (!currentSubpath || !/^\s+- /.test(line)) continue; const symbols = manifest.get(currentSubpath); if (!symbols) continue; - for (const match of line.matchAll(/`([A-Za-z0-9_]+)(?:\([^)]*\))?`/g)) { - const name = match[1]; - if (name) symbols.add(name); - } + for (const name of extractBacktickedNames(line)) symbols.add(name); } return manifest; } From be00095242d861726c9179f12491a823127e0782 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 16:04:48 +0000 Subject: [PATCH 3/5] fix: compile client-api.md's actual code snippets, not just its symbol manifest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review feedback on #1463's drift guard: the existing guard only parsed the doc's "Public subpath API" bullet manifest and compared imported symbol names, so a fenced ```ts snippet could drift or stop compiling without the guard noticing. Added test/integration/client-api-doc-snippets.test.ts, which extracts every fenced ```ts block from client-api.md and typechecks it against the real agent-device/* sources (reusing examples/sdk/tsconfig.json's existing paths mapping, read via `tsc --showConfig` so there's one source of truth). Free identifiers that continue a `client`/`snapshot` from an earlier snippet are stubbed — typed against the real SDK return type, not `any`, so continuation snippets still get real checking. Lives in the Node integration lane (test/integration/*.test.ts), not vitest's unit-core: it spawns a real tsc Program, well past the unit suite's 2.5s budget. Running this check against the existing doc surfaced real, pre-existing snippet bugs (unrelated to the new examples), fixed here: - "sessions.artifacts": `result.cloudArtifacts` accessed without narrowing the `CloudArtifactsResult | DaemonArtifactsResult` union first. - "Device cloud sessions": `platform`/`device` were passed into the client constructor config, which doesn't accept them; moved to the `apps.open()` call where those fields actually belong. - "Android ADB providers": the inline `exec` handler had no parameter types, so it failed under strict/noImplicitAny; annotated with the real `AndroidAdbExecutorOptions` type. Two further gaps the check surfaced are pre-existing product/API-surface questions out of scope for this PR (not the new examples), so they're allowlisted in KNOWN_DOC_GAPS with comments rather than silently patched: - "Remote Metro helpers" documents prepareRemoteMetro/reloadRemoteMetro/ stopMetroTunnel/resolveRemoteConfigProfile as public, but none of them are exported from agent-device/metro or agent-device/remote-config today. - "Web sessions"/audio probe pass `platform` to `observability.network()`/ `.audio()`, but NetworkOptions/AudioOptions have no `platform` field even though the CLI's network/audio commands accept `--platform`. Refs #1463 --- examples/README.md | 6 +- .../client-api-examples-drift.test.ts | 5 +- .../client-api-doc-snippets.test.ts | 190 ++++++++++++++++++ website/docs/docs/client-api.md | 14 +- 4 files changed, 206 insertions(+), 9 deletions(-) create mode 100644 test/integration/client-api-doc-snippets.test.ts diff --git a/examples/README.md b/examples/README.md index 35b066412..271fa4bfd 100644 --- a/examples/README.md +++ b/examples/README.md @@ -2,8 +2,10 @@ 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); `src/__tests__/client-api-examples-drift.test.ts` -guards these files against drifting out of sync with that doc. +[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/ diff --git a/src/__tests__/client-api-examples-drift.test.ts b/src/__tests__/client-api-examples-drift.test.ts index adde542a9..61218967d 100644 --- a/src/__tests__/client-api-examples-drift.test.ts +++ b/src/__tests__/client-api-examples-drift.test.ts @@ -17,7 +17,10 @@ import { describe, test } from 'vitest'; // 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. +// 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'; 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..af47b24bb --- /dev/null +++ b/test/integration/client-api-doc-snippets.test.ts @@ -0,0 +1,190 @@ +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 { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +// 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 = execFileSync(TSC_BIN, ['-p', EXAMPLES_SDK_TSCONFIG, '--showConfig'], { + encoding: 'utf8', + }); + 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 — continuing a +// `client`/`snapshot` from an earlier snippet in the doc's prose. Typed against +// the real SDK return types (not `any`) so continuation snippets still get +// meaningful checking; anything else (illustrative host-glue names the doc +// invents, like a bridge's own transport function) falls back to `any`, which +// only weakens the check for that invented name, not for the real SDK calls +// around it. +const KNOWN_CONTINUATION_STUB_TYPES: Record = { + client: `ReturnType`, + androidClient: `ReturnType`, + snapshot: `Awaited['capture']['snapshot']>>`, +}; + +// Pre-existing gaps this check surfaced that are out of scope for #1463 to fix +// (they predate this PR and touch unrelated public API surface, not the new +// examples): flagged for the maintainer rather than silently patched. Remove +// an entry once the underlying gap is closed. +const KNOWN_DOC_GAPS = [ + // "Remote Metro helpers": prepareRemoteMetro/reloadRemoteMetro/stopMetroTunnel + // aren't exported from `agent-device/metro`, and resolveRemoteConfigProfile + // isn't exported from `agent-device/remote-config` — the doc documents a + // public surface neither subpath's src/sdk/*.ts re-exports today. + `Module '"agent-device/metro"' has no exported member 'prepareRemoteMetro'`, + `Module '"agent-device/metro"' has no exported member 'reloadRemoteMetro'`, + `Module '"agent-device/metro"' has no exported member 'stopMetroTunnel'`, + `'"agent-device/remote-config"' has no exported member named 'resolveRemoteConfigProfile'`, + // "Web sessions" / audio probe: NetworkOptions/AudioOptions have no `platform` + // field, even though the CLI's `network`/`audio` commands accept `--platform` + // — the typed client's contracts are narrower than the CLI surface here. + `'platform' does not exist in type 'NetworkOptions'`, + `'platform' does not exist in type 'AudioOptions'`, +]; + +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 { + try { + execFileSync( + TSC_BIN, + ['--noEmit', '-p', path.join(tmpDir, 'tsconfig.json'), '--pretty', 'false'], + { + encoding: 'utf8', + cwd: tmpDir, + }, + ); + return ''; + } catch (error) { + const { stdout, stderr } = error as { stdout?: string; stderr?: string }; + return `${stdout ?? ''}${stderr ?? ''}`; + } +} + +// One auto-stub pass: for every "Cannot find name 'X'" diagnostic, declare `X` +// (typed precisely if it's a known SDK-derived continuation, `any` otherwise) +// at the top of that snippet file and recompile. A missing/renamed *import* +// binding is a different diagnostic code and is never suppressed this way. +function stubFreeNamesAndRecompile(tmpDir: string, firstPassOutput: string): string { + const freeNamesByFile = new Map>(); + for (const line of firstPassOutput.split('\n')) { + const match = /^(snippet-\d+\.ts)\(\d+,\d+\): error TS2304: Cannot find name '([^']+)'/.exec( + line, + ); + if (match?.[1] && match[2]) { + const names = freeNamesByFile.get(match[1]) ?? new Set(); + names.add(match[2]); + freeNamesByFile.set(match[1], names); + } + } + 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_CONTINUATION_STUB_TYPES[name] ?? 'any'};`) + .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)); + const diagnosticLines = output.split('\n').filter((line) => /error TS\d+:/.test(line)); + return diagnosticLines.filter((line) => !KNOWN_DOC_GAPS.some((gap) => line.includes(gap))); + } 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 update the SDK if the doc was right and the ' + + 'API regressed. If this is a new, deliberately out-of-scope gap, add it to KNOWN_DOC_GAPS ' + + 'with a comment explaining why.', + ); +}); diff --git a/website/docs/docs/client-api.md b/website/docs/docs/client-api.md index cc857ed49..bfa53474b 100644 --- a/website/docs/docs/client-api.md +++ b/website/docs/docs/client-api.md @@ -124,8 +124,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 +140,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 +197,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 From e40f1c8235dc30792490ce0a328580e5bd053ba0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 16:36:07 +0000 Subject: [PATCH 4/5] fix: close the doc-snippet compiler's stubbing hole and the two suppressed gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the second round of review feedback on #1463's drift guard: 1. stubFreeNamesAndRecompile auto-stubbed every "Cannot find name" as `any`, so a typo like `cliet.apps.open()` would silently pass on the second compile. It now only stubs identifiers in an explicit allowlist (KNOWN_FREE_NAME_STUB_TYPES) — the real SDK-derived continuations (`client`, `androidClient`, `snapshot`) plus the doc's own invented host-glue names, each typed precisely rather than loosely. Anything else is left as a real compile failure. Added a regression test that feeds a `cliet` typo through the guard and asserts it fails. 2. KNOWN_DOC_GAPS filtered six real compiler errors out of the final assertion while the test claimed every snippet compiles. Investigated both and fixed the actual contracts instead of suppressing them: - `prepareMetroRuntime`/`reloadMetro` (src/metro/client-metro.ts) and `stopMetroTunnel` (src/metro/metro.ts) already existed and matched the doc's described workflow almost exactly (same result shape) but were never re-exported from `agent-device/metro`; same for `resolveRemoteConfigProfile` and `agent-device/remote-config`. Added the four exports and fixed the doc's stale function names (`prepareRemoteMetro`/`reloadRemoteMetro`) and one stale field name (`profileKey` -> `companionProfileKey` on the prepare call) to match. - `NetworkOptions`/`AudioOptions` (src/contracts/client-observability.ts) had no `platform` field even though the CLI's `network`/`audio` commands accept `--platform` for the same use case, and the client methods already forward the options object to the daemon generically (`executeCommand('network'|'audio', options)`) — so this was a type gap, not a runtime one. Switched both from AgentDeviceRequestOverrides to DeviceCommandBaseOptions (matching PerfOptions' existing pattern), closing the gap for real instead of stripping `platform` from the doc. - The "Android installFromSource()" snippet was missing its `createAgentDeviceClient` import outright; added it. KNOWN_DOC_GAPS is gone — every fenced snippet now compiles for real, and the test's assertion matches what it claims. 3. Switched the raw `execFileSync` calls to `runCmdSync` from src/utils/exec.ts, per AGENTS.md's process-execution invariant (this is a .ts integration test, not a packaging fixture that needs to stay dependency-free). Refs #1463 --- src/contracts/client-observability.ts | 4 +- src/sdk/metro.ts | 3 +- src/sdk/remote-config.ts | 1 + .../client-api-doc-snippets.test.ts | 124 +++++++++--------- website/docs/docs/client-api.md | 12 +- 5 files changed, 77 insertions(+), 67 deletions(-) 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 index af47b24bb..b4069a7de 100644 --- a/test/integration/client-api-doc-snippets.test.ts +++ b/test/integration/client-api-doc-snippets.test.ts @@ -3,8 +3,8 @@ import os from 'node:os'; import path from 'node:path'; import assert from 'node:assert/strict'; import test from 'node:test'; -import { execFileSync } from 'node:child_process'; 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 @@ -36,9 +36,7 @@ function extractTsSnippets(markdown: string): string[] { // 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 = execFileSync(TSC_BIN, ['-p', EXAMPLES_SDK_TSCONFIG, '--showConfig'], { - encoding: 'utf8', - }); + 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( @@ -49,39 +47,31 @@ function resolveExamplesSdkPaths(): Record { return paths; } -// Free identifiers a snippet references without declaring — continuing a -// `client`/`snapshot` from an earlier snippet in the doc's prose. Typed against -// the real SDK return types (not `any`) so continuation snippets still get -// meaningful checking; anything else (illustrative host-glue names the doc -// invents, like a bridge's own transport function) falls back to `any`, which -// only weakens the check for that invented name, not for the real SDK calls -// around it. -const KNOWN_CONTINUATION_STUB_TYPES: Record = { +// 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`, }; -// Pre-existing gaps this check surfaced that are out of scope for #1463 to fix -// (they predate this PR and touch unrelated public API surface, not the new -// examples): flagged for the maintainer rather than silently patched. Remove -// an entry once the underlying gap is closed. -const KNOWN_DOC_GAPS = [ - // "Remote Metro helpers": prepareRemoteMetro/reloadRemoteMetro/stopMetroTunnel - // aren't exported from `agent-device/metro`, and resolveRemoteConfigProfile - // isn't exported from `agent-device/remote-config` — the doc documents a - // public surface neither subpath's src/sdk/*.ts re-exports today. - `Module '"agent-device/metro"' has no exported member 'prepareRemoteMetro'`, - `Module '"agent-device/metro"' has no exported member 'reloadRemoteMetro'`, - `Module '"agent-device/metro"' has no exported member 'stopMetroTunnel'`, - `'"agent-device/remote-config"' has no exported member named 'resolveRemoteConfigProfile'`, - // "Web sessions" / audio probe: NetworkOptions/AudioOptions have no `platform` - // field, even though the CLI's `network`/`audio` commands accept `--platform` - // — the typed client's contracts are narrower than the CLI surface here. - `'platform' does not exist in type 'NetworkOptions'`, - `'platform' does not exist in type 'AudioOptions'`, -]; - 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'); @@ -112,44 +102,46 @@ function writeSnippetProgram(snippets: string[]): string { } function runTsc(tmpDir: string): string { - try { - execFileSync( - TSC_BIN, - ['--noEmit', '-p', path.join(tmpDir, 'tsconfig.json'), '--pretty', 'false'], - { - encoding: 'utf8', - cwd: tmpDir, - }, - ); - return ''; - } catch (error) { - const { stdout, stderr } = error as { stdout?: string; stderr?: string }; - return `${stdout ?? ''}${stderr ?? ''}`; - } + const result = runCmdSync( + TSC_BIN, + ['--noEmit', '-p', path.join(tmpDir, 'tsconfig.json'), '--pretty', 'false'], + { cwd: tmpDir, allowFailure: true }, + ); + return `${result.stdout}${result.stderr}`; } -// One auto-stub pass: for every "Cannot find name 'X'" diagnostic, declare `X` -// (typed precisely if it's a known SDK-derived continuation, `any` otherwise) -// at the top of that snippet file and recompile. A missing/renamed *import* -// binding is a different diagnostic code and is never suppressed this way. -function stubFreeNamesAndRecompile(tmpDir: string, firstPassOutput: string): string { +// 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 firstPassOutput.split('\n')) { + for (const line of tscOutput.split('\n')) { const match = /^(snippet-\d+\.ts)\(\d+,\d+\): error TS2304: Cannot find name '([^']+)'/.exec( line, ); - if (match?.[1] && match[2]) { + 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(match[2]); + 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_CONTINUATION_STUB_TYPES[name] ?? 'any'};`) + .map((name) => `declare const ${name}: ${KNOWN_FREE_NAME_STUB_TYPES[name]};`) .join('\n'); fs.writeFileSync( filePath, @@ -163,8 +155,7 @@ function compileDocSnippets(snippets: string[]): string[] { const tmpDir = writeSnippetProgram(snippets); try { const output = stubFreeNamesAndRecompile(tmpDir, runTsc(tmpDir)); - const diagnosticLines = output.split('\n').filter((line) => /error TS\d+:/.test(line)); - return diagnosticLines.filter((line) => !KNOWN_DOC_GAPS.some((gap) => line.includes(gap))); + return output.split('\n').filter((line) => /error TS\d+:/.test(line)); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); } @@ -183,8 +174,21 @@ test("every fenced ```ts snippet in client-api.md compiles against agent-device' [], `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 update the SDK if the doc was right and the ' + - 'API regressed. If this is a new, deliberately out-of-scope gap, add it to KNOWN_DOC_GAPS ' + - 'with a comment explaining why.', + '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 bfa53474b..83e35fd98 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)` @@ -345,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({ @@ -406,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({ @@ -414,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, @@ -424,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, }); From 8bd5db9dd4674711590a4dffdd80aadcd47befe9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 16:41:37 +0000 Subject: [PATCH 5/5] docs: fix stale reloadRemoteMetro() prose reference to reloadMetro() The prose right after the Remote Metro helpers snippet still named the old function; the compile guard only checks the fenced snippet, not surrounding prose, so it didn't catch this leftover from the prior rename. Refs #1463 --- website/docs/docs/client-api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/docs/docs/client-api.md b/website/docs/docs/client-api.md index 83e35fd98..33aee1dfc 100644 --- a/website/docs/docs/client-api.md +++ b/website/docs/docs/client-api.md @@ -443,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