chrome-devtools-frontend
Version:
Chrome DevTools UI
710 lines (644 loc) • 21.7 kB
text/typescript
// 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: string = `(${injectedScript.toString()})(${
JSON.stringify(AnnouncementApi.ARIA_LIVE)}, ${JSON.stringify(AnnouncementApi.JS_TRIGGERED)});`;
export const TEARDOWN_SCRIPT_SOURCE: string = `(${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;
}
}