diff --git a/src/commands/interaction/runtime/settle.test.ts b/src/commands/interaction/runtime/settle.test.ts index 899ff2254..6f9e113ac 100644 --- a/src/commands/interaction/runtime/settle.test.ts +++ b/src/commands/interaction/runtime/settle.test.ts @@ -65,6 +65,7 @@ function createSettleDevice(params: { captureSnapshot: () => Promise | BackendSnapshotResult; tap?: () => Promise>; clock?: ReturnType; + appBundleId?: string; }): ReturnType { return createAgentDevice({ backend: { @@ -76,7 +77,13 @@ function createSettleDevice(params: { typeText: async () => {}, } satisfies AgentDeviceBackend, artifacts: createLocalArtifactAdapter(), - sessions: createMemorySessionStore([{ name: 'default', snapshot: params.stored }]), + sessions: createMemorySessionStore([ + { + name: 'default', + snapshot: params.stored, + ...(params.appBundleId ? { appBundleId: params.appBundleId } : {}), + }, + ]), policy: localCommandPolicy(), clock: params.clock ?? createFakeClock(), }); @@ -680,6 +687,1030 @@ test('a keyboard window hosting an app composer field is never window-classified assert.ok(!diff.lines.some((line) => /\[key\]|shift/.test(line.text))); }); +// #1198: Android settle-diff scope. Shapes below are trimmed from a real +// `snapshot --raw --json` capture against the react-navigation playground on +// an Android emulator (July 2026, stock-UIAutomator fallback path) — status +// bar and IME each render as their own whole top-level root, one node per +// window, `bundleId` carrying the OWNING package on every node. +const ANDROID_APP_BUNDLE_ID = 'org.reactnavigation.playground'; +const ANDROID_SYSTEM_UI_BUNDLE_ID = 'com.android.systemui'; +const ANDROID_IME_BUNDLE_ID = 'com.google.android.inputmethod.latin'; + +function androidStatusBarNodes(startIndex: number, clockLabel = '12:23') { + const root = startIndex; + return [ + { + index: root, + depth: 0, + type: 'android.widget.FrameLayout', + bundleId: ANDROID_SYSTEM_UI_BUNDLE_ID, + rect: { x: 0, y: 0, width: 1344, height: 159 }, + }, + { + // The marker every real status-bar capture carries; the window-run + // drops as a whole because this member matches the status_bar* prefix. + index: root + 1, + depth: 1, + parentIndex: root, + type: 'android.widget.FrameLayout', + identifier: 'com.android.systemui:id/status_bar_container', + bundleId: ANDROID_SYSTEM_UI_BUNDLE_ID, + rect: { x: 0, y: 0, width: 1344, height: 159 }, + }, + { + index: root + 2, + depth: 10, + parentIndex: root + 1, + type: 'android.widget.TextView', + identifier: 'com.android.systemui:id/clock', + label: clockLabel, + bundleId: ANDROID_SYSTEM_UI_BUNDLE_ID, + rect: { x: 40, y: 40, width: 80, height: 40 }, + }, + { + index: root + 3, + depth: 11, + parentIndex: root + 1, + type: 'android.widget.FrameLayout', + identifier: 'com.android.systemui:id/mobile_combo', + label: 'T-Mobile, one bar.', + bundleId: ANDROID_SYSTEM_UI_BUNDLE_ID, + rect: { x: 900, y: 40, width: 60, height: 40 }, + }, + { + index: root + 4, + depth: 11, + parentIndex: root + 1, + type: 'android.view.View', + label: 'Battery 100 percent.', + bundleId: ANDROID_SYSTEM_UI_BUNDLE_ID, + rect: { x: 1200, y: 40, width: 60, height: 40 }, + }, + ]; +} + +// Real systemui VolumeDialog window captured live alongside the status bar +// (volume keyevent held open + `snapshot --raw --json`, android-helper +// multi-window backend): same package as the status bar but only +// volume_dialog* ids — no status/nav-bar marker in the run, so it must +// survive with actionable refs. +function androidVolumeDialogNodes(startIndex: number) { + const root = startIndex; + return [ + { + index: root, + depth: 0, + type: 'android.widget.FrameLayout', + bundleId: ANDROID_SYSTEM_UI_BUNDLE_ID, + rect: { x: 816, y: 159, width: 528, height: 2833 }, + }, + { + index: root + 1, + depth: 2, + parentIndex: root, + type: 'android.widget.FrameLayout', + identifier: 'com.android.systemui:id/volume_dialog', + bundleId: ANDROID_SYSTEM_UI_BUNDLE_ID, + rect: { x: 1100, y: 1000, width: 220, height: 1100 }, + }, + { + index: root + 2, + depth: 3, + parentIndex: root + 1, + type: 'android.widget.ImageButton', + identifier: 'com.android.systemui:id/volume_dialog_settings', + label: 'Sound settings', + bundleId: ANDROID_SYSTEM_UI_BUNDLE_ID, + rect: { x: 1140, y: 1950, width: 140, height: 140 }, + hittable: true, + }, + { + index: root + 3, + depth: 3, + parentIndex: root + 1, + type: 'android.widget.SeekBar', + identifier: 'com.android.systemui:id/volume_dialog_slider', + label: 'Media', + bundleId: ANDROID_SYSTEM_UI_BUNDLE_ID, + rect: { x: 1140, y: 1100, width: 140, height: 800 }, + hittable: true, + }, + { + index: root + 4, + depth: 3, + parentIndex: root + 1, + type: 'android.widget.ImageButton', + label: 'Ring, tap to change ringer mode', + bundleId: ANDROID_SYSTEM_UI_BUNDLE_ID, + rect: { x: 1140, y: 950, width: 140, height: 140 }, + hittable: true, + }, + ]; +} + +function androidImeNodes(startIndex: number) { + const root = startIndex; + return [ + { + index: root, + depth: 0, + type: 'android.widget.FrameLayout', + bundleId: ANDROID_IME_BUNDLE_ID, + rect: { x: 0, y: 159, width: 1344, height: 2833 }, + hittable: true, + }, + { + index: root + 1, + depth: 8, + parentIndex: root, + type: 'android.speech.SpeechRecognizer.VoiceDictationButton', + label: 'Use voice typing', + identifier: 'com.google.android.inputmethod.latin:id/0_resource_name_obfuscated', + bundleId: ANDROID_IME_BUNDLE_ID, + rect: { x: 20, y: 2600, width: 100, height: 100 }, + hittable: true, + }, + { + index: root + 2, + depth: 8, + parentIndex: root, + type: 'android.widget.FrameLayout', + label: 'Delete', + bundleId: ANDROID_IME_BUNDLE_ID, + rect: { x: 1100, y: 2350, width: 130, height: 130 }, + hittable: true, + }, + { + index: root + 3, + depth: 8, + parentIndex: root, + type: 'android.widget.FrameLayout', + label: 'Done', + bundleId: ANDROID_IME_BUNDLE_ID, + rect: { x: 1100, y: 2600, width: 130, height: 130 }, + hittable: true, + }, + { + index: root + 4, + depth: 8, + parentIndex: root, + type: 'android.widget.FrameLayout', + label: 'Show emoji keyboard', + bundleId: ANDROID_IME_BUNDLE_ID, + rect: { x: 20, y: 2850, width: 100, height: 100 }, + hittable: true, + }, + { + index: root + 5, + depth: 8, + parentIndex: root, + type: 'android.widget.FrameLayout', + label: 'Open more stylus options', + bundleId: ANDROID_IME_BUNDLE_ID, + rect: { x: 150, y: 2850, width: 100, height: 100 }, + hittable: true, + }, + // Key grid: same collapse rule applies, so no per-key noise either. + ...Array.from({ length: 10 }, (_, key) => ({ + index: root + 6 + key, + depth: 11, + parentIndex: root, + type: 'android.inputmethodservice.Keyboard$Key', + label: String.fromCharCode(97 + key), + bundleId: ANDROID_IME_BUNDLE_ID, + rect: { x: key * 130, y: 2100, width: 120, height: 130 }, + hittable: true, + })), + ]; +} + +function androidAppNodes() { + return [ + { + index: 0, + depth: 5, + type: 'android.widget.EditText', + label: 'Hello world 3', + bundleId: ANDROID_APP_BUNDLE_ID, + rect: { x: 60, y: 400, width: 1200, height: 100 }, + hittable: true, + focused: true, + }, + { + index: 1, + depth: 5, + type: 'android.widget.Button', + label: 'Discard and go back', + bundleId: ANDROID_APP_BUNDLE_ID, + rect: { x: 60, y: 550, width: 400, height: 100 }, + hittable: true, + }, + { + index: 2, + depth: 5, + type: 'android.widget.Button', + label: 'Push Article', + bundleId: ANDROID_APP_BUNDLE_ID, + rect: { x: 60, y: 700, width: 400, height: 100 }, + hittable: true, + }, + ]; +} + +test('Android status bar and IME chrome never spend the settled diff budget (#1198)', async () => { + // The status bar clock ticks and the keyboard is summoned between captures + // — exactly the noise the July 2026 Android settle benchmark flagged. The + // systemui status bar disappears entirely (both sides) and the IME + // collapses to its one container line. + const before = makeSnapshotState([...androidAppNodes(), ...androidStatusBarNodes(10, '12:23')]); + const settledTree = makeSnapshotState([ + ...androidAppNodes(), + ...androidStatusBarNodes(10, '12:24'), + ...androidImeNodes(30), + ]); + let captures = 0; + const device = createSettleDevice({ + stored: before, + appBundleId: ANDROID_APP_BUNDLE_ID, + captureSnapshot: () => { + captures += 1; + return { snapshot: captures === 1 ? before : settledTree }; + }, + }); + + const result = await device.interactions.press(selector('label="Discard and go back"'), { + session: 'default', + settle: {}, + }); + + const diff = result.settle?.diff; + assert.ok(diff); + const texts = diff.lines.map((line) => line.text).join('\n'); + // Status bar churn (clock tick, signal/battery text) never appears. + assert.ok(!/12:2[34]|T-Mobile|Battery/.test(texts)); + // IME toolbar buttons and the per-key grid never appear. + assert.ok(!/Delete|Done|voice typing|emoji keyboard|stylus/i.test(texts)); + assert.ok(!diff.lines.some((line) => /^@e\S+ \[group\] "[a-j]"$/.test(line.text))); + // Only the IME container line is added; nothing else changed. + assert.equal(diff.summary.additions, 1); + assert.equal(diff.summary.removals, 0); +}); + +test('Android IME-only settle changes do not suppress the settle tail, and chrome never populates it (#1198)', async () => { + // Mirrors the iOS "keyboard-only changes... do not suppress" case above: + // the only tree change is the keyboard summoning (plus unrelated status + // bar churn), so the tail must still surface the real screen buttons — + // never the IME toolbar or status bar nodes that used to flood it. + const before = makeSnapshotState([...androidAppNodes(), ...androidStatusBarNodes(10, '12:23')]); + const settledTree = makeSnapshotState([ + ...androidAppNodes(), + ...androidStatusBarNodes(10, '12:24'), + ...androidImeNodes(30), + ]); + let captures = 0; + const device = createSettleDevice({ + stored: before, + appBundleId: ANDROID_APP_BUNDLE_ID, + captureSnapshot: () => { + captures += 1; + return { snapshot: captures === 1 ? before : settledTree }; + }, + }); + + const result = await device.interactions.press(selector('label="Discard and go back"'), { + session: 'default', + settle: {}, + }); + + const settle = result.settle; + assert.ok(settle); + assert.equal(settle.diff?.summary.additions, 1); + assert.ok(settle.tail, 'keyboard-only additions must not suppress the tail'); + assert.deepEqual( + settle.tail?.map((entry) => entry.label), + ['Hello world 3', 'Discard and go back', 'Push Article'], + ); +}); + +test('an Android IME-looking root hosting app-owned content is never collapsed as chrome (#1198)', async () => { + // Per-node classification: the app-owned "Send" under an IME root survives; + // the sibling "q" key is dropped for its own foreign package, not by + // subtree collapse. Indexes start above androidAppNodes()'s 0-2. + const mixedRoot = [ + { + index: 10, + depth: 0, + type: 'android.widget.FrameLayout', + bundleId: ANDROID_IME_BUNDLE_ID, + rect: { x: 0, y: 159, width: 1344, height: 2833 }, + hittable: true, + }, + { + index: 11, + depth: 1, + parentIndex: 10, + type: 'android.widget.Button', + label: 'Send', + bundleId: ANDROID_APP_BUNDLE_ID, + rect: { x: 20, y: 200, width: 100, height: 60 }, + hittable: true, + }, + { + index: 12, + depth: 1, + parentIndex: 10, + type: 'android.inputmethodservice.Keyboard$Key', + label: 'q', + bundleId: ANDROID_IME_BUNDLE_ID, + rect: { x: 5, y: 590, width: 39, height: 54 }, + hittable: true, + }, + ]; + const before = makeSnapshotState([...androidAppNodes()]); + const settledTree = makeSnapshotState([...androidAppNodes(), ...mixedRoot]); + let captures = 0; + const device = createSettleDevice({ + stored: before, + appBundleId: ANDROID_APP_BUNDLE_ID, + captureSnapshot: () => { + captures += 1; + return { snapshot: captures === 1 ? before : settledTree }; + }, + }); + + const result = await device.interactions.press(selector('label="Discard and go back"'), { + session: 'default', + settle: {}, + }); + + const diff = result.settle?.diff; + assert.ok(diff); + const texts = diff.lines.map((line) => line.text).join('\n'); + // The app-owned button always survives. + assert.match(texts, /Send/); + // The foreign "q" key is still dropped by the independent app-scope rule + // (it is simply foreign content, never app-owned) — just not because the + // whole root got collapsed as chrome, which would have taken "Send" with it. + assert.ok(!diff.lines.some((line) => /"q"/.test(line.text))); +}); + +test('a real capture with a cross-window parentIndex artifact never loses app content to IME collapse (#1198)', () => { + // Real `snapshot -i --json` capture (RN playground, emulator, Gboard up): + // interactive-only pruning reparented the app's "Tab View, back" (index 6) + // onto IME toolbar button index 5 — a subtree walk from the IME root would + // hide the whole screen (indexes 6-13); per-node classification must not. + const settledNodes = makeSnapshotState([ + { + index: 0, + depth: 6, + type: 'android.widget.FrameLayout', + bundleId: ANDROID_IME_BUNDLE_ID, + rect: { x: 24, y: 1152, width: 168, height: 792 }, + hittable: true, + }, + { + index: 1, + depth: 8, + parentIndex: 0, + type: 'android.speech.SpeechRecognizer.VoiceDictationButton', + label: 'Use voice typing', + identifier: 'com.google.android.inputmethod.latin:id/0_resource_name_obfuscated', + bundleId: ANDROID_IME_BUNDLE_ID, + rect: { x: 36, y: 1164, width: 144, height: 144 }, + hittable: true, + }, + { + index: 2, + depth: 9, + parentIndex: 0, + type: 'android.widget.FrameLayout', + label: 'Delete', + bundleId: ANDROID_IME_BUNDLE_ID, + rect: { x: 36, y: 1320, width: 144, height: 144 }, + hittable: true, + }, + { + index: 3, + depth: 9, + parentIndex: 0, + type: 'android.widget.FrameLayout', + label: 'Done', + bundleId: ANDROID_IME_BUNDLE_ID, + rect: { x: 1164, y: 1320, width: 144, height: 144 }, + hittable: true, + }, + { + index: 4, + depth: 9, + parentIndex: 0, + type: 'android.widget.FrameLayout', + label: 'Show emoji keyboard', + bundleId: ANDROID_IME_BUNDLE_ID, + rect: { x: 36, y: 1476, width: 144, height: 144 }, + hittable: true, + }, + { + // The last IME toolbar button — the real capture's app content chains + // off THIS node's index (5), not off the IME root (0). + index: 5, + depth: 9, + parentIndex: 0, + type: 'android.widget.FrameLayout', + label: 'Open more stylus options', + bundleId: ANDROID_IME_BUNDLE_ID, + rect: { x: 1164, y: 1476, width: 144, height: 144 }, + hittable: true, + }, + { + index: 6, + depth: 2, + parentIndex: 5, + type: 'android.widget.Button', + label: 'Tab View, back', + bundleId: ANDROID_APP_BUNDLE_ID, + rect: { x: 36, y: 108, width: 108, height: 108 }, + hittable: true, + }, + { + index: 7, + depth: 3, + parentIndex: 6, + type: 'android.widget.TextView', + label: 'arrow_back', + bundleId: ANDROID_APP_BUNDLE_ID, + rect: { x: 60, y: 132, width: 60, height: 60 }, + }, + { + index: 8, + depth: 2, + parentIndex: 5, + type: 'android.widget.ScrollView', + bundleId: ANDROID_APP_BUNDLE_ID, + rect: { x: 0, y: 216, width: 1344, height: 2600 }, + hittable: true, + }, + { + index: 9, + depth: 3, + parentIndex: 8, + type: 'android.widget.EditText', + label: 'hello from the fixed settle', + bundleId: ANDROID_APP_BUNDLE_ID, + rect: { x: 60, y: 400, width: 1200, height: 100 }, + hittable: true, + focused: true, + }, + { + index: 10, + depth: 3, + parentIndex: 8, + type: 'android.widget.Button', + label: 'Discard and go back', + bundleId: ANDROID_APP_BUNDLE_ID, + rect: { x: 60, y: 550, width: 400, height: 100 }, + hittable: true, + }, + { + index: 11, + depth: 4, + parentIndex: 10, + type: 'android.widget.TextView', + label: 'Discard and go back', + bundleId: ANDROID_APP_BUNDLE_ID, + rect: { x: 80, y: 570, width: 360, height: 60 }, + }, + { + index: 12, + depth: 3, + parentIndex: 8, + type: 'android.widget.Button', + label: 'Push Article', + bundleId: ANDROID_APP_BUNDLE_ID, + rect: { x: 60, y: 700, width: 400, height: 100 }, + hittable: true, + }, + { + index: 13, + depth: 4, + parentIndex: 12, + type: 'android.widget.TextView', + label: 'Push Article', + bundleId: ANDROID_APP_BUNDLE_ID, + rect: { x: 80, y: 720, width: 360, height: 60 }, + }, + ]).nodes; + + // Every app-content line survives whether or not the session even knows + // its own appBundleId — the per-node IME rule alone is enough here, since + // none of these nodes are foreign-but-not-IME. + for (const appBundleId of [undefined, ANDROID_APP_BUNDLE_ID]) { + const result = buildSettleTailEntries(settledNodes, new Set(), appBundleId); + assert.deepEqual( + result.tail?.map((entry) => entry.label), + [ + 'Tab View, back', + 'arrow_back', + undefined, // ScrollView: no label + 'hello from the fixed settle', + 'Discard and go back', + 'Discard and go back', + 'Push Article', + 'Push Article', + ], + ); + } +}); + +// Real Android Sharesheet (`am start -a android.intent.action.SEND`, +// ResolverActivity) captured live via `snapshot --raw --json` on the same +// emulator/session as the other #1198 fixtures: 41 nodes, every one owned by +// package `android` — the shape shared by permission prompts, the package +// installer, and chooser/resolver sheets. Indexes offset by +100 to stay +// clear of androidAppNodes(). +function androidSharesheetNodes() { + return [ + { + index: 100, + depth: 0, + type: 'android.widget.FrameLayout', + bundleId: 'android', + rect: { x: 0, y: 0, width: 1344, height: 2992 }, + }, + { + index: 101, + depth: 1, + parentIndex: 100, + type: 'android.widget.LinearLayout', + bundleId: 'android', + rect: { x: 0, y: 0, width: 1344, height: 2992 }, + }, + { + index: 102, + depth: 2, + parentIndex: 101, + type: 'android.widget.FrameLayout', + identifier: 'android:id/content', + bundleId: 'android', + rect: { x: 0, y: 0, width: 1344, height: 2992 }, + }, + { + index: 103, + depth: 3, + parentIndex: 102, + type: 'android.widget.ScrollView', + identifier: 'android:id/contentPanel', + bundleId: 'android', + rect: { x: 0, y: 0, width: 1344, height: 2992 }, + }, + { + index: 104, + depth: 4, + parentIndex: 103, + type: 'android.widget.RelativeLayout', + identifier: 'android:id/title_container', + bundleId: 'android', + rect: { x: 0, y: 1937, width: 1344, height: 191 }, + }, + { + index: 105, + depth: 5, + parentIndex: 104, + type: 'android.widget.TextView', + label: 'Share', + identifier: 'android:id/title', + bundleId: 'android', + rect: { x: 72, y: 1991, width: 1200, height: 65 }, + }, + { + index: 106, + depth: 4, + parentIndex: 103, + type: 'android.view.View', + identifier: 'android:id/divider', + bundleId: 'android', + rect: { x: 0, y: 2128, width: 1344, height: 3 }, + }, + { + index: 107, + depth: 4, + parentIndex: 103, + type: 'android.widget.TabHost', + identifier: 'android:id/profile_tabhost', + bundleId: 'android', + rect: { x: 0, y: 2131, width: 1344, height: 861 }, + }, + { + index: 108, + depth: 5, + parentIndex: 107, + type: 'android.widget.LinearLayout', + bundleId: 'android', + rect: { x: 0, y: 2131, width: 1344, height: 861 }, + }, + { + index: 109, + depth: 6, + parentIndex: 108, + type: 'android.widget.FrameLayout', + identifier: 'android:id/tabcontent', + bundleId: 'android', + rect: { x: 0, y: 2131, width: 1344, height: 861 }, + }, + { + index: 110, + depth: 7, + parentIndex: 109, + type: 'com.android.internal.widget.ViewPager', + identifier: 'android:id/profile_pager', + bundleId: 'android', + rect: { x: 0, y: 2131, width: 1344, height: 861 }, + }, + { + index: 111, + depth: 8, + parentIndex: 110, + type: 'android.widget.RelativeLayout', + bundleId: 'android', + rect: { x: 0, y: 2131, width: 1344, height: 861 }, + }, + { + index: 112, + depth: 9, + parentIndex: 111, + type: 'android.widget.ListView', + identifier: 'android:id/resolver_list', + bundleId: 'android', + rect: { x: 0, y: 2131, width: 1344, height: 861 }, + }, + { + index: 113, + depth: 10, + parentIndex: 112, + type: 'android.widget.LinearLayout', + bundleId: 'android', + rect: { x: 0, y: 2131, width: 1344, height: 168 }, + }, + { + index: 114, + depth: 11, + parentIndex: 113, + type: 'android.widget.ImageView', + identifier: 'android:id/icon', + bundleId: 'android', + rect: { x: 24, y: 2167, width: 96, height: 96 }, + }, + { + index: 115, + depth: 11, + parentIndex: 113, + type: 'android.widget.LinearLayout', + bundleId: 'android', + rect: { x: 144, y: 2182, width: 326, height: 65 }, + }, + { + index: 116, + depth: 12, + parentIndex: 115, + type: 'android.widget.TextView', + label: 'Quick Share', + identifier: 'android:id/text1', + bundleId: 'android', + rect: { x: 144, y: 2182, width: 254, height: 65 }, + }, + { + index: 117, + depth: 10, + parentIndex: 112, + type: 'android.widget.LinearLayout', + bundleId: 'android', + rect: { x: 0, y: 2299, width: 1344, height: 168 }, + }, + { + index: 118, + depth: 11, + parentIndex: 117, + type: 'android.widget.ImageView', + identifier: 'android:id/icon', + bundleId: 'android', + rect: { x: 24, y: 2335, width: 96, height: 96 }, + }, + { + index: 119, + depth: 11, + parentIndex: 117, + type: 'android.widget.LinearLayout', + bundleId: 'android', + rect: { x: 144, y: 2350, width: 277, height: 65 }, + }, + { + index: 120, + depth: 12, + parentIndex: 119, + type: 'android.widget.TextView', + label: 'Bluetooth', + identifier: 'android:id/text1', + bundleId: 'android', + rect: { x: 144, y: 2350, width: 205, height: 65 }, + }, + { + index: 121, + depth: 10, + parentIndex: 112, + type: 'android.widget.LinearLayout', + bundleId: 'android', + rect: { x: 0, y: 2467, width: 1344, height: 168 }, + }, + { + index: 122, + depth: 11, + parentIndex: 121, + type: 'android.widget.ImageView', + identifier: 'android:id/icon', + bundleId: 'android', + rect: { x: 24, y: 2503, width: 96, height: 96 }, + }, + { + index: 123, + depth: 11, + parentIndex: 121, + type: 'android.widget.LinearLayout', + bundleId: 'android', + rect: { x: 144, y: 2490, width: 197, height: 122 }, + }, + { + index: 124, + depth: 12, + parentIndex: 123, + type: 'android.widget.TextView', + label: 'Gmail', + identifier: 'android:id/text1', + bundleId: 'android', + rect: { x: 144, y: 2490, width: 125, height: 65 }, + }, + { + index: 125, + depth: 12, + parentIndex: 123, + type: 'android.widget.TextView', + label: 'Chat', + identifier: 'android:id/text2', + bundleId: 'android', + rect: { x: 144, y: 2555, width: 87, height: 57 }, + }, + { + index: 126, + depth: 10, + parentIndex: 112, + type: 'android.widget.LinearLayout', + bundleId: 'android', + rect: { x: 0, y: 2635, width: 1344, height: 168 }, + }, + { + index: 127, + depth: 11, + parentIndex: 126, + type: 'android.widget.ImageView', + identifier: 'android:id/icon', + bundleId: 'android', + rect: { x: 24, y: 2671, width: 96, height: 96 }, + }, + { + index: 128, + depth: 11, + parentIndex: 126, + type: 'android.widget.LinearLayout', + bundleId: 'android', + rect: { x: 144, y: 2686, width: 239, height: 65 }, + }, + { + index: 129, + depth: 12, + parentIndex: 128, + type: 'android.widget.TextView', + label: 'Chrome', + identifier: 'android:id/text1', + bundleId: 'android', + rect: { x: 144, y: 2686, width: 167, height: 65 }, + }, + { + index: 130, + depth: 10, + parentIndex: 112, + type: 'android.widget.LinearLayout', + bundleId: 'android', + rect: { x: 0, y: 2803, width: 1344, height: 168 }, + }, + { + index: 131, + depth: 11, + parentIndex: 130, + type: 'android.widget.ImageView', + identifier: 'android:id/icon', + bundleId: 'android', + rect: { x: 24, y: 2839, width: 96, height: 96 }, + }, + { + index: 132, + depth: 11, + parentIndex: 130, + type: 'android.widget.LinearLayout', + bundleId: 'android', + rect: { x: 144, y: 2826, width: 399, height: 122 }, + }, + { + index: 133, + depth: 12, + parentIndex: 132, + type: 'android.widget.TextView', + label: 'Drive', + identifier: 'android:id/text1', + bundleId: 'android', + rect: { x: 144, y: 2826, width: 108, height: 65 }, + }, + { + index: 134, + depth: 12, + parentIndex: 132, + type: 'android.widget.TextView', + label: 'Copy to clipboard', + identifier: 'android:id/text2', + bundleId: 'android', + rect: { x: 144, y: 2891, width: 327, height: 57 }, + }, + { + index: 135, + depth: 10, + parentIndex: 112, + type: 'android.widget.LinearLayout', + bundleId: 'android', + rect: { x: 0, y: 2971, width: 1344, height: 21 }, + }, + { + index: 136, + depth: 4, + parentIndex: 103, + type: 'android.widget.LinearLayout', + identifier: 'android:id/button_bar_container', + bundleId: 'android', + rect: { x: 0, y: 2707, width: 1344, height: 285 }, + }, + { + index: 137, + depth: 5, + parentIndex: 136, + type: 'android.view.View', + identifier: 'android:id/resolver_button_bar_divider', + bundleId: 'android', + rect: { x: 0, y: 2707, width: 1344, height: 3 }, + }, + { + index: 138, + depth: 5, + parentIndex: 136, + type: 'android.widget.LinearLayout', + identifier: 'android:id/button_bar', + bundleId: 'android', + rect: { x: 0, y: 2710, width: 1344, height: 282 }, + }, + { + index: 139, + depth: 6, + parentIndex: 138, + type: 'android.widget.Button', + label: 'Just once', + identifier: 'android:id/button_once', + bundleId: 'android', + rect: { x: 829, y: 2734, width: 255, height: 162 }, + hittable: true, + }, + { + index: 140, + depth: 6, + parentIndex: 138, + type: 'android.widget.Button', + label: 'Always', + identifier: 'android:id/button_always', + bundleId: 'android', + rect: { x: 1084, y: 2734, width: 206, height: 162 }, + hittable: true, + }, + ]; +} + +test('a system dialog (real Sharesheet capture) stays fully visible in the settled diff (#1198)', async () => { + // PR #1200 review blocker: the initial fix dropped EVERY foreign package, + // so a blocking system dialog appearing mid-action produced an empty diff + // and a tail pointing at now-covered app buttons. Keep-unknown-foreign is + // the rule now: only IME + persistent system chrome are ever filtered. + const before = makeSnapshotState([...androidAppNodes()]); + const settledTree = makeSnapshotState([...androidAppNodes(), ...androidSharesheetNodes()]); + let captures = 0; + const device = createSettleDevice({ + stored: before, + appBundleId: ANDROID_APP_BUNDLE_ID, + captureSnapshot: () => { + captures += 1; + return { snapshot: captures === 1 ? before : settledTree }; + }, + }); + + const result = await device.interactions.press(selector('label="Discard and go back"'), { + session: 'default', + settle: {}, + }); + + const diff = result.settle?.diff; + assert.ok(diff); + // The dialog registers as change, not silence. + assert.ok(diff.summary.additions > 0, 'dialog additions must appear in the settled diff'); + assert.equal(diff.summary.removals, 0); + const added = diff.lines.filter((line) => line.kind === 'added'); + const texts = added.map((line) => line.text).join('\n'); + assert.match(texts, /Just once/); + assert.match(texts, /Always/); + assert.match(texts, /"Share"/); + // The dialog's buttons arrive WITH actionable refs. + assert.ok( + added.some((line) => /Just once/.test(line.text) && line.ref), + 'dialog buttons must carry refs', + ); +}); + +// The same live Sharesheet through `snapshot -i --json` — the interactive-only +// shape settled captures actually have (10 nodes; the raw fixture above would +// exhaust the 20-entry tail cap on unlabeled containers alone). +function androidSharesheetInteractiveNodes() { + return [ + { + index: 100, + depth: 3, + type: 'android.widget.ScrollView', + bundleId: 'android', + rect: { x: 0, y: 0, width: 1344, height: 2992 }, + }, + { + index: 101, + depth: 12, + parentIndex: 100, + type: 'android.widget.TextView', + label: 'Quick Share', + bundleId: 'android', + rect: { x: 144, y: 2182, width: 254, height: 65 }, + }, + { + index: 102, + depth: 12, + parentIndex: 100, + type: 'android.widget.TextView', + label: 'Gmail', + bundleId: 'android', + rect: { x: 144, y: 2490, width: 125, height: 65 }, + }, + { + index: 103, + depth: 12, + parentIndex: 100, + type: 'android.widget.TextView', + label: 'Chrome', + bundleId: 'android', + rect: { x: 144, y: 2686, width: 167, height: 65 }, + }, + { + index: 104, + depth: 6, + parentIndex: 100, + type: 'android.widget.Button', + label: 'Just once', + bundleId: 'android', + rect: { x: 829, y: 2734, width: 255, height: 162 }, + hittable: true, + }, + { + index: 105, + depth: 6, + parentIndex: 100, + type: 'android.widget.Button', + label: 'Always', + bundleId: 'android', + rect: { x: 1084, y: 2734, width: 206, height: 162 }, + hittable: true, + }, + ]; +} + +test("a system dialog's buttons are settle-tail candidates, never chrome (#1198)", () => { + const settledNodes = makeSnapshotState([ + ...androidAppNodes(), + ...androidSharesheetInteractiveNodes(), + ]).nodes; + + const result = buildSettleTailEntries(settledNodes, new Set(), ANDROID_APP_BUNDLE_ID); + + const labels = (result.tail ?? []).map((entry) => entry.label); + assert.ok(labels.includes('Just once'), `tail must list the dialog buttons, got: ${labels}`); + assert.ok(labels.includes('Always'), `tail must list the dialog buttons, got: ${labels}`); +}); + test('added lines win diff-budget slots over removals under truncation', async () => { // 120 removals precede the additions positionally; the fresh-ref additions // must still survive the 80-line cap. @@ -1071,3 +2102,156 @@ test('buildSettleTailEntries drops the keyboard container and its chrome descend assert.deepEqual(result.tail, [{ ref: 'e1', role: 'button', label: 'Send' }]); }); + +test('buildSettleTailEntries drops Android IME chrome and status-bar chrome (#1198)', () => { + const settledNodes = makeSnapshotState([ + { + index: 0, + depth: 0, + type: 'android.widget.Button', + label: 'Send', + bundleId: 'org.reactnavigation.playground', + rect: { x: 10, y: 20, width: 100, height: 40 }, + hittable: true, + }, + { + // Status-bar marker: this systemui run drops whole. + index: 1, + depth: 0, + type: 'android.widget.FrameLayout', + identifier: 'com.android.systemui:id/status_bar', + bundleId: 'com.android.systemui', + }, + { + index: 2, + depth: 1, + parentIndex: 1, + type: 'android.widget.TextView', + identifier: 'com.android.systemui:id/clock', + label: '12:23', + bundleId: 'com.android.systemui', + }, + { + index: 3, + depth: 0, + type: 'android.widget.FrameLayout', + bundleId: 'com.google.android.inputmethod.latin', + hittable: true, + }, + { + index: 4, + depth: 1, + parentIndex: 3, + type: 'android.widget.FrameLayout', + label: 'Delete', + bundleId: 'com.google.android.inputmethod.latin', + hittable: true, + }, + ]).nodes; + + const result = buildSettleTailEntries(settledNodes, new Set(), 'org.reactnavigation.playground'); + + assert.deepEqual(result.tail, [{ ref: 'e1', role: 'button', label: 'Send' }]); +}); + +test('buildSettleTailEntries keeps unknown-foreign packages and drops only marked systemui chrome, with or without appBundleId (#1198)', () => { + // Keep-unknown-foreign default: a system dialog's buttons (package + // `android`) stay tail candidates; only the marked status/nav-bar + // window-run drops, and that does not depend on knowing the session's app. + const settledNodes = makeSnapshotState([ + { + index: 0, + depth: 0, + type: 'android.widget.Button', + label: 'Send', + bundleId: 'org.reactnavigation.playground', + rect: { x: 10, y: 20, width: 100, height: 40 }, + hittable: true, + }, + { + index: 1, + depth: 0, + type: 'android.widget.FrameLayout', + identifier: 'com.android.systemui:id/status_bar_container', + bundleId: 'com.android.systemui', + rect: { x: 0, y: 0, width: 1344, height: 159 }, + }, + { + index: 2, + depth: 1, + parentIndex: 1, + type: 'android.widget.TextView', + identifier: 'com.android.systemui:id/clock', + label: '12:23', + bundleId: 'com.android.systemui', + rect: { x: 40, y: 40, width: 80, height: 40 }, + }, + { + index: 3, + depth: 0, + type: 'android.widget.Button', + label: 'Just once', + identifier: 'android:id/button_once', + bundleId: 'android', + rect: { x: 829, y: 2734, width: 255, height: 162 }, + hittable: true, + }, + ]).nodes; + + for (const appBundleId of [undefined, 'org.reactnavigation.playground']) { + const result = buildSettleTailEntries(settledNodes, new Set(), appBundleId); + assert.deepEqual( + result.tail?.map((entry) => entry.label), + ['Send', 'Just once'], + ); + } +}); + +test('a systemui volume dialog survives the settled diff and tail while the status bar drops (#1198)', async () => { + // PR #1200 second review round: systemui is not all chrome — it hosts + // actionable overlays (volume panel, media pickers). Only window-runs + // carrying a status/nav-bar marker drop; the VolumeDialog run (real + // capture, same package, volume_dialog* ids only) must stay actionable. + const before = makeSnapshotState([...androidAppNodes(), ...androidStatusBarNodes(10, '12:23')]); + const settledTree = makeSnapshotState([ + ...androidAppNodes(), + ...androidStatusBarNodes(10, '12:24'), + ...androidVolumeDialogNodes(30), + ]); + let captures = 0; + const device = createSettleDevice({ + stored: before, + appBundleId: ANDROID_APP_BUNDLE_ID, + captureSnapshot: () => { + captures += 1; + return { snapshot: captures === 1 ? before : settledTree }; + }, + }); + + const result = await device.interactions.press(selector('label="Discard and go back"'), { + session: 'default', + settle: {}, + }); + + const diff = result.settle?.diff; + assert.ok(diff); + const added = diff.lines.filter((line) => line.kind === 'added'); + const texts = added.map((line) => line.text).join('\n'); + // The volume dialog registers as change, with actionable refs. + assert.match(texts, /Sound settings/); + assert.match(texts, /Media/); + assert.ok( + added.some((line) => /Sound settings/.test(line.text) && line.ref), + 'volume dialog controls must carry refs', + ); + // Status-bar churn (clock tick) still never appears. + assert.ok(!/12:2[34]/.test(diff.lines.map((line) => line.text).join('\n'))); + + // Tail-candidate check on the same shape: dialog controls are candidates, + // status-bar nodes are not. + const labels = ( + buildSettleTailEntries(settledTree.nodes, new Set(), ANDROID_APP_BUNDLE_ID).tail ?? [] + ).map((entry) => entry.label); + assert.ok(labels.includes('Sound settings'), `tail must list dialog controls, got: ${labels}`); + assert.ok(!labels.includes('12:24'), 'status bar must stay excluded'); +}); diff --git a/src/commands/interaction/runtime/settle.ts b/src/commands/interaction/runtime/settle.ts index fb7381191..b29eff794 100644 --- a/src/commands/interaction/runtime/settle.ts +++ b/src/commands/interaction/runtime/settle.ts @@ -1,8 +1,13 @@ import type { Point, SnapshotNode } from '../../../kernel/snapshot.ts'; -import type { AgentDeviceRuntime, CommandContext } from '../../../runtime-contract.ts'; +import type { + AgentDeviceRuntime, + CommandContext, + CommandSessionRecord, +} from '../../../runtime-contract.ts'; import { isSparseSnapshotQualityVerdict } from '../../../snapshot/snapshot-quality.ts'; import { buildSnapshotDiff } from '../../../snapshot/snapshot-diff.ts'; import { displayLabel, formatRole } from '../../../snapshot/snapshot-lines.ts'; +import { isAndroidInputMethodSnapshotNode } from '../../../snapshot/android-input-method-overlays.ts'; import { normalizeType } from '../../../utils/text-surface.ts'; import { summarizeAxEvidence } from '../../../utils/ax-digest.ts'; import type { @@ -82,7 +87,7 @@ export async function settleAfterInteraction( }, }; } - const stored = await storeSettledSnapshot(runtime, options, outcome.lastCapture); + const { stored, session } = await storeSettledSnapshot(runtime, options, outcome.lastCapture); const settledNodes = outcome.lastCapture.snapshot.nodes; return { observation: { @@ -99,6 +104,7 @@ export async function settleAfterInteraction( resolveBaselineNodes(params.resolved), settledNodes, params.resolved.point, + session?.appBundleId, ) : {}), ...resolveSettleHint(outcome, stored, settledNodes.length), @@ -140,6 +146,7 @@ function resolveBaselineNodes(resolved: ResolvedInteractionTarget): SnapshotNode function buildSettleDiff( baselineNodes: SnapshotNode[], settledNodes: SnapshotNode[], + appBundleId: string | undefined, ): NonNullable { // Flattened compare, like `diff -i`: both sides are interactive-flavored // captures and depth jitter across captures should not read as change. When @@ -147,8 +154,8 @@ function buildSettleDiff( // snapshot), extra baseline-only lines surface as removals — advisory noise, // the same baseline caveat --verify's changedFromBefore already accepts. const diff = buildSnapshotDiff( - withoutKeyboardChrome(baselineNodes), - withoutKeyboardChrome(settledNodes), + withoutSettleChrome(baselineNodes, appBundleId), + withoutSettleChrome(settledNodes, appBundleId), { flatten: true, withRefs: true }, ); const changed = diff.lines.filter((line) => line.kind !== 'unchanged'); @@ -168,9 +175,10 @@ function buildSettleDiffAndTail( baselineNodes: SnapshotNode[], settledNodes: SnapshotNode[], actionPoint: Point | undefined, + appBundleId: string | undefined, ): Pick { - const diff = buildSettleDiff(baselineNodes, settledNodes); - return { diff, ...buildSettleTail(diff, settledNodes, actionPoint) }; + const diff = buildSettleDiff(baselineNodes, settledNodes, appBundleId); + return { diff, ...buildSettleTail(diff, settledNodes, actionPoint, appBundleId) }; } /** @@ -195,20 +203,19 @@ function buildSettleTail( diff: NonNullable, settledNodes: SnapshotNode[], actionPoint: Point | undefined, + appBundleId: string | undefined, ): Pick { const addedRefs = new Set( diff.lines.filter((line) => line.kind === 'added' && line.ref).map((line) => line.ref), ); - const keyboardChromeRefs = collectKeyboardChromeRefs(settledNodes); + const chromeRefs = collectSettleChromeRefs(settledNodes, appBundleId); const byRef = new Map(settledNodes.filter((node) => node.ref).map((node) => [node.ref, node])); const hasMeaningfulAddedRef = [...addedRefs].some( (ref) => - ref !== undefined && - !keyboardChromeRefs.has(ref) && - !isSelfEchoNode(byRef.get(ref), actionPoint), + ref !== undefined && !chromeRefs.has(ref) && !isSelfEchoNode(byRef.get(ref), actionPoint), ); if (hasMeaningfulAddedRef) return {}; - return buildSettleTailEntries(settledNodes, addedRefs); + return buildSettleTailEntries(settledNodes, addedRefs, appBundleId); } function isSelfEchoNode(node: SnapshotNode | undefined, actionPoint: Point | undefined): boolean { @@ -242,20 +249,22 @@ const STRUCTURAL_TAIL_ROLES = new Set(['application', 'window']); * after the dismissing element leaves — requiring `hittable === true` here * was stricter than `snapshot -i`'s own bar and silently dropped exactly the * buttons the tail exists to surface (#1167 post-merge benchmark). Structural - * application/window chrome and any keyboard container/chrome subtree are - * excluded on top of that bar: never a next actionable target either way. + * application/window chrome, any keyboard container/chrome subtree (iOS), and + * Android IME/persistent-system chrome (#1198) are excluded on top of that + * bar: never a next actionable target either way. */ export function buildSettleTailEntries( settledNodes: SnapshotNode[], excludeRefs: ReadonlySet, + appBundleId?: string, ): Pick { - const keyboardChromeRefs = collectKeyboardChromeRefs(settledNodes); + const chromeRefs = collectSettleChromeRefs(settledNodes, appBundleId); const candidates = settledNodes.filter( (node) => node.ref && node.interactionBlocked !== 'covered' && !excludeRefs.has(node.ref) && - !keyboardChromeRefs.has(node.ref) && + !chromeRefs.has(node.ref) && !STRUCTURAL_TAIL_ROLES.has(formatRole(node.type ?? 'Element')), ); if (candidates.length === 0) return {}; @@ -347,14 +356,149 @@ function collectSubtreeIndexes( return indexes; } -function withoutKeyboardChrome(nodes: SnapshotNode[]): SnapshotNode[] { - const { strippedIndexes } = collectKeyboardChrome(nodes); +// SystemUI hosts BOTH persistent chrome and actionable overlays (volume +// panel, media/output pickers), so chrome is never a package-level fact. +// Within `com.android.systemui`, only window-runs carrying a status-bar or +// navigation-bar marker resource-id drop; every other systemui surface is +// kept. Marker set live-verified on the emulator: the status-bar window +// carries `status_bar*` ids throughout while the VolumeDialog window carries +// only `volume_dialog*` ids (`input_method_nav*` bars are IME-owned and +// handled by the IME tier). +const ANDROID_SYSTEM_CHROME_PACKAGE = 'com.android.systemui'; +const ANDROID_SYSTEM_CHROME_MARKER_PREFIXES = [ + 'com.android.systemui:id/status_bar', + 'com.android.systemui:id/navigation_bar', +]; + +function hasAndroidSystemChromeMarker(node: SnapshotNode): boolean { + const identifier = node.identifier ?? ''; + return ANDROID_SYSTEM_CHROME_MARKER_PREFIXES.some((prefix) => identifier.startsWith(prefix)); +} + +/** + * Android settle chrome (#1198): IME-owned nodes collapse to one surviving + * line per contiguous run; systemui status/nav-bar window-runs drop from both + * diff sides; every other foreign node — system dialogs (package `android`), + * permission prompts, AND actionable systemui overlays like the volume panel + * — is kept in full. Constraint: package membership is strictly per-node by + * the node's own `bundleId` — parentIndex chains can cross windows on Android + * (enforced by the settle.test.ts cross-window regression test); run grouping + * only ever walks parent chains BETWEEN same-package nodes, so it cannot + * swallow another package's node. Inert on iOS/macOS: those nodes never set + * `bundleId`. + */ +function collectAndroidSettleChrome( + nodes: SnapshotNode[], + appBundleId: string | undefined, +): KeyboardChrome { + const byIndex = new Map(nodes.map((node) => [node.index, node])); + const imeIndexes = new Set( + nodes.filter((node) => isAndroidInputMethodSnapshotNode(node)).map((node) => node.index), + ); + const imeContainerIndexes = new Set( + [...imeIndexes].filter((index) => { + const parentIndex = byIndex.get(index)?.parentIndex; + const parent = parentIndex !== undefined ? byIndex.get(parentIndex) : undefined; + return !parent || !imeIndexes.has(parent.index); + }), + ); + // appBundleId is the session's pre-action value (not refreshed inside the + // settle loop); it is only a never-drop-the-app-under-test guard here, so + // staleness cannot hide a foreign dialog. + const systemChromeIndexes = + appBundleId === ANDROID_SYSTEM_CHROME_PACKAGE + ? new Set() + : collectAndroidSystemChromeRunIndexes(nodes, byIndex, imeIndexes); + // The one surviving container line per IME run; the rest of the run and all + // status/nav-bar chrome never spend diff/tail budget. + const strippedIndexes = new Set( + [...imeIndexes].filter((index) => !imeContainerIndexes.has(index)), + ); + for (const index of systemChromeIndexes) strippedIndexes.add(index); + const refs = new Set( + nodes + .filter( + (node) => node.ref && (imeIndexes.has(node.index) || systemChromeIndexes.has(node.index)), + ) + .map((node) => node.ref), + ); + if (strippedIndexes.size === 0 && refs.size === 0) return EMPTY_KEYBOARD_CHROME; + return { strippedIndexes, refs }; +} + +/** + * Systemui window-runs (contiguous same-package parent chains) that contain a + * status/nav-bar marker anywhere in the run. The whole marked run drops — + * unmarked wrappers above `status_bar_container` churn with the bar itself — + * while unmarked runs (volume panel, media pickers) are kept whole. + */ +function collectAndroidSystemChromeRunIndexes( + nodes: SnapshotNode[], + byIndex: Map, + imeIndexes: ReadonlySet, +): Set { + const systemUiIndexes = new Set( + nodes + .filter( + (node) => node.bundleId === ANDROID_SYSTEM_CHROME_PACKAGE && !imeIndexes.has(node.index), + ) + .map((node) => node.index), + ); + if (systemUiIndexes.size === 0) return new Set(); + // Union-find-lite: each systemui node resolves to its run root (the nearest + // ancestor chain member whose parent is absent or not systemui). + const runRootByIndex = new Map(); + const resolveRunRoot = (index: number): number => { + const cached = runRootByIndex.get(index); + if (cached !== undefined) return cached; + const parentIndex = byIndex.get(index)?.parentIndex; + const root = + parentIndex !== undefined && systemUiIndexes.has(parentIndex) + ? resolveRunRoot(parentIndex) + : index; + runRootByIndex.set(index, root); + return root; + }; + const markedRunRoots = new Set( + [...systemUiIndexes] + .filter((index) => { + const node = byIndex.get(index); + return node !== undefined && hasAndroidSystemChromeMarker(node); + }) + .map((index) => resolveRunRoot(index)), + ); + return new Set([...systemUiIndexes].filter((index) => markedRunRoots.has(resolveRunRoot(index)))); +} + +/** iOS keyboard-window chrome unioned with Android IME/system chrome. */ +function collectSettleChrome( + nodes: SnapshotNode[], + appBundleId: string | undefined, +): KeyboardChrome { + const keyboard = collectKeyboardChrome(nodes); + const android = collectAndroidSettleChrome(nodes, appBundleId); + if (keyboard.strippedIndexes.size === 0 && keyboard.refs.size === 0) return android; + if (android.strippedIndexes.size === 0 && android.refs.size === 0) return keyboard; + return { + strippedIndexes: new Set([...keyboard.strippedIndexes, ...android.strippedIndexes]), + refs: new Set([...keyboard.refs, ...android.refs]), + }; +} + +function withoutSettleChrome( + nodes: SnapshotNode[], + appBundleId: string | undefined, +): SnapshotNode[] { + const { strippedIndexes } = collectSettleChrome(nodes, appBundleId); if (strippedIndexes.size === 0) return nodes; return nodes.filter((node) => !strippedIndexes.has(node.index)); } -function collectKeyboardChromeRefs(nodes: SnapshotNode[]): ReadonlySet { - return collectKeyboardChrome(nodes).refs; +function collectSettleChromeRefs( + nodes: SnapshotNode[], + appBundleId: string | undefined, +): ReadonlySet { + return collectSettleChrome(nodes, appBundleId).refs; } /** @@ -461,15 +605,17 @@ function resolveSettleHint( // Only settled captures issue a diff/ref payload; unsettled stored captures // conservatively mark prior refs stale through the runtime session writer. // Sparse-quality captures are not stored (mirroring captureSelectorSnapshot) -// and therefore issue no refs. +// and therefore issue no refs. The fetched session is returned alongside +// `stored` so the caller can read `appBundleId` for settle-chrome scoping +// (#1198) without a second `sessions.get` round trip. async function storeSettledSnapshot( runtime: AgentDeviceRuntime, options: CommandContext, capture: CapturedSnapshot, -): Promise { - if (isSparseSnapshotQualityVerdict(capture.snapshot.snapshotQuality)) return false; +): Promise<{ stored: boolean; session?: CommandSessionRecord }> { + if (isSparseSnapshotQualityVerdict(capture.snapshot.snapshotQuality)) return { stored: false }; const session = await runtime.sessions.get(options.session ?? 'default'); - if (!session) return false; + if (!session) return { stored: false }; await runtime.sessions.set({ ...session, snapshot: capture.snapshot }); - return true; + return { stored: true, session }; }