UNPKG

chrome-devtools-frontend

Version:
710 lines (644 loc) • 21.7 kB
// Copyright 2026 The Chromium Authors // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import type * as Common from '../../core/common/common.js'; import * as i18n from '../../core/i18n/i18n.js'; import * as SDK from '../../core/sdk/sdk.js'; import type * as Protocol from '../../generated/protocol.js'; import {AccessibilitySubPane} from './AccessibilitySubPane.js'; const UIStrings = { /** * @description Title for the ARIA-Live and JS announcements recording tool */ ariaLiveRecording: 'Announcements recording', } as const; const str_ = i18n.i18n.registerUIStrings('panels/accessibility/AccessibilityAnnouncementRecordingView.ts', UIStrings); const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); export const BINDING_NAME = '__announcementsRecorderBinding'; export const enum AnnouncementApi { ARIA_LIVE = 'aria-live', JS_TRIGGERED = 'js-triggered', } declare global { interface Window { // eslint-disable-next-line @typescript-eslint/naming-convention __announcementsRecorderBinding?: (payload: string) => void; // eslint-disable-next-line @typescript-eslint/naming-convention __announcementsRecorderBinding_loaded?: boolean; // eslint-disable-next-line @typescript-eslint/naming-convention __announcementsRecorderBinding_cleanup?: () => void; } } export interface A11yAnnouncement { api: AnnouncementApi; message: string; politeness: string; element: string; elementId?: string; stack?: string; target?: SDK.Target.Target; time: number; } export function injectedScript(ariaLiveApi: string, jsTriggeredApi: string): void { // Prevent duplicate script evaluation if already injected. if (window.__announcementsRecorderBinding_loaded) { return; } // Assigns a unique tracking identifier using an in-memory WeakMap (does not mutate DOM elements). const elementIdMap = new WeakMap<Element, string>(); let recordIdCounter = 0; function getOrCreateRecordId(element: Element|null|undefined): string { if (!element || element.nodeType !== Node.ELEMENT_NODE) { return ''; } let id = elementIdMap.get(element); if (!id) { recordIdCounter++; id = String(recordIdCounter); elementIdMap.set(element, id); } return id; } // Traverses to parent element or across shadow root boundary to host element. function getParentOrHost(node: Node|null|undefined): Element|null { if (!node) { return null; } if (node.parentElement) { return node.parentElement; } const root = node.getRootNode(); if (root && root !== node && root instanceof ShadowRoot) { return root.host; } return null; } // Checks whether an element is visible to accessibility tree (opacity and CSS visibility). function checkVisibility(element: Element|null|undefined): boolean { if (!element) { return false; } try { return element.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true, }); } catch { return true; } } // Checks if any ancestor element is hidden via aria-hidden="true" or CSS visibility across shadow boundaries. function isAncestorHidden(element: Element|null|undefined): boolean { let current = getParentOrHost(element); while (current) { if (current.getAttribute('aria-hidden') === 'true') { return true; } if (!checkVisibility(current)) { return true; } current = getParentOrHost(current); } return false; } // Intercepts programmatic JS announcements by wrapping ariaNotify on the given prototype. function patchAriaNotify(proto: object, name: string): ((...args: unknown[]) => unknown)|undefined { if (!proto || typeof proto !== 'object') { return undefined; } if (!Object.isExtensible(proto) || Object.isFrozen(proto) || Object.isSealed(proto)) { throw new TypeError('Prototype object is non-extensible, frozen, or sealed'); } const desc = Object.getOwnPropertyDescriptor(proto, name); if (desc && (!desc.writable && !desc.set)) { throw new TypeError('Property ' + name + ' is read-only'); } const protoRecord = proto as Record<string, unknown>; const original = protoRecord[name] as ((...args: unknown[]) => unknown) | undefined; const wrapped = function(this: Element|Document, message?: string, options?: {politeness?: string}): unknown { const bindingFn = window.__announcementsRecorderBinding; if (typeof bindingFn === 'function') { try { const announcement = { api: jsTriggeredApi, message: String(message || ''), politeness: (options && options.politeness) || 'polite', elementId: getOrCreateRecordId(this as Element), element: (this as Element).outerHTML || (this as Node).nodeName, stack: new Error().stack || '', time: Date.now(), }; bindingFn(JSON.stringify(announcement)); } catch { } } if (typeof original === 'function') { return original.apply(this, arguments as unknown as unknown[]); } return undefined; }; Object.defineProperty(proto, name, { value: wrapped, writable: true, configurable: true, enumerable: desc ? desc.enumerable : false, }); if (protoRecord[name] !== wrapped) { throw new TypeError('Failed to override ' + name + ' on prototype'); } return original; } // Wrap ariaNotify on prototypes inside isolated try/catch so failure doesn't block MutationObserver. let originalElementAriaNotify: ((...args: unknown[]) => unknown)|undefined; let originalDocumentAriaNotify: ((...args: unknown[]) => unknown)|undefined; let originalAttachShadow: ((init: ShadowRootInit) => ShadowRoot)|undefined; try { originalElementAriaNotify = patchAriaNotify(Element.prototype, 'ariaNotify'); originalDocumentAriaNotify = patchAriaNotify(Document.prototype, 'ariaNotify'); } catch (e: unknown) { const bindingFn = window.__announcementsRecorderBinding; if (typeof bindingFn === 'function') { try { const error = e as {message?: string}; bindingFn(JSON.stringify({ api: 'blocked', reason: error && error.message ? String(error.message) : String(e), })); } catch { } } } // Derives politeness from explicit aria-live attribute or implicit ARIA roles. function derivePoliteness(element: Element): string { const explicit = element.getAttribute('aria-live'); if (explicit) { return explicit; } const role = element.getAttribute('role'); if (role === 'status' || role === 'log') { return 'polite'; } if (role === 'alert') { return 'assertive'; } return 'off'; } let lastRecordedId: string|null = null; let lastRecordedText: string|null = null; let lastRecordedPoliteness: string|null = null; let lastRecordedTime = 0; // Emits an announcement payload for an active live region node via the CDP binding. function recordLiveNode(node: Node|null|undefined): void { if (!node || node.nodeType !== Node.ELEMENT_NODE) { return; } const element = node as Element; const politeness = derivePoliteness(element); if (politeness === 'off') { return; } if (element.getAttribute('aria-hidden') === 'true') { return; } if (!checkVisibility(element)) { return; } if (isAncestorHidden(element)) { return; } const text = (element.textContent || '').trim(); if (!text) { return; } const elementId = getOrCreateRecordId(element); const now = Date.now(); // Debounce duplicate announcements on the same element within 50ms. if (lastRecordedId === elementId && lastRecordedText === text && lastRecordedPoliteness === politeness && (now - lastRecordedTime) < 50) { return; } lastRecordedId = elementId; lastRecordedText = text; lastRecordedPoliteness = politeness; lastRecordedTime = now; const bindingFn = window.__announcementsRecorderBinding; if (typeof bindingFn === 'function') { try { const announcement = { api: ariaLiveApi, message: text, politeness, elementId, element: element.outerHTML || element.nodeName, time: now, }; bindingFn(JSON.stringify(announcement)); } catch { } } } // Selector targeting live regions and standard ARIA roles with live semantics. const selector = '[aria-live], [role="status"], [role="alert"], [role="log"]'; function findLiveParent(node: Node|null|undefined): Element|null { let current: Node|null|undefined = node; while (current) { if (current.nodeType === Node.ELEMENT_NODE) { const el = current as Element; if (el.matches && el.matches(selector)) { return el; } if (el.closest) { const match = el.closest(selector); if (match) { return match; } } } current = getParentOrHost(current); } return null; } const observer = new MutationObserver((mutations: MutationRecord[]) => { for (const mutation of mutations) { if (mutation.type === 'childList') { const target = mutation.target; if (target && target.nodeType === Node.ELEMENT_NODE) { const liveParent = findLiveParent(target); if (liveParent) { recordLiveNode(liveParent); } } for (const node of mutation.addedNodes) { if (node.nodeType === Node.ELEMENT_NODE) { const el = node as Element; if (el.shadowRoot) { observeSubtree(el.shadowRoot); scanAndObserveShadowRoots(el.shadowRoot); } scanAndObserveShadowRoots(el); if (el.matches && el.matches(selector)) { recordLiveNode(el); } const children = el.querySelectorAll ? el.querySelectorAll(selector) : []; for (const child of children) { recordLiveNode(child); } } } } else if (mutation.type === 'characterData') { const parent = mutation.target.parentElement || getParentOrHost(mutation.target); if (parent) { const liveParent = findLiveParent(parent); if (liveParent) { recordLiveNode(liveParent); } } } else if (mutation.type === 'attributes') { // Only record attribute mutations if the mutated element itself is a live region. const target = mutation.target; if (target && target.nodeType === Node.ELEMENT_NODE) { const liveTarget = findLiveParent(target); if (liveTarget) { recordLiveNode(liveTarget); } } } } }); const observedRoots = new WeakSet<Node>(); function observeSubtree(root: Node|null|undefined): void { if (!root || observedRoots.has(root)) { return; } try { observer.observe(root, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ['aria-live', 'aria-hidden', 'hidden', 'style', 'class', 'role'], }); observedRoots.add(root); } catch { } } function scanAndObserveShadowRoots(node: Node|null|undefined): void { if (!node) { return; } if (node.nodeType === Node.ELEMENT_NODE) { const el = node as Element; if (el.shadowRoot) { observeSubtree(el.shadowRoot); scanAndObserveShadowRoots(el.shadowRoot); } } const children = (node as Element).children || []; for (let i = 0; i < children.length; i++) { scanAndObserveShadowRoots(children[i]); } } const rootNode = document.body || document.documentElement; if (rootNode) { observeSubtree(rootNode); scanAndObserveShadowRoots(rootNode); } // Intercept attachShadow to automatically observe dynamically created open shadow roots. try { const origAttachShadow = Element.prototype.attachShadow; if (typeof origAttachShadow === 'function') { originalAttachShadow = origAttachShadow; Element.prototype.attachShadow = function(this: Element, init: ShadowRootInit): ShadowRoot { const shadow = origAttachShadow.apply(this, [init]); if (init && init.mode === 'open') { observeSubtree(shadow); } return shadow; }; } } catch { } window.__announcementsRecorderBinding_loaded = true; // Registers cleanup function invoked during recording teardown. window.__announcementsRecorderBinding_cleanup = function(): void { observer.disconnect(); if (originalElementAriaNotify) { try { (Element.prototype as unknown as Record<string, unknown>)['ariaNotify'] = originalElementAriaNotify; } catch { } } if (originalDocumentAriaNotify) { try { (Document.prototype as unknown as Record<string, unknown>)['ariaNotify'] = originalDocumentAriaNotify; } catch { } } if (originalAttachShadow) { try { Element.prototype.attachShadow = originalAttachShadow; } catch { } } delete window.__announcementsRecorderBinding_loaded; delete window.__announcementsRecorderBinding_cleanup; }; } export function teardownScript(): void { if (typeof window.__announcementsRecorderBinding_cleanup === 'function') { window.__announcementsRecorderBinding_cleanup(); } } export const INJECTED_SCRIPT_SOURCE = `(${injectedScript.toString()})(${JSON.stringify(AnnouncementApi.ARIA_LIVE)}, ${ JSON.stringify(AnnouncementApi.JS_TRIGGERED)});`; export const TEARDOWN_SCRIPT_SOURCE = `(${teardownScript.toString()})();`; export function checkForBlockedPayload(payload: unknown): string|null { if (typeof payload !== 'string') { return null; } try { const parsed = JSON.parse(payload); if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) && parsed.api === 'blocked') { return typeof parsed.reason === 'string' ? parsed.reason : ''; } } catch { return null; } return null; } export function validateAndSanitizeAnnouncement(payload: unknown): A11yAnnouncement|null { if (typeof payload !== 'string') { return null; } let parsed: unknown; try { parsed = JSON.parse(payload); } catch { return null; } if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { return null; } const parsedObj = parsed as { api?: unknown, message?: unknown, politeness?: unknown, element?: unknown, time?: unknown, elementId?: unknown, stack?: unknown, }; if (parsedObj.api !== AnnouncementApi.ARIA_LIVE && parsedObj.api !== AnnouncementApi.JS_TRIGGERED) { return null; } if (typeof parsedObj.message !== 'string') { return null; } if (typeof parsedObj.politeness !== 'string') { return null; } if (typeof parsedObj.element !== 'string') { return null; } if (typeof parsedObj.time !== 'number' || !Number.isFinite(parsedObj.time)) { return null; } let elementId: string|undefined = undefined; if ('elementId' in parsedObj) { if (typeof parsedObj.elementId !== 'string') { return null; } elementId = parsedObj.elementId; } let stack: string|undefined = undefined; if ('stack' in parsedObj) { if (typeof parsedObj.stack !== 'string') { return null; } stack = parsedObj.stack; } return { api: parsedObj.api as AnnouncementApi, message: parsedObj.message, politeness: parsedObj.politeness, element: parsedObj.element, ...(elementId !== undefined ? {elementId} : {}), ...(stack !== undefined ? {stack} : {}), time: parsedObj.time, }; } export class AccessibilityAnnouncementRecordingView extends AccessibilitySubPane implements SDK.TargetManager.Observer { #announcements: A11yAnnouncement[] = []; #isRecording = false; #blockedTargets = new Map<SDK.Target.Target, string>(); #scriptIdentifiers = new Map<SDK.Target.Target, Protocol.Page.ScriptIdentifier>(); #targets = new Set<SDK.Target.Target>(); #enabledTargets = new Set<SDK.Target.Target>(); constructor() { super({ title: i18nString(UIStrings.ariaLiveRecording), viewId: 'aria-live-recording', }); SDK.TargetManager.TargetManager.instance().observeTargets(this, {scoped: true}); } override wasShown(): void { super.wasShown(); this.requestUpdate(); } async targetAdded(target: SDK.Target.Target): Promise<void> { if (target.type() !== SDK.Target.Type.FRAME || this.#targets.has(target)) { return; } this.#targets.add(target); if (this.#isRecording) { await this.#enableTarget(target); } } async targetRemoved(target: SDK.Target.Target): Promise<void> { this.#targets.delete(target); this.#blockedTargets.delete(target); await this.#disableTarget(target); } async #enableTarget(target: SDK.Target.Target): Promise<void> { if (this.#enabledTargets.has(target)) { return; } this.#enabledTargets.add(target); const runtimeModel = target.model(SDK.RuntimeModel.RuntimeModel); if (!runtimeModel) { this.#enabledTargets.delete(target); return; } runtimeModel.addEventListener(SDK.RuntimeModel.Events.BindingCalled, this.#onBindingCalled, this); try { await runtimeModel.addBinding({ name: BINDING_NAME, }); const pageAgent = target.pageAgent(); const {identifier} = await pageAgent.invoke_addScriptToEvaluateOnNewDocument({ source: INJECTED_SCRIPT_SOURCE, runImmediately: true, }); if (!this.#enabledTargets.has(target)) { try { await pageAgent.invoke_removeScriptToEvaluateOnNewDocument({identifier}); } catch { } return; } this.#scriptIdentifiers.set(target, identifier); } catch { await this.#disableTarget(target); } } #handleRecordingBlocked(target: SDK.Target.Target|null, blockedReason: string): void { if (target) { this.#blockedTargets.set(target, blockedReason); } this.requestUpdate(); } async #disableTarget(target: SDK.Target.Target): Promise<void> { if (!this.#enabledTargets.has(target)) { return; } this.#enabledTargets.delete(target); const runtimeModel = target.model(SDK.RuntimeModel.RuntimeModel); if (runtimeModel) { runtimeModel.removeEventListener(SDK.RuntimeModel.Events.BindingCalled, this.#onBindingCalled, this); try { await runtimeModel.removeBinding({ name: BINDING_NAME, }); } catch { } } const identifier = this.#scriptIdentifiers.get(target); if (identifier) { try { await target.pageAgent().invoke_removeScriptToEvaluateOnNewDocument({ identifier, }); } catch { } this.#scriptIdentifiers.delete(target); } try { const runtimeAgent = target.runtimeAgent(); await runtimeAgent.invoke_evaluate({ expression: TEARDOWN_SCRIPT_SOURCE, userGesture: false, awaitPromise: false, }); } catch { } } #onBindingCalled(event: Common.EventTarget .EventTargetEvent<SDK.RuntimeModel.EventTypes[SDK.RuntimeModel.Events.BindingCalled]>): void { const {name, payload} = event.data; if (!this.#isRecording) { return; } if (name !== BINDING_NAME) { return; } const runtimeModel = event.source instanceof SDK.RuntimeModel.RuntimeModel ? event.source : null; const target = runtimeModel?.target() ?? null; const blockedReason = checkForBlockedPayload(payload); if (blockedReason !== null) { this.#handleRecordingBlocked(target, blockedReason); return; } const announcement = validateAndSanitizeAnnouncement(payload); if (!announcement) { return; } if (target) { announcement.target = target; } const last = this.#announcements[this.#announcements.length - 1]; if (last && last.api === announcement.api && last.message === announcement.message && last.politeness === announcement.politeness && last.element === announcement.element && Math.abs(announcement.time - last.time) < 50) { return; } this.#announcements.push(announcement); this.requestUpdate(); } async startRecording(): Promise<void> { if (this.#isRecording) { return; } this.#isRecording = true; this.#blockedTargets.clear(); for (const target of this.#targets) { await this.#enableTarget(target); } this.requestUpdate(); } async stopRecording(): Promise<void> { if (!this.#isRecording) { return; } this.#isRecording = false; for (const target of this.#targets) { await this.#disableTarget(target); } this.requestUpdate(); } clearAnnouncements(): void { this.#announcements = []; this.requestUpdate(); } announcementsForTest(): A11yAnnouncement[] { return [...this.#announcements]; } blockedReasonForTargetForTest(target: SDK.Target.Target): string|undefined { return this.#blockedTargets.get(target); } blockedTargetsForTest(): Map<SDK.Target.Target, string> { return new Map(this.#blockedTargets); } isRecordingForTest(): boolean { return this.#isRecording; } }