From 142e9d932fa4fa0b869c926331127dd5fce0eb91 Mon Sep 17 00:00:00 2001 From: Douwe Bos Date: Mon, 25 May 2026 18:01:34 +0200 Subject: [PATCH] feat(tap): Add @eN snapshot refs for capture-driven taps `capture-ui` now assigns each element a `@eN` ref and persists its coordinates per session, so `tap-on @e3` taps the cached point directly. Stale snapshots warn but still act, since refs are explicitly ephemeral. Also extracts a pure `selectDebuggerUrl` from `resolveDebuggerUrl` and types the `recordingPath` field on `Session` directly instead of casting. --- .changeset/snapshot-refs.md | 5 + packages/cli/src/commands/capture-ui.ts | 8 ++ packages/cli/src/commands/tap.ts | 20 ++- packages/cli/src/drivers/a11y.ts | 6 + packages/cli/src/drivers/flow-recorder.ts | 16 +-- packages/cli/src/drivers/metro-cdp.ts | 42 ++++-- packages/cli/src/session.ts | 2 + packages/cli/src/snapshot-store.ts | 130 +++++++++++++++++++ packages/cli/tests/all-tests.ts | 5 +- packages/cli/tests/flow-recorder.test.ts | 95 ++++++++++++++ packages/cli/tests/metro-cdp.test.ts | 110 ++++++++++++++++ packages/cli/tests/snapshot-ref.test.ts | 148 ++++++++++++++++++++++ 12 files changed, 562 insertions(+), 25 deletions(-) create mode 100644 .changeset/snapshot-refs.md create mode 100644 packages/cli/src/snapshot-store.ts create mode 100644 packages/cli/tests/flow-recorder.test.ts create mode 100644 packages/cli/tests/metro-cdp.test.ts create mode 100644 packages/cli/tests/snapshot-ref.test.ts diff --git a/.changeset/snapshot-refs.md b/.changeset/snapshot-refs.md new file mode 100644 index 0000000..378a37d --- /dev/null +++ b/.changeset/snapshot-refs.md @@ -0,0 +1,5 @@ +--- +"@houwert/conductor": minor +--- + +Add ephemeral `@eN` element refs. `capture-ui` now assigns each accessible element a short ref (`@e1`, `@e2`, …) and persists its screen coordinates per session, so `tap-on @e3` can act on the captured point directly without re-querying or fuzzy text/id matching. Stale snapshots (different device or older than 60s) emit an advisory warning rather than hard-failing. diff --git a/packages/cli/src/commands/capture-ui.ts b/packages/cli/src/commands/capture-ui.ts index 90226e6..adf96e0 100644 --- a/packages/cli/src/commands/capture-ui.ts +++ b/packages/cli/src/commands/capture-ui.ts @@ -13,6 +13,7 @@ import { buildWebA11y, A11ySnapshotEntry, } from '../drivers/a11y.js'; +import { buildStoredSnapshot, saveSnapshot } from '../snapshot-store.js'; export interface CaptureBundle { version: 1; @@ -114,6 +115,13 @@ export async function captureUI( capabilities: { perViewPixels: false, depthData: false }, }; + // Persist `@eN` refs so `tap-on @e3` can act on this capture without a + // re-query. Keyed by session — see snapshot-store.ts. + await saveSnapshot( + sessionName, + buildStoredSnapshot(a11ySnapshot, { deviceId: sessionName, platform }) + ); + const json = JSON.stringify(bundle); if (outputPath) { diff --git a/packages/cli/src/commands/tap.ts b/packages/cli/src/commands/tap.ts index 140e39a..c369696 100644 --- a/packages/cli/src/commands/tap.ts +++ b/packages/cli/src/commands/tap.ts @@ -1,4 +1,4 @@ -export const HELP = ` tap-on Tap element by text or id +export const HELP = ` tap-on Tap element by text, id, or @eN snapshot ref --id Match by accessibility id instead of text --text Match by text only (not id) --index Pick the nth match (0-based) @@ -21,6 +21,7 @@ import { AndroidDriver } from '../drivers/android.js'; import { WebDriver } from '../drivers/web.js'; import { waitForIOSElement, waitForAndroidElement, waitForWebElement } from '../drivers/wait.js'; import { makeIOSDirectResolver } from '../drivers/direct-ios-selector.js'; +import { isRefQuery, loadSnapshot, resolveRef } from '../snapshot-store.js'; import { sleep } from '../utils.js'; export async function tap( @@ -62,6 +63,9 @@ export async function tap( ...(flags.rightOf && { rightOf: { query: flags.rightOf } }), }; + // A bare `@eN` query taps the cached coordinates from the last `capture-ui` + // snapshot, skipping fuzzy text/id resolution. Explicit --text/--id win. + const useRef = isRefQuery(query) && !flags.text && !flags.id; const label = flags.text ? `text="${flags.text}"` : flags.id ? `id="${flags.id}"` : `"${query}"`; const result = await runDirect(async (driver) => { @@ -72,8 +76,18 @@ export async function tap( ); } - let el; - if (driver instanceof IOSDriver) { + let el: { centerX: number; centerY: number }; + if (useRef) { + const { entry, staleReason } = resolveRef(await loadSnapshot(sessionName), query, { + deviceId: sessionName, + }); + if (staleReason) { + process.stderr.write( + `warning: ${query} — ${staleReason}; re-run capture-ui if the tap misses\n` + ); + } + el = { centerX: entry.centerX, centerY: entry.centerY }; + } else if (driver instanceof IOSDriver) { el = await waitForIOSElement( (o) => driver.viewHierarchy(false, [], { cache: o?.cached }).then((h) => h.axElement), sel, diff --git a/packages/cli/src/drivers/a11y.ts b/packages/cli/src/drivers/a11y.ts index c00463c..de4fb6e 100644 --- a/packages/cli/src/drivers/a11y.ts +++ b/packages/cli/src/drivers/a11y.ts @@ -29,6 +29,9 @@ export interface A11yState { export interface A11ySnapshotEntry { nodeId: string; + /** Ephemeral, snapshot-scoped element ref (`@e1`, `@e2`, …). Sequential over + * the snapshot array, 1-indexed. Only valid for the snapshot it was built in. */ + ref: string; order: number; frame: A11yFrame; label: string; @@ -136,6 +139,7 @@ export function buildIOSA11y(root: AXElement): A11yBuildResult { accessibilityOrder = order++; snapshot.push({ nodeId: path, + ref: `@e${accessibilityOrder + 1}`, order: accessibilityOrder, frame: { x: node.frame.X, @@ -403,6 +407,7 @@ export function buildAndroidA11y(xml: string): A11yBuildResult\n`) + `---\n# Recording started ${new Date().toISOString()}\n`; fs.writeFileSync(target, header, 'utf-8'); - await updateSession({ recordingPath: target } as Partial, sessionName); + await updateSession({ recordingPath: target }, sessionName); return target; } export async function finishRecording(sessionName: string): Promise { - const session = (await getSession(sessionName)) as SessionWithRecording; + const session = await getSession(sessionName); if (!session.recordingPath) return null; const out = session.recordingPath; fs.appendFileSync(out, `# Recording finished ${new Date().toISOString()}\n`); - delete session.recordingPath; - await updateSession(session as Partial, sessionName); + // Setting to undefined clears the key on save — JSON.stringify drops it. + await updateSession({ recordingPath: undefined }, sessionName); return out; } export async function getActiveRecording(sessionName: string): Promise { - const session = (await getSession(sessionName)) as SessionWithRecording; + const session = await getSession(sessionName); return session.recordingPath ?? null; } diff --git a/packages/cli/src/drivers/metro-cdp.ts b/packages/cli/src/drivers/metro-cdp.ts index ab73595..0358d42 100644 --- a/packages/cli/src/drivers/metro-cdp.ts +++ b/packages/cli/src/drivers/metro-cdp.ts @@ -9,7 +9,7 @@ * `metro-discovery.ts` — do not duplicate discovery logic here. */ import WebSocket from 'ws'; -import { fetchTargets } from './log-sources/metro.js'; +import { fetchTargets, type MetroTarget } from './log-sources/metro.js'; import { selectTargetForDevice, getDeviceDisplayName } from './log-sources/metro-discovery.js'; export interface CdpCallOptions { @@ -40,13 +40,19 @@ interface CdpRequest { } /** - * Resolve a Metro target's `webSocketDebuggerUrl` honoring deviceId / targetIndex. - * Throws with a clear message if Metro is unreachable or no target matches. + * Pick a debugger `webSocketDebuggerUrl` from an already-fetched target list. + * Pure — the async `fetchTargets` / `getDeviceDisplayName` calls happen in + * `resolveDebuggerUrl`. `displayName` is the device's resolved display name, + * used for device-scoped selection when present. Throws with a clear message + * when no target matches. */ -export async function resolveDebuggerUrl(opts: CdpCallOptions): Promise { +export function selectDebuggerUrl( + targets: MetroTarget[], + opts: Pick, + displayName?: string +): string { const port = opts.port ?? 8081; const host = opts.host ?? 'localhost'; - const targets = await fetchTargets(port, host); const withWs = targets.filter((t) => t.webSocketDebuggerUrl); if (withWs.length === 0) { @@ -62,12 +68,9 @@ export async function resolveDebuggerUrl(opts: CdpCallOptions): Promise return withWs[opts.targetIndex].webSocketDebuggerUrl!; } - if (opts.deviceId && opts.platform) { - const displayName = await getDeviceDisplayName(opts.platform, opts.deviceId); - if (displayName) { - const target = selectTargetForDevice(withWs, displayName); - if (target) return target.webSocketDebuggerUrl!; - } + if (displayName) { + const target = selectTargetForDevice(withWs, displayName); + if (target) return target.webSocketDebuggerUrl!; } // Prefer the Hermes/React target by title, otherwise first. @@ -75,6 +78,23 @@ export async function resolveDebuggerUrl(opts: CdpCallOptions): Promise return target.webSocketDebuggerUrl!; } +/** + * Resolve a Metro target's `webSocketDebuggerUrl` honoring deviceId / targetIndex. + * Throws with a clear message if Metro is unreachable or no target matches. + */ +export async function resolveDebuggerUrl(opts: CdpCallOptions): Promise { + const port = opts.port ?? 8081; + const host = opts.host ?? 'localhost'; + const targets = await fetchTargets(port, host); + + let displayName: string | undefined; + if (opts.deviceId && opts.platform) { + displayName = (await getDeviceDisplayName(opts.platform, opts.deviceId)) ?? undefined; + } + + return selectDebuggerUrl(targets, opts, displayName); +} + /** * Open a short-lived CDP socket, send a single method, return the result. * Closes the socket whether the call succeeds or throws. diff --git a/packages/cli/src/session.ts b/packages/cli/src/session.ts index ca3b4a9..878af2f 100644 --- a/packages/cli/src/session.ts +++ b/packages/cli/src/session.ts @@ -5,6 +5,8 @@ import path from 'path'; export interface Session { appId?: string; deviceId?: string; + /** Path of the active `flow record` recording, when one is in progress. */ + recordingPath?: string; } const CONDUCTOR_DIR = path.join(os.homedir(), '.conductor'); diff --git a/packages/cli/src/snapshot-store.ts b/packages/cli/src/snapshot-store.ts new file mode 100644 index 0000000..2be34db --- /dev/null +++ b/packages/cli/src/snapshot-store.ts @@ -0,0 +1,130 @@ +/** + * Snapshot-scoped ephemeral element refs. + * + * `capture-ui` assigns each accessible element a short ref (`@e1`, `@e2`, …) and + * persists its resolved screen coordinates here, keyed by session. `tap-on @e3` + * then taps the cached point directly — no fuzzy text/id matching. + * + * Refs are deliberately ephemeral: a stale snapshot warns (it does not hard-fail), + * and the agent is expected to re-run `capture-ui` and act on fresh refs. + */ +import fs from 'fs/promises'; +import os from 'os'; +import path from 'path'; +import type { A11ySnapshotEntry, A11yFrame } from './drivers/a11y.js'; + +const SNAPSHOTS_DIR = path.join(os.homedir(), '.conductor', 'snapshots'); + +/** A snapshot older than this is considered stale. */ +export const SNAPSHOT_STALE_MS = 60_000; + +export interface SnapshotRefEntry { + ref: string; + centerX: number; + centerY: number; + frame: A11yFrame; + /** Accessibility label — used to render a friendly message and for replay portability. */ + label: string; + /** Tree-path id of the source node within the capture's hierarchy. */ + nodeId: string; +} + +export interface StoredSnapshot { + version: 1; + /** ISO timestamp of the capture. */ + capturedAt: string; + deviceId: string; + platform: string; + refs: Record; +} + +export function snapshotFilePath(sessionName = 'default'): string { + return path.join(SNAPSHOTS_DIR, `${sessionName}.json`); +} + +/** True when `s` looks like an ephemeral element ref (`@e3`). */ +export function isRefQuery(s: string): boolean { + return /^@e\d+$/i.test(s.trim()); +} + +/** Build a `StoredSnapshot` from a freshly built a11y snapshot. */ +export function buildStoredSnapshot( + entries: A11ySnapshotEntry[], + device: { deviceId: string; platform: string } +): StoredSnapshot { + const refs: Record = {}; + for (const e of entries) { + refs[e.ref] = { + ref: e.ref, + centerX: e.frame.x + e.frame.w / 2, + centerY: e.frame.y + e.frame.h / 2, + frame: e.frame, + label: e.label, + nodeId: e.nodeId, + }; + } + return { + version: 1, + capturedAt: new Date().toISOString(), + deviceId: device.deviceId, + platform: device.platform, + refs, + }; +} + +export async function saveSnapshot(sessionName: string, snapshot: StoredSnapshot): Promise { + await fs.mkdir(SNAPSHOTS_DIR, { recursive: true }); + await fs.writeFile(snapshotFilePath(sessionName), JSON.stringify(snapshot, null, 2)); +} + +export async function loadSnapshot(sessionName: string): Promise { + try { + const data = await fs.readFile(snapshotFilePath(sessionName), 'utf-8'); + return JSON.parse(data) as StoredSnapshot; + } catch { + return null; + } +} + +export interface RefResolution { + entry: SnapshotRefEntry; + /** Non-null when the snapshot may no longer match what's on screen. Advisory. */ + staleReason: string | null; +} + +/** + * Resolve an `@eN` ref against the session's last `capture-ui` snapshot. + * Throws when there is no snapshot or the ref is unknown. `staleReason` is + * advisory — callers warn but still act, since refs are explicitly ephemeral. + */ +export function resolveRef( + snapshot: StoredSnapshot | null, + ref: string, + ctx?: { deviceId?: string } +): RefResolution { + if (!snapshot) { + throw new Error( + `no snapshot for this session — run \`conductor capture-ui\` before using ${ref}` + ); + } + const norm = ref.trim().toLowerCase(); + const key = Object.keys(snapshot.refs).find((k) => k.toLowerCase() === norm); + if (!key) { + const avail = Object.keys(snapshot.refs); + const shown = avail.slice(0, 8).join(', '); + throw new Error( + `${ref} is not in the last snapshot ` + + `(${avail.length} ref${avail.length === 1 ? '' : 's'}: ${shown}${avail.length > 8 ? ', …' : ''})` + ); + } + + let staleReason: string | null = null; + const ageMs = Date.now() - new Date(snapshot.capturedAt).getTime(); + if (ageMs > SNAPSHOT_STALE_MS) { + staleReason = `snapshot is ${Math.round(ageMs / 1000)}s old`; + } else if (ctx?.deviceId && snapshot.deviceId && ctx.deviceId !== snapshot.deviceId) { + staleReason = `snapshot was captured on a different device (${snapshot.deviceId})`; + } + + return { entry: snapshot.refs[key], staleReason }; +} diff --git a/packages/cli/tests/all-tests.ts b/packages/cli/tests/all-tests.ts index b05421c..a55d27b 100644 --- a/packages/cli/tests/all-tests.ts +++ b/packages/cli/tests/all-tests.ts @@ -29,6 +29,9 @@ import { daemonIdle } from './daemon-idle.test.js'; import { devicePoolSuite } from './device-pool.test.js'; import { androidSdk } from './android-sdk.test.js'; import { startDeviceAndroid } from './start-device-android.test.js'; +import { metroCdp } from './metro-cdp.test.js'; +import { snapshotRef } from './snapshot-ref.test.js'; +import { flowRecorder } from './flow-recorder.test.js'; import { getDriver } from '../src/runner.js'; import { IOSDriver } from '../src/drivers/ios.js'; import { parseFlowFile, executeFlow } from '../src/drivers/flow-runner.js'; @@ -71,7 +74,7 @@ async function detectDevice(deviceUdid: string | undefined): Promise { const { deviceUdid, suiteFilter } = parseArgs(); const device = await detectDevice(deviceUdid); - let suites = [parser, iosExec, androidExec, fileBased, scriptSuite, elementResolver, a11ySuite, envFlag, daemonIdle, devicePoolSuite, androidSdk, startDeviceAndroid]; + let suites = [parser, iosExec, androidExec, fileBased, scriptSuite, elementResolver, a11ySuite, envFlag, daemonIdle, devicePoolSuite, androidSdk, startDeviceAndroid, metroCdp, snapshotRef, flowRecorder]; if (device) { console.log(`\nDevice: ${device}`); diff --git a/packages/cli/tests/flow-recorder.test.ts b/packages/cli/tests/flow-recorder.test.ts new file mode 100644 index 0000000..f64fdd4 --- /dev/null +++ b/packages/cli/tests/flow-recorder.test.ts @@ -0,0 +1,95 @@ +/** + * Unit tests for the flow recorder. + * + * Covers the `commandToYamlStep` CLI→Maestro-YAML mapping and the + * start/finish recording lifecycle — in particular that `finishRecording` + * actually clears the active recording from the session. + */ +import fs from 'fs/promises'; +import { TestSuite, assert } from './runner.js'; +import { + commandToYamlStep, + startRecording, + finishRecording, + getActiveRecording, +} from '../src/drivers/flow-recorder.js'; +import { clearSession } from '../src/session.js'; + +export const flowRecorder = new TestSuite('flow-recorder'); + +flowRecorder.test('commandToYamlStep maps tap-on by positional text', async () => { + assert(commandToYamlStep('tap-on', ['Sign', 'in'], {}) === '- tapOn: "Sign in"', 'joins words'); +}); + +flowRecorder.test('commandToYamlStep maps tap-on --id and --text to object form', async () => { + assert( + commandToYamlStep('tap-on', [], { id: 'submitBtn' }) === '- tapOn:\n id: "submitBtn"', + '--id → id selector' + ); + assert( + commandToYamlStep('tap-on', ['ignored'], { text: 'OK' }) === '- tapOn:\n text: "OK"', + '--text wins over the positional arg' + ); +}); + +flowRecorder.test('commandToYamlStep returns null for an empty tap-on', async () => { + assert(commandToYamlStep('tap-on', [], {}) === null, 'no selector → not recorded'); +}); + +flowRecorder.test('commandToYamlStep maps input-text and press-key', async () => { + assert(commandToYamlStep('input-text', ['hello'], {}) === '- inputText: "hello"', 'input-text'); + assert(commandToYamlStep('press-key', ['enter'], {}) === '- pressKey: "enter"', 'press-key'); +}); + +flowRecorder.test('commandToYamlStep maps launch-app with --clear-state', async () => { + assert( + commandToYamlStep('launch-app', ['com.example'], {}) === '- launchApp:\n appId: com.example', + 'launch-app without flags' + ); + assert( + commandToYamlStep('launch-app', ['com.example'], { 'clear-state': true }) === + '- launchApp:\n appId: com.example\n clearState: true', + '--clear-state is recorded' + ); +}); + +flowRecorder.test('commandToYamlStep maps set-location, or skips it when incomplete', async () => { + assert( + commandToYamlStep('set-location', [], { lat: 1, lng: 2 }) === + '- setLocation:\n latitude: 1\n longitude: 2', + 'lat+lng → setLocation' + ); + assert(commandToYamlStep('set-location', [], { lat: 1 }) === null, 'missing lng → not recorded'); +}); + +flowRecorder.test('commandToYamlStep maps bare commands and ignores unknowns', async () => { + assert(commandToYamlStep('back', [], {}) === '- back', 'back'); + assert(commandToYamlStep('hide-keyboard', [], {}) === '- hideKeyboard', 'hide-keyboard'); + assert(commandToYamlStep('take-screenshot', [], {}) === null, 'non-action command → null'); +}); + +flowRecorder.test('startRecording / finishRecording lifecycle clears the session', async () => { + const session = `__conductor_rec_test_${process.pid}__`; + let recordingPath: string | undefined; + try { + recordingPath = await startRecording(session, undefined, 'com.example.app'); + const active = await getActiveRecording(session); + assert(active === recordingPath, 'recording is active after start'); + + const header = await fs.readFile(recordingPath, 'utf-8'); + assert(header.includes('appId: com.example.app'), 'recording file carries the appId header'); + + const finished = await finishRecording(session); + assert(finished === recordingPath, 'finishRecording returns the closed path'); + + // The bug this guards: finishRecording must actually clear the active + // recording, not leave a stale recordingPath in the session file. + const afterFinish = await getActiveRecording(session); + assert(afterFinish === null, 'no active recording after finish'); + + assert((await finishRecording(session)) === null, 'a second finish is a no-op'); + } finally { + if (recordingPath) await fs.unlink(recordingPath).catch(() => {}); + await clearSession(session); + } +}); diff --git a/packages/cli/tests/metro-cdp.test.ts b/packages/cli/tests/metro-cdp.test.ts new file mode 100644 index 0000000..3bb31bd --- /dev/null +++ b/packages/cli/tests/metro-cdp.test.ts @@ -0,0 +1,110 @@ +/** + * Unit tests for the Metro CDP client's target-selection logic. + * + * `selectDebuggerUrl` is the pure core of `resolveDebuggerUrl` — it picks a + * `webSocketDebuggerUrl` from an already-fetched target list, so it can be + * exercised without a live Metro server. + */ +import { TestSuite, assert } from './runner.js'; +import { selectDebuggerUrl } from '../src/drivers/metro-cdp.js'; +import type { MetroTarget } from '../src/drivers/log-sources/metro.js'; + +export const metroCdp = new TestSuite('metro-cdp target selection'); + +function target(overrides: Partial): MetroTarget { + return { webSocketDebuggerUrl: 'ws://localhost:8081/x', ...overrides }; +} + +metroCdp.test('throws when there are no targets', async () => { + let threw = false; + try { + selectDebuggerUrl([], {}); + } catch (err) { + threw = true; + assert( + err instanceof Error && /no debugger targets/.test(err.message), + 'error should mention missing targets' + ); + } + assert(threw, 'should throw on empty target list'); +}); + +metroCdp.test('throws when no target has a websocket url', async () => { + let threw = false; + try { + selectDebuggerUrl([{ title: 'no ws' }], {}); + } catch { + threw = true; + } + assert(threw, 'should throw when targets lack webSocketDebuggerUrl'); +}); + +metroCdp.test('targetIndex selects the matching target', async () => { + const targets = [ + target({ webSocketDebuggerUrl: 'ws://a' }), + target({ webSocketDebuggerUrl: 'ws://b' }), + target({ webSocketDebuggerUrl: 'ws://c' }), + ]; + assert(selectDebuggerUrl(targets, { targetIndex: 1 }) === 'ws://b', 'index 1 → b'); +}); + +metroCdp.test('targetIndex out of range throws', async () => { + const targets = [target({ webSocketDebuggerUrl: 'ws://a' })]; + let threw = false; + try { + selectDebuggerUrl(targets, { targetIndex: 5 }); + } catch (err) { + threw = true; + assert(err instanceof Error && /out of range/.test(err.message), 'mentions out of range'); + } + assert(threw, 'should throw for index past the end'); +}); + +metroCdp.test('negative targetIndex throws', async () => { + const targets = [target({ webSocketDebuggerUrl: 'ws://a' })]; + let threw = false; + try { + selectDebuggerUrl(targets, { targetIndex: -1 }); + } catch { + threw = true; + } + assert(threw, 'should throw for a negative index'); +}); + +metroCdp.test('displayName picks the matching device target', async () => { + const targets = [ + target({ webSocketDebuggerUrl: 'ws://other', deviceName: 'iPhone 14' }), + target({ webSocketDebuggerUrl: 'ws://mine', deviceName: 'iPhone 15 Pro' }), + ]; + assert( + selectDebuggerUrl(targets, {}, 'iPhone 15 Pro') === 'ws://mine', + 'should select the target whose deviceName matches' + ); +}); + +metroCdp.test('unmatched displayName falls back to the Hermes/React target', async () => { + const targets = [ + target({ webSocketDebuggerUrl: 'ws://plain', title: 'Page' }), + target({ webSocketDebuggerUrl: 'ws://hermes', title: 'Hermes React Native' }), + ]; + assert( + selectDebuggerUrl(targets, {}, 'No Such Device') === 'ws://hermes', + 'no device match → prefer the Hermes target' + ); +}); + +metroCdp.test('prefers a Hermes/React-titled target over the first', async () => { + const targets = [ + target({ webSocketDebuggerUrl: 'ws://first', title: 'Other' }), + target({ webSocketDebuggerUrl: 'ws://react', title: 'React Native Bridge' }), + ]; + assert(selectDebuggerUrl(targets, {}) === 'ws://react', 'should prefer the React target'); +}); + +metroCdp.test('falls back to the first target when nothing else matches', async () => { + const targets = [ + target({ webSocketDebuggerUrl: 'ws://first', title: 'Alpha' }), + target({ webSocketDebuggerUrl: 'ws://second', title: 'Beta' }), + ]; + assert(selectDebuggerUrl(targets, {}) === 'ws://first', 'should fall back to the first target'); +}); diff --git a/packages/cli/tests/snapshot-ref.test.ts b/packages/cli/tests/snapshot-ref.test.ts new file mode 100644 index 0000000..c53eba4 --- /dev/null +++ b/packages/cli/tests/snapshot-ref.test.ts @@ -0,0 +1,148 @@ +/** + * Unit tests for snapshot-scoped ephemeral element refs (`@eN`). + * + * Covers ref-query detection, building a stored snapshot from an a11y snapshot, + * the save/load round-trip, and `resolveRef` (including staleness detection). + */ +import fs from 'fs/promises'; +import { TestSuite, assert } from './runner.js'; +import { + isRefQuery, + buildStoredSnapshot, + saveSnapshot, + loadSnapshot, + resolveRef, + snapshotFilePath, + SNAPSHOT_STALE_MS, +} from '../src/snapshot-store.js'; +import type { A11ySnapshotEntry } from '../src/drivers/a11y.js'; + +export const snapshotRef = new TestSuite('snapshot-ref'); + +function entry(ref: string, x: number, y: number, label: string): A11ySnapshotEntry { + const order = Number(ref.replace(/\D/g, '')) - 1; + return { + nodeId: `0.${order}`, + ref, + order, + frame: { x, y, w: 100, h: 40 }, + label, + hint: '', + role: 'button', + traits: ['button'], + announcement: label, + value: '', + state: { enabled: true, selected: false, focused: false }, + }; +} + +snapshotRef.test('isRefQuery accepts @eN, rejects everything else', async () => { + assert(isRefQuery('@e1'), '@e1 is a ref'); + assert(isRefQuery('@e42'), '@e42 is a ref'); + assert(isRefQuery(' @e3 '), 'surrounding whitespace is tolerated'); + assert(isRefQuery('@E5'), 'ref matching is case-insensitive'); + assert(!isRefQuery('e3'), 'missing @ is not a ref'); + assert(!isRefQuery('@e'), '@e with no digits is not a ref'); + assert(!isRefQuery('@email'), '@email is not a ref'); + assert(!isRefQuery('Sign in'), 'plain text is not a ref'); +}); + +snapshotRef.test('buildStoredSnapshot maps refs to center points', async () => { + const snap = buildStoredSnapshot( + [entry('@e1', 10, 20, 'First'), entry('@e2', 0, 100, 'Second')], + { deviceId: 'sess', platform: 'ios' } + ); + assert(Object.keys(snap.refs).length === 2, 'two refs stored'); + assert(snap.refs['@e1'].centerX === 60, 'center x = x + w/2 (10 + 50)'); + assert(snap.refs['@e1'].centerY === 40, 'center y = y + h/2 (20 + 20)'); + assert(snap.refs['@e2'].label === 'Second', 'label is carried through'); + assert(snap.version === 1 && snap.platform === 'ios', 'metadata is set'); +}); + +snapshotRef.test('saveSnapshot / loadSnapshot round-trip', async () => { + const session = `__conductor_test_${process.pid}__`; + try { + const snap = buildStoredSnapshot([entry('@e1', 10, 20, 'OK')], { + deviceId: session, + platform: 'android', + }); + await saveSnapshot(session, snap); + const loaded = await loadSnapshot(session); + assert(loaded !== null, 'snapshot loads back'); + assert(loaded!.refs['@e1'].label === 'OK', 'round-tripped ref is intact'); + } finally { + await fs.unlink(snapshotFilePath(session)).catch(() => {}); + } +}); + +snapshotRef.test('loadSnapshot returns null when no snapshot exists', async () => { + const loaded = await loadSnapshot(`__conductor_missing_${process.pid}__`); + assert(loaded === null, 'missing snapshot resolves to null'); +}); + +snapshotRef.test('resolveRef returns the entry for a known fresh ref', async () => { + const snap = buildStoredSnapshot([entry('@e3', 0, 0, 'Go')], { + deviceId: 'sess', + platform: 'ios', + }); + const res = resolveRef(snap, '@e3', { deviceId: 'sess' }); + assert(res.entry.label === 'Go', 'resolves to the right entry'); + assert(res.staleReason === null, 'a just-built snapshot is not stale'); +}); + +snapshotRef.test('resolveRef is case-insensitive on the ref', async () => { + const snap = buildStoredSnapshot([entry('@e1', 0, 0, 'A')], { + deviceId: 'sess', + platform: 'ios', + }); + assert(resolveRef(snap, '@E1').entry.ref === '@e1', '@E1 resolves @e1'); +}); + +snapshotRef.test('resolveRef throws when there is no snapshot', async () => { + let threw = false; + try { + resolveRef(null, '@e1'); + } catch (err) { + threw = true; + assert(err instanceof Error && /capture-ui/.test(err.message), 'message points at capture-ui'); + } + assert(threw, 'should throw with no snapshot'); +}); + +snapshotRef.test('resolveRef throws for an unknown ref', async () => { + const snap = buildStoredSnapshot([entry('@e1', 0, 0, 'A')], { + deviceId: 'sess', + platform: 'ios', + }); + let threw = false; + try { + resolveRef(snap, '@e9'); + } catch (err) { + threw = true; + assert(err instanceof Error && /not in the last snapshot/.test(err.message), 'clear message'); + } + assert(threw, 'should throw for a ref outside the snapshot'); +}); + +snapshotRef.test('resolveRef flags an aged snapshot as stale', async () => { + const snap = buildStoredSnapshot([entry('@e1', 0, 0, 'A')], { + deviceId: 'sess', + platform: 'ios', + }); + snap.capturedAt = new Date(Date.now() - SNAPSHOT_STALE_MS - 5_000).toISOString(); + const res = resolveRef(snap, '@e1'); + assert(res.staleReason !== null && /old/.test(res.staleReason), 'age staleness is reported'); + assert(res.entry.label === 'A', 'still resolves the entry despite staleness'); +}); + +snapshotRef.test('resolveRef flags a device mismatch as stale', async () => { + const snap = buildStoredSnapshot([entry('@e1', 0, 0, 'A')], { + deviceId: 'deviceA', + platform: 'ios', + }); + const res = resolveRef(snap, '@e1', { deviceId: 'deviceB' }); + assert( + res.staleReason !== null && /different device/.test(res.staleReason), + 'device mismatch is reported' + ); +});