diff --git a/package.json b/package.json index cdd1f9355..cf29d104a 100644 --- a/package.json +++ b/package.json @@ -12,10 +12,6 @@ "import": "./dist/src/index.js", "types": "./dist/src/index.d.ts" }, - "./artifacts": { - "import": "./dist/src/artifacts.js", - "types": "./dist/src/artifacts.d.ts" - }, "./metro": { "import": "./dist/src/metro.js", "types": "./dist/src/metro.d.ts" @@ -24,21 +20,9 @@ "import": "./dist/src/remote-config.js", "types": "./dist/src/remote-config.d.ts" }, - "./install-source": { - "import": "./dist/src/install-source.js", - "types": "./dist/src/install-source.d.ts" - }, "./contracts": { "import": "./dist/src/contracts.js", "types": "./dist/src/contracts.d.ts" - }, - "./selectors": { - "import": "./dist/src/selectors.js", - "types": "./dist/src/selectors.d.ts" - }, - "./finders": { - "import": "./dist/src/finders.js", - "types": "./dist/src/finders.d.ts" } }, "engines": { diff --git a/rslib.config.ts b/rslib.config.ts index ebcb2f57b..73f18bdd6 100644 --- a/rslib.config.ts +++ b/rslib.config.ts @@ -16,14 +16,10 @@ export default defineConfig({ }, source: { entry: { - artifacts: 'src/artifacts.ts', index: 'src/index.ts', - 'install-source': 'src/install-source.ts', metro: 'src/metro.ts', 'remote-config': 'src/remote-config.ts', contracts: 'src/contracts.ts', - selectors: 'src/selectors.ts', - finders: 'src/finders.ts', }, tsconfigPath: 'tsconfig.lib.json', }, diff --git a/src/__tests__/artifacts-public.test.ts b/src/__tests__/artifacts-public.test.ts deleted file mode 100644 index 95706ef9c..000000000 --- a/src/__tests__/artifacts-public.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -import assert from 'node:assert/strict'; -import { test } from 'vitest'; - -import { resolveAndroidArchivePackageName } from '../artifacts.ts'; - -const resolver: (archivePath: string) => Promise = - resolveAndroidArchivePackageName; - -test('package subpath exports android archive package resolver', () => { - assert.equal(typeof resolver, 'function'); -}); diff --git a/src/__tests__/client-metro-auto-companion.test.ts b/src/__tests__/client-metro-auto-companion.test.ts index 8cca28b6f..266146e98 100644 --- a/src/__tests__/client-metro-auto-companion.test.ts +++ b/src/__tests__/client-metro-auto-companion.test.ts @@ -257,3 +257,100 @@ test('prepareMetroRuntime fails fast on non-retryable bridge errors after compan fs.rmSync(tempRoot, { recursive: true, force: true }); } }); + +test('prepareMetroRuntime retries malformed retryable bridge responses after companion startup', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-metro-companion-html-')); + const projectRoot = path.join(tempRoot, 'project'); + fs.mkdirSync(path.join(projectRoot, 'node_modules'), { recursive: true }); + fs.writeFileSync( + path.join(projectRoot, 'package.json'), + JSON.stringify({ + name: 'metro-auto-companion-html-test', + private: true, + dependencies: { + 'react-native': '0.0.0-test', + }, + }), + ); + + vi.mocked(ensureMetroCompanion).mockResolvedValue({ + pid: 123, + spawned: true, + statePath: path.join(projectRoot, '.agent-device', 'metro-companion.json'), + logPath: path.join(projectRoot, '.agent-device', 'metro-companion.log'), + }); + + const fetchMock = vi.fn(); + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + text: async () => 'packager-status:running', + }); + fetchMock.mockResolvedValueOnce({ + ok: false, + status: 409, + text: async () => JSON.stringify({ ok: false, error: 'Metro companion is not connected' }), + }); + fetchMock.mockResolvedValueOnce({ + ok: false, + status: 503, + text: async () => 'upstream unavailable', + }); + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + text: async () => + JSON.stringify({ + ok: true, + data: { + enabled: true, + base_url: 'https://proxy.example.test', + status_url: 'https://proxy.example.test/status', + bundle_url: 'https://proxy.example.test/index.bundle?platform=ios', + ios_runtime: { + metro_bundle_url: 'https://proxy.example.test/index.bundle?platform=ios', + }, + android_runtime: { + metro_bundle_url: 'https://proxy.example.test/index.bundle?platform=android', + }, + upstream: { + bundle_url: + 'https://public.example.test/index.bundle?platform=ios&dev=true&minify=false', + }, + probe: { + reachable: true, + status_code: 200, + latency_ms: 5, + detail: 'ok', + }, + }, + }), + }); + vi.stubGlobal('fetch', fetchMock); + vi.useFakeTimers(); + + try { + const preparePromise = prepareMetroRuntime({ + projectRoot, + publicBaseUrl: 'https://public.example.test', + proxyBaseUrl: 'https://proxy.example.test', + proxyBearerToken: 'shared-token', + metroPort: 8081, + reuseExisting: true, + installDependenciesIfNeeded: false, + probeTimeoutMs: 10, + }); + + await vi.advanceTimersByTimeAsync(1_000); + const result = await preparePromise; + + assert.equal(result.bridge?.enabled, true); + assert.equal( + result.iosRuntime.bundleUrl, + 'https://proxy.example.test/index.bundle?platform=ios', + ); + assert.equal(fetchMock.mock.calls.length, 4); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +}); diff --git a/src/__tests__/client-metro-startup-cleanup.test.ts b/src/__tests__/client-metro-startup-cleanup.test.ts new file mode 100644 index 000000000..26f7a1e20 --- /dev/null +++ b/src/__tests__/client-metro-startup-cleanup.test.ts @@ -0,0 +1,75 @@ +import { afterEach, test, vi } from 'vitest'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +vi.mock('../utils/exec.ts', () => ({ + runCmdDetached: vi.fn(), + runCmdSync: vi.fn(), +})); + +vi.mock('../utils/process-identity.ts', () => ({ + waitForProcessExit: vi.fn(), +})); + +import { runCmdDetached } from '../utils/exec.ts'; +import { waitForProcessExit } from '../utils/process-identity.ts'; +import { prepareMetroRuntime } from '../client-metro.ts'; + +afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +test('prepareMetroRuntime stops a spawned Metro process when startup readiness times out', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-metro-startup-cleanup-')); + const projectRoot = path.join(tempRoot, 'project'); + fs.mkdirSync(path.join(projectRoot, 'node_modules'), { recursive: true }); + fs.writeFileSync( + path.join(projectRoot, 'package.json'), + JSON.stringify({ + name: 'metro-cleanup-test', + private: true, + dependencies: { + 'react-native': '0.0.0-test', + }, + }), + ); + + vi.mocked(runCmdDetached).mockReturnValue(987); + vi.mocked(waitForProcessExit).mockResolvedValue(true); + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + text: async () => 'packager-status:not-running', + }); + vi.stubGlobal('fetch', fetchMock); + vi.useFakeTimers(); + + try { + const preparePromise = prepareMetroRuntime({ + projectRoot, + publicBaseUrl: 'https://public.example.test', + metroPort: 8081, + reuseExisting: true, + installDependenciesIfNeeded: false, + probeTimeoutMs: 10, + startupTimeoutMs: 30_000, + }); + + const expectedFailure = assert.rejects( + preparePromise, + /Metro did not become ready at http:\/\/127\.0\.0\.1:8081\/status within 30000ms/, + ); + await vi.advanceTimersByTimeAsync(30_000); + await expectedFailure; + assert.equal(vi.mocked(runCmdDetached).mock.calls.length, 1); + assert.deepEqual(killSpy.mock.calls[0], [987, 'SIGTERM']); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +}); diff --git a/src/__tests__/client-public.test.ts b/src/__tests__/client-public.test.ts index 83af570de..13d4b5d48 100644 --- a/src/__tests__/client-public.test.ts +++ b/src/__tests__/client-public.test.ts @@ -5,6 +5,7 @@ import { type AgentDeviceClient, type CaptureScreenshotResult, type CaptureSnapshotResult, + type AgentDeviceDaemonTransport, centerOfRect, type Point, type Rect, @@ -13,6 +14,7 @@ import { type SnapshotVisibility, type SnapshotVisibilityReason, } from '../index.ts'; +import type { DaemonRequest, DaemonResponse } from '../contracts.ts'; const rect = { x: 1, y: 2, width: 3, height: 4 } satisfies Rect; const point = { x: 2, y: 4 } satisfies Point; @@ -58,3 +60,17 @@ test('package root exports createAgentDeviceClient', () => { assert.equal(typeof client.capture.snapshot, 'function'); assert.deepEqual(centerOfRect(rect), { x: 3, y: 4 }); }); + +test('public daemon transport is typed against public daemon contracts', async () => { + const transport: AgentDeviceDaemonTransport = async ( + request: Omit, + ): Promise => ({ + ok: true, + data: { + command: request.command, + }, + }); + const response = await transport({ command: 'devices', positionals: [] }); + + assert.equal(response.ok, true); +}); diff --git a/src/__tests__/client.test.ts b/src/__tests__/client.test.ts index 4b3c78fde..90491f2aa 100644 --- a/src/__tests__/client.test.ts +++ b/src/__tests__/client.test.ts @@ -4,7 +4,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { createAgentDeviceClient, type AgentDeviceClientConfig } from '../client.ts'; -import type { DaemonRequest, DaemonResponse } from '../daemon/types.ts'; +import type { DaemonRequest, DaemonResponse } from '../contracts.ts'; import { AppError } from '../utils/errors.ts'; function createTransport( diff --git a/src/__tests__/finders-public.test.ts b/src/__tests__/finders-public.test.ts deleted file mode 100644 index 25d8d12a7..000000000 --- a/src/__tests__/finders-public.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { test } from 'vitest'; -import assert from 'node:assert/strict'; -import { - findBestMatchesByLocator, - normalizeRole, - normalizeText, - parseFindArgs, -} from '../finders.ts'; -import type { SnapshotNode } from '../utils/snapshot.ts'; - -function makeNode(ref: string, label?: string): SnapshotNode { - return { - index: Number(ref.replace('e', '')) || 0, - ref, - type: 'XCUIElementTypeButton', - label, - }; -} - -test('public finders entrypoint re-exports pure helpers', () => { - const nodes: SnapshotNode[] = [makeNode('e1', 'Continue')]; - - const parsed = parseFindArgs(['label', 'Continue', 'click']); - const best = findBestMatchesByLocator(nodes, 'label', 'Continue', true); - - assert.equal(normalizeText(' Continue\nNow '), 'continue now'); - assert.equal(normalizeRole('XCUIElementTypeApplication.XCUIElementTypeButton'), 'button'); - assert.equal(parsed.action, 'click'); - assert.equal(best.matches.length, 0); -}); diff --git a/src/__tests__/selectors-public.test.ts b/src/__tests__/selectors-public.test.ts deleted file mode 100644 index 36f4d054f..000000000 --- a/src/__tests__/selectors-public.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { test } from 'vitest'; -import assert from 'node:assert/strict'; -import { - findSelectorChainMatch, - formatSelectorFailure, - isNodeEditable, - isNodeVisible, - isSelectorToken, - parseSelectorChain, - resolveSelectorChain, - tryParseSelectorChain, - type Selector, - type SelectorChain, - type SelectorDiagnostics, - type SelectorResolution, -} from '../selectors.ts'; -import type { SnapshotNode } from '../utils/snapshot.ts'; - -const nodes: SnapshotNode[] = [ - { - ref: 'e1', - index: 0, - type: 'android.widget.Button', - label: 'Continue', - rect: { x: 0, y: 0, width: 120, height: 48 }, - enabled: true, - }, - { - ref: 'e2', - index: 1, - type: 'android.widget.EditText', - label: 'Email', - rect: { x: 0, y: 64, width: 200, height: 48 }, - enabled: true, - }, -]; - -test('public selector subpath exposes platform-aware matching helpers', () => { - const chain: SelectorChain = parseSelectorChain('role=button label="Continue" visible=true'); - const firstSelector: Selector = chain.selectors[0]; - assert.equal(firstSelector.raw, 'role=button label="Continue" visible=true'); - assert.equal(tryParseSelectorChain(chain.raw)?.raw, chain.raw); - assert.equal(isSelectorToken('visible=true'), true); - - const match = findSelectorChainMatch(nodes, chain, { - platform: 'android', - requireRect: true, - }); - assert.ok(match); - assert.equal(match.matches, 1); - - const resolved: SelectorResolution | null = resolveSelectorChain(nodes, chain, { - platform: 'android', - requireRect: true, - }); - assert.equal(resolved?.node.ref, 'e1'); - - assert.equal(isNodeVisible(nodes[0]), true); - assert.equal(isNodeEditable(nodes[1], 'android'), true); -}); - -test('public selector diagnostics format failures', () => { - const chain = parseSelectorChain('label=Missing'); - const diagnostics: SelectorDiagnostics[] = [{ selector: 'label=Missing', matches: 0 }]; - - assert.equal( - formatSelectorFailure(chain, diagnostics, { unique: false }), - 'Selector did not match (label=Missing -> 0)', - ); -}); diff --git a/src/artifacts.ts b/src/artifacts.ts deleted file mode 100644 index 34ed3c323..000000000 --- a/src/artifacts.ts +++ /dev/null @@ -1 +0,0 @@ -export { resolveAndroidArchivePackageName } from './platforms/android/manifest.ts'; diff --git a/src/cli.ts b/src/cli.ts index 5afab4e2d..ad0a33832 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -7,7 +7,11 @@ import { sendToDaemon } from './daemon-client.ts'; import fs from 'node:fs'; import type { BatchStep } from './core/dispatch.ts'; import { parseBatchStepsJson } from './core/batch.ts'; -import { createAgentDeviceClient, type AgentDeviceClientConfig } from './client.ts'; +import { + createAgentDeviceClient, + type AgentDeviceClientConfig, + type AgentDeviceDaemonTransport, +} from './client.ts'; import { tryRunClientBackedCommand } from './cli/commands/router.ts'; import { createRequestId, @@ -151,7 +155,9 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): cwd: process.cwd(), debug: Boolean(flags.verbose), }; - const client = createAgentDeviceClient(clientConfig, { transport: deps.sendToDaemon }); + const client = createAgentDeviceClient(clientConfig, { + transport: deps.sendToDaemon as AgentDeviceDaemonTransport, + }); try { if (command === 'batch') { if (positionals.length > 0) { diff --git a/src/client-metro.ts b/src/client-metro.ts index f5e5e7045..cd007cbfc 100644 --- a/src/client-metro.ts +++ b/src/client-metro.ts @@ -10,8 +10,12 @@ import type { import { AppError } from './utils/errors.ts'; import { runCmdSync, runCmdDetached } from './utils/exec.ts'; import { resolveUserPath } from './utils/path-resolution.ts'; +import { waitForProcessExit } from './utils/process-identity.ts'; import { buildBundleUrl, normalizeBaseUrl } from './utils/url.ts'; +const METRO_TERM_TIMEOUT_MS = 1_000; +const METRO_KILL_TIMEOUT_MS = 1_000; + export type MetroPrepareKind = 'auto' | 'react-native' | 'expo'; type ResolvedMetroKind = Exclude; type EnvSource = NodeJS.ProcessEnv | Record; @@ -290,6 +294,26 @@ function startMetroProcess( }; } +async function stopSpawnedMetroProcess(pid: number): Promise { + if (!Number.isInteger(pid) || pid <= 0) return; + try { + process.kill(pid, 'SIGTERM'); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ESRCH' || code === 'EPERM') return; + throw error; + } + if (await waitForProcessExit(pid, METRO_TERM_TIMEOUT_MS)) return; + try { + process.kill(pid, 'SIGKILL'); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ESRCH' || code === 'EPERM') return; + throw error; + } + await waitForProcessExit(pid, METRO_KILL_TIMEOUT_MS); +} + function createProxyHeaders(baseUrl: string, bearerToken: string): Record { return { Authorization: `Bearer ${bearerToken}`, @@ -354,7 +378,11 @@ async function configureMetroBridge(input: ProxyBridgeRequestOptions): Promise { + if (!responseText) return {}; + try { + const parsed = JSON.parse(responseText) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('Expected a JSON object'); + } + return parsed as Record; + } catch (error) { + const snippet = responseText.slice(0, 200); + const detail = error instanceof Error ? error.message : String(error); + throw createMetroBridgeRequestError( + `/api/metro/bridge returned invalid JSON (${statusCode}) from ${baseUrl}: ${detail}. body=${JSON.stringify(snippet)}`, + isRetryableBridgeHttpFailure(statusCode, responseText), + ); + } } function normalizeBridgeResponse(response: MetroBridgeDescriptor): MetroBridgeResult { @@ -391,6 +439,26 @@ function normalizeBridgeResponse(response: MetroBridgeDescriptor): MetroBridgeRe }; } +function normalizeMetroBridgeResponsePayload( + responsePayload: Record, +): MetroBridgeResult { + const descriptor = responsePayload.data ?? responsePayload; + if (!descriptor || typeof descriptor !== 'object' || Array.isArray(descriptor)) { + throw createMetroBridgeRequestError( + '/api/metro/bridge returned malformed descriptor: Expected a JSON object.', + false, + ); + } + try { + return normalizeBridgeResponse(descriptor as MetroBridgeDescriptor); + } catch (error) { + throw createMetroBridgeRequestError( + `/api/metro/bridge returned malformed descriptor: ${error instanceof Error ? error.message : String(error)}`, + false, + ); + } +} + function describeBridgeFailure( baseUrl: string, bridgeError: string | null, @@ -581,6 +649,7 @@ export async function prepareMetroRuntime( pid = startedProcess.pid; if (!(await waitForMetroReady(statusUrl, startupTimeoutMs, probeTimeoutMs))) { + await stopSpawnedMetroProcess(pid).catch(() => {}); throw new Error( `Metro did not become ready at ${statusUrl} within ${startupTimeoutMs}ms. Check ${logPath}.`, ); diff --git a/src/client-types.ts b/src/client-types.ts index 79d91efc4..40c5fc938 100644 --- a/src/client-types.ts +++ b/src/client-types.ts @@ -5,7 +5,7 @@ import type { DaemonRequest, DaemonResponse, SessionRuntimeHints, -} from './daemon/types.ts'; +} from './contracts.ts'; import type { DeviceKind, DeviceTarget, Platform, PlatformSelector } from './utils/device.ts'; import type { ScreenshotOverlayRef, SnapshotNode, SnapshotVisibility } from './utils/snapshot.ts'; import type { MetroPrepareKind, PrepareMetroRuntimeResult } from './client-metro.ts'; diff --git a/src/finders.ts b/src/finders.ts deleted file mode 100644 index 6eb974dde..000000000 --- a/src/finders.ts +++ /dev/null @@ -1,17 +0,0 @@ -export type { FindLocator } from './utils/finders.ts'; -export { normalizeRole, normalizeText, parseFindArgs } from './utils/finders.ts'; - -import { - findBestMatchesByLocator as findBestMatchesByLocatorInternal, - type FindLocator, -} from './utils/finders.ts'; -import type { SnapshotNode } from './utils/snapshot.ts'; - -export function findBestMatchesByLocator( - nodes: SnapshotNode[], - locator: FindLocator, - query: string, - requireRect?: boolean, -) { - return findBestMatchesByLocatorInternal(nodes, locator, query, { requireRect }); -} diff --git a/src/install-source.ts b/src/install-source.ts deleted file mode 100644 index b464b4117..000000000 --- a/src/install-source.ts +++ /dev/null @@ -1,13 +0,0 @@ -export { - ARCHIVE_EXTENSIONS, - isBlockedIpAddress, - isBlockedSourceHostname, - isTrustedInstallSourceUrl, - materializeInstallablePath, - validateDownloadSourceUrl, -} from './platforms/install-source.ts'; - -export type { - MaterializeInstallSource, - MaterializedInstallable, -} from './platforms/install-source.ts'; diff --git a/src/platforms/__tests__/install-source.test.ts b/src/platforms/__tests__/install-source.test.ts index aa61662ca..30a801c61 100644 --- a/src/platforms/__tests__/install-source.test.ts +++ b/src/platforms/__tests__/install-source.test.ts @@ -12,7 +12,7 @@ import { isTrustedInstallSourceUrl, materializeInstallablePath, validateDownloadSourceUrl, -} from '../../install-source.ts'; +} from '../install-source.ts'; import { prepareAndroidInstallArtifact } from '../android/install-artifact.ts'; import { prepareIosInstallArtifact } from '../ios/install-artifact.ts'; @@ -49,7 +49,7 @@ test('validateDownloadSourceUrl rejects unsupported protocols', async () => { ); }); -test('public install-source helpers expose the SSRF and archive surface', () => { +test('install-source helpers expose the SSRF and archive surface', () => { assert.deepEqual(ARCHIVE_EXTENSIONS, ['.zip', '.tar', '.tar.gz', '.tgz']); assert.equal(Object.isFrozen(ARCHIVE_EXTENSIONS), true); assert.equal(isBlockedSourceHostname('localhost'), true); diff --git a/src/selectors.ts b/src/selectors.ts deleted file mode 100644 index 7be48b8bf..000000000 --- a/src/selectors.ts +++ /dev/null @@ -1,17 +0,0 @@ -export type { - Selector, - SelectorChain, - SelectorDiagnostics, - SelectorResolution, -} from './daemon/selectors.ts'; - -export { - findSelectorChainMatch, - formatSelectorFailure, - isNodeEditable, - isNodeVisible, - isSelectorToken, - parseSelectorChain, - resolveSelectorChain, - tryParseSelectorChain, -} from './daemon/selectors.ts'; diff --git a/website/docs/docs/client-api.md b/website/docs/docs/client-api.md index 87c013cf4..510512f46 100644 --- a/website/docs/docs/client-api.md +++ b/website/docs/docs/client-api.md @@ -23,16 +23,6 @@ Public subpath API exposed for Node consumers: - types: `RemoteConfigProfile`, `RemoteConfigProfileOptions`, `ResolvedRemoteConfigProfile` - `agent-device/contracts` - types: `SessionRuntimeHints`, `DaemonInstallSource`, `DaemonLockPolicy`, `DaemonRequestMeta`, `DaemonRequest`, `DaemonArtifact`, `DaemonResponseData`, `DaemonError`, `DaemonResponse` -- `agent-device/selectors` - - `parseSelectorChain(expression)` - - `tryParseSelectorChain(expression)` - - `resolveSelectorChain(nodes, chain, options)` - - `findSelectorChainMatch(nodes, chain, options)` - - `formatSelectorFailure(chain, diagnostics, options)` - - `isNodeVisible(node)` - - `isSelectorToken(token)` - - `isNodeEditable(node, platform)` - - types: `Selector`, `SelectorChain`, `SelectorDiagnostics`, `SelectorResolution` ## Basic usage @@ -199,22 +189,3 @@ await stopMetroTunnel({ ``` Use `agent-device/remote-config` for profile loading and path resolution, `agent-device/metro` for Metro preparation and tunnel lifecycle, and `agent-device/contracts` when a server consumer needs daemon request or runtime contract types. - -## Selector helpers - -Use `agent-device/selectors` when a remote daemon or bridge needs to parse and match selector expressions without deep-importing daemon internals. Matching is platform-aware because role normalization and editability checks differ by backend. - -```ts -import { findSelectorChainMatch, parseSelectorChain } from 'agent-device/selectors'; - -const chain = parseSelectorChain('role=button label="Continue" visible=true'); - -const match = findSelectorChainMatch(snapshot.nodes, chain, { - platform: 'android', - requireRect: true, -}); - -if (!match) { - // Build a daemon-shaped error with formatSelectorFailure(...) if needed. -} -```