@@ -15,9 +15,12 @@ interface PageAccessibilityState {
1515 url : string
1616 focusedDocument ?: boolean
1717 active : ElementEvidence
18+ activeInScope : boolean
19+ activeDialogKey ?: string
1820 dialogs : ElementEvidence [ ]
1921 findings : Array < Omit < BrowserAccessibilityFinding , "fingerprint" | "stateDigest" | "transitionId" | "actionId" > >
2022 diagnostics : Array < { code : string ; message : string } >
23+ includeScopeMatches : number
2124}
2225
2326interface ElementEvidence {
@@ -35,9 +38,10 @@ export function createBrowserAccessibilityCollector(page: Page, contract: Browse
3538 const scans : BrowserAccessibilityScan [ ] = [ ]
3639 const focusHistory : BrowserAccessibilityEvidence [ "focusHistory" ] = [ ]
3740 const diagnostics : BrowserAccessibilityEvidence [ "diagnostics" ] = [ ]
38- const dialogTriggers = new Map < string , string > ( )
41+ const dialogTriggers = new Map < string , ElementEvidence > ( )
3942 let before : PageAccessibilityState | undefined
40- let previousActive : string | undefined
43+ let previousActiveKey : string | undefined
44+ let previousActiveLocator : string | undefined
4145 let currentAction : BrowserAdaptiveAction | undefined
4246 let truncated = false
4347 let scanAttempts = 0
@@ -58,26 +62,28 @@ export function createBrowserAccessibilityCollector(page: Page, contract: Browse
5862 const findings = [ ...state . findings ]
5963 const action = input . action ?? currentAction
6064
61- if ( contract . ruleTags . includes ( "focus-loss" ) && action && state . url === before ?. url && state . active . locator === "body" && before . active . locator !== "body" ) {
62- findings . push ( finding ( "focus" , "focus-loss" , "browser-focus-lost" , "serious" , "focus-lost-to-document" , state . active , "an interaction target " , "document body" ) )
65+ if ( contract . ruleTags . includes ( "focus-loss" ) && action && state . url === before ?. url && state . active . locator === "body" && before . active . locator !== "body" && before . activeInScope ) {
66+ findings . push ( finding ( "focus" , "focus-loss" , "browser-focus-lost" , "serious" , "focus-lost-to-document" , before . active , "focus retained or moved intentionally " , "document body" ) )
6367 }
6468
6569 if ( contract . ruleTags . includes ( "dialog-focus" ) ) {
66- const beforeDialogs = new Set ( ( before ?. dialogs ?? [ ] ) . map ( ( dialog ) => dialog . locator ) )
67- const currentDialogs = new Set ( state . dialogs . map ( ( dialog ) => dialog . locator ) )
70+ const beforeDialogs = new Set ( ( before ?. dialogs ?? [ ] ) . map ( elementKey ) )
71+ const currentDialogs = new Set ( state . dialogs . map ( elementKey ) )
6872 for ( const dialog of state . dialogs ) {
69- if ( ! beforeDialogs . has ( dialog . locator ) && action ) dialogTriggers . set ( dialog . locator , before ?. active . locator ?? "body" )
70- if ( ! state . active . insideDialog ) {
73+ const key = elementKey ( dialog )
74+ if ( ! beforeDialogs . has ( key ) && action ) dialogTriggers . set ( key , before ?. active ?? state . active )
75+ if ( state . activeDialogKey !== key ) {
7176 findings . push ( finding ( "focus" , "dialog-focus" , "browser-dialog-focus-entry" , "serious" , "dialog-focus-not-contained" , dialog , "focus inside the open dialog" , state . active . locator ) )
7277 }
7378 }
7479 for ( const dialog of before ?. dialogs ?? [ ] ) {
75- if ( currentDialogs . has ( dialog . locator ) ) continue
76- const trigger = dialogTriggers . get ( dialog . locator )
77- if ( trigger && state . active . locator !== trigger ) {
78- findings . push ( finding ( "focus" , "dialog-focus" , "browser-dialog-focus-restoration" , "serious" , "dialog-focus-not-restored" , state . active , trigger , state . active . locator ) )
80+ const key = elementKey ( dialog )
81+ if ( currentDialogs . has ( key ) ) continue
82+ const trigger = dialogTriggers . get ( key )
83+ if ( trigger && elementKey ( state . active ) !== elementKey ( trigger ) ) {
84+ findings . push ( finding ( "focus" , "dialog-focus" , "browser-dialog-focus-restoration" , "serious" , "dialog-focus-not-restored" , dialog , trigger . locator , state . active . locator ) )
7985 }
80- dialogTriggers . delete ( dialog . locator )
86+ dialogTriggers . delete ( key )
8187 }
8288 }
8389
@@ -90,17 +96,20 @@ export function createBrowserAccessibilityCollector(page: Page, contract: Browse
9096 } )
9197 if ( findings . length > retained . length ) truncated = true
9298
93- if ( state . active . locator !== previousActive && focusHistory . length < contract . budgets . maxFocusTransitions ) {
94- focusHistory . push ( { index : focusHistory . length , phase : input . phase , actionId : action ?. id , from : previousActive , to : state . active . locator , visible : state . active . visible , insideDialog : state . active . insideDialog } )
95- } else if ( state . active . locator !== previousActive ) {
99+ const activeKey = elementKey ( state . active )
100+ if ( activeKey !== previousActiveKey && focusHistory . length < contract . budgets . maxFocusTransitions ) {
101+ focusHistory . push ( { index : focusHistory . length , phase : input . phase , actionId : action ?. id , from : previousActiveLocator , to : state . active . locator , visible : state . active . visible , insideDialog : state . active . insideDialog } )
102+ } else if ( activeKey !== previousActiveKey ) {
96103 truncated = true
97104 }
98- previousActive = state . active . locator
105+ previousActiveKey = activeKey
106+ previousActiveLocator = state . active . locator
99107
100108 const tree = await accessibilityTree ( page , contract )
101109 const requiredUnavailable = tree . status !== "captured" && contract . capabilities . accessibilityTree === "required"
102110 const treeUnavailable = tree . status !== "captured" && contract . capabilities . accessibilityTree !== "disabled"
103- const status : BrowserAccessibilityScan [ "status" ] = retained . length > 0 ? "findings" : treeUnavailable ? "unsupported" : state . diagnostics . some ( ( item ) => item . code === "browser_accessibility_scope_invalid" ) ? "inconclusive" : "passed"
111+ const scopeInconclusive = state . diagnostics . some ( ( item ) => item . code === "browser_accessibility_scope_invalid" || item . code === "browser_accessibility_scope_unmatched" )
112+ const status : BrowserAccessibilityScan [ "status" ] = retained . length > 0 ? "findings" : treeUnavailable ? "unsupported" : scopeInconclusive ? "inconclusive" : "passed"
104113 const scan : BrowserAccessibilityScan = {
105114 index : scanAttempts - 1 ,
106115 phase : input . phase ,
@@ -119,7 +128,8 @@ export function createBrowserAccessibilityCollector(page: Page, contract: Browse
119128 } ,
120129 async reset ( ) {
121130 before = undefined
122- previousActive = undefined
131+ previousActiveKey = undefined
132+ previousActiveLocator = undefined
123133 currentAction = undefined
124134 dialogTriggers . clear ( )
125135 } ,
@@ -167,12 +177,19 @@ async function inspectPage(page: Page, contract: BrowserAccessibilityContract):
167177 }
168178 const main = states . find ( ( state ) => state . url === page . url ( ) ) ?? states [ 0 ]
169179 const focused = [ ...states ] . reverse ( ) . find ( ( state ) => state . focusedDocument )
180+ const stateDiagnostics = states . flatMap ( ( state ) => state . diagnostics )
181+ if ( contract . includeScopes . length > 0 && states . reduce ( ( count , state ) => count + state . includeScopeMatches , 0 ) === 0 ) {
182+ stateDiagnostics . push ( { code : "browser_accessibility_scope_unmatched" , message : "No declared accessibility include scope matched an inspectable frame; the scan is inconclusive." } )
183+ }
170184 return {
171185 url : page . url ( ) ,
172186 active : focused ?. active ?? main ?. active ?? { locator : "body" , frameId : "document" , tag : "body" , visible : true , insideDialog : false } ,
187+ activeInScope : focused ?. activeInScope ?? main ?. activeInScope ?? false ,
188+ activeDialogKey : focused ?. activeDialogKey ?? main ?. activeDialogKey ,
173189 dialogs : states . flatMap ( ( state ) => state . dialogs ) ,
174190 findings : states . flatMap ( ( state ) => state . findings ) . slice ( 0 , contract . budgets . maxViolationsPerScan * contract . budgets . maxTargetsPerViolation ) ,
175- diagnostics : [ ...states . flatMap ( ( state ) => state . diagnostics ) , ...diagnostics ] ,
191+ diagnostics : [ ...stateDiagnostics , ...diagnostics ] ,
192+ includeScopeMatches : states . reduce ( ( count , state ) => count + state . includeScopeMatches , 0 ) ,
176193 }
177194}
178195
@@ -216,6 +233,7 @@ async function inspectFrame(frame: Frame, frameId: string, contract: BrowserAcce
216233 return { locator : path ( target ) , frameId, tag : target . tagName . toLowerCase ( ) , role : role ( target ) , visible : visible ( target ) , insideDialog : Boolean ( target . closest ( "dialog,[role='dialog'],[role='alertdialog']" ) ) , states, box : { x : Math . round ( rect . x ) , y : Math . round ( rect . y ) , width : Math . round ( rect . width ) , height : Math . round ( rect . height ) , viewportWidth : innerWidth , viewportHeight : innerHeight } }
217234 }
218235 const invalidScopes = [ ...includeScopes , ...excludeScopes ] . filter ( ( selector ) => { try { document . querySelector ( selector ) ; return false } catch { return true } } )
236+ const includeScopeMatches = includeScopes . reduce ( ( count , selector ) => { try { return count + ( document . querySelector ( selector ) ? 1 : 0 ) } catch { return count } } , 0 )
219237 const inScope = ( element : Element ) => {
220238 const included = includeScopes . length === 0 || includeScopes . some ( ( selector ) => { try { return element . matches ( selector ) || Boolean ( element . closest ( selector ) ) } catch { return false } } )
221239 const excluded = excludeScopes . some ( ( selector ) => { try { return element . matches ( selector ) || Boolean ( element . closest ( selector ) ) } catch { return false } } )
@@ -278,13 +296,16 @@ async function inspectFrame(frame: Frame, frameId: string, contract: BrowserAcce
278296 }
279297 }
280298 const active = evidence ( document . activeElement )
281- if ( rules . includes ( "focus-visible" ) && document . activeElement && document . activeElement !== document . body && ! active . visible ) findings . push ( { oracle : "focus" , rule : "focus-visible" , code : "browser-focused-element-hidden" , impact : "serious" , classification : "focus-in-hidden-inert-or-offscreen-content" , target : active , expected : "visible and operable" , actual : active . states ?. inert === "true" ? "inert" : "hidden, clipped, or offscreen" } )
299+ const activeInScope = Boolean ( document . activeElement && inScope ( document . activeElement ) )
300+ const activeDialog = document . activeElement ?. closest ( "dialog,[role='dialog'],[role='alertdialog']" )
301+ const activeDialogKey = activeDialog ? `${ frameId } \n${ path ( activeDialog ) } ` : undefined
302+ if ( rules . includes ( "focus-visible" ) && document . activeElement && document . activeElement !== document . body && activeInScope && ! active . visible ) findings . push ( { oracle : "focus" , rule : "focus-visible" , code : "browser-focused-element-hidden" , impact : "serious" , classification : "focus-in-hidden-inert-or-offscreen-content" , target : active , expected : "visible and operable" , actual : active . states ?. inert === "true" ? "inert" : "hidden, clipped, or offscreen" } )
282303 const dialogs = Array . from ( document . querySelectorAll ( "dialog,[role='dialog'],[role='alertdialog']" ) ) . filter ( ( element ) => {
283304 let nativeModal = false
284305 try { nativeModal = element . matches ( ":modal" ) } catch { nativeModal = element . tagName === "DIALOG" && ( element as HTMLDialogElement ) . open }
285306 return ( nativeModal || element . getAttribute ( "aria-modal" ) === "true" ) && visible ( element ) && inScope ( element )
286307 } ) . map ( evidence )
287- return { url : location . href , focusedDocument : document . hasFocus ( ) , active, dialogs, findings, diagnostics : invalidScopes . length > 0 ? [ { code : "browser_accessibility_scope_invalid" , message : "One or more accessibility scope selectors were invalid; the scan is inconclusive." } ] : [ ] }
308+ return { url : location . href , focusedDocument : document . hasFocus ( ) , active, activeInScope , activeDialogKey , dialogs, findings, diagnostics : invalidScopes . length > 0 ? [ { code : "browser_accessibility_scope_invalid" , message : "One or more accessibility scope selectors were invalid; the scan is inconclusive." } ] : [ ] , includeScopeMatches }
288309 } , { includeScopes : contract . includeScopes , excludeScopes : contract . excludeScopes , rules : contract . ruleTags , maxTargets : contract . budgets . maxViolationsPerScan * contract . budgets . maxTargetsPerViolation , frameId } )
289310}
290311
@@ -304,6 +325,8 @@ function finding(oracle: BrowserAccessibilityFinding["oracle"], rule: BrowserAcc
304325 return { oracle, rule, code, impact, classification, target, expected, actual }
305326}
306327
328+ function elementKey ( element : ElementEvidence ) : string { return `${ element . frameId } \n${ element . locator } ` }
329+
307330function impactRank ( impact : BrowserAccessibilityFinding [ "impact" ] ) : number { return { minor : 0 , moderate : 1 , serious : 2 , critical : 3 } [ impact ] }
308331
309332function accessibilityFrameIdentities ( page : Page , maximum : number ) : Array < { id : string ; frame : Frame } > {
0 commit comments