Dependency audit / audit (push) Canceled after 0s
CI / plan (push) Canceled after 0s
Gitoxide helper admission / macos-latest (push) Canceled after 0s
Gitoxide helper admission / ubuntu-latest (push) Canceled after 0s
Gitoxide helper admission / windows-latest (push) Canceled after 0s
Runtime Host peer admission / quality (push) Canceled after 0s
Windows recovery / windows_recovery (push) Canceled after 0s
CI / heavy (push) Canceled after 0s
CI / test (push) Canceled after 0s
271 lines
7.8 KiB
JavaScript
271 lines
7.8 KiB
JavaScript
/*
|
|
* 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,
|
|
};
|
|
}
|