Skip to content

Commit 897e232

Browse files
authored
fix: enforce accessibility scopes for focus findings (#2072)
1 parent a4976e2 commit 897e232

3 files changed

Lines changed: 157 additions & 22 deletions

File tree

docs/adversarial-runtime.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,19 @@ each novel state, and the final state. The contract accepts `ruleTags`,
177177
`includeScopes`, `excludeScopes`, `impactThreshold`, `cadence`, required or
178178
optional `rules`, `focus`, and `accessibilityTree` capabilities, plus explicit
179179
scan, violation, focus-history, tree-size, and keyboard-action budgets.
180+
Scope selectors are resolved independently in every inspectable frame. A target
181+
is included when it matches or descends from an `includeScopes` selector (or
182+
when no include selector is declared), and it is always removed when it matches
183+
or descends from an `excludeScopes` selector. Excludes therefore override
184+
overlapping or nested includes. The same rule applies to static, keyboard,
185+
focus, and dialog findings. Focus may cross a scope boundary and remain in the
186+
bounded transition history or a finding's expected/actual context, but an
187+
out-of-scope element is never itself emitted as the finding target.
188+
189+
Invalid scope selectors and include selectors that match no inspectable frame
190+
make the scan inconclusive instead of producing a silent pass. Frames that
191+
cannot be inspected produce an explicit diagnostic; findings from inspectable
192+
same-origin frames retain their stable frame identity.
180193

181194
```json
182195
{

packages/runtime-playground/src/browser-accessibility-collector.ts

Lines changed: 45 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -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

2326
interface 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+
307330
function impactRank(impact: BrowserAccessibilityFinding["impact"]): number { return { minor: 0, moderate: 1, serious: 2, critical: 3 }[impact] }
308331

309332
function accessibilityFrameIdentities(page: Page, maximum: number): Array<{ id: string; frame: Frame }> {

0 commit comments

Comments
 (0)