源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
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
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
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
/*
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user