@@ -7,8 +7,7 @@ import type {
77import { isSparseSnapshotQualityVerdict } from '../../../snapshot/snapshot-quality.ts' ;
88import { buildSnapshotDiff } from '../../../snapshot/snapshot-diff.ts' ;
99import { displayLabel , formatRole } from '../../../snapshot/snapshot-lines.ts' ;
10- import { isAndroidInputMethodSnapshotNode } from '../../../snapshot/android-input-method-overlays.ts' ;
11- import { normalizeType } from '../../../utils/text-surface.ts' ;
10+ import { collectSettleChromeRefs , withoutSettleChrome } from '../../../core/snapshot-chrome.ts' ;
1211import { summarizeAxEvidence } from '../../../utils/ax-digest.ts' ;
1312import type {
1413 InteractionEvidence ,
@@ -279,285 +278,6 @@ export function buildSettleTailEntries(
279278 } ;
280279}
281280
282- // Editable text roles (normalized `node.type` vocabulary) used by the
283- // keyboard-window guard below.
284- const EDITABLE_TEXT_TYPES = new Set ( [
285- 'textfield' ,
286- 'securetextfield' ,
287- 'textview' ,
288- 'textarea' ,
289- 'searchfield' ,
290- 'edittext' ,
291- ] ) ;
292-
293- /**
294- * Keyboard chrome classification, live-verified against the iPhone 17 Pro
295- * simulator (iOS 26, July 2026): the software keyboard renders in its OWN
296- * dedicated window (UIRemoteKeyboardWindow). That window contains the
297- * `[Keyboard]` container (keys plus real XCUIElementTypeButton chrome like
298- * shift/Emoji/return) AND a SIBLING subtree holding the "Next keyboard" and
299- * "Dictate" buttons — siblings of the container, so a container-descendant
300- * walk alone provably misses them. The rule is therefore: every node inside
301- * a window that has a `[Keyboard]` descendant is keyboard chrome.
302- *
303- * Detection stays structural (`parentIndex` chains), never label-based:
304- * keyboard chrome text is locale-dependent (the verified capture had Polish
305- * key labels) and a label list would silently stop matching under a
306- * different input language.
307- *
308- * Conservative guard: a window that also hosts an editable text node OUTSIDE
309- * the keyboard container is never window-classified — iOS hosts
310- * inputAccessoryView content (e.g. a messaging composer) in the keyboard
311- * window, and hiding the field the user is typing into would be worse than
312- * leaking chrome. Such windows fall back to the container-descendant walk.
313- */
314- type KeyboardChrome = {
315- /** Chrome node indexes EXCLUDING the containers: stripped from the diff. */
316- strippedIndexes : ReadonlySet < number > ;
317- /** Refs of ALL chrome incl. containers/window: trigger + tail exclusion. */
318- refs : ReadonlySet < string > ;
319- } ;
320-
321- const EMPTY_KEYBOARD_CHROME : KeyboardChrome = { strippedIndexes : new Set ( ) , refs : new Set ( ) } ;
322-
323- function collectKeyboardChrome ( nodes : SnapshotNode [ ] ) : KeyboardChrome {
324- const containerIndexes = new Set (
325- nodes . filter ( ( node ) => normalizeType ( node . type ?? '' ) === 'keyboard' ) . map ( ( node ) => node . index ) ,
326- ) ;
327- if ( containerIndexes . size === 0 ) return EMPTY_KEYBOARD_CHROME ;
328- const byIndex = new Map ( nodes . map ( ( node ) => [ node . index , node ] ) ) ;
329- const chromeIndexes = collectSubtreeIndexes ( nodes , byIndex , containerIndexes ) ;
330- const windowIndexes = resolveKeyboardWindowIndexes ( nodes , byIndex , chromeIndexes ) ;
331- for ( const index of collectSubtreeIndexes ( nodes , byIndex , windowIndexes ) ) {
332- chromeIndexes . add ( index ) ;
333- }
334- const refs = new Set (
335- nodes . filter ( ( node ) => node . ref && chromeIndexes . has ( node . index ) ) . map ( ( node ) => node . ref ) ,
336- ) ;
337- // The [Keyboard] container line itself survives the diff so "keyboard
338- // appeared/left" stays one visible signal line; everything else collapses.
339- const strippedIndexes = new Set (
340- [ ...chromeIndexes ] . filter ( ( index ) => ! containerIndexes . has ( index ) ) ,
341- ) ;
342- return { strippedIndexes, refs } ;
343- }
344-
345- /** The root indexes plus every node whose parent chain passes through one. */
346- function collectSubtreeIndexes (
347- nodes : SnapshotNode [ ] ,
348- byIndex : Map < number , SnapshotNode > ,
349- rootIndexes : ReadonlySet < number > ,
350- ) : Set < number > {
351- const indexes = new Set ( rootIndexes ) ;
352- if ( rootIndexes . size === 0 ) return indexes ;
353- for ( const node of nodes ) {
354- if ( hasAncestorIn ( node , byIndex , rootIndexes ) ) indexes . add ( node . index ) ;
355- }
356- return indexes ;
357- }
358-
359- // SystemUI hosts BOTH persistent chrome and actionable overlays (volume
360- // panel, media/output pickers), so chrome is never a package-level fact.
361- // Within `com.android.systemui`, only window-runs carrying a status-bar or
362- // navigation-bar marker resource-id drop; every other systemui surface is
363- // kept. Marker set live-verified on the emulator: the status-bar window
364- // carries `status_bar*` ids throughout while the VolumeDialog window carries
365- // only `volume_dialog*` ids (`input_method_nav*` bars are IME-owned and
366- // handled by the IME tier).
367- const ANDROID_SYSTEM_CHROME_PACKAGE = 'com.android.systemui' ;
368- const ANDROID_SYSTEM_CHROME_MARKER_PREFIXES = [
369- 'com.android.systemui:id/status_bar' ,
370- 'com.android.systemui:id/navigation_bar' ,
371- ] ;
372-
373- function hasAndroidSystemChromeMarker ( node : SnapshotNode ) : boolean {
374- const identifier = node . identifier ?? '' ;
375- return ANDROID_SYSTEM_CHROME_MARKER_PREFIXES . some ( ( prefix ) => identifier . startsWith ( prefix ) ) ;
376- }
377-
378- /**
379- * Android settle chrome (#1198): IME-owned nodes collapse to one surviving
380- * line per contiguous run; systemui status/nav-bar window-runs drop from both
381- * diff sides; every other foreign node — system dialogs (package `android`),
382- * permission prompts, AND actionable systemui overlays like the volume panel
383- * — is kept in full. Constraint: package membership is strictly per-node by
384- * the node's own `bundleId` — parentIndex chains can cross windows on Android
385- * (enforced by the settle.test.ts cross-window regression test); run grouping
386- * only ever walks parent chains BETWEEN same-package nodes, so it cannot
387- * swallow another package's node. Inert on iOS/macOS: those nodes never set
388- * `bundleId`.
389- */
390- function collectAndroidSettleChrome (
391- nodes : SnapshotNode [ ] ,
392- appBundleId : string | undefined ,
393- ) : KeyboardChrome {
394- const byIndex = new Map ( nodes . map ( ( node ) => [ node . index , node ] ) ) ;
395- const imeIndexes = new Set (
396- nodes . filter ( ( node ) => isAndroidInputMethodSnapshotNode ( node ) ) . map ( ( node ) => node . index ) ,
397- ) ;
398- const imeContainerIndexes = new Set (
399- [ ...imeIndexes ] . filter ( ( index ) => {
400- const parentIndex = byIndex . get ( index ) ?. parentIndex ;
401- const parent = parentIndex !== undefined ? byIndex . get ( parentIndex ) : undefined ;
402- return ! parent || ! imeIndexes . has ( parent . index ) ;
403- } ) ,
404- ) ;
405- // appBundleId is the session's pre-action value (not refreshed inside the
406- // settle loop); it is only a never-drop-the-app-under-test guard here, so
407- // staleness cannot hide a foreign dialog.
408- const systemChromeIndexes =
409- appBundleId === ANDROID_SYSTEM_CHROME_PACKAGE
410- ? new Set < number > ( )
411- : collectAndroidSystemChromeRunIndexes ( nodes , byIndex , imeIndexes ) ;
412- // The one surviving container line per IME run; the rest of the run and all
413- // status/nav-bar chrome never spend diff/tail budget.
414- const strippedIndexes = new Set (
415- [ ...imeIndexes ] . filter ( ( index ) => ! imeContainerIndexes . has ( index ) ) ,
416- ) ;
417- for ( const index of systemChromeIndexes ) strippedIndexes . add ( index ) ;
418- const refs = new Set (
419- nodes
420- . filter (
421- ( node ) => node . ref && ( imeIndexes . has ( node . index ) || systemChromeIndexes . has ( node . index ) ) ,
422- )
423- . map ( ( node ) => node . ref ) ,
424- ) ;
425- if ( strippedIndexes . size === 0 && refs . size === 0 ) return EMPTY_KEYBOARD_CHROME ;
426- return { strippedIndexes, refs } ;
427- }
428-
429- /**
430- * Systemui window-runs (contiguous same-package parent chains) that contain a
431- * status/nav-bar marker anywhere in the run. The whole marked run drops —
432- * unmarked wrappers above `status_bar_container` churn with the bar itself —
433- * while unmarked runs (volume panel, media pickers) are kept whole.
434- */
435- function collectAndroidSystemChromeRunIndexes (
436- nodes : SnapshotNode [ ] ,
437- byIndex : Map < number , SnapshotNode > ,
438- imeIndexes : ReadonlySet < number > ,
439- ) : Set < number > {
440- const systemUiIndexes = new Set (
441- nodes
442- . filter (
443- ( node ) => node . bundleId === ANDROID_SYSTEM_CHROME_PACKAGE && ! imeIndexes . has ( node . index ) ,
444- )
445- . map ( ( node ) => node . index ) ,
446- ) ;
447- if ( systemUiIndexes . size === 0 ) return new Set ( ) ;
448- // Union-find-lite: each systemui node resolves to its run root (the nearest
449- // ancestor chain member whose parent is absent or not systemui).
450- const runRootByIndex = new Map < number , number > ( ) ;
451- const resolveRunRoot = ( index : number ) : number => {
452- const cached = runRootByIndex . get ( index ) ;
453- if ( cached !== undefined ) return cached ;
454- const parentIndex = byIndex . get ( index ) ?. parentIndex ;
455- const root =
456- parentIndex !== undefined && systemUiIndexes . has ( parentIndex )
457- ? resolveRunRoot ( parentIndex )
458- : index ;
459- runRootByIndex . set ( index , root ) ;
460- return root ;
461- } ;
462- const markedRunRoots = new Set (
463- [ ...systemUiIndexes ]
464- . filter ( ( index ) => {
465- const node = byIndex . get ( index ) ;
466- return node !== undefined && hasAndroidSystemChromeMarker ( node ) ;
467- } )
468- . map ( ( index ) => resolveRunRoot ( index ) ) ,
469- ) ;
470- return new Set ( [ ...systemUiIndexes ] . filter ( ( index ) => markedRunRoots . has ( resolveRunRoot ( index ) ) ) ) ;
471- }
472-
473- /** iOS keyboard-window chrome unioned with Android IME/system chrome. */
474- function collectSettleChrome (
475- nodes : SnapshotNode [ ] ,
476- appBundleId : string | undefined ,
477- ) : KeyboardChrome {
478- const keyboard = collectKeyboardChrome ( nodes ) ;
479- const android = collectAndroidSettleChrome ( nodes , appBundleId ) ;
480- if ( keyboard . strippedIndexes . size === 0 && keyboard . refs . size === 0 ) return android ;
481- if ( android . strippedIndexes . size === 0 && android . refs . size === 0 ) return keyboard ;
482- return {
483- strippedIndexes : new Set ( [ ...keyboard . strippedIndexes , ...android . strippedIndexes ] ) ,
484- refs : new Set ( [ ...keyboard . refs , ...android . refs ] ) ,
485- } ;
486- }
487-
488- function withoutSettleChrome (
489- nodes : SnapshotNode [ ] ,
490- appBundleId : string | undefined ,
491- ) : SnapshotNode [ ] {
492- const { strippedIndexes } = collectSettleChrome ( nodes , appBundleId ) ;
493- if ( strippedIndexes . size === 0 ) return nodes ;
494- return nodes . filter ( ( node ) => ! strippedIndexes . has ( node . index ) ) ;
495- }
496-
497- function collectSettleChromeRefs (
498- nodes : SnapshotNode [ ] ,
499- appBundleId : string | undefined ,
500- ) : ReadonlySet < string > {
501- return collectSettleChrome ( nodes , appBundleId ) . refs ;
502- }
503-
504- /**
505- * Windows eligible for whole-window chrome classification: nearest `[window]`
506- * ancestor of each `[Keyboard]` container, minus windows hosting editable
507- * text outside the container subtree (the inputAccessoryView guard above).
508- */
509- function resolveKeyboardWindowIndexes (
510- nodes : SnapshotNode [ ] ,
511- byIndex : Map < number , SnapshotNode > ,
512- containerChromeIndexes : ReadonlySet < number > ,
513- ) : Set < number > {
514- const windowIndexes = new Set < number > ( ) ;
515- for ( const index of containerChromeIndexes ) {
516- const node = byIndex . get ( index ) ;
517- if ( ! node || normalizeType ( node . type ?? '' ) !== 'keyboard' ) continue ;
518- const windowAncestor = findNearestWindowAncestor ( node , byIndex ) ;
519- if ( windowAncestor !== undefined ) windowIndexes . add ( windowAncestor ) ;
520- }
521- for ( const windowIndex of windowIndexes ) {
522- const windowSet = new Set ( [ windowIndex ] ) ;
523- const hostsEditableText = nodes . some (
524- ( node ) =>
525- EDITABLE_TEXT_TYPES . has ( normalizeType ( node . type ?? '' ) ) &&
526- ! containerChromeIndexes . has ( node . index ) &&
527- hasAncestorIn ( node , byIndex , windowSet ) ,
528- ) ;
529- if ( hostsEditableText ) windowIndexes . delete ( windowIndex ) ;
530- }
531- return windowIndexes ;
532- }
533-
534- function findNearestWindowAncestor (
535- node : SnapshotNode ,
536- byIndex : Map < number , SnapshotNode > ,
537- ) : number | undefined {
538- let current = typeof node . parentIndex === 'number' ? byIndex . get ( node . parentIndex ) : undefined ;
539- while ( current ) {
540- if ( normalizeType ( current . type ?? '' ) === 'window' ) return current . index ;
541- current =
542- typeof current . parentIndex === 'number' ? byIndex . get ( current . parentIndex ) : undefined ;
543- }
544- return undefined ;
545- }
546-
547- function hasAncestorIn (
548- node : SnapshotNode ,
549- byIndex : Map < number , SnapshotNode > ,
550- ancestorIndexes : ReadonlySet < number > ,
551- ) : boolean {
552- let current = typeof node . parentIndex === 'number' ? byIndex . get ( node . parentIndex ) : undefined ;
553- while ( current ) {
554- if ( ancestorIndexes . has ( current . index ) ) return true ;
555- current =
556- typeof current . parentIndex === 'number' ? byIndex . get ( current . parentIndex ) : undefined ;
557- }
558- return false ;
559- }
560-
561281/**
562282 * Truncation policy: added lines win. They carry the settled tree's fresh
563283 * refs — the actionable half of the diff — while removals only describe what
0 commit comments