| // Copyright 2014 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 ProtocolProxyApi from '../../generated/protocol-proxy-api.js'; |
| import type * as Protocol from '../../generated/protocol.js'; |
| |
| import {DeferredDOMNode, type DOMNode} from './DOMModel.js'; |
| import {SDKModel} from './SDKModel.js'; |
| import {Capability, type Target} from './Target.js'; |
| |
| export const enum CoreAxPropertyName { |
| NAME = 'name', |
| DESCRIPTION = 'description', |
| VALUE = 'value', |
| ROLE = 'role', |
| } |
| |
| export interface CoreOrProtocolAxProperty { |
| name: CoreAxPropertyName|Protocol.Accessibility.AXPropertyName; |
| value: Protocol.Accessibility.AXValue; |
| } |
| |
| export class AccessibilityNode { |
| readonly #accessibilityModel: AccessibilityModel; |
| readonly #id: Protocol.Accessibility.AXNodeId; |
| readonly #backendDOMNodeId: Protocol.DOM.BackendNodeId|null; |
| readonly #deferredDOMNode: DeferredDOMNode|null; |
| readonly #ignored: boolean; |
| readonly #ignoredReasons: Protocol.Accessibility.AXProperty[]|undefined; |
| readonly #role: Protocol.Accessibility.AXValue|null; |
| readonly #name: Protocol.Accessibility.AXValue|null; |
| readonly #description: Protocol.Accessibility.AXValue|null; |
| readonly #value: Protocol.Accessibility.AXValue|null; |
| readonly #properties: Protocol.Accessibility.AXProperty[]|null; |
| readonly #parentId: Protocol.Accessibility.AXNodeId|null; |
| readonly #frameId: Protocol.Page.FrameId|null; |
| readonly #childIds: Protocol.Accessibility.AXNodeId[]|null; |
| |
| constructor(accessibilityModel: AccessibilityModel, payload: Protocol.Accessibility.AXNode) { |
| this.#accessibilityModel = accessibilityModel; |
| |
| this.#id = payload.nodeId; |
| accessibilityModel.setAXNodeForAXId(this.#id, this); |
| if (payload.backendDOMNodeId) { |
| accessibilityModel.setAXNodeForBackendDOMNodeId(payload.backendDOMNodeId, this); |
| this.#backendDOMNodeId = payload.backendDOMNodeId; |
| this.#deferredDOMNode = new DeferredDOMNode(accessibilityModel.target(), payload.backendDOMNodeId); |
| } else { |
| this.#backendDOMNodeId = null; |
| this.#deferredDOMNode = null; |
| } |
| this.#ignored = payload.ignored; |
| if (this.#ignored && 'ignoredReasons' in payload) { |
| this.#ignoredReasons = payload.ignoredReasons; |
| } |
| |
| this.#role = payload.role || null; |
| this.#name = payload.name || null; |
| this.#description = payload.description || null; |
| this.#value = payload.value || null; |
| this.#properties = payload.properties || null; |
| this.#childIds = [...new Set(payload.childIds)]; |
| this.#parentId = payload.parentId || null; |
| if (payload.frameId && !payload.parentId) { |
| this.#frameId = payload.frameId; |
| accessibilityModel.setRootAXNodeForFrameId(payload.frameId, this); |
| } else { |
| this.#frameId = null; |
| } |
| } |
| |
| id(): Protocol.Accessibility.AXNodeId { |
| return this.#id; |
| } |
| |
| accessibilityModel(): AccessibilityModel { |
| return this.#accessibilityModel; |
| } |
| |
| ignored(): boolean { |
| return this.#ignored; |
| } |
| |
| ignoredReasons(): Protocol.Accessibility.AXProperty[]|null { |
| return this.#ignoredReasons || null; |
| } |
| |
| role(): Protocol.Accessibility.AXValue|null { |
| return this.#role || null; |
| } |
| |
| coreProperties(): CoreOrProtocolAxProperty[] { |
| const properties: CoreOrProtocolAxProperty[] = []; |
| |
| if (this.#name) { |
| properties.push({name: CoreAxPropertyName.NAME, value: this.#name}); |
| } |
| if (this.#description) { |
| properties.push({name: CoreAxPropertyName.DESCRIPTION, value: this.#description}); |
| } |
| if (this.#value) { |
| properties.push({name: CoreAxPropertyName.VALUE, value: this.#value}); |
| } |
| |
| return properties; |
| } |
| |
| name(): Protocol.Accessibility.AXValue|null { |
| return this.#name || null; |
| } |
| |
| description(): Protocol.Accessibility.AXValue|null { |
| return this.#description || null; |
| } |
| |
| value(): Protocol.Accessibility.AXValue|null { |
| return this.#value || null; |
| } |
| |
| properties(): Protocol.Accessibility.AXProperty[]|null { |
| return this.#properties || null; |
| } |
| |
| parentNode(): AccessibilityNode|null { |
| if (this.#parentId) { |
| return this.#accessibilityModel.axNodeForId(this.#parentId); |
| } |
| return null; |
| } |
| |
| isDOMNode(): boolean { |
| return Boolean(this.#backendDOMNodeId); |
| } |
| |
| backendDOMNodeId(): Protocol.DOM.BackendNodeId|null { |
| return this.#backendDOMNodeId; |
| } |
| |
| deferredDOMNode(): DeferredDOMNode|null { |
| return this.#deferredDOMNode; |
| } |
| |
| highlightDOMNode(): void { |
| const deferredNode = this.deferredDOMNode(); |
| if (!deferredNode) { |
| return; |
| } |
| // Highlight node in page. |
| deferredNode.highlight(); |
| } |
| |
| children(): AccessibilityNode[] { |
| if (!this.#childIds) { |
| return []; |
| } |
| |
| const children = []; |
| for (const childId of this.#childIds) { |
| const child = this.#accessibilityModel.axNodeForId(childId); |
| if (child) { |
| children.push(child); |
| } |
| } |
| |
| return children; |
| } |
| |
| numChildren(): number { |
| if (!this.#childIds) { |
| return 0; |
| } |
| return this.#childIds.length; |
| } |
| |
| hasOnlyUnloadedChildren(): boolean { |
| if (!this.#childIds || !this.#childIds.length) { |
| return false; |
| } |
| return this.#childIds.every(id => this.#accessibilityModel.axNodeForId(id) === null); |
| } |
| |
| hasUnloadedChildren(): boolean { |
| if (!this.#childIds || !this.#childIds.length) { |
| return false; |
| } |
| return this.#childIds.some(id => this.#accessibilityModel.axNodeForId(id) === null); |
| } |
| // Only the root node gets a frameId, so nodes have to walk up the tree to find their frameId. |
| getFrameId(): Protocol.Page.FrameId|null { |
| return this.#frameId || this.parentNode()?.getFrameId() || null; |
| } |
| } |
| |
| export const enum Events { |
| TREE_UPDATED = 'TreeUpdated', |
| } |
| |
| export interface EventTypes { |
| [Events.TREE_UPDATED]: {root?: AccessibilityNode}; |
| } |
| |
| export class AccessibilityModel extends SDKModel<EventTypes> implements ProtocolProxyApi.AccessibilityDispatcher { |
| agent: ProtocolProxyApi.AccessibilityApi; |
| #axIdToAXNode = new Map<string, AccessibilityNode>(); |
| #backendDOMNodeIdToAXNode = new Map<Protocol.DOM.BackendNodeId, AccessibilityNode>(); |
| #frameIdToAXNode = new Map<Protocol.Page.FrameId, AccessibilityNode>(); |
| #pendingChildRequests = new Map<string, Promise<Protocol.Accessibility.GetChildAXNodesResponse>>(); |
| #root: AccessibilityNode|null = null; |
| |
| constructor(target: Target) { |
| super(target); |
| target.registerAccessibilityDispatcher(this); |
| this.agent = target.accessibilityAgent(); |
| void this.resumeModel(); |
| } |
| |
| clear(): void { |
| this.#root = null; |
| this.#axIdToAXNode.clear(); |
| this.#backendDOMNodeIdToAXNode.clear(); |
| this.#frameIdToAXNode.clear(); |
| } |
| |
| override async resumeModel(): Promise<void> { |
| await this.agent.invoke_enable(); |
| } |
| |
| override async suspendModel(): Promise<void> { |
| await this.agent.invoke_disable(); |
| } |
| |
| async requestPartialAXTree(node: DOMNode): Promise<void> { |
| const {nodes} = await this.agent.invoke_getPartialAXTree({nodeId: node.id, fetchRelatives: true}); |
| if (!nodes) { |
| return; |
| } |
| const axNodes = []; |
| for (const payload of nodes) { |
| axNodes.push(new AccessibilityNode(this, payload)); |
| } |
| } |
| |
| loadComplete({root}: Protocol.Accessibility.LoadCompleteEvent): void { |
| this.clear(); |
| this.#root = new AccessibilityNode(this, root); |
| this.dispatchEventToListeners(Events.TREE_UPDATED, {root: this.#root}); |
| } |
| |
| nodesUpdated({nodes}: Protocol.Accessibility.NodesUpdatedEvent): void { |
| this.createNodesFromPayload(nodes); |
| this.dispatchEventToListeners(Events.TREE_UPDATED, {}); |
| return; |
| } |
| |
| private createNodesFromPayload(payloadNodes: Protocol.Accessibility.AXNode[]): AccessibilityNode[] { |
| const accessibilityNodes = payloadNodes.map(node => { |
| const sdkNode = new AccessibilityNode(this, node); |
| return sdkNode; |
| }); |
| |
| return accessibilityNodes; |
| } |
| |
| async requestRootNode(frameId?: Protocol.Page.FrameId): Promise<AccessibilityNode|undefined> { |
| if (frameId && this.#frameIdToAXNode.has(frameId)) { |
| return this.#frameIdToAXNode.get(frameId); |
| } |
| if (!frameId && this.#root) { |
| return this.#root; |
| } |
| const {node} = await this.agent.invoke_getRootAXNode({frameId}); |
| if (!node) { |
| return; |
| } |
| return this.createNodesFromPayload([node])[0]; |
| } |
| |
| async requestAXChildren(nodeId: Protocol.Accessibility.AXNodeId, frameId?: Protocol.Page.FrameId): |
| Promise<AccessibilityNode[]> { |
| const parent = this.#axIdToAXNode.get(nodeId); |
| if (!parent) { |
| throw new Error('Cannot request children before parent'); |
| } |
| if (!parent.hasUnloadedChildren()) { |
| return parent.children(); |
| } |
| |
| const request = this.#pendingChildRequests.get(nodeId); |
| if (request) { |
| await request; |
| } else { |
| const request = this.agent.invoke_getChildAXNodes({id: nodeId, frameId}); |
| this.#pendingChildRequests.set(nodeId, request); |
| const result = await request; |
| if (!result.getError()) { |
| this.createNodesFromPayload(result.nodes); |
| this.#pendingChildRequests.delete(nodeId); |
| } |
| } |
| return parent.children(); |
| } |
| |
| async requestAndLoadSubTreeToNode(node: DOMNode): Promise<AccessibilityNode[]|null> { |
| // Node may have already been loaded, so don't bother requesting it again. |
| const result = []; |
| let ancestor = this.axNodeForDOMNode(node); |
| while (ancestor) { |
| result.push(ancestor); |
| const parent = ancestor.parentNode(); |
| if (!parent) { |
| return result; |
| } |
| ancestor = parent; |
| } |
| const {nodes} = await this.agent.invoke_getAXNodeAndAncestors({backendNodeId: node.backendNodeId()}); |
| if (!nodes) { |
| return null; |
| } |
| const ancestors = this.createNodesFromPayload(nodes); |
| |
| return ancestors; |
| } |
| |
| axNodeForId(axId: Protocol.Accessibility.AXNodeId): AccessibilityNode|null { |
| return this.#axIdToAXNode.get(axId) || null; |
| } |
| |
| setRootAXNodeForFrameId(frameId: Protocol.Page.FrameId, axNode: AccessibilityNode): void { |
| this.#frameIdToAXNode.set(frameId, axNode); |
| } |
| |
| setAXNodeForAXId(axId: string, axNode: AccessibilityNode): void { |
| this.#axIdToAXNode.set(axId, axNode); |
| } |
| |
| axNodeForDOMNode(domNode: DOMNode|null): AccessibilityNode|null { |
| if (!domNode) { |
| return null; |
| } |
| return this.#backendDOMNodeIdToAXNode.get(domNode.backendNodeId()) ?? null; |
| } |
| |
| setAXNodeForBackendDOMNodeId(backendDOMNodeId: Protocol.DOM.BackendNodeId, axNode: AccessibilityNode): void { |
| this.#backendDOMNodeIdToAXNode.set(backendDOMNodeId, axNode); |
| } |
| |
| getAgent(): ProtocolProxyApi.AccessibilityApi { |
| return this.agent; |
| } |
| } |
| |
| SDKModel.register(AccessibilityModel, {capabilities: Capability.DOM, autostart: false}); |