From 133436dec3f247aaaa7c0baf43a5d7c3158e37ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 12 Sep 2026 07:57:05 +0200 Subject: [PATCH] feat(snapshot): disclose a cut capture on every platform; raise the iOS bridge node cap to 5000 Every backend sets truncated: true when it cuts a capture at one of its limits, but only JSON carried it. One shared warning now renders from that flag in the cross-platform warnings assembly and tells the agent what fell off (what comes last in document order) and what to do. The iOS Simulator AX bridge cap moves from 1500 to 5000 nodes, the Android helper's bound. Measured on a synthetic 600-row screen, acquisition time did not change with the cap while the 1500 cut dropped the on-screen footer. --- CHANGELOG.md | 10 ++++++ .../snapshot-presentation/quality-warnings.ts | 14 +++++++++ .../snapshot-presentation/warnings.test.ts | 13 +++++++- .../src/snapshot-source/limits.ts | 2 +- src/commands/capture/runtime/snapshot.test.ts | 31 ++++++++++++++++++- src/commands/capture/runtime/snapshot.ts | 6 +++- website/docs/docs/commands.md | 6 ++++ 7 files changed, 78 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8474e803bc..7fde883601 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ ## Unreleased +- Changed: a capture that a backend cut at one of its limits now says so in the snapshot's + warnings, on every platform, instead of only setting `truncated: true` in JSON. The text path + had no disclosure at all, so an agent read a screen missing its footer, tab bar, or the items + after a long list as complete — the backends walk the tree in document order, so what falls + off is what comes last, on screen or not. One shared warning renders from the shared flag; the + limit and dimension stay backend-side. +- Changed: the iOS Simulator AX bridge caps a capture at 5000 nodes, up from 1500, the Android + helper's bound. Measured on a synthetic 600-row screen, acquisition time did not move with the + cap (the native read fetches the whole tree; the cap only stops conversion) while the 1500 cut + dropped the screen's on-screen footer. - Fixed: Android snapshots carry the accessibility `selected` state an app sets on a control, so `is selected`, a `selected=true` selector, and a Maestro `selected:` qualifier work on Android (#2462). The helper never serialized the attribute, and the host reads only the helper's XML, so diff --git a/packages/capture-kit/src/snapshot/snapshot-presentation/quality-warnings.ts b/packages/capture-kit/src/snapshot/snapshot-presentation/quality-warnings.ts index 5853f6aacc..49c090a1ed 100644 --- a/packages/capture-kit/src/snapshot/snapshot-presentation/quality-warnings.ts +++ b/packages/capture-kit/src/snapshot/snapshot-presentation/quality-warnings.ts @@ -33,6 +33,20 @@ function customActionCoverageWarning(verdict: SnapshotQualityVerdict): string[] return lines; } +/** + * One disclosure for every backend that reports a cut capture: the iOS Simulator bridge and the + * Android helper stop at a node cap, the XCTest runner and the web provider at their own bounds. + * Every one walks the tree in document order, so what falls off is what comes last — footers, + * tab bars, the items after a long list — even when it is on screen. The fact is the shared + * `truncated` flag; the dimension and limit stay backend-side, so the copy names neither. + */ +export function truncatedCaptureWarning(truncated: boolean | undefined): string[] { + if (truncated !== true) return []; + return [ + 'This capture was cut at a backend limit, so elements later in the tree — footers, tab bars, items after a long list — may be missing even when they are on screen; their refs and selectors cannot be resolved from this snapshot. Navigate or scroll so fewer elements render and re-run, and use screenshot as visual truth for what is missing.', + ]; +} + export function recoveredSnapshotQualityWarning( backend: SnapshotQualityVerdict['backend'], ): string { diff --git a/packages/capture-kit/src/snapshot/snapshot-presentation/warnings.test.ts b/packages/capture-kit/src/snapshot/snapshot-presentation/warnings.test.ts index 217134a837..ce541e4154 100644 --- a/packages/capture-kit/src/snapshot/snapshot-presentation/warnings.test.ts +++ b/packages/capture-kit/src/snapshot/snapshot-presentation/warnings.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; import { readSnapshotQualityVerdict } from '../../snapshot-quality-verdict.ts'; -import { renderSnapshotQualityWarnings } from './quality-warnings.ts'; +import { renderSnapshotQualityWarnings, truncatedCaptureWarning } from './quality-warnings.ts'; const sharedRecoveryReason = 'iOS XCTest snapshot failed while serializing the accessibility tree. Error kAXErrorIllegalArgument getting snapshot for element '; @@ -270,3 +270,14 @@ test('the coverage line is independent of degradation state', () => { assert.equal(warnings.length, 1); assert.match(warnings[0] ?? '', /read for 3 of 8 merged elements/); }); + +test('a cut capture is disclosed once, from the shared truncated flag alone', () => { + assert.deepEqual(truncatedCaptureWarning(false), []); + assert.deepEqual(truncatedCaptureWarning(undefined), []); + const [warning, ...rest] = truncatedCaptureWarning(true); + assert.deepEqual(rest, []); + assert.match(warning ?? '', /cut at a backend limit/); + assert.match(warning ?? '', /footers, tab bars/); + assert.match(warning ?? '', /refs and selectors cannot be resolved/); + assert.match(warning ?? '', /screenshot/); +}); diff --git a/packages/platform-apple/src/snapshot-source/limits.ts b/packages/platform-apple/src/snapshot-source/limits.ts index 07308f1866..f5a9a09b2e 100644 --- a/packages/platform-apple/src/snapshot-source/limits.ts +++ b/packages/platform-apple/src/snapshot-source/limits.ts @@ -10,7 +10,7 @@ const MAXIMUM_NODES = 10_000; export const DEFAULT_SNAPSHOT_SOURCE_LIMITS: SnapshotSourceLimits = Object.freeze({ maxRequestBytes: 64 * 1024, maxResponseBytes: 4 * 1024 * 1024, - maxNodes: 1500, + maxNodes: 5000, maxTraversalDepth: 64, maxDurationMs: 5_000, }); diff --git a/src/commands/capture/runtime/snapshot.test.ts b/src/commands/capture/runtime/snapshot.test.ts index 194abfe412..123f1ec45b 100644 --- a/src/commands/capture/runtime/snapshot.test.ts +++ b/src/commands/capture/runtime/snapshot.test.ts @@ -350,7 +350,7 @@ test('runtime snapshot renders the structured quality verdict and skips legacy d const result = await device.capture.snapshot({ session: 'default' }); - assert.equal(result.warnings?.length, 2); + assert.equal(result.warnings?.length, 3); assert.match( String(result.warnings?.[0]), /Detected an overly complex or slow accessibility tree/, @@ -359,6 +359,8 @@ test('runtime snapshot renders the structured quality verdict and skips legacy d assert.match(String(result.warnings?.[0]), /It is OK to continue/); assert.match(String(result.warnings?.[0]), /snapshotQuality\.reason/); assert.match(String(result.warnings?.[1]), /@e2 \[Other\] merges many labels/); + // The fixture is a cut capture; the shared disclosure follows the verdict's own warnings. + assert.match(String(result.warnings?.[2]), /cut at a backend limit/); assert.deepEqual(result.snapshotQuality?.state, 'recovered'); }); @@ -703,3 +705,30 @@ function assertReactNativeOverlayWarning(warnings: string[] | undefined) { assert.match(warnings[0] ?? '', /agent-device react-native dismiss-overlay/); assert.match(warnings[0] ?? '', /verifies the overlay is gone/); } + +test('runtime snapshot discloses a cut capture the same way on every backend', async () => { + for (const backend of ['android', 'xctest'] as const) { + const device = createSnapshotOnlyDevice({ + nodes: [{ ref: 'e1', index: 0, depth: 0, type: 'Window', label: 'Home' }], + truncated: true, + backend, + }); + + const result = await device.capture.snapshot({ session: 'default' }); + + assert.equal(result.truncated, true, backend); + const cut = (result.warnings ?? []).filter((warning) => /cut at a backend limit/.test(warning)); + assert.equal(cut.length, 1, `${backend}: ${JSON.stringify(result.warnings)}`); + } + + const complete = createSnapshotOnlyDevice({ + nodes: [{ ref: 'e1', index: 0, depth: 0, type: 'Window', label: 'Home' }], + truncated: false, + backend: 'android', + }); + const result = await complete.capture.snapshot({ session: 'default' }); + assert.equal( + (result.warnings ?? []).some((warning) => /cut at a backend limit/.test(warning)), + false, + ); +}); diff --git a/src/commands/capture/runtime/snapshot.ts b/src/commands/capture/runtime/snapshot.ts index 460f7246da..ab00599900 100644 --- a/src/commands/capture/runtime/snapshot.ts +++ b/src/commands/capture/runtime/snapshot.ts @@ -21,7 +21,10 @@ import { buildSnapshotDiff, countSnapshotComparableLines, } from '@agent-device/capture-kit/snapshot-diff'; -import { renderSnapshotQualityWarnings } from '@agent-device/capture-kit/quality-warnings'; +import { + renderSnapshotQualityWarnings, + truncatedCaptureWarning, +} from '@agent-device/capture-kit/quality-warnings'; import { buildSnapshotVisibility } from '@agent-device/capture-kit/snapshot-visibility'; import { ANDROID_SYSTEM_SURFACE_DISCLOSURE } from '@agent-device/contracts/android-system-surface-disclosure'; import { formatReactNativeOverlayWarning } from '../../react-native/overlay.ts'; @@ -247,6 +250,7 @@ function buildSnapshotWarnings(params: { ...renderSnapshotQualityWarnings(params.annotations.quality, params.snapshot.nodes), ); } + warnings.push(...truncatedCaptureWarning(snapshotTruncationForResult(params.snapshot))); warnings.push(...buildEmptyAndroidInteractiveWarnings(params)); if (!params.annotations.quality) { // Legacy runners without a structured verdict keep the old daemon-side heuristics. diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 03925fb50d..3e1e6a1219 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -377,6 +377,12 @@ agent-device get attrs @e1 Android `--raw` is the acquired tree: it also keeps nodes Android marks invisible and stale application windows. The helper does not report `checked`/`checkable` state, and it caps captures at 5000 nodes before any `--scope` applies (`truncated: true`). +- `truncated: true` means the backend cut the capture at one of its limits — the Android helper + and the iOS Simulator AX bridge at 5000 nodes, the XCTest runner and the web provider at their + own bounds. Every backend walks the tree in document order, so what falls off is what comes + last: footers, tab bars, items after a long list, even when on screen. The snapshot carries a + warning that says so; navigate or scroll so fewer elements render and re-run, and use + `screenshot` as visual truth for the rest. - `--scope ` returns the subtree of the first node in document order whose label, value, or identifier contains the scope text (case-insensitive) and whose subtree still has content in the requested projection, re-rooted at depth 0; no match returns an empty snapshot rather than the