| /* |
| * Licensed to the Apache Software Foundation (ASF) under one |
| * or more contributor license agreements. See the NOTICE file |
| * distributed with this work for additional information |
| * regarding copyright ownership. The ASF licenses this file |
| * to you under the Apache License, Version 2.0 (the |
| * "License"); you may not use this file except in compliance |
| * with the License. You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, |
| * software distributed under the License is distributed on an |
| * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| * KIND, either express or implied. See the License for the |
| * specific language governing permissions and limitations |
| * under the License. |
| */ |
| |
| const ACTIONABLE_AX_ROLES = new Set([ |
| 'button', |
| 'checkbox', |
| 'combobox', |
| 'link', |
| 'menuitem', |
| 'menuitemcheckbox', |
| 'menuitemradio', |
| 'option', |
| 'radio', |
| 'searchbox', |
| 'slider', |
| 'spinbutton', |
| 'switch', |
| 'tab', |
| 'textbox', |
| 'treeitem', |
| ]); |
| |
| const NAMED_SCOPE_ROLES = new Set([ |
| 'alert', |
| 'alertdialog', |
| 'article', |
| 'dialog', |
| 'form', |
| 'group', |
| 'list', |
| 'listitem', |
| 'main', |
| 'navigation', |
| 'region', |
| 'row', |
| 'status', |
| 'tablist', |
| 'toolbar', |
| ]); |
| |
| function axValue(value) { |
| return value?.value; |
| } |
| |
| export function axString(value) { |
| const raw = axValue(value); |
| return typeof raw === 'string' ? raw.trim() : ''; |
| } |
| |
| function properties(node) { |
| return new Map( |
| (node.properties ?? []).map((property) => [property.name, axValue(property.value)]), |
| ); |
| } |
| |
| function exposedNodes(nodes) { |
| return (Array.isArray(nodes) ? nodes : []).filter((node) => !node?.ignored); |
| } |
| |
| function applicationNodes(nodes) { |
| return exposedNodes(nodes).filter( |
| (node) => !['RootWebArea', 'WebArea'].includes(axString(node.role)), |
| ); |
| } |
| |
| function actionableNodes(nodes) { |
| return exposedNodes(nodes).filter((node) => ACTIONABLE_AX_ROLES.has(axString(node.role))); |
| } |
| |
| function subtreeText(node, nodesById, cache) { |
| const cached = cache.get(node.nodeId); |
| if (cached !== undefined) return cached; |
| const names = []; |
| const queue = [...(node.childIds ?? [])]; |
| let index = 0; |
| while (index < queue.length && names.length < 3) { |
| const child = nodesById.get(queue[index]); |
| index += 1; |
| if (!child) continue; |
| const role = axString(child.role); |
| const name = axString(child.name); |
| if (name && !ACTIONABLE_AX_ROLES.has(role)) names.push(name); |
| queue.push(...(child.childIds ?? [])); |
| } |
| const text = names.join(' · '); |
| cache.set(node.nodeId, text); |
| return text; |
| } |
| |
| function namedScope(node, nodesById, subtreeTextCache) { |
| const scopes = []; |
| let parentId = node.parentId; |
| while (parentId && scopes.length < 3) { |
| const parent = nodesById.get(parentId); |
| if (!parent) break; |
| const role = axString(parent.role); |
| if (NAMED_SCOPE_ROLES.has(role)) { |
| const name = axString(parent.name) || subtreeText(parent, nodesById, subtreeTextCache); |
| if (name) scopes.push(`${role}:${name}`); |
| } |
| parentId = parent.parentId; |
| } |
| const key = scopes.join(' > '); |
| return { |
| key, |
| label: key.length > 160 ? `${key.slice(0, 159)}…` : key, |
| }; |
| } |
| |
| export function findUnnamedActionableAxNodes(nodes) { |
| return actionableNodes(nodes) |
| .filter((node) => !axString(node.name)) |
| .map((node) => ({ |
| role: axString(node.role), |
| backendDOMNodeId: node.backendDOMNodeId, |
| })); |
| } |
| |
| export function findAxLandmarkProblems(nodes) { |
| const mainLandmarks = exposedNodes(nodes).filter((node) => axString(node.role) === 'main'); |
| return mainLandmarks.length > 1 |
| ? [ |
| { |
| code: 'multiple_main_landmarks', |
| nodes: mainLandmarks.map((node) => ({ |
| name: axString(node.name), |
| backendDOMNodeId: node.backendDOMNodeId, |
| })), |
| }, |
| ] |
| : []; |
| } |
| |
| export function findMissingActionableState(nodes) { |
| const missing = []; |
| for (const node of actionableNodes(nodes)) { |
| const role = axString(node.role); |
| const state = properties(node); |
| if ( |
| ['checkbox', 'menuitemcheckbox', 'menuitemradio', 'radio', 'switch'].includes(role) && |
| !state.has('checked') |
| ) { |
| missing.push({ role, name: axString(node.name), property: 'checked' }); |
| } |
| if (['option', 'tab'].includes(role) && !state.has('selected')) { |
| missing.push({ role, name: axString(node.name), property: 'selected' }); |
| } |
| if ( |
| (role === 'combobox' || (role === 'button' && state.has('controls'))) && |
| !state.has('expanded') |
| ) { |
| missing.push({ role, name: axString(node.name), property: 'expanded' }); |
| } |
| if (['slider', 'spinbutton'].includes(role) && axValue(node.value) === undefined) { |
| missing.push({ role, name: axString(node.name), property: 'value' }); |
| } |
| } |
| return missing; |
| } |
| |
| export function findUnnamedRequiredContainers(nodes) { |
| return exposedNodes(nodes) |
| .filter((node) => ['alertdialog', 'dialog'].includes(axString(node.role))) |
| .filter((node) => !axString(node.name)) |
| .map((node) => ({ |
| role: axString(node.role), |
| backendDOMNodeId: node.backendDOMNodeId, |
| })); |
| } |
| |
| export function findAmbiguousActionableAxNodes(nodes) { |
| const exposed = exposedNodes(nodes); |
| const nodesById = new Map( |
| (Array.isArray(nodes) ? nodes : []) |
| .filter((node) => typeof node.nodeId === 'string') |
| .map((node) => [node.nodeId, node]), |
| ); |
| const subtreeTextCache = new Map(); |
| const grouped = new Map(); |
| for (const node of actionableNodes(exposed)) { |
| const role = axString(node.role); |
| const name = axString(node.name); |
| if (!name) continue; |
| const key = `${role}\u0000${name}`; |
| const entries = grouped.get(key) ?? []; |
| const scope = namedScope(node, nodesById, subtreeTextCache); |
| entries.push({ |
| role, |
| name, |
| scope: scope.label, |
| scopeKey: scope.key, |
| backendDOMNodeId: node.backendDOMNodeId, |
| }); |
| grouped.set(key, entries); |
| } |
| |
| const ambiguous = []; |
| for (const entries of grouped.values()) { |
| if (entries.length < 2) continue; |
| const byScope = new Map(); |
| for (const entry of entries) { |
| const scoped = byScope.get(entry.scopeKey) ?? []; |
| scoped.push(entry); |
| byScope.set(entry.scopeKey, scoped); |
| } |
| for (const scoped of byScope.values()) { |
| if (scoped.length < 2) continue; |
| ambiguous.push({ |
| role: scoped[0].role, |
| name: scoped[0].name, |
| scope: scoped[0].scope, |
| count: scoped.length, |
| backendDOMNodeIds: scoped.map((entry) => entry.backendDOMNodeId), |
| }); |
| } |
| } |
| return ambiguous; |
| } |
| |
| export function auditAxTree(nodes) { |
| const sourceNodes = Array.isArray(nodes) ? nodes : []; |
| const exposed = exposedNodes(sourceNodes); |
| const application = applicationNodes(sourceNodes); |
| const problems = []; |
| if (application.length === 0) problems.push({ code: 'empty_ax_tree', nodes: [] }); |
| |
| const unnamed = findUnnamedActionableAxNodes(sourceNodes); |
| if (unnamed.length > 0) problems.push({ code: 'unnamed_actionable', nodes: unnamed }); |
| |
| problems.push(...findAxLandmarkProblems(sourceNodes)); |
| |
| const missingState = findMissingActionableState(sourceNodes); |
| if (missingState.length > 0) { |
| problems.push({ code: 'missing_actionable_state', nodes: missingState }); |
| } |
| |
| const ambiguous = findAmbiguousActionableAxNodes(sourceNodes); |
| if (ambiguous.length > 0) { |
| problems.push({ code: 'ambiguous_actionable', nodes: ambiguous }); |
| } |
| |
| const unnamedContainers = findUnnamedRequiredContainers(sourceNodes); |
| if (unnamedContainers.length > 0) { |
| problems.push({ code: 'unnamed_required_container', nodes: unnamedContainers }); |
| } |
| |
| return { |
| sourceNodeCount: sourceNodes.length, |
| exposedNodeCount: exposed.length, |
| applicationNodeCount: application.length, |
| actionableNodeCount: actionableNodes(sourceNodes).length, |
| problems, |
| }; |
| } |