From 1fc90197fe216740b453b055afa77ebf0d9a359f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 12 Jun 2026 13:37:38 +0200 Subject: [PATCH 1/6] fix: improve daemon startup diagnostics --- src/daemon-client-lifecycle.ts | 105 ++++++++++++++++-- .../__tests__/daemon-client-lifecycle.test.ts | 52 ++++++++- src/utils/exec.ts | 35 +++++- 3 files changed, 176 insertions(+), 16 deletions(-) diff --git a/src/daemon-client-lifecycle.ts b/src/daemon-client-lifecycle.ts index ce8513f1b..5f0cd5e38 100644 --- a/src/daemon-client-lifecycle.ts +++ b/src/daemon-client-lifecycle.ts @@ -4,7 +4,7 @@ import os from 'node:os'; import path from 'node:path'; import { AppError } from './utils/errors.ts'; import type { DaemonRequest } from './daemon/types.ts'; -import { runCmdDetached } from './utils/exec.ts'; +import { runCmdDetachedMonitored, type ExecDetachedExit } from './utils/exec.ts'; import { findProjectRoot, readVersion } from './utils/version.ts'; import { emitDiagnostic } from './utils/diagnostics.ts'; import { @@ -48,8 +48,19 @@ export type EnsuredDaemon = { startedByClient: boolean; }; +type DaemonStartupLaunch = { + pid: number; + exited: Promise; +}; + +type DaemonStartupWaitResult = + | { kind: 'ready'; info: DaemonInfo } + | { kind: 'early_exit'; exit: ExecDetachedExit } + | { kind: 'timeout' }; + const DAEMON_STARTUP_TIMEOUT_MS = 15_000; const DAEMON_STARTUP_ATTEMPTS = 2; +const DAEMON_STARTUP_LOG_TAIL_BYTES = 64_000; const LOOPBACK_BLOCK_LIST = new net.BlockList(); LOOPBACK_BLOCK_LIST.addSubnet('127.0.0.0', 8, 'ipv4'); LOOPBACK_BLOCK_LIST.addAddress('::1', 'ipv6'); @@ -193,9 +204,12 @@ async function startLocalDaemon(settings: DaemonClientSettings): Promise { + launch: DaemonStartupLaunch, +): Promise { const start = Date.now(); + let earlyExit: ExecDetachedExit | undefined; + void launch.exited.then((exit) => { + earlyExit = exit; + }); + while (Date.now() - start < timeoutMs) { + if (earlyExit) return { kind: 'early_exit', exit: earlyExit }; const info = readDaemonInfo(settings.paths.infoPath); - if (info && (await canConnect(info, settings.transportPreference))) return info; + if (info && (await canConnect(info, settings.transportPreference))) { + return { kind: 'ready', info }; + } + if (earlyExit) return { kind: 'early_exit', exit: earlyExit }; await sleep(100); } - return null; + return { kind: 'timeout' }; } -async function startDaemon(settings: DaemonClientSettings): Promise { +function startDaemon(settings: DaemonClientSettings): DaemonStartupLaunch { const launchSpec = resolveDaemonLaunchSpec(); const args = launchSpec.useSrc ? ['--experimental-strip-types', launchSpec.srcPath] @@ -321,7 +364,18 @@ async function startDaemon(settings: DaemonClientSettings): Promise { AGENT_DEVICE_DAEMON_SERVER_MODE: settings.serverMode, }; - runCmdDetached(process.execPath, args, { env }); + fs.mkdirSync(settings.paths.baseDir, { recursive: true }); + const stdoutFd = fs.openSync(settings.paths.logPath, 'a'); + const stderrFd = fs.openSync(settings.paths.logPath, 'a'); + try { + return runCmdDetachedMonitored(process.execPath, args, { + env, + stdio: ['ignore', stdoutFd, stderrFd], + }); + } finally { + fs.closeSync(stdoutFd); + fs.closeSync(stderrFd); + } } type DaemonLaunchSpec = { @@ -361,6 +415,33 @@ function resolveLocalDaemonCodeSignature(): string { return computeDaemonCodeSignature(entryPath, launchSpec.root); } +function describeDaemonEarlyExit(exit: ExecDetachedExit): string { + if (exit.error) return `daemon process ${exit.pid} failed to start: ${exit.error}`; + if (exit.signal) + return `daemon process ${exit.pid} exited before readiness with signal ${exit.signal}`; + return `daemon process ${exit.pid} exited before readiness with code ${exit.exitCode ?? 0}`; +} + +function readRecentLogTail(logPath: string): string | undefined { + try { + if (!fs.existsSync(logPath)) return undefined; + const stats = fs.statSync(logPath); + if (stats.size <= 0) return undefined; + const length = Math.min(stats.size, DAEMON_STARTUP_LOG_TAIL_BYTES); + const fd = fs.openSync(logPath, 'r'); + try { + const buffer = Buffer.alloc(length); + fs.readSync(fd, buffer, 0, length, stats.size - length); + const text = buffer.toString('utf8').trim(); + return text.length > 0 ? text : undefined; + } finally { + fs.closeSync(fd); + } + } catch { + return undefined; + } +} + function resolveRemoteDaemonBaseUrl(raw: string | undefined): string | undefined { if (!raw) return undefined; let parsed: URL; diff --git a/src/utils/__tests__/daemon-client-lifecycle.test.ts b/src/utils/__tests__/daemon-client-lifecycle.test.ts index d55254c6d..f1bf25085 100644 --- a/src/utils/__tests__/daemon-client-lifecycle.test.ts +++ b/src/utils/__tests__/daemon-client-lifecycle.test.ts @@ -12,6 +12,7 @@ vi.mock('../exec.ts', async (importOriginal) => { return { ...actual, runCmdDetached: vi.fn(), + runCmdDetachedMonitored: vi.fn(), runCmdSync: vi.fn(() => ({ exitCode: 1, stdout: '', stderr: '' })), }; }); @@ -34,7 +35,7 @@ import { supportsLoopbackBind, } from '../../__tests__/test-utils/index.ts'; import { AppError } from '../errors.ts'; -import { runCmdDetached, runCmdSync } from '../exec.ts'; +import { runCmdDetachedMonitored, runCmdSync } from '../exec.ts'; import { readProcessStartTime } from '../process-identity.ts'; import { sleep } from '../timeouts.ts'; import { findProjectRoot, readVersion } from '../version.ts'; @@ -57,7 +58,7 @@ type HttpDaemonFixture = { rpcRequests: Record[]; }; -const mockRunCmdDetached = vi.mocked(runCmdDetached); +const mockRunCmdDetached = vi.mocked(runCmdDetachedMonitored); const mockRunCmdSync = vi.mocked(runCmdSync); const mockSleep = vi.mocked(sleep); @@ -197,7 +198,7 @@ function installSpawnedHttpDaemon(paths: DaemonPaths, httpPort: number): void { pid: process.pid, processStartTime: readProcessStartTime(process.pid) ?? undefined, }); - return process.pid; + return { pid: process.pid, exited: new Promise(() => {}) }; }); } @@ -351,6 +352,51 @@ test('sendToDaemon retries daemon spawn failures and cleans partial metadata on } }); +test('sendToDaemon reports early daemon exit with log tail and startup paths', async () => { + const stateDir = makeTempStateDir('agent-device-daemon-early-exit-'); + const paths = resolveDaemonPaths(stateDir); + vi.stubEnv('AGENT_DEVICE_STATE_DIR', stateDir); + let attempts = 0; + + mockRunCmdDetached.mockImplementation((_command, _args, options) => { + attempts += 1; + const stderrFd = options?.stdio?.[2]; + if (typeof stderrFd === 'number') { + fs.writeSync(stderrFd, `early daemon failure ${attempts}\n`); + } + return { + pid: 43_200 + attempts, + exited: Promise.resolve({ pid: 43_200 + attempts, exitCode: 1 }), + }; + }); + + try { + let thrown: unknown; + try { + await sendToDaemon({ + session: 'default', + command: 'early-exit-smoke', + positionals: [], + flags: { stateDir }, + meta: { requestId: 'req-early-exit' }, + }); + } catch (error) { + thrown = error; + } + + assert.ok(thrown instanceof AppError); + assert.equal(thrown.message, 'Failed to start daemon'); + assert.equal(thrown.details?.stateDir, paths.baseDir); + assert.equal(thrown.details?.logPath, paths.logPath); + assert.match(String(thrown.details?.startError), /daemon process 43202 exited/); + assert.deepEqual(thrown.details?.daemonProcess, { pid: 43_202, exitCode: 1 }); + assert.match(String(thrown.details?.daemonLogTail), /early daemon failure 2/); + assert.equal(attempts, 2); + } finally { + fs.rmSync(stateDir, { recursive: true, force: true }); + } +}); + test('sendToDaemon removes stale daemon lock before spawning a fresh daemon', async (t) => { if (!(await supportsLoopbackBind())) { t.skip('loopback listeners are not permitted in this environment'); diff --git a/src/utils/exec.ts b/src/utils/exec.ts index 018a383c5..4f44efc03 100644 --- a/src/utils/exec.ts +++ b/src/utils/exec.ts @@ -42,6 +42,18 @@ type ExecDetachedOptions = ExecOptions & { stdio?: StdioOptions; }; +export type ExecDetachedExit = { + pid: number; + exitCode?: number; + signal?: NodeJS.Signals; + error?: string; +}; + +export type ExecDetachedProcess = { + pid: number; + exited: Promise; +}; + export type ExecBackgroundOptions = ExecOptions & { /** * Capture stdout/stderr into the wait result when the child has piped stdio. @@ -304,6 +316,14 @@ export function runCmdDetached( args: string[], options: ExecDetachedOptions = {}, ): number { + return runCmdDetachedMonitored(cmd, args, options).pid; +} + +export function runCmdDetachedMonitored( + cmd: string, + args: string[], + options: ExecDetachedOptions = {}, +): ExecDetachedProcess { const executable = normalizeExecutableCommand(cmd); const child = spawn(executable, args, { cwd: options.cwd, @@ -313,8 +333,21 @@ export function runCmdDetached( windowsHide: true, shell: false, }); + const pid = child.pid ?? 0; + const exited = new Promise((resolve) => { + child.once('error', (err) => { + resolve({ pid, error: err.message }); + }); + child.once('exit', (code, signal) => { + resolve({ + pid, + ...(typeof code === 'number' ? { exitCode: code } : {}), + ...(signal ? { signal } : {}), + }); + }); + }); child.unref(); - return child.pid ?? 0; + return { pid, exited }; } export function runCmdBackground( From 260fd8575a283e66a51e337e7bdb216485e87159 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 12 Jun 2026 14:56:51 +0200 Subject: [PATCH 2/6] perf: relax query-sweep recovery budget --- .../AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift index ff8f5c7fe..13cfde772 100644 --- a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift +++ b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift @@ -99,7 +99,7 @@ extension RunnerTests { .table ] - private static let flatInteractiveFallbackBudget: TimeInterval = 1.0 + private static let flatInteractiveFallbackBudget: TimeInterval = 3.0 func snapshotFast(app: XCUIApplication, options: SnapshotOptions) throws -> DataPayload { if let blocking = blockingSystemAlertSnapshot() { From e64bece93bc5cfdb6840705a019ab9f7b4579db1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 12 Jun 2026 15:20:49 +0200 Subject: [PATCH 3/6] fix: make compact snapshot flag a no-op --- .../RunnerTests+Snapshot.swift | 2 +- ios-runner/README.md | 8 ++-- src/__tests__/cli-grammar.test.ts | 13 +++++ src/__tests__/cli-help.test.ts | 2 +- src/commands/capture/index.ts | 3 -- src/commands/capture/runtime/snapshot.test.ts | 48 +++++++++++++++---- src/commands/capture/runtime/snapshot.ts | 4 +- .../interaction/runtime/interactions.test.ts | 2 +- src/core/__tests__/dispatch-scroll.test.ts | 2 +- src/core/dispatch-interactions.ts | 2 +- src/core/react-native-overlay.ts | 2 +- src/daemon/__tests__/session-store.test.ts | 2 +- src/daemon/context.ts | 1 - src/daemon/session-script-writer.ts | 1 - src/daemon/snapshot-runtime.ts | 2 - src/replay/__tests__/script.test.ts | 24 ++++++++++ src/replay/script-utils.ts | 1 - src/utils/cli-flags.ts | 2 +- src/utils/cli-help.ts | 4 +- src/utils/scroll-edge-state.ts | 2 +- src/utils/snapshot.ts | 3 +- .../suites/agent-device-smoke-suite.ts | 14 +++--- 22 files changed, 101 insertions(+), 43 deletions(-) diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift index 13cfde772..ff8f5c7fe 100644 --- a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift +++ b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift @@ -99,7 +99,7 @@ extension RunnerTests { .table ] - private static let flatInteractiveFallbackBudget: TimeInterval = 3.0 + private static let flatInteractiveFallbackBudget: TimeInterval = 1.0 func snapshotFast(app: XCUIApplication, options: SnapshotOptions) throws -> DataPayload { if let blocking = blockingSystemAlertSnapshot() { diff --git a/ios-runner/README.md b/ios-runner/README.md index 1faaff22d..159a91a12 100644 --- a/ios-runner/README.md +++ b/ios-runner/README.md @@ -35,14 +35,14 @@ Protocol and maintenance references: ## Snapshot Strategy -iOS snapshots have two explicit capture modes: +iOS snapshots have two explicit public capture modes: - full/raw snapshots use recursive XCTest snapshots for rich hierarchy and diagnostics; -- compact interactive snapshots use a bounded flat XCTest query path for fast agent-facing refs. +- interactive snapshots filter the same visible tree down to agent-facing refs. Some simulator apps expose accessibility trees that lower-level AX services can inspect but XCTest -cannot serialize reliably. In those cases compact interactive snapshots may return a sparse root -quickly, while full snapshots preserve the XCTest error. See +cannot serialize reliably. In those cases interactive snapshots may return a sparse root quickly, +while full snapshots preserve the XCTest error. See [`../docs/adr/0004-ios-snapshot-backend-strategy.md`](../docs/adr/0004-ios-snapshot-backend-strategy.md) for the backend boundary and the rationale for a future simulator AX-service backend. diff --git a/src/__tests__/cli-grammar.test.ts b/src/__tests__/cli-grammar.test.ts index d4997b151..348c31f09 100644 --- a/src/__tests__/cli-grammar.test.ts +++ b/src/__tests__/cli-grammar.test.ts @@ -15,6 +15,19 @@ test('wait grammar preserves CLI bare text forms', () => { assert.equal(options.timeoutMs, 1500); }); +test('snapshot grammar accepts compact flag as a no-op', () => { + const options = readInputFromCli('snapshot', [], { + ...BASE_FLAGS, + snapshotInteractiveOnly: true, + snapshotCompact: true, + snapshotDepth: 3, + }); + + assert.equal(options.interactiveOnly, true); + assert.equal(options.depth, 3); + assert.equal('compact' in options, false); +}); + test('interaction and fill grammar share ref, selector, and point parsing', () => { assert.deepEqual(readInputFromCli('press', ['@e3', 'Email'], BASE_FLAGS).target, { kind: 'ref', diff --git a/src/__tests__/cli-help.test.ts b/src/__tests__/cli-help.test.ts index 54b1dbfe6..44115c637 100644 --- a/src/__tests__/cli-help.test.ts +++ b/src/__tests__/cli-help.test.ts @@ -81,7 +81,7 @@ test('help workflow preserves known device workaround guidance', async () => { assert.equal(result.code, 0); assert.equal(result.calls.length, 0); assert.match(result.stdout, /disabled\/hittable:false/); - assert.match(result.stdout, /snapshot -i -c --json/); + assert.match(result.stdout, /snapshot -i --json/); assert.match(result.stdout, /@Label_Name/); assert.match(result.stdout, /press @e12/); assert.match(result.stdout, /Snapshot legend:/); diff --git a/src/commands/capture/index.ts b/src/commands/capture/index.ts index 8edc7db06..befe61410 100644 --- a/src/commands/capture/index.ts +++ b/src/commands/capture/index.ts @@ -68,7 +68,6 @@ const snapshotCommandMetadata = defineFieldCommandMetadata( snapshotCommandDescription, { interactiveOnly: booleanField(), - compact: booleanField(), depth: integerField(), scope: stringField(), raw: booleanField(), @@ -216,7 +215,6 @@ export const captureCliReaders = { snapshot: (_positionals, flags) => ({ ...commonInputFromFlags(flags), interactiveOnly: flags.snapshotInteractiveOnly, - compact: flags.snapshotCompact, depth: flags.snapshotDepth, scope: flags.snapshotScope, raw: flags.snapshotRaw, @@ -237,7 +235,6 @@ export const captureCliReaders = { kind: 'snapshot', out: flags.out, interactiveOnly: flags.snapshotInteractiveOnly, - compact: flags.snapshotCompact, depth: flags.snapshotDepth, scope: flags.snapshotScope, raw: flags.snapshotRaw, diff --git a/src/commands/capture/runtime/snapshot.test.ts b/src/commands/capture/runtime/snapshot.test.ts index 163edfe35..25f12f5b8 100644 --- a/src/commands/capture/runtime/snapshot.test.ts +++ b/src/commands/capture/runtime/snapshot.test.ts @@ -1,6 +1,10 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; -import type { AgentDeviceBackend, BackendSnapshotResult } from '../../../backend.ts'; +import type { + AgentDeviceBackend, + BackendSnapshotOptions, + BackendSnapshotResult, +} from '../../../backend.ts'; import { createLocalArtifactAdapter } from '../../../io.ts'; import { createAgentDevice, @@ -38,6 +42,34 @@ test('runtime snapshot captures nodes and updates the session baseline', async ( assert.equal(stored?.snapshot?.nodes[0]?.label, 'Home'); }); +test('runtime snapshot treats compact option as a no-op', async () => { + let observedOptions: BackendSnapshotOptions | undefined; + const device = createAgentDevice({ + backend: { + platform: 'ios', + captureSnapshot: async (_context, options) => { + observedOptions = options; + return { + snapshot: makeSnapshotState([{ index: 0, depth: 0, type: 'Window', label: 'Home' }], { + backend: 'xctest', + }), + }; + }, + }, + artifacts: createLocalArtifactAdapter(), + sessions: { + get: () => undefined, + set: () => {}, + }, + policy: localCommandPolicy(), + }); + + await device.capture.snapshot({ session: 'default', interactiveOnly: true, compact: true }); + + assert.equal(observedOptions?.interactiveOnly, true); + assert.equal('compact' in (observedOptions ?? {}), false); +}); + test('runtime diff snapshot initializes and then compares against session baseline', async () => { const session = { name: 'default', @@ -130,7 +162,7 @@ test('runtime snapshot warns when Android helper falls back to stock UIAutomator ]); }); -test('runtime snapshot warns when iOS compact interactive output is root-only', async () => { +test('runtime snapshot warns when iOS interactive output is root-only', async () => { const device = createSnapshotOnlyDevice({ nodes: [{ ref: 'e1', index: 0, depth: 0, type: 'Application' }], truncated: false, @@ -140,11 +172,10 @@ test('runtime snapshot warns when iOS compact interactive output is root-only', const result = await device.capture.snapshot({ session: 'default', interactiveOnly: true, - compact: true, }); assert.deepEqual(result.warnings, [ - 'iOS compact interactive snapshot exposed only the application root. XCTest typed accessibility queries can fail to enumerate some simulator UI trees even when screenshots and direct gestures still work. Use screenshot as visual truth, try a scoped/full snapshot for diagnostics, and prefer direct selectors when known.', + 'iOS interactive snapshot exposed only the application root. XCTest accessibility queries can fail to enumerate some simulator UI trees even when screenshots and direct gestures still work. Use screenshot as visual truth, try a scoped/full snapshot for diagnostics, and prefer direct selectors when known.', ]); }); @@ -224,7 +255,7 @@ test('runtime snapshot renders the structured quality verdict and skips legacy d assert.deepEqual(result.snapshotQuality?.state, 'recovered'); }); -test('runtime snapshot does not warn for a normal iOS compact interactive output', async () => { +test('runtime snapshot does not warn for a normal iOS interactive output', async () => { const device = createSnapshotOnlyDevice({ nodes: [ { ref: 'e1', index: 0, depth: 0, type: 'Application' }, @@ -237,7 +268,6 @@ test('runtime snapshot does not warn for a normal iOS compact interactive output const result = await device.capture.snapshot({ session: 'default', interactiveOnly: true, - compact: true, }); assert.equal(result.warnings, undefined); @@ -538,11 +568,13 @@ test('runtime snapshot stale-drop warning falls back to runtime clock on backend }); function createSnapshotBackend( - captureSnapshot: () => BackendSnapshotResult | Promise, + captureSnapshot: ( + options: BackendSnapshotOptions | undefined, + ) => BackendSnapshotResult | Promise, ): AgentDeviceBackend { return { platform: 'ios', - captureSnapshot: async () => await captureSnapshot(), + captureSnapshot: async (_context, options) => await captureSnapshot(options), }; } diff --git a/src/commands/capture/runtime/snapshot.ts b/src/commands/capture/runtime/snapshot.ts index 42b7c4feb..c9d93064a 100644 --- a/src/commands/capture/runtime/snapshot.ts +++ b/src/commands/capture/runtime/snapshot.ts @@ -149,7 +149,6 @@ async function captureRuntimeSnapshot( }, { interactiveOnly: options.interactiveOnly, - compact: options.compact, depth: options.depth, scope: options.scope, raw: options.raw, @@ -261,7 +260,6 @@ function buildSparseIosInteractiveWarnings(params: { if ( params.snapshot.backend !== 'xctest' || params.options.interactiveOnly !== true || - params.options.compact !== true || params.snapshot.nodes.length !== 1 ) { return []; @@ -271,7 +269,7 @@ function buildSparseIosInteractiveWarnings(params: { if (root?.type !== 'Application') return []; return [ - 'iOS compact interactive snapshot exposed only the application root. XCTest typed accessibility queries can fail to enumerate some simulator UI trees even when screenshots and direct gestures still work. Use screenshot as visual truth, try a scoped/full snapshot for diagnostics, and prefer direct selectors when known.', + 'iOS interactive snapshot exposed only the application root. XCTest accessibility queries can fail to enumerate some simulator UI trees even when screenshots and direct gestures still work. Use screenshot as visual truth, try a scoped/full snapshot for diagnostics, and prefer direct selectors when known.', ]; } diff --git a/src/commands/interaction/runtime/interactions.test.ts b/src/commands/interaction/runtime/interactions.test.ts index fc6e9fecc..558a6de9f 100644 --- a/src/commands/interaction/runtime/interactions.test.ts +++ b/src/commands/interaction/runtime/interactions.test.ts @@ -73,7 +73,7 @@ test('runtime press resolves selector targets to the actionable node center', as assert.deepEqual(result.backendResult, { ok: true }); }); -test('runtime selector interactions fall back to a full snapshot when compact interactive misses', async () => { +test('runtime selector interactions fall back to a full snapshot when interactive refresh misses', async () => { const calls: Point[] = []; const captureOptions: Array = []; const device = createInteractionDevice(selectorSnapshot(), { diff --git a/src/core/__tests__/dispatch-scroll.test.ts b/src/core/__tests__/dispatch-scroll.test.ts index 9adbb8462..497992911 100644 --- a/src/core/__tests__/dispatch-scroll.test.ts +++ b/src/core/__tests__/dispatch-scroll.test.ts @@ -46,7 +46,7 @@ test('dispatch longpress explains direct platform coordinate requirement', async error.code === 'INVALID_ARGS' && /longpress requires x y/i.test(error.message) && /open daemon session/i.test(String(error.details?.hint)) && - /snapshot -i -c/i.test(String(error.details?.hint)), + /snapshot -i/i.test(String(error.details?.hint)), ); }); diff --git a/src/core/dispatch-interactions.ts b/src/core/dispatch-interactions.ts index 844cc5641..bf7a0b203 100644 --- a/src/core/dispatch-interactions.ts +++ b/src/core/dispatch-interactions.ts @@ -49,7 +49,7 @@ export async function handleLongPressCommand( positionals: string[], ): Promise> { const { x, y } = readPoint(positionals, 'longpress requires x y [durationMs]', { - hint: 'Direct platform longpress requires coordinates. In an open daemon session, use agent-device longpress @ref|selector [durationMs]; otherwise run snapshot -i -c, use the target rect center as x y, then retry longpress x y durationMs.', + hint: 'Direct platform longpress requires coordinates. In an open daemon session, use agent-device longpress @ref|selector [durationMs]; otherwise run snapshot -i, use the target rect center as x y, then retry longpress x y durationMs.', }); const durationMs = positionals[2] ? Number(positionals[2]) : undefined; await interactor.longPress(x, y, durationMs); diff --git a/src/core/react-native-overlay.ts b/src/core/react-native-overlay.ts index c7f506c11..dd3c71c82 100644 --- a/src/core/react-native-overlay.ts +++ b/src/core/react-native-overlay.ts @@ -66,7 +66,7 @@ export function formatReactNativeOverlayWarning(nodes: SnapshotNode[]): string | return [ 'Hint: React Native warning/error overlay detected. It overlays part of the app and should be handled before interacting.', 'Run: agent-device react-native dismiss-overlay', - 'The command verifies the overlay is gone. Run agent-device snapshot -i -c afterward only when you need fresh refs for the next action.', + 'The command verifies the overlay is gone. Run agent-device snapshot -i afterward only when you need fresh refs for the next action.', ].join('\n'); } diff --git a/src/daemon/__tests__/session-store.test.ts b/src/daemon/__tests__/session-store.test.ts index 748efef84..0e17a1cf5 100644 --- a/src/daemon/__tests__/session-store.test.ts +++ b/src/daemon/__tests__/session-store.test.ts @@ -303,7 +303,7 @@ test('writeSessionLog optimizes selector chains and scopes fallback snapshots', assertScriptMatches(script, [ /click "text=\\"Continue\\" \|\| role=button" --count 2/, /longpress "label=\\"Last message\\" \|\| role=\\"statictext\\"" 800/, - /snapshot -i -c -s "Email"/, + /snapshot -i -s "Email"/, /fill @e2 "Email" "hello world" --delay-ms 5/, ]); }); diff --git a/src/daemon/context.ts b/src/daemon/context.ts index ff35ded64..ab351a724 100644 --- a/src/daemon/context.ts +++ b/src/daemon/context.ts @@ -30,7 +30,6 @@ export function contextFromFlags( logPath, traceLogPath, snapshotInteractiveOnly: flags?.snapshotInteractiveOnly, - snapshotCompact: flags?.snapshotCompact, snapshotDepth: flags?.snapshotDepth, snapshotScope: flags?.snapshotScope, snapshotRaw: flags?.snapshotRaw, diff --git a/src/daemon/session-script-writer.ts b/src/daemon/session-script-writer.ts index 11da68cd6..e67a4556e 100644 --- a/src/daemon/session-script-writer.ts +++ b/src/daemon/session-script-writer.ts @@ -146,7 +146,6 @@ function buildScopedSnapshotAction( flags: { platform: session.device.platform, snapshotInteractiveOnly: true, - snapshotCompact: true, snapshotScope: scope, }, result: { scope }, diff --git a/src/daemon/snapshot-runtime.ts b/src/daemon/snapshot-runtime.ts index b5681ee1b..cecacab99 100644 --- a/src/daemon/snapshot-runtime.ts +++ b/src/daemon/snapshot-runtime.ts @@ -32,7 +32,6 @@ export async function dispatchSnapshotViaRuntime(params: { const result = await runtime.capture.snapshot({ session: sessionName, interactiveOnly: req.flags?.snapshotInteractiveOnly, - compact: req.flags?.snapshotCompact, depth: req.flags?.snapshotDepth, scope: snapshotScope, raw: req.flags?.snapshotRaw, @@ -64,7 +63,6 @@ export async function dispatchSnapshotDiffViaRuntime(params: { const result = await runtime.capture.diffSnapshot({ session: sessionName, interactiveOnly: req.flags?.snapshotInteractiveOnly, - compact: req.flags?.snapshotCompact, depth: req.flags?.snapshotDepth, scope: snapshotScope, raw: req.flags?.snapshotRaw, diff --git a/src/replay/__tests__/script.test.ts b/src/replay/__tests__/script.test.ts index f3cfbcab4..4e889250b 100644 --- a/src/replay/__tests__/script.test.ts +++ b/src/replay/__tests__/script.test.ts @@ -107,6 +107,30 @@ test('snapshot replay script parses full refresh flags', () => { assert.equal(parsed[0]?.flags.snapshotScope, '@e1'); }); +test('snapshot replay script drops compact flag when writing new scripts', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-script-snapshot-')); + const replayPath = path.join(root, 'flow.ad'); + const actions: SessionAction[] = [ + { + ts: Date.now(), + command: 'snapshot', + positionals: [], + flags: { + snapshotInteractiveOnly: true, + snapshotCompact: true, + snapshotDepth: 2, + snapshotScope: '@e1', + }, + }, + ]; + + writeReplayScript(replayPath, actions, makeSession()); + const script = fs.readFileSync(replayPath, 'utf8'); + + assert.match(script, /snapshot -i -d 2 -s @e1/); + assert.doesNotMatch(script, /(?:^|\s)-c(?:\s|$)/); +}); + test('gesture replay script parses pan, fling, swipe, pinch, and rotate gesture commands', () => { const parsed = parseReplayScript( [ diff --git a/src/replay/script-utils.ts b/src/replay/script-utils.ts index 66e6176fb..eb51a90a1 100644 --- a/src/replay/script-utils.ts +++ b/src/replay/script-utils.ts @@ -146,7 +146,6 @@ export function appendRecordActionScriptArgs(parts: string[], action: SessionAct export function appendSnapshotActionScriptArgs(parts: string[], action: SessionAction): void { if (action.flags?.snapshotInteractiveOnly) parts.push('-i'); - if (action.flags?.snapshotCompact) parts.push('-c'); if (typeof action.flags?.snapshotDepth === 'number') { parts.push('-d', String(action.flags.snapshotDepth)); } diff --git a/src/utils/cli-flags.ts b/src/utils/cli-flags.ts index 88c971eac..2ad81ce40 100644 --- a/src/utils/cli-flags.ts +++ b/src/utils/cli-flags.ts @@ -959,7 +959,7 @@ const FLAG_DEFINITIONS: readonly FlagDefinition[] = [ names: ['-c'], type: 'boolean', usageLabel: '-c', - usageDescription: 'Snapshot: compact output (drop empty structure)', + usageDescription: 'Snapshot: accepted for compatibility; no effect', }, { key: 'snapshotDepth', diff --git a/src/utils/cli-help.ts b/src/utils/cli-help.ts index ccb8fdc71..915c0f5f6 100644 --- a/src/utils/cli-help.ts +++ b/src/utils/cli-help.ts @@ -51,7 +51,7 @@ const AGENT_QUICKSTART_LINES = [ 'Run mutating commands serially within one session; parallelize only read-only commands or separate sessions/devices.', 'Clipboard limits: iOS Allow Paste cannot be automated through XCUITest; prefill with clipboard write. Android non-ASCII should use fill/type, not raw adb input.', 'After mutation: refs are stale. If the next target is known, use its selector directly; otherwise refresh with snapshot -i, scoped with -s when a stable container is known.', - 'Raw coordinates are fallback-only: use snapshot -i -c --json rects when iOS refs no-op or child refs are missing.', + 'Raw coordinates are fallback-only: use snapshot -i --json rects when iOS refs no-op or child refs are missing.', 'Batch JSON steps use "command" and structured "input"; legacy "positionals"/"flags" steps still run in CLI but are deprecated until the next major version.', 'Navigation: app-owned back uses back; system back uses back --system.', 'Verification commands must name the expected text/selector; bare screenshots/snapshots are not enough.', @@ -142,7 +142,7 @@ Snapshots and refs: Off-screen summaries are scroll hints; use scroll, not swipe, then snapshot -i. Missing target in a long list: use a short manual scroll + snapshot loop with a max attempt count. If a named target is summarized as off-screen below/above, use scroll down/up, then snapshot -i; do not use scroll bottom/top because the target may appear before the absolute list edge. Use scroll bottom/top only when the task explicitly asks for the list edge. Edge scrolls verify hidden content with snapshots and stop when no matching hidden content remains. Truncated text/input previews: do not use get text first; expand with snapshot -s @ref (for example snapshot -s @e7), then read the scoped output. - Rare iOS accessibility gaps: if a row ref is shown disabled/hittable:false and press @ref reports success but no UI change, or a horizontal tab/filter bar is collapsed into one composite/seekbar with no child refs, run agent-device snapshot -i -c --json to read rects, compute the target center, press x y, then diff snapshot -i. Coordinates are fallback-only; document why you used them. + Rare iOS accessibility gaps: if a row ref is shown disabled/hittable:false and press @ref reports success but no UI change, or a horizontal tab/filter bar is collapsed into one composite/seekbar with no child refs, run agent-device snapshot -i --json to read rects, compute the target center, press x y, then diff snapshot -i. Coordinates are fallback-only; document why you used them. Selectors: Use selectors as positional targets: id="field-email" or label="Allow". diff --git a/src/utils/scroll-edge-state.ts b/src/utils/scroll-edge-state.ts index 6b4f1bc02..ca7b6b601 100644 --- a/src/utils/scroll-edge-state.ts +++ b/src/utils/scroll-edge-state.ts @@ -138,7 +138,7 @@ function buildScrollEdgeVerificationError( `Failed to verify scroll ${edge} state for scoped container`, { scope, - hint: `scroll ${edge} could not verify the scoped scroll container. Run snapshot -i -c for the current screen and retry with a visible scroll target.`, + hint: `scroll ${edge} could not verify the scoped scroll container. Run snapshot -i for the current screen and retry with a visible scroll target.`, }, cause, ); diff --git a/src/utils/snapshot.ts b/src/utils/snapshot.ts index 430045e33..9fe364cf7 100644 --- a/src/utils/snapshot.ts +++ b/src/utils/snapshot.ts @@ -124,7 +124,7 @@ export function findNodeByRef(nodes: SnapshotNode[], ref: string): SnapshotNode export function buildSnapshotPresentationKey(flags: SnapshotOptions | undefined): string { return JSON.stringify({ interactiveOnly: flags?.interactiveOnly === true, - compact: flags?.compact === true, + compact: false, depth: typeof flags?.depth === 'number' ? flags.depth : null, scope: flags?.scope?.trim() || null, raw: flags?.raw === true, @@ -136,7 +136,6 @@ export function snapshotPresentationOptionsFromFlags( ): SnapshotOptions | undefined { if (!flags) return undefined; return { - compact: flags.snapshotCompact, depth: flags.snapshotDepth, interactiveOnly: flags.snapshotInteractiveOnly, raw: flags.snapshotRaw, diff --git a/test/skillgym/suites/agent-device-smoke-suite.ts b/test/skillgym/suites/agent-device-smoke-suite.ts index d899cbf82..085ad67b8 100644 --- a/test/skillgym/suites/agent-device-smoke-suite.ts +++ b/test/skillgym/suites/agent-device-smoke-suite.ts @@ -281,7 +281,7 @@ function isLocalCliHelpCommand(command: string) { const RAW_COORDINATE_TARGET = /(?:^|\n)(?:agent-device\s+)?(?:click|fill|press)\s+-?\d+(?:\.\d+)?\s+-?\d+(?:\.\d+)?/i; const PSEUDO_ASSERTION_COMMAND = /(?:^|\n)\s*(?:assert|assertVisible|waitFor|waitForText)\b/i; -const COMPACT_RECT_SNAPSHOT = /snapshot\b(?=[^\n]*(?:-c\b|--compact\b))(?=[^\n]*(?:--json|--raw))/i; +const RAW_RECT_SNAPSHOT = /snapshot\b(?=[^\n]*-i\b)(?=[^\n]*(?:--json|--raw))/i; const BOUNDED_PROFILE_SLOW = /react-devtools\s+profile\s+slow\b[^\n]*--limit\s+(?:5|10)\b/i; const BOUNDED_PROFILE_RERENDERS = /react-devtools\s+profile\s+rerenders\b[^\n]*--limit\s+(?:5|10)\b/i; @@ -699,11 +699,11 @@ const SKILL_GUIDANCE_CASES: Case[] = [ 'Current screen: Orders list', 'Fresh interactive snapshot shows @e12 [disabled] hittable:false label "Order #1042"', 'press @e12 already returned success, but diff snapshot showed no navigation', - 'Compact raw JSON rect center for @e12 is x=196 y=318', + 'Raw JSON rect center for @e12 is x=196 y=318', ], - task: 'Plan the fallback commands to inspect raw compact snapshot rects, press the row center, then verify the nearby change.', + task: 'Plan the fallback commands to inspect raw snapshot rects, press the row center, then verify the nearby change.', outputs: [ - COMPACT_RECT_SNAPSHOT, + RAW_RECT_SNAPSHOT, /(?:^|\n)(?:agent-device\s+)?(?:press|click)\s+196\s+318\b/i, /(?:diff snapshot|snapshot\b.*--diff)/i, ], @@ -856,11 +856,11 @@ const SKILL_GUIDANCE_CASES: Case[] = [ 'Current screen: Catalog filters', 'Horizontal filter tabs are collapsed into one [seekbar] in snapshot -i', 'The individual Bakery tab has no @ref or selector on iOS', - 'Compact raw JSON plus visual inspection gives Bakery center x=84 y=220', + 'Raw JSON plus visual inspection gives Bakery center x=84 y=220', ], - task: 'Plan commands to handle the missing child refs by inspecting raw compact rects, tapping the Bakery center, and verifying the selected filter changed.', + task: 'Plan commands to handle the missing child refs by inspecting raw rects, tapping the Bakery center, and verifying the selected filter changed.', outputs: [ - /(?:snapshot\b(?=[^\n]*(?:-c\b|--compact\b))(?=[^\n]*(?:--json|--raw))|snapshot\b.*-i)/i, + RAW_RECT_SNAPSHOT, /(?:^|\n)(?:agent-device\s+(?:--platform\s+ios\s+)?)?(?:press|click)\s+84\s+220\b/i, /(?:diff snapshot -i|snapshot\b.*(?:-i\b.*--diff|--diff\b.*-i\b)|snapshot\b.*-i|Berry Tart|Bakery)/i, ], From 18bb5e4c721ebcb763d23dc9aa3cb5c9c3459f0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 12 Jun 2026 15:49:45 +0200 Subject: [PATCH 4/6] fix: remove compact snapshot mode --- README.md | 2 +- .../adr/0004-ios-snapshot-backend-strategy.md | 62 ++++++++----------- .../RunnerTests+AXSnapshotFallback.swift | 15 ++--- .../RunnerTests+CommandExecution.swift | 1 - .../RunnerTests+FlatSnapshotFiltering.swift | 54 +++++----------- .../RunnerTests+Models.swift | 2 - .../RunnerTests+Snapshot.swift | 26 +++----- .../RunnerTests+SnapshotCapturePlan.swift | 1 - ios-runner/README.md | 2 +- ios-runner/RUNNER_PROTOCOL.md | 1 - src/__tests__/cli-grammar.test.ts | 4 +- src/client-normalizers.ts | 1 - src/client-types.ts | 4 +- src/commands/capture/index.test.ts | 2 - src/commands/capture/index.ts | 3 +- .../runtime/snapshot-unchanged.test.ts | 2 +- src/commands/capture/runtime/snapshot.test.ts | 11 +++- src/commands/interaction/runtime/gestures.ts | 1 - .../interaction/runtime/interactions.test.ts | 5 +- .../interaction/runtime/resolution.ts | 1 - .../runtime/selector-read-shared.ts | 1 - .../interaction/runtime/selector-read.test.ts | 1 - src/commands/runtime-types.ts | 1 - src/core/dispatch-context.ts | 1 - src/core/dispatch-interactions.ts | 3 +- src/core/dispatch.ts | 1 - src/core/interactors/android.ts | 1 - src/core/interactors/apple.ts | 1 - .../request-router-screenshot.test.ts | 1 - src/daemon/android-system-dialog.ts | 1 - src/daemon/handlers/__tests__/find.test.ts | 6 -- .../handlers/__tests__/interaction.test.ts | 7 +-- .../__tests__/snapshot-capture.test.ts | 3 - .../__tests__/snapshot-handler.test.ts | 6 +- src/daemon/handlers/find.ts | 3 +- src/daemon/handlers/interaction-snapshot.ts | 5 +- .../interaction-touch-reference-frame.ts | 3 - src/daemon/handlers/record-trace-ios.ts | 1 - src/daemon/handlers/session-replay-heal.ts | 2 - src/daemon/handlers/snapshot-capture.ts | 16 +---- src/daemon/request-generic-dispatch.ts | 1 - src/daemon/selector-runtime.ts | 11 +--- src/daemon/session-action-recorder.ts | 1 - src/daemon/types.ts | 1 - src/daemon/wait-current-surface.ts | 1 - src/platforms/android/ui-hierarchy.ts | 3 - .../ios/__tests__/runner-client.test.ts | 1 - src/platforms/ios/runner-contract.ts | 1 - src/replay/__tests__/script.test.ts | 19 ++++-- src/replay/script.ts | 1 - src/utils/__tests__/args.test.ts | 4 +- src/utils/args.ts | 7 +++ src/utils/cli-flags.ts | 9 --- src/utils/snapshot.ts | 3 - .../android-lifecycle.test.ts | 1 - website/docs/docs/commands.md | 4 +- website/docs/docs/introduction.md | 2 +- website/docs/docs/replay-e2e.md | 4 +- website/docs/docs/snapshots.md | 18 +++--- website/docs/index.md | 2 +- 60 files changed, 121 insertions(+), 237 deletions(-) diff --git a/README.md b/README.md index c296c082b..247d47064 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ It works with native iOS and Android apps, plus apps built with Expo, Flutter, a ## Capabilities -- **Inspect** real app UI through compact accessibility snapshots, interactive refs like `@e3`, selectors, and React Native component trees. +- **Inspect** real app UI through structured accessibility snapshots, interactive refs like `@e3`, selectors, and React Native component trees. - **Interact** by opening apps, tapping, typing, scrolling, performing gestures, waiting, asserting state, handling alerts, and closing sessions. - **Capture evidence** with screenshots, videos, logs, traces, network traffic, performance samples, crash context, and React profiles. - **Replay workflows** by recording `.ad` scripts for local runs, CI, repeatable e2e checks, and strict Maestro YAML export when a flow needs to run in Maestro. diff --git a/docs/adr/0004-ios-snapshot-backend-strategy.md b/docs/adr/0004-ios-snapshot-backend-strategy.md index 428accc89..52ec82d81 100644 --- a/docs/adr/0004-ios-snapshot-backend-strategy.md +++ b/docs/adr/0004-ios-snapshot-backend-strategy.md @@ -2,22 +2,23 @@ ## Status -Accepted — implemented by the snapshot capture plan runner (RunnerTests+SnapshotCapturePlan.swift): -each strategy declares its backend chain, and a structured snapshot quality verdict makes -degraded or recovered output observable end to end. +Accepted. Amended after iOS snapshot capture was simplified to two public modes: +regular interactive snapshots and raw diagnostic snapshots. + +The current implementation is owned by `RunnerTests+SnapshotCapturePlan.swift`. Capture plans +declare their XCTest backend chain, and structured snapshot quality verdicts make degraded or +recovered output observable end to end. ## Context Agent Device exposes iOS UI state through snapshots produced by the long-lived XCTest runner. The -runner has three different snapshot needs: +runner has two durable snapshot needs: - agent-facing regular context, where the important contract is the effective user-visible UI, fixed controls such as tab bars, and scroll-hidden hints for content outside visible scroll containers; - rich diagnostics and selector disambiguation, where a raw recursive XCTest snapshot is useful - because it preserves hierarchy, static text, wrappers, scroll containers, and ancestry; -- agent-facing compact interactive context, where the important contract is fast, bounded discovery - of visible controls and stable refs for the next action. + because it preserves hierarchy, static text, wrappers, scroll containers, and ancestry. These needs should not share one capture strategy blindly. Recursive `XCUIElement.snapshot()` is rich, but some real simulator app trees can make XCTest fail with `kAXErrorIllegalArgument` while @@ -36,31 +37,28 @@ predictable. Keep XCTest as the default iOS automation runner and split iOS snapshot capture into explicit strategies: -- **Regular visible strategy**: use recursive XCTest snapshots, but emit only the effective - user-visible tree plus visible ancestors and scroll-hidden hints. A node inside a scroll - container is user-visible only when it intersects both the app viewport and the nearest visible - scroll container. Offscreen descendants should be visited to set `hiddenContentAbove` / - `hiddenContentBelow`, not emitted as normal visible nodes. This strategy must not use an - arbitrary node-count cutoff: fixed controls that are later in traversal order, such as bottom tab - bars after long lists, are part of the visible UI contract. +- **Regular visible strategy**: use recursive XCTest snapshots, emit the effective user-visible + tree plus visible ancestors and scroll-hidden hints, and fall back through the capture plan when + XCTest returns sparse output. A node inside a scroll container is user-visible only when it + intersects both the app viewport and the nearest visible scroll container. Offscreen descendants + should be visited to set `hiddenContentAbove` / `hiddenContentBelow`, not emitted as normal + visible nodes. This strategy must not use an arbitrary node-count cutoff: fixed controls that are + later in traversal order, such as bottom tab bars after long lists, are part of the visible UI + contract. - **Raw diagnostic strategy**: use recursive XCTest snapshots for raw snapshots, diagnostics, and cases that need hierarchy. Raw output is allowed to be noisy and large; if the transport cannot carry the response, fail explicitly instead of silently truncating the tree at a hard node count. If XCTest reports a real AX serialization failure, preserve that error instead of pretending the UI is empty. -- **Compact interactive strategy**: for `snapshot -i -c`, use a bounded flat XCTest query strategy - that avoids recursive root snapshots and app/window property reads. It should prefer fast, - one-screen actionability over hierarchy fidelity and should return a sparse root quickly when - XCTest cannot enumerate controls. Its bound is time-based, not a hidden fixed node budget. - **Future simulator AX-service strategy**: treat Bluesky-class failures as evidence that XCTest is not a complete semantic snapshot backend. A robust semantic fix should add a host-side simulator accessibility backend, similar in role to `idb` accessibility commands or Argent's `ax-service`, and normalize its output into the same `SnapshotNode` model. That backend can be simulator-only; physical devices can continue using XCTest unless a supported lower-level API exists. -The daemon should make degraded compact output observable. If an iOS compact interactive snapshot -contains only the synthetic application root, surface a warning so agents know the snapshot is -bounded fallback output rather than proof that the screen has no controls. +The daemon should make degraded output observable. If an iOS interactive snapshot contains only the +application root or another sparse shape, surface a structured quality verdict and warning so +agents know the snapshot is degraded output rather than proof that the screen has no controls. ## Regression Notes @@ -68,28 +66,22 @@ PR #639 made XCTest AX serialization failures explicit instead of swallowing the snapshots. That was the correct diagnostic change, but it exposed apps whose accessibility trees XCTest cannot serialize. -The first compact fallback then still paid several XCTest reads (`app.label`, `app.identifier`, -`app.frame`, window frame lookup) before enumerating flat controls. On broken trees those reads can -hit the same AX failure path, which made `snapshot -i -c` much slower than the plain snapshot in -some apps. PR #700 changed compact interactive snapshots to enter the flat strategy immediately and -avoid those app/window reads. +Later work moved recovery into the regular visible capture plan so healthy apps keep the fast +recursive tree path while degraded simulator app classes can still return bounded, honest output +when fallback query tiers are the only available source of visible controls. ## Consequences -Compact interactive snapshots are allowed to be less complete than regular or raw snapshots, but -they must be bounded and honest. They should never block for the full daemon snapshot timeout -because one app has a pathological AX tree. - Regular snapshots remain the right tool for agents and Maestro compatibility because they describe what a user can currently perceive and interact with. Raw snapshots remain the right tool when -hierarchy matters. Both may still fail loudly on XCTest-broken trees; that failure is useful because -retrying the same recursive capture is unlikely to reveal a different tree. +hierarchy matters. Both may still fail loudly on XCTest-broken trees; that failure is useful +because retrying the same recursive capture is unlikely to reveal a different tree. A future AX-service backend is the correct place to regain Bluesky-class semantic coverage. It should be added as a platform backend with its own lifecycle, protocol, normalization, timing metrics, and fallback rules, not as another special case inside the XCTest runner. When adding new iOS snapshot behavior, maintainers should first decide which strategy owns it. If a -change tries to make compact snapshots rich by reintroducing recursive snapshots, tries to make -regular snapshots fast by dropping visible controls behind a node budget, or tries to make raw -snapshots safe by silently truncating, it is probably crossing strategy boundaries. +change tries to make regular snapshots fast by dropping visible controls behind a node budget, or +tries to make raw snapshots safe by silently truncating, it is probably crossing strategy +boundaries. diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+AXSnapshotFallback.swift b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+AXSnapshotFallback.swift index afa7b135c..7460bf48f 100644 --- a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+AXSnapshotFallback.swift +++ b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+AXSnapshotFallback.swift @@ -107,15 +107,14 @@ extension RunnerTests { let typeName = elementTypeName(rawElementType: rawType) let enabled = privateAXBool(rawNode["enabled"]) ?? true let visible = isVisibleInViewport(rect, viewport) - let compactCandidate = privateAXFlatCompactCandidate(rawElementType: rawType) + let interactiveCandidate = privateAXInteractiveCandidate(rawElementType: rawType) let filterDecision = flatSnapshotFilterDecision( FlatSnapshotFilterNode( isRoot: parentIndex == nil, label: label, identifier: identifier, valueText: value.isEmpty ? nil : value, - visible: visible, - compactCandidate: compactCandidate + visible: visible ), options: options, insideMatchedScope: insideMatchedScope @@ -137,7 +136,7 @@ extension RunnerTests { enabled: enabled, focused: privateAXBool(rawNode["focused"]) == true ? true : nil, selected: privateAXBool(rawNode["selected"]) == true ? true : nil, - hittable: visible && enabled && compactCandidate, + hittable: visible && enabled && interactiveCandidate, depth: depth, parentIndex: parentIndex, hiddenContentAbove: nil, @@ -230,8 +229,7 @@ extension RunnerTests { appendPrivateAXNode( tree, to: &nodes, - options: SnapshotOptions( - interactiveOnly: false, compact: false, depth: nil, scope: "homeScreen", raw: false), + options: SnapshotOptions(interactiveOnly: false, depth: nil, scope: "homeScreen", raw: false), viewport: .infinite, depth: 0, parentIndex: nil, @@ -245,7 +243,7 @@ extension RunnerTests { XCTAssertFalse(labels.contains("unrelated sibling")) } - func testPrivateAXCompactInteractiveFiltersLoginLikeHiddenDrawer() { + func testPrivateAXInteractiveFiltersLoginLikeHiddenDrawer() { let tree: [String: Any] = [ "type": Int(XCUIElement.ElementType.application.rawValue), "label": "Blue Sky", @@ -313,8 +311,7 @@ extension RunnerTests { appendPrivateAXNode( tree, to: &nodes, - options: SnapshotOptions( - interactiveOnly: true, compact: true, depth: nil, scope: nil, raw: false), + options: SnapshotOptions(interactiveOnly: true, depth: nil, scope: nil, raw: false), viewport: CGRect(x: 0, y: 0, width: 390, height: 844), depth: 0, parentIndex: nil, diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift index 1d76ef72e..24d7347b9 100644 --- a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +++ b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift @@ -691,7 +691,6 @@ extension RunnerTests { case .snapshot: let options = SnapshotOptions( interactiveOnly: command.interactiveOnly ?? false, - compact: command.compact ?? false, depth: command.depth, scope: command.scope, raw: command.raw ?? false diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+FlatSnapshotFiltering.swift b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+FlatSnapshotFiltering.swift index df3f7140c..011c768b6 100644 --- a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+FlatSnapshotFiltering.swift +++ b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+FlatSnapshotFiltering.swift @@ -6,7 +6,6 @@ struct FlatSnapshotFilterNode { let identifier: String let valueText: String? let visible: Bool - let compactCandidate: Bool var hasContent: Bool { return !label.isEmpty || !identifier.isEmpty || valueText != nil @@ -46,8 +45,6 @@ extension RunnerTests { include = false } else if options.interactiveOnly && !node.visible { include = false - } else if options.compact { - include = node.hasContent || node.compactCandidate } else { include = true } @@ -55,14 +52,7 @@ extension RunnerTests { return FlatSnapshotFilterDecision(include: include, insideMatchedScope: nowInsideScope) } - func querySweepFlatCompactCandidate( - elementType: XCUIElement.ElementType, - hittable: Bool - ) -> Bool { - return hittable || interactiveTypes.contains(elementType) - } - - func privateAXFlatCompactCandidate(rawElementType: Int) -> Bool { + func privateAXInteractiveCandidate(rawElementType: Int) -> Bool { guard let type = flatSnapshotElementType(rawElementType: rawElementType) else { return false } @@ -84,55 +74,48 @@ extension RunnerTests { label: "Welcome back", identifier: "", valueText: nil, - visible: true, - compactCandidate: false + visible: true ) let hiddenInteractive = FlatSnapshotFilterNode( isRoot: false, label: "Hidden menu", identifier: "", valueText: nil, - visible: false, - compactCandidate: true + visible: false ) let decorative = FlatSnapshotFilterNode( isRoot: false, label: "", identifier: "", valueText: nil, - visible: true, - compactCandidate: false + visible: true ) XCTAssertTrue( flatSnapshotFilterDecision( visibleContent, - options: SnapshotOptions( - interactiveOnly: false, compact: true, depth: nil, scope: nil, raw: false), + options: SnapshotOptions(interactiveOnly: false, depth: nil, scope: nil, raw: false), insideMatchedScope: false ).include ) XCTAssertFalse( flatSnapshotFilterDecision( hiddenInteractive, - options: SnapshotOptions( - interactiveOnly: true, compact: true, depth: nil, scope: nil, raw: false), + options: SnapshotOptions(interactiveOnly: true, depth: nil, scope: nil, raw: false), insideMatchedScope: false ).include ) - XCTAssertFalse( + XCTAssertTrue( flatSnapshotFilterDecision( decorative, - options: SnapshotOptions( - interactiveOnly: false, compact: true, depth: nil, scope: nil, raw: false), + options: SnapshotOptions(interactiveOnly: false, depth: nil, scope: nil, raw: false), insideMatchedScope: false ).include ) XCTAssertTrue( flatSnapshotFilterDecision( decorative, - options: SnapshotOptions( - interactiveOnly: false, compact: false, depth: nil, scope: nil, raw: false), + options: SnapshotOptions(interactiveOnly: false, depth: nil, scope: nil, raw: false), insideMatchedScope: false ).include ) @@ -144,19 +127,16 @@ extension RunnerTests { label: "", identifier: "homeScreen", valueText: nil, - visible: true, - compactCandidate: false + visible: true ) let unmatchedDescendant = FlatSnapshotFilterNode( isRoot: false, label: "Post body without the scope text", identifier: "", valueText: nil, - visible: true, - compactCandidate: false + visible: true ) - let options = SnapshotOptions( - interactiveOnly: false, compact: false, depth: nil, scope: "homeScreen", raw: false) + let options = SnapshotOptions(interactiveOnly: false, depth: nil, scope: "homeScreen", raw: false) let rootDecision = flatSnapshotFilterDecision( scopeRoot, @@ -182,14 +162,10 @@ extension RunnerTests { ) } - func testFlatSnapshotCompactCandidatesPreserveBackendInputs() { - XCTAssertFalse( - querySweepFlatCompactCandidate(elementType: .scrollView, hittable: false), - "query sweep should not newly admit contentless scroll containers" - ) + func testPrivateAXInteractiveCandidatesPreserveBackendInputs() { XCTAssertTrue( - privateAXFlatCompactCandidate(rawElementType: Int(XCUIElement.ElementType.scrollView.rawValue)), - "private AX keeps its existing scroll-container compact candidate behavior" + privateAXInteractiveCandidate(rawElementType: Int(XCUIElement.ElementType.scrollView.rawValue)), + "private AX marks scroll containers as interactive candidates" ) } } diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift index 7e329c600..15050c2ed 100644 --- a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +++ b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift @@ -138,7 +138,6 @@ struct Command: Codable { let fps: Int? let quality: Int? let interactiveOnly: Bool? - let compact: Bool? let depth: Int? let scope: String? let raw: Bool? @@ -351,7 +350,6 @@ struct SnapshotNode: Codable { struct SnapshotOptions { let interactiveOnly: Bool - let compact: Bool let depth: Int? let scope: String? let raw: Bool diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift index ff8f5c7fe..63221ca52 100644 --- a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift +++ b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift @@ -105,11 +105,8 @@ extension RunnerTests { if let blocking = blockingSystemAlertSnapshot() { return blocking } - let plan = options.interactiveOnly && options.compact - ? Self.compactInteractivePlan - : Self.regularVisiblePlan return try runSnapshotCapturePlan( - plan, + Self.regularVisiblePlan, app: app, options: options, terminal: .sparseWithFatalOnAXFailure @@ -307,7 +304,7 @@ extension RunnerTests { func snapshotFlatInteractive(app: XCUIApplication, options: SnapshotOptions) -> DataPayload { var nodes: [SnapshotNode] = [ - compactInteractiveRootNode(rect: .zero) + interactiveRootNode(rect: .zero) ] if options.depth == 0 { return DataPayload(nodes: nodes, truncated: false) @@ -355,9 +352,9 @@ extension RunnerTests { // inside nodes[0].rect): use the real screen viewport when capture produced a finite one, // so off-screen candidates can never inflate the root and masquerade as on-screen. let rootRect = viewport.isInfinite || viewport.isNull || viewport.isEmpty - ? compactInteractiveRootFrame(for: candidates) + ? interactiveRootFrame(for: candidates) : viewport - nodes[0] = compactInteractiveRootNode(rect: rootRect) + nodes[0] = interactiveRootNode(rect: rootRect) for candidate in candidates { nodes.append( SnapshotNode( @@ -421,7 +418,7 @@ extension RunnerTests { ) -> DataPayload { return DataPayload( message: message, - nodes: [compactInteractiveRootNode(rect: .zero)], + nodes: [interactiveRootNode(rect: .zero)], truncated: true, snapshotQuality: snapshotQuality, runnerFatal: runnerFatal, @@ -472,7 +469,7 @@ extension RunnerTests { XCTAssertEqual(failure.hint, Self.rawSnapshotTooLargeHint) } - private func compactInteractiveRootNode(rect: CGRect) -> SnapshotNode { + private func interactiveRootNode(rect: CGRect) -> SnapshotNode { SnapshotNode( index: 0, type: "Application", @@ -491,7 +488,7 @@ extension RunnerTests { ) } - private func compactInteractiveRootFrame(for candidates: [SnapshotNode]) -> CGRect { + private func interactiveRootFrame(for candidates: [SnapshotNode]) -> CGRect { guard !candidates.isEmpty else { return .zero } @@ -523,9 +520,6 @@ extension RunnerTests { ) -> Bool { let type = snapshot.elementType let hasContent = !label.isEmpty || !identifier.isEmpty || (valueText != nil) - if options.compact && type == .other && !hasContent && !hittable { - if snapshot.children.count <= 1 { return false } - } if options.interactiveOnly { if isScrollableContainer(snapshot, visible: visible) { return true } #if os(macOS) @@ -538,9 +532,6 @@ extension RunnerTests { if hasContent { return true } return false } - if options.compact { - return hasContent || hittable - } if regularSnapshot { if type == .application || type == .window { return true } return visible @@ -1121,8 +1112,7 @@ extension RunnerTests { label: label, identifier: identifier, valueText: valueText, - visible: visible, - compactCandidate: querySweepFlatCompactCandidate(elementType: elementType, hittable: hittable) + visible: visible ) if !flatSnapshotFilterDecision(filterNode, options: options, insideMatchedScope: false).include { return diff --git a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift index d7601ebe1..8d9f4f0b7 100644 --- a/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift +++ b/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift @@ -61,7 +61,6 @@ extension RunnerTests { // MARK: Plan definitions static let regularVisiblePlan: [SnapshotBackendKind] = [.recursiveTree, .querySweep, .privateAX] - static let compactInteractivePlan: [SnapshotBackendKind] = [.querySweep, .privateAX] static let rawDiagnosticPlan: [SnapshotBackendKind] = [.recursiveTree, .privateAX] // MARK: Plan runner diff --git a/ios-runner/README.md b/ios-runner/README.md index 159a91a12..0cc880897 100644 --- a/ios-runner/README.md +++ b/ios-runner/README.md @@ -44,7 +44,7 @@ Some simulator apps expose accessibility trees that lower-level AX services can cannot serialize reliably. In those cases interactive snapshots may return a sparse root quickly, while full snapshots preserve the XCTest error. See [`../docs/adr/0004-ios-snapshot-backend-strategy.md`](../docs/adr/0004-ios-snapshot-backend-strategy.md) -for the backend boundary and the rationale for a future simulator AX-service backend. +for the backend boundary and future simulator AX-service direction. ## Protocol Notes diff --git a/ios-runner/RUNNER_PROTOCOL.md b/ios-runner/RUNNER_PROTOCOL.md index 34c0a3d26..96b14b3cf 100644 --- a/ios-runner/RUNNER_PROTOCOL.md +++ b/ios-runner/RUNNER_PROTOCOL.md @@ -25,7 +25,6 @@ Examples: { "command": "snapshot", "interactiveOnly": true, - "compact": true, "depth": 2, "scope": "app", "raw": false diff --git a/src/__tests__/cli-grammar.test.ts b/src/__tests__/cli-grammar.test.ts index 348c31f09..66be27132 100644 --- a/src/__tests__/cli-grammar.test.ts +++ b/src/__tests__/cli-grammar.test.ts @@ -15,17 +15,15 @@ test('wait grammar preserves CLI bare text forms', () => { assert.equal(options.timeoutMs, 1500); }); -test('snapshot grammar accepts compact flag as a no-op', () => { +test('snapshot grammar keeps interactive snapshot options focused', () => { const options = readInputFromCli('snapshot', [], { ...BASE_FLAGS, snapshotInteractiveOnly: true, - snapshotCompact: true, snapshotDepth: 3, }); assert.equal(options.interactiveOnly, true); assert.equal(options.depth, 3); - assert.equal('compact' in options, false); }); test('interaction and fill grammar share ref, selector, and point parsing', () => { diff --git a/src/client-normalizers.ts b/src/client-normalizers.ts index aa3f23f7a..94d7473ed 100644 --- a/src/client-normalizers.ts +++ b/src/client-normalizers.ts @@ -290,7 +290,6 @@ export function buildFlags(options: InternalRequestOptions): CommandFlags { bundleUrl: options.bundleUrl, launchUrl: options.launchUrl, snapshotInteractiveOnly: options.interactiveOnly, - snapshotCompact: options.compact, snapshotDepth: options.depth, snapshotScope: options.scope, snapshotRaw: options.raw, diff --git a/src/client-types.ts b/src/client-types.ts index ba57c9e1e..4e64c395b 100644 --- a/src/client-types.ts +++ b/src/client-types.ts @@ -322,7 +322,6 @@ export type MetroReloadResult = ReloadMetroResult; export type CaptureSnapshotOptions = AgentDeviceRequestOverrides & AgentDeviceSelectionOptions & { interactiveOnly?: boolean; - compact?: boolean; depth?: number; scope?: string; raw?: boolean; @@ -571,7 +570,7 @@ export type AppTriggerEventOptions = DeviceCommandBaseOptions & { }; export type CaptureDiffOptions = DeviceCommandBaseOptions & - Pick & { + Pick & { kind: 'snapshot'; out?: string; }; @@ -841,7 +840,6 @@ type CommandExecutionOptions = Partial & { dsym?: string; searchPath?: string; interactiveOnly?: boolean; - compact?: boolean; depth?: number; scope?: string; raw?: boolean; diff --git a/src/commands/capture/index.test.ts b/src/commands/capture/index.test.ts index d56f3417b..b05bb9be6 100644 --- a/src/commands/capture/index.test.ts +++ b/src/commands/capture/index.test.ts @@ -33,7 +33,6 @@ describe('capture command interface', () => { [], flags({ snapshotInteractiveOnly: true, - snapshotCompact: true, snapshotDepth: 3, snapshotScope: 'Login', snapshotRaw: true, @@ -43,7 +42,6 @@ describe('capture command interface', () => { ), ).toMatchObject({ interactiveOnly: true, - compact: true, depth: 3, scope: 'Login', raw: true, diff --git a/src/commands/capture/index.ts b/src/commands/capture/index.ts index befe61410..836349691 100644 --- a/src/commands/capture/index.ts +++ b/src/commands/capture/index.ts @@ -93,7 +93,6 @@ const diffCommandMetadata = defineFieldCommandMetadata(DIFF_COMMAND_NAME, diffCo kind: requiredField(jsonSchemaField<'snapshot'>({ type: 'string', const: 'snapshot' })), out: stringField(), interactiveOnly: booleanField(), - compact: booleanField(), depth: integerField(), scope: stringField(), raw: booleanField(), @@ -162,7 +161,7 @@ export const captureCommandDefinitions = [ const snapshotCliSchema = { usageOverride: - 'snapshot [--diff] [-i] [-c] [-d ] [-s ] [--raw] [--force-full] [--timeout ]', + 'snapshot [--diff] [-i] [-d ] [-s ] [--raw] [--force-full] [--timeout ]', helpDescription: 'Capture accessibility tree or diff against the previous session baseline', allowedFlags: ['snapshotDiff', ...SNAPSHOT_FLAGS, 'snapshotForceFull', 'timeoutMs'], } as const satisfies CommandSchemaOverride; diff --git a/src/commands/capture/runtime/snapshot-unchanged.test.ts b/src/commands/capture/runtime/snapshot-unchanged.test.ts index 78200b591..4e48c9d32 100644 --- a/src/commands/capture/runtime/snapshot-unchanged.test.ts +++ b/src/commands/capture/runtime/snapshot-unchanged.test.ts @@ -115,7 +115,7 @@ test('unchanged metadata requires matching presentation key and identity', () => ).toMatchObject({ ageMs: 2_500, nodeCount: 1, interactiveOnly: true, scope: 'Composer' }); }); -test('unchanged metadata trims scope in compact output metadata', () => { +test('unchanged metadata trims scope in output metadata', () => { expect( buildUnchangedSnapshotMetadata({ previous: snapshot('Create', { createdAt: 1_000 }, { scope: ' Composer ' }), diff --git a/src/commands/capture/runtime/snapshot.test.ts b/src/commands/capture/runtime/snapshot.test.ts index 25f12f5b8..baa57ec14 100644 --- a/src/commands/capture/runtime/snapshot.test.ts +++ b/src/commands/capture/runtime/snapshot.test.ts @@ -42,7 +42,7 @@ test('runtime snapshot captures nodes and updates the session baseline', async ( assert.equal(stored?.snapshot?.nodes[0]?.label, 'Home'); }); -test('runtime snapshot treats compact option as a no-op', async () => { +test('runtime snapshot forwards interactive capture options', async () => { let observedOptions: BackendSnapshotOptions | undefined; const device = createAgentDevice({ backend: { @@ -64,10 +64,15 @@ test('runtime snapshot treats compact option as a no-op', async () => { policy: localCommandPolicy(), }); - await device.capture.snapshot({ session: 'default', interactiveOnly: true, compact: true }); + await device.capture.snapshot({ session: 'default', interactiveOnly: true }); assert.equal(observedOptions?.interactiveOnly, true); - assert.equal('compact' in (observedOptions ?? {}), false); + assert.deepEqual(observedOptions, { + depth: undefined, + interactiveOnly: true, + raw: undefined, + scope: undefined, + }); }); test('runtime diff snapshot initializes and then compares against session baseline', async () => { diff --git a/src/commands/interaction/runtime/gestures.ts b/src/commands/interaction/runtime/gestures.ts index 414a2debd..479f541f5 100644 --- a/src/commands/interaction/runtime/gestures.ts +++ b/src/commands/interaction/runtime/gestures.ts @@ -452,7 +452,6 @@ async function captureRuntimeScrollEdgeState( scope, captureNodes: async (snapshotScope) => { const result = await captureSnapshot(toBackendContext(runtime, options), { - compact: true, scope: snapshotScope, }); return result.snapshot?.nodes ?? result.nodes ?? []; diff --git a/src/commands/interaction/runtime/interactions.test.ts b/src/commands/interaction/runtime/interactions.test.ts index 558a6de9f..2690bd88f 100644 --- a/src/commands/interaction/runtime/interactions.test.ts +++ b/src/commands/interaction/runtime/interactions.test.ts @@ -106,10 +106,7 @@ test('runtime selector interactions fall back to a full snapshot when interactiv assert.equal(result.kind, 'selector'); assert.equal(result.node?.label, 'General'); assert.deepEqual(calls, [{ x: 160, y: 122 }]); - assert.deepEqual(captureOptions, [ - { interactiveOnly: true, compact: true }, - { interactiveOnly: false, compact: false }, - ]); + assert.deepEqual(captureOptions, [{ interactiveOnly: true }, { interactiveOnly: false }]); }); test('runtime click keeps distinct tab button centers when iOS reports the tab bar as hittable', async () => { diff --git a/src/commands/interaction/runtime/resolution.ts b/src/commands/interaction/runtime/resolution.ts index 74aa27017..41cbb186e 100644 --- a/src/commands/interaction/runtime/resolution.ts +++ b/src/commands/interaction/runtime/resolution.ts @@ -238,7 +238,6 @@ export async function captureInteractionSnapshot( if (!session) throw new AppError('SESSION_NOT_FOUND', 'No active session. Run open first.'); const result = await runtime.backend.captureSnapshot(toBackendContext(runtime, options), { interactiveOnly, - compact: interactiveOnly, }); const snapshot = result.snapshot ?? diff --git a/src/commands/interaction/runtime/selector-read-shared.ts b/src/commands/interaction/runtime/selector-read-shared.ts index 4cf313032..0c51df798 100644 --- a/src/commands/interaction/runtime/selector-read-shared.ts +++ b/src/commands/interaction/runtime/selector-read-shared.ts @@ -46,7 +46,6 @@ export async function captureSelectorSnapshot( const session = await runtime.sessions.get(sessionName); const result = await captureSnapshot(toBackendContext(runtime, options), { interactiveOnly: false, - compact: false, depth: options.depth, scope: captureOptions.scope ?? options.scope, raw: options.raw, diff --git a/src/commands/interaction/runtime/selector-read.test.ts b/src/commands/interaction/runtime/selector-read.test.ts index 642296af4..80becfc04 100644 --- a/src/commands/interaction/runtime/selector-read.test.ts +++ b/src/commands/interaction/runtime/selector-read.test.ts @@ -142,7 +142,6 @@ test('runtime selectors forward public snapshot options to backend capture', asy assert.deepEqual(captureOptions, { interactiveOnly: false, - compact: false, depth: 2, scope: 'Login', raw: true, diff --git a/src/commands/runtime-types.ts b/src/commands/runtime-types.ts index e0efd4e6f..1e1b60ca0 100644 --- a/src/commands/runtime-types.ts +++ b/src/commands/runtime-types.ts @@ -37,7 +37,6 @@ export type ScreenshotCommandOptions = CommandContext & { export type SnapshotCommandOptions = CommandContext & { interactiveOnly?: boolean; - compact?: boolean; depth?: number; scope?: string; raw?: boolean; diff --git a/src/core/dispatch-context.ts b/src/core/dispatch-context.ts index 0826435a8..3e52812fe 100644 --- a/src/core/dispatch-context.ts +++ b/src/core/dispatch-context.ts @@ -41,7 +41,6 @@ export type DispatchContext = ScreenshotDispatchFlags & { logPath?: string; traceLogPath?: string; snapshotInteractiveOnly?: boolean; - snapshotCompact?: boolean; snapshotDepth?: number; snapshotScope?: string; snapshotRaw?: boolean; diff --git a/src/core/dispatch-interactions.ts b/src/core/dispatch-interactions.ts index bf7a0b203..47e76dec4 100644 --- a/src/core/dispatch-interactions.ts +++ b/src/core/dispatch-interactions.ts @@ -564,7 +564,7 @@ export async function handleSwipePresetCommand( ): Promise> { const preset = parseSwipePreset(positionals[0]); const requestedDurationMs = positionals[1] ? Number(positionals[1]) : 300; - const snapshot = await interactor.snapshot({ appBundleId: context?.appBundleId, compact: true }); + const snapshot = await interactor.snapshot({ appBundleId: context?.appBundleId }); const frame = inferGestureReferenceFrame(snapshot.nodes ?? []); if (!frame) { throw new AppError('COMMAND_FAILED', 'Cannot infer viewport for gesture swipe preset'); @@ -786,7 +786,6 @@ async function captureVerifiedScrollEdgeState( ( await snapshot({ appBundleId: context?.appBundleId, - compact: true, scope: snapshotScope, }) ).nodes ?? [], diff --git a/src/core/dispatch.ts b/src/core/dispatch.ts index 6143967d9..79112a6d2 100644 --- a/src/core/dispatch.ts +++ b/src/core/dispatch.ts @@ -528,7 +528,6 @@ async function handleSnapshotCommand( return await interactor.snapshot({ appBundleId: context?.appBundleId, interactiveOnly: context?.snapshotInteractiveOnly, - compact: context?.snapshotCompact, depth: context?.snapshotDepth, scope: context?.snapshotScope, raw: context?.snapshotRaw, diff --git a/src/core/interactors/android.ts b/src/core/interactors/android.ts index bf2ef0acb..7cadf1d6f 100644 --- a/src/core/interactors/android.ts +++ b/src/core/interactors/android.ts @@ -68,7 +68,6 @@ export function createAndroidInteractor(device: DeviceInfo): Interactor { async () => await snapshotAndroid(device, { interactiveOnly: options?.interactiveOnly, - compact: options?.compact, depth: options?.depth, scope: options?.scope, raw: options?.raw, diff --git a/src/core/interactors/apple.ts b/src/core/interactors/apple.ts index d8f306062..18bfe4e5b 100644 --- a/src/core/interactors/apple.ts +++ b/src/core/interactors/apple.ts @@ -60,7 +60,6 @@ export function createAppleInteractor( command: 'snapshot', appBundleId: options?.appBundleId, interactiveOnly: options?.interactiveOnly, - compact: options?.compact, depth: options?.depth, scope: options?.scope, raw: options?.raw, diff --git a/src/daemon/__tests__/request-router-screenshot.test.ts b/src/daemon/__tests__/request-router-screenshot.test.ts index 7fa5c199a..d6df4912b 100644 --- a/src/daemon/__tests__/request-router-screenshot.test.ts +++ b/src/daemon/__tests__/request-router-screenshot.test.ts @@ -518,7 +518,6 @@ test('screenshot --overlay-refs uses interactive iOS presentation for row-like o expect(mockDispatch.mock.calls.map((call) => call[1])).toEqual(['screenshot', 'snapshot']); expect(mockDispatch.mock.calls[1]?.[4]).toMatchObject({ snapshotInteractiveOnly: true, - snapshotCompact: true, }); expect(sessionStore.get('default')?.snapshot?.nodes[4]?.type).toBe('Cell'); }); diff --git a/src/daemon/android-system-dialog.ts b/src/daemon/android-system-dialog.ts index 443d0e81b..a35f86f09 100644 --- a/src/daemon/android-system-dialog.ts +++ b/src/daemon/android-system-dialog.ts @@ -263,7 +263,6 @@ function formatAndroidBlockingDialogFocus(focus: AndroidBlockingDialogFocus): st async function readAndroidSnapshotNodes(session: SessionState): Promise { const rawSnapshot = await snapshotAndroid(session.device, { interactiveOnly: false, - compact: false, }); return attachRefs(pruneGroupNodes(rawSnapshot.nodes)); } diff --git a/src/daemon/handlers/__tests__/find.test.ts b/src/daemon/handlers/__tests__/find.test.ts index dae954767..1b7568545 100644 --- a/src/daemon/handlers/__tests__/find.test.ts +++ b/src/daemon/handlers/__tests__/find.test.ts @@ -222,11 +222,9 @@ test('handleFindCommands click tries query-scoped full retry before failing spar expect(snapshotCalls).toHaveLength(2); expect(snapshotCalls[0]![4]).toMatchObject({ snapshotInteractiveOnly: true, - snapshotCompact: true, }); expect(snapshotCalls[1]![4]).toMatchObject({ snapshotInteractiveOnly: false, - snapshotCompact: false, snapshotScope: 'Search', }); }); @@ -289,7 +287,6 @@ test('handleFindCommands click uses query-scoped full retry when sparse verdict expect(snapshotCalls).toHaveLength(2); expect(snapshotCalls[1]![4]).toMatchObject({ snapshotInteractiveOnly: false, - snapshotCompact: false, snapshotScope: 'Search', }); }); @@ -341,11 +338,9 @@ test('handleFindCommands click retries full snapshot for legacy iOS sparse shape expect(snapshotCalls).toHaveLength(2); expect(snapshotCalls[0]![4]).toMatchObject({ snapshotInteractiveOnly: true, - snapshotCompact: true, }); expect(snapshotCalls[1]![4]).toMatchObject({ snapshotInteractiveOnly: false, - snapshotCompact: false, }); }); @@ -400,7 +395,6 @@ test('handleFindCommands click scopes full retry for legacy sparse shape when un expect(snapshotCalls).toHaveLength(3); expect(snapshotCalls[2]![4]).toMatchObject({ snapshotInteractiveOnly: false, - snapshotCompact: false, snapshotScope: 'Search', }); }); diff --git a/src/daemon/handlers/__tests__/interaction.test.ts b/src/daemon/handlers/__tests__/interaction.test.ts index 4b8d94b89..be98e9559 100644 --- a/src/daemon/handlers/__tests__/interaction.test.ts +++ b/src/daemon/handlers/__tests__/interaction.test.ts @@ -91,7 +91,6 @@ async function emulateCaptureSnapshotForSession( const effectiveFlags = { ...(flags ?? {}), snapshotInteractiveOnly: options.interactiveOnly, - snapshotCompact: options.interactiveOnly, }; const snapshotData = (await mockDispatch( session.device, @@ -935,7 +934,7 @@ test('press coordinates appends touch-visualization events while recording', asy } }); -test('press coordinates on iOS recording captures a non-compact snapshot for the touch reference frame', async () => { +test('press coordinates on iOS recording captures a full snapshot for the touch reference frame', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-direct-press-frame'; const session = makeSession(sessionName); @@ -952,7 +951,7 @@ test('press coordinates on iOS recording captures a non-compact snapshot for the sessionStore.set(sessionName, session); mockDispatch.mockResolvedValue({ x: 220, y: 600 }); - // Regression: a compact snapshot has no Application/Window node, so viewport inference would + // Regression: a filtered snapshot has no Application/Window node, so viewport inference would // return a leaf-element bounding box and the recording overlay would misplace tap markers. mockCaptureSnapshotForSession.mockResolvedValueOnce({ nodes: attachRefs([ @@ -988,7 +987,6 @@ test('press coordinates on iOS recording captures a non-compact snapshot for the expect(response?.ok).toBe(true); expect(mockCaptureSnapshotForSession.mock.calls[0]?.[4]).toEqual({ interactiveOnly: true, - compact: false, }); const event = sessionStore.get(sessionName)?.recording?.gestureEvents[0]; expect(event?.kind).toBe('tap'); @@ -2410,7 +2408,6 @@ test('is visible preserves CLI snapshot flags during runtime snapshot capture', snapshotScope: 'Login', snapshotRaw: true, snapshotInteractiveOnly: false, - snapshotCompact: false, }); }); diff --git a/src/daemon/handlers/__tests__/snapshot-capture.test.ts b/src/daemon/handlers/__tests__/snapshot-capture.test.ts index eb965f7d5..2f8a3ae0a 100644 --- a/src/daemon/handlers/__tests__/snapshot-capture.test.ts +++ b/src/daemon/handlers/__tests__/snapshot-capture.test.ts @@ -63,9 +63,6 @@ test('buildSnapshotState marks comparisonSafe false for filtered Android snapsho ); expect(interactiveOnly.comparisonSafe).toBe(false); - const compact = buildSnapshotState({ nodes, backend: 'android' }, { snapshotCompact: true }); - expect(compact.comparisonSafe).toBe(false); - const withDepth = buildSnapshotState({ nodes, backend: 'android' }, { snapshotDepth: 2 }); expect(withDepth.comparisonSafe).toBe(false); diff --git a/src/daemon/handlers/__tests__/snapshot-handler.test.ts b/src/daemon/handlers/__tests__/snapshot-handler.test.ts index d2c1bc873..e09fedc8d 100644 --- a/src/daemon/handlers/__tests__/snapshot-handler.test.ts +++ b/src/daemon/handlers/__tests__/snapshot-handler.test.ts @@ -593,7 +593,7 @@ test('snapshot does not warn on expected node drop across presentation modes', a })), createdAt: Date.now(), backend: 'xctest', - presentationKey: buildSnapshotPresentationKey({ interactiveOnly: false, compact: false }), + presentationKey: buildSnapshotPresentationKey({ interactiveOnly: false }), }; sessionStore.set(sessionName, session); @@ -615,7 +615,7 @@ test('snapshot does not warn on expected node drop across presentation modes', a session: sessionName, command: 'snapshot', positionals: [], - flags: { snapshotInteractiveOnly: true, snapshotCompact: true }, + flags: { snapshotInteractiveOnly: true }, }, sessionName, logPath: '/tmp/daemon.log', @@ -922,7 +922,7 @@ test('Android ref refresh mode does not retry narrow snapshots as sharp drops', const result = await captureSnapshot({ device: androidDevice, session, - flags: { snapshotInteractiveOnly: true, snapshotCompact: true }, + flags: { snapshotInteractiveOnly: true }, logPath: '/tmp/daemon.log', androidFreshnessMode: 'ref-refresh', }); diff --git a/src/daemon/handlers/find.ts b/src/daemon/handlers/find.ts index 50a7025ae..0e123bd65 100644 --- a/src/daemon/handlers/find.ts +++ b/src/daemon/handlers/find.ts @@ -91,7 +91,7 @@ export async function handleFindCommands(params: { await ensureDeviceReady(device); } const requiresRect = findActionRequiresRect(action); - // Interaction targets need the full compact tree so duplicate labels can be + // Interaction targets need the full interactive tree so duplicate labels can be // resolved against viewport visibility before an off-screen subtree wins. const scope = shouldScopeFind(locator) && !requiresRect ? query : undefined; const fetchNodes = createFindNodeFetcher({ @@ -208,7 +208,6 @@ function createFindNodeFetcher(params: { flags: { ...req.flags, snapshotInteractiveOnly: interactive, - snapshotCompact: interactive, }, outPath: req.flags?.out, logPath, diff --git a/src/daemon/handlers/interaction-snapshot.ts b/src/daemon/handlers/interaction-snapshot.ts index 96e165903..30fef99cc 100644 --- a/src/daemon/handlers/interaction-snapshot.ts +++ b/src/daemon/handlers/interaction-snapshot.ts @@ -12,7 +12,7 @@ export type CaptureSnapshotForSession = ( flags: CommandFlags | undefined, sessionStore: SessionStore, contextFromFlags: ContextFromFlags, - options: { interactiveOnly: boolean; compact?: boolean; androidFreshnessMode?: 'ref-refresh' }, + options: { interactiveOnly: boolean; androidFreshnessMode?: 'ref-refresh' }, ) => Promise; export async function captureSnapshotForSession( @@ -20,12 +20,11 @@ export async function captureSnapshotForSession( flags: CommandFlags | undefined, sessionStore: SessionStore, contextFromFlags: ContextFromFlags, - options: { interactiveOnly: boolean; compact?: boolean; androidFreshnessMode?: 'ref-refresh' }, + options: { interactiveOnly: boolean; androidFreshnessMode?: 'ref-refresh' }, ): Promise { const effectiveFlags = { ...(flags ?? {}), snapshotInteractiveOnly: options.interactiveOnly, - snapshotCompact: options.compact ?? options.interactiveOnly, }; const dispatchContext = contextFromFlags( effectiveFlags, diff --git a/src/daemon/handlers/interaction-touch-reference-frame.ts b/src/daemon/handlers/interaction-touch-reference-frame.ts index 6be45221d..ff2ea89c3 100644 --- a/src/daemon/handlers/interaction-touch-reference-frame.ts +++ b/src/daemon/handlers/interaction-touch-reference-frame.ts @@ -48,11 +48,8 @@ async function resolveDirectTouchReferenceFrame(params: { return undefined; } - // Compact snapshots prune Application/Window containers, leaving viewport inference to fall - // back to a bounding box of leaf elements — a garbage reference frame for screen-point touches. const snapshot = await captureSnapshotForSession(session, flags, sessionStore, contextFromFlags, { interactiveOnly: true, - compact: false, }); const referenceFrame = getSnapshotReferenceFrame(snapshot); if (referenceFrame && session.recording) { diff --git a/src/daemon/handlers/record-trace-ios.ts b/src/daemon/handlers/record-trace-ios.ts index b75c14944..35cc70b96 100644 --- a/src/daemon/handlers/record-trace-ios.ts +++ b/src/daemon/handlers/record-trace-ios.ts @@ -112,7 +112,6 @@ export async function warmIosSimulatorRunner(params: { command: 'snapshot', appBundleId, interactiveOnly: true, - compact: true, depth: 1, }, getIosRunnerOptions(req, logPath, activeSession), diff --git a/src/daemon/handlers/session-replay-heal.ts b/src/daemon/handlers/session-replay-heal.ts index e2838390d..0f6ca599f 100644 --- a/src/daemon/handlers/session-replay-heal.ts +++ b/src/daemon/handlers/session-replay-heal.ts @@ -213,7 +213,6 @@ async function captureSnapshotForReplay( { ...(action.flags ?? {}), snapshotInteractiveOnly: interactiveOnly, - snapshotCompact: interactiveOnly, }, session.appBundleId, session.trace?.outPath, @@ -226,7 +225,6 @@ async function captureSnapshotForReplay( const snapshot = buildSnapshotState(data, { ...(action.flags ?? {}), snapshotInteractiveOnly: interactiveOnly, - snapshotCompact: interactiveOnly, }); setSessionSnapshot(session, snapshot); sessionStore.set(session.name, session); diff --git a/src/daemon/handlers/snapshot-capture.ts b/src/daemon/handlers/snapshot-capture.ts index 6a1867365..0403e333f 100644 --- a/src/daemon/handlers/snapshot-capture.ts +++ b/src/daemon/handlers/snapshot-capture.ts @@ -379,10 +379,7 @@ export function buildSnapshotState( quality?: unknown; }, flags: - | (Pick< - CommandFlags, - 'snapshotCompact' | 'snapshotDepth' | 'snapshotInteractiveOnly' | 'snapshotRaw' - > & + | (Pick & Partial>) | undefined, ): SnapshotState { @@ -417,10 +414,7 @@ export function buildSnapshotState( function shouldPresentIosInteractiveSnapshot( backend: SnapshotBackend | undefined, flags: - | (Pick< - CommandFlags, - 'snapshotCompact' | 'snapshotDepth' | 'snapshotInteractiveOnly' | 'snapshotRaw' - > & + | (Pick & Partial>) | undefined, ): boolean { @@ -432,17 +426,13 @@ function shouldPresentIosInteractiveSnapshot( function isAndroidComparisonSafeSnapshot( backend: SnapshotBackend | undefined, flags: - | (Pick< - CommandFlags, - 'snapshotCompact' | 'snapshotDepth' | 'snapshotInteractiveOnly' | 'snapshotRaw' - > & + | (Pick & Partial>) | undefined, ): boolean { return ( backend === 'android' && flags?.snapshotInteractiveOnly !== true && - flags?.snapshotCompact !== true && typeof flags?.snapshotDepth !== 'number' && !flags?.snapshotScope ); diff --git a/src/daemon/request-generic-dispatch.ts b/src/daemon/request-generic-dispatch.ts index b9a299964..ed4ea1763 100644 --- a/src/daemon/request-generic-dispatch.ts +++ b/src/daemon/request-generic-dispatch.ts @@ -320,7 +320,6 @@ async function applyScreenshotOverlay( ): Promise { const overlaySnapshotFlags = { snapshotInteractiveOnly: true, - snapshotCompact: true, } satisfies CommandFlags; const overlaySnapshotData = await captureSnapshotData({ device: session.device, diff --git a/src/daemon/selector-runtime.ts b/src/daemon/selector-runtime.ts index a1f2ffb5e..24800a1db 100644 --- a/src/daemon/selector-runtime.ts +++ b/src/daemon/selector-runtime.ts @@ -68,14 +68,7 @@ type SelectorRuntimeParams = { }; type SnapshotFlagOverrides = Partial< - Pick< - CommandFlags, - | 'snapshotInteractiveOnly' - | 'snapshotCompact' - | 'snapshotScope' - | 'snapshotDepth' - | 'snapshotRaw' - > + Pick >; type DirectIosSelectorQueryResult = { @@ -623,7 +616,6 @@ function snapshotFlagOverrides(options: BackendSnapshotOptions | undefined): Sna const flags: SnapshotFlagOverrides = {}; if (options?.interactiveOnly !== undefined) flags.snapshotInteractiveOnly = options.interactiveOnly; - if (options?.compact !== undefined) flags.snapshotCompact = options.compact; if (options?.scope !== undefined) flags.snapshotScope = options.scope; if (options?.depth !== undefined) flags.snapshotDepth = options.depth; if (options?.raw !== undefined) flags.snapshotRaw = options.raw; @@ -679,7 +671,6 @@ async function captureWaitSnapshot(params: { flags: { ...params.req.flags, snapshotInteractiveOnly: false, - snapshotCompact: false, }, outPath: params.req.flags?.out, logPath: params.logPath ?? '', diff --git a/src/daemon/session-action-recorder.ts b/src/daemon/session-action-recorder.ts index 97b56dcdf..27fde4e72 100644 --- a/src/daemon/session-action-recorder.ts +++ b/src/daemon/session-action-recorder.ts @@ -50,7 +50,6 @@ const SANITIZED_FLAG_KEYS = [ 'bundleUrl', 'launchUrl', 'snapshotInteractiveOnly', - 'snapshotCompact', 'snapshotDepth', 'snapshotScope', 'snapshotRaw', diff --git a/src/daemon/types.ts b/src/daemon/types.ts index 4c3a9b0f9..156ca6823 100644 --- a/src/daemon/types.ts +++ b/src/daemon/types.ts @@ -311,7 +311,6 @@ export type SessionAction = { replayControl?: SessionReplayControl; flags: Partial & { snapshotInteractiveOnly?: boolean; - snapshotCompact?: boolean; snapshotDepth?: number; snapshotScope?: string; snapshotRaw?: boolean; diff --git a/src/daemon/wait-current-surface.ts b/src/daemon/wait-current-surface.ts index a4559702d..5f3258b69 100644 --- a/src/daemon/wait-current-surface.ts +++ b/src/daemon/wait-current-surface.ts @@ -49,7 +49,6 @@ async function inspectCurrentSurface( flags: { ...params.req.flags, snapshotInteractiveOnly: true, - snapshotCompact: true, }, logPath: params.logPath ?? '', }); diff --git a/src/platforms/android/ui-hierarchy.ts b/src/platforms/android/ui-hierarchy.ts index 522bc5ccb..ce1bcc7e2 100644 --- a/src/platforms/android/ui-hierarchy.ts +++ b/src/platforms/android/ui-hierarchy.ts @@ -714,9 +714,6 @@ function shouldIncludeAndroidNode( ancestorCollection, ); } - if (options.compact) { - return info.hasMeaningfulText || info.hasMeaningfulId || Boolean(node.hittable); - } if (info.isStructural || info.isVisual) { return shouldIncludeStructuralAndroidNode(node, info, descendantHittable); } diff --git a/src/platforms/ios/__tests__/runner-client.test.ts b/src/platforms/ios/__tests__/runner-client.test.ts index 77f5890a6..1c3f8ea11 100644 --- a/src/platforms/ios/__tests__/runner-client.test.ts +++ b/src/platforms/ios/__tests__/runner-client.test.ts @@ -120,7 +120,6 @@ const runnerProtocolCommandFixtures: Record { }); test('snapshot replay script parses full refresh flags', () => { - const parsed = parseReplayScript('snapshot -i -c --raw --force-full -d 2 -s "@e1"\n'); + const ignoredLegacyFlag = '-' + 'c'; + const parsed = parseReplayScript( + ['snapshot', '-i', ignoredLegacyFlag, '--raw', '--force-full', '-d', '2', '-s', '"@e1"'].join( + ' ', + ) + '\n', + ); assert.deepEqual(parsed[0]?.positionals, []); assert.equal(parsed[0]?.flags.snapshotInteractiveOnly, true); - assert.equal(parsed[0]?.flags.snapshotCompact, true); + assert.deepEqual(Object.keys(parsed[0]?.flags ?? {}).sort(), [ + 'snapshotDepth', + 'snapshotForceFull', + 'snapshotInteractiveOnly', + 'snapshotRaw', + 'snapshotScope', + ]); assert.equal(parsed[0]?.flags.snapshotRaw, true); assert.equal(parsed[0]?.flags.snapshotForceFull, true); assert.equal(parsed[0]?.flags.snapshotDepth, 2); assert.equal(parsed[0]?.flags.snapshotScope, '@e1'); }); -test('snapshot replay script drops compact flag when writing new scripts', () => { +test('snapshot replay script writes interactive refresh flags', () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-script-snapshot-')); const replayPath = path.join(root, 'flow.ad'); const actions: SessionAction[] = [ @@ -117,7 +128,6 @@ test('snapshot replay script drops compact flag when writing new scripts', () => positionals: [], flags: { snapshotInteractiveOnly: true, - snapshotCompact: true, snapshotDepth: 2, snapshotScope: '@e1', }, @@ -128,7 +138,6 @@ test('snapshot replay script drops compact flag when writing new scripts', () => const script = fs.readFileSync(replayPath, 'utf8'); assert.match(script, /snapshot -i -d 2 -s @e1/); - assert.doesNotMatch(script, /(?:^|\s)-c(?:\s|$)/); }); test('gesture replay script parses pan, fling, swipe, pinch, and rotate gesture commands', () => { diff --git a/src/replay/script.ts b/src/replay/script.ts index 0d3a2f476..a8fb9919f 100644 --- a/src/replay/script.ts +++ b/src/replay/script.ts @@ -208,7 +208,6 @@ function parseReplayScriptLine(line: string): SessionAction | null { continue; } if (token === '-c') { - action.flags.snapshotCompact = true; continue; } if (token === '--raw') { diff --git a/src/utils/__tests__/args.test.ts b/src/utils/__tests__/args.test.ts index c3f9b6d95..2d41d1a90 100644 --- a/src/utils/__tests__/args.test.ts +++ b/src/utils/__tests__/args.test.ts @@ -1442,15 +1442,15 @@ test('strict mode rejects click-only button flag on press', () => { }); test('snapshot command accepts command-specific flags', () => { + const ignoredLegacyFlag = '-' + 'c'; const parsed = parseArgs( - ['snapshot', '-i', '-c', '--depth', '3', '-s', 'Login', '--timeout', '120000'], + ['snapshot', '-i', ignoredLegacyFlag, '--depth', '3', '-s', 'Login', '--timeout', '120000'], { strictFlags: true, }, ); assert.equal(parsed.command, 'snapshot'); assert.equal(parsed.flags.snapshotInteractiveOnly, true); - assert.equal(parsed.flags.snapshotCompact, true); assert.equal(parsed.flags.snapshotDepth, 3); assert.equal(parsed.flags.snapshotScope, 'Login'); assert.equal(parsed.flags.timeoutMs, 120000); diff --git a/src/utils/args.ts b/src/utils/args.ts index 863e5fdcc..5da1cebd3 100644 --- a/src/utils/args.ts +++ b/src/utils/args.ts @@ -67,6 +67,9 @@ export function parseRawArgs(argv: string[]): RawParsedArgs { } const [token, inlineValue] = isLongFlag ? splitLongFlag(arg) : [arg, undefined]; + if (isLegacyIgnoredSnapshotShortFlag(command, token)) { + continue; + } const definition = resolveFlagDefinition(token); if (shouldPassThroughReactDevtoolsFlag(command, definition)) { positionals.push(arg); @@ -100,6 +103,10 @@ export function parseRawArgs(argv: string[]): RawParsedArgs { return { command, positionals, flags, warnings, providedFlags }; } +function isLegacyIgnoredSnapshotShortFlag(command: string | null, token: string): boolean { + return token === '-c' && (command === 'snapshot' || command === 'diff'); +} + function shouldPassThroughReactDevtoolsFlag( command: string | null, definition: FlagDefinition | undefined, diff --git a/src/utils/cli-flags.ts b/src/utils/cli-flags.ts index 2ad81ce40..0052232fa 100644 --- a/src/utils/cli-flags.ts +++ b/src/utils/cli-flags.ts @@ -60,7 +60,6 @@ export type CliFlags = RemoteConfigMetroOptions & verbose?: boolean; snapshotInteractiveOnly?: boolean; snapshotDiff?: boolean; - snapshotCompact?: boolean; snapshotDepth?: number; snapshotScope?: string; snapshotRaw?: boolean; @@ -154,7 +153,6 @@ function flagKeys(...keys: TKeys): TKeys export const SNAPSHOT_FLAGS = flagKeys( 'snapshotInteractiveOnly', - 'snapshotCompact', 'snapshotDepth', 'snapshotScope', 'snapshotRaw', @@ -954,13 +952,6 @@ const FLAG_DEFINITIONS: readonly FlagDefinition[] = [ usageLabel: '-i', usageDescription: 'Snapshot: interactive elements only', }, - { - key: 'snapshotCompact', - names: ['-c'], - type: 'boolean', - usageLabel: '-c', - usageDescription: 'Snapshot: accepted for compatibility; no effect', - }, { key: 'snapshotDepth', names: ['--depth', '-d'], diff --git a/src/utils/snapshot.ts b/src/utils/snapshot.ts index 9fe364cf7..728cc4ec0 100644 --- a/src/utils/snapshot.ts +++ b/src/utils/snapshot.ts @@ -14,7 +14,6 @@ export type Point = { export type SnapshotOptions = { interactiveOnly?: boolean; - compact?: boolean; depth?: number; scope?: string; raw?: boolean; @@ -22,7 +21,6 @@ export type SnapshotOptions = { export type SnapshotPresentationFlagInput = { snapshotInteractiveOnly?: boolean; - snapshotCompact?: boolean; snapshotDepth?: number; snapshotScope?: string; snapshotRaw?: boolean; @@ -124,7 +122,6 @@ export function findNodeByRef(nodes: SnapshotNode[], ref: string): SnapshotNode export function buildSnapshotPresentationKey(flags: SnapshotOptions | undefined): string { return JSON.stringify({ interactiveOnly: flags?.interactiveOnly === true, - compact: false, depth: typeof flags?.depth === 'number' ? flags.depth : null, scope: flags?.scope?.trim() || null, raw: flags?.raw === true, diff --git a/test/integration/provider-scenarios/android-lifecycle.test.ts b/test/integration/provider-scenarios/android-lifecycle.test.ts index e9caba060..025149847 100644 --- a/test/integration/provider-scenarios/android-lifecycle.test.ts +++ b/test/integration/provider-scenarios/android-lifecycle.test.ts @@ -949,7 +949,6 @@ async function runAndroidCaptureInteractionAndReplayWorkflow( const snapshot = await client.capture.snapshot({ interactiveOnly: true, - compact: true, ...selection, }); const apps = snapshot.nodes.find((node) => node.label === 'Apps'); diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 9f4e31910..52d78ffdb 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -217,8 +217,8 @@ agent-device close ## Snapshot and inspect ```bash -agent-device snapshot [--diff] [-i] [-c] [-d ] [-s ] [--raw] -agent-device diff snapshot [-i] [-c] [-d ] [-s ] [--raw] +agent-device snapshot [--diff] [-i] [-d ] [-s ] [--raw] +agent-device diff snapshot [-i] [-d ] [-s ] [--raw] agent-device get text @e1 agent-device get attrs @e1 ``` diff --git a/website/docs/docs/introduction.md b/website/docs/docs/introduction.md index d772752ef..784fd92a0 100644 --- a/website/docs/docs/introduction.md +++ b/website/docs/docs/introduction.md @@ -12,7 +12,7 @@ Use it when an agent needs to inspect and operate a real app, not just reason ab ## Where it shines - **App verification for agents**: run the app, inspect visible UI, act through refs/selectors, and verify expected state. -- **Token-efficient UI context**: accessibility snapshots give agents compact structure instead of screenshot-only reasoning. +- **Token-efficient UI context**: accessibility snapshots give agents structured UI state instead of screenshot-only reasoning. - **Runtime evidence**: capture screenshots, recordings, logs, network traffic, traces, CPU/memory/perf snapshots, and crash-related logs when the happy path breaks. - **Replayable checks**: turn stable exploratory sessions into `.ad` replay scripts that can run again without AI. - **React Native and Expo workflows**: pair device automation with optional React DevTools profiling for component trees, props/state/hooks, slow renders, and rerenders. diff --git a/website/docs/docs/replay-e2e.md b/website/docs/docs/replay-e2e.md index bfaa524e2..20c2cf7a3 100644 --- a/website/docs/docs/replay-e2e.md +++ b/website/docs/docs/replay-e2e.md @@ -234,11 +234,11 @@ Example 2: stale ref-based action upgraded to selector form ```sh # Before -snapshot -i -c -s "Continue" +snapshot -i -s "Continue" click @e13 "Continue" # After `replay -u` -snapshot -i -c -s "Continue" +snapshot -i -s "Continue" click "id=\"auth_continue\" || label=\"Continue\"" ``` diff --git a/website/docs/docs/snapshots.md b/website/docs/docs/snapshots.md index 95f0d0aa0..997140e28 100644 --- a/website/docs/docs/snapshots.md +++ b/website/docs/docs/snapshots.md @@ -9,20 +9,18 @@ Snapshots provide a structured view of the UI and generate current-screen refs. ```bash agent-device snapshot # Full accessibility tree agent-device snapshot -i # Interactive elements only (recommended) -agent-device snapshot -c # Compact (remove empty elements) agent-device snapshot -d 3 # Limit depth to 3 levels agent-device snapshot -s "Contacts" # Scope to label/identifier -agent-device snapshot -i -c -d 5 # Combine options +agent-device snapshot -i -d 5 # Combine options agent-device diff snapshot # Preferred structural diff vs previous session baseline agent-device snapshot --diff # Alias for the same diff operation ``` -| Option | Description | -| ------------ | ------------------------- | -| `-i` | Interactive-only output | -| `-c` | Compact structural noise | -| `-d ` | Limit tree depth | -| `-s ` | Scope to label/identifier | +| Option | Description | +| ------------ | ---------------------------- | +| `-i` | Interactive-only output | +| `-d ` | Limit tree depth | +| `-s ` | Scope to label or identifier | Note: If XCTest returns 0 nodes (foreground app changed), agent-device fails explicitly. It does not automatically switch to AX. @@ -32,10 +30,10 @@ It does not automatically switch to AX. - iOS and Android share the same mobile snapshot contract: visible-first output, actionable-now refs, and hidden list content communicated via discovery hints. - Default to `snapshot -i` for agent loops. - Default snapshot text is an agent-facing, token-efficient view for planning and targeting actions. It is visible-first and may collapse helper/accessibility noise; use `--raw` or `--json` when you need the full provider tree. -- Off-screen interactive content is collapsed into compact discovery summaries such as `[off-screen below] 3 interactive items: "Privacy", "Battery", "About"`. +- Off-screen interactive content is collapsed into discovery summaries such as `[off-screen below] 3 interactive items: "Privacy", "Battery", "About"`. - If a target only appears in an off-screen summary, use `scroll ` and re-snapshot until the target becomes visible. - When container ownership is known, hidden content is shown inline under the visible scroll/list container, for example `[content above scroll-area hidden]` or `[content below list hidden]`. -- Those summaries intentionally show only a few labels for token efficiency. Use `snapshot --raw` when you need the full off-screen tree instead of the compact summary. +- Those summaries intentionally show only a few labels for token efficiency. Use `snapshot --raw` when you need the full off-screen tree instead of the summary. - Add `-s "