blob: 35ec47a37e0c282cdd8cdf26cb19b90c0fc8a8ac [file]
/*
* 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,
};
}