@@ -37,6 +37,8 @@ interface SnapshotRefLocator {
3737 attributes : Record < string , string >
3838 ancestor : string
3939 context : string
40+ /** Hash of the element's DOM text, to tell revealed content from replaced content. */
41+ textHash : number
4042}
4143
4244declare global {
@@ -606,6 +608,14 @@ export function collectSnapshot(
606608 const refOfElement = window . __simAgentRefOfElement ?? new WeakMap ( )
607609 window . __simAgentRefOfElement = refOfElement
608610 const issuedRefCap = 5000
611+ const textHashOf = ( el : Element ) : number => {
612+ const text = el . textContent || ''
613+ let hash = 0
614+ for ( let index = 0 ; index < text . length ; index ++ ) {
615+ hash = ( hash * 31 + text . charCodeAt ( index ) ) | 0
616+ }
617+ return hash
618+ }
609619 const registerElement = ( el : Element , role : string , name : string ) : number => {
610620 const locator = {
611621 url : pageUrlFor ( el ) ,
@@ -615,6 +625,7 @@ export function collectSnapshot(
615625 attributes : locatorAttributes ( el ) ,
616626 ancestor : ancestorSignature ( el ) ,
617627 context : contextSignature ( el ) ,
628+ textHash : textHashOf ( el ) ,
618629 }
619630 const priorId = refOfElement . get ( el )
620631 const prior = priorId === undefined ? undefined : issuedRefs . get ( priorId )
@@ -624,6 +635,8 @@ export function collectSnapshot(
624635 prior . locator . tag === locator . tag &&
625636 prior . locator . role === locator . role &&
626637 prior . locator . name === locator . name &&
638+ prior . locator . ancestor === locator . ancestor &&
639+ prior . locator . context === locator . context &&
627640 Object . keys ( { ...prior . locator . attributes , ...locator . attributes } ) . every (
628641 ( key ) => prior . locator . attributes [ key ] === locator . attributes [ key ]
629642 )
@@ -889,7 +902,10 @@ export function collectSnapshot(
889902 * real stale ref, never permission to click something nearby.
890903 */
891904 window . __simAgentResolveElement = ( id : number , allowRecovery = true ) => {
892- if ( scopedRoot && ! scopedRoot . isConnected ) {
905+ // A ref from an earlier snapshot resolves only to its own live node: no
906+ // lookalike recovery, and the latest snapshot's scope does not apply to it.
907+ const inLatestSnapshot = locators [ id ] !== undefined
908+ if ( inLatestSnapshot && scopedRoot && ! scopedRoot . isConnected ) {
893909 window . __simAgentStaleReason = 'the scoped snapshot root left the DOM'
894910 return null
895911 }
@@ -937,12 +953,22 @@ export function collectSnapshot(
937953 ) {
938954 return false
939955 }
940- // A still-mounted control whose own menu or disclosure opened (often by
941- // the hover or click being validated) appends its new content to its
942- // name; a recycled row replaces the name instead.
956+ // A mounted control whose own menu opened (often by the hover or click
957+ // being validated) appends that menu to its name: either hidden content
958+ // was revealed (its DOM text is unchanged) or it declares a popup. A
959+ // recycled row changes its DOM text with neither.
943960 const name = nameFor ( candidate )
961+ const opensPopup =
962+ textHashOf ( candidate ) === locator . textHash ||
963+ [ 'aria-haspopup' , 'aria-expanded' , 'aria-controls' ] . some (
964+ ( attribute ) => ( candidate . getAttribute ( attribute ) || 'false' ) !== 'false'
965+ ) ||
966+ candidate . querySelector (
967+ '[role="menu"], [role="menubar"], [role="listbox"], [role="tooltip"], [role="dialog"], [role="tree"]'
968+ ) !== null
944969 const nameGrewInPlace =
945970 connected &&
971+ opensPopup &&
946972 locator . name . length > 0 &&
947973 name . startsWith ( locator . name ) &&
948974 ( locator . name . length >= 120 || / \s / . test ( name . charAt ( locator . name . length ) ) )
@@ -1037,7 +1063,7 @@ export function collectSnapshot(
10371063 }
10381064 }
10391065
1040- if ( ! allowRecovery ) {
1066+ if ( ! allowRecovery || ! inLatestSnapshot ) {
10411067 window . __simAgentStaleReason = 'the original snapshot node is detached or hidden'
10421068 return null
10431069 }
@@ -1915,11 +1941,30 @@ export function focusElementForTyping(id: number, moveFocus = true): unknown {
19151941 coveredByRelatedPopup = true
19161942 }
19171943 }
1918- // Top-page typing goes through keyboard focus, so a non-modal overlay does not block it (modals
1919- // fail the aria-hidden/inert checks above); framed fields keep the check for their mapped point.
1944+ // Top-page typing goes through keyboard focus, so a non-modal overlay does not block it; a modal
1945+ // or dialog cover still refuses, and framed fields keep the check for their mapped point.
19201946 const topPageField = view === window && window . top === window
1947+ const insideModal = ( node : Element | null ) : boolean => {
1948+ for ( let current = node ; current ; current = composedParent ( current ) ) {
1949+ const role = current . getAttribute ( 'role' )
1950+ if (
1951+ current . getAttribute ( 'aria-modal' ) === 'true' ||
1952+ role === 'dialog' ||
1953+ role === 'alertdialog' ||
1954+ tagFor ( current ) === 'DIALOG'
1955+ ) {
1956+ return true
1957+ }
1958+ }
1959+ return false
1960+ }
19211961 let coveredBy : string | undefined
1922- if ( ! chosenPoint && topPageField && blockedPoints . length > 0 ) {
1962+ if (
1963+ ! chosenPoint &&
1964+ topPageField &&
1965+ blockedPoints . length > 0 &&
1966+ ! blockedPoints . some ( ( point ) => insideModal ( point . hit ) )
1967+ ) {
19231968 chosenPoint = { x : blockedPoints [ 0 ] . x , y : blockedPoints [ 0 ] . y }
19241969 coveredBy = blockerLabel ( firstBlocker )
19251970 }
0 commit comments