This commit is contained in:
2026-04-12 01:06:31 +07:00
commit 10d660cbcb
1066 changed files with 228596 additions and 0 deletions

View File

@@ -0,0 +1,363 @@
#!/usr/bin/env node
/**
* Get ARIA-based accessibility snapshot with stable element refs
* Usage: node aria-snapshot.js [--url https://example.com] [--output snapshot.yaml]
*
* Returns YAML-formatted accessibility tree with:
* - Semantic roles (button, link, textbox, heading, etc.)
* - Accessible names (what screen readers announce)
* - Element states (checked, disabled, expanded)
* - Stable refs [ref=eN] that persist for interaction
*
* Session behavior:
* By default, browser stays running for session persistence
* Use --close true to fully close browser
*/
import { getBrowser, getPage, closeBrowser, disconnectBrowser, parseArgs, outputJSON, outputError } from './lib/browser.js';
import fs from 'fs/promises';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
/**
* Get ARIA snapshot script to inject into page
* Builds YAML-formatted accessibility tree with element references
*/
function getAriaSnapshotScript() {
return `
(function() {
// Store refs on window for later retrieval via selectRef
window.__chromeDevToolsRefs = window.__chromeDevToolsRefs || new Map();
let refCounter = window.__chromeDevToolsRefCounter || 1;
// ARIA roles we care about for interaction
const INTERACTIVE_ROLES = new Set([
'button', 'link', 'textbox', 'checkbox', 'radio', 'combobox',
'listbox', 'option', 'menuitem', 'menuitemcheckbox', 'menuitemradio',
'tab', 'switch', 'slider', 'spinbutton', 'searchbox', 'tree', 'treeitem',
'grid', 'gridcell', 'row', 'rowheader', 'columnheader'
]);
// Landmark roles for structure
const LANDMARK_ROLES = new Set([
'banner', 'navigation', 'main', 'complementary', 'contentinfo',
'search', 'form', 'region', 'article', 'dialog', 'alertdialog'
]);
// Implicit ARIA roles from HTML elements
const IMPLICIT_ROLES = {
'A': (el) => el.href ? 'link' : null,
'BUTTON': () => 'button',
'INPUT': (el) => {
const type = el.type?.toLowerCase();
if (type === 'checkbox') return 'checkbox';
if (type === 'radio') return 'radio';
if (type === 'submit' || type === 'button' || type === 'reset') return 'button';
if (type === 'search') return 'searchbox';
if (type === 'range') return 'slider';
if (type === 'number') return 'spinbutton';
return 'textbox';
},
'TEXTAREA': () => 'textbox',
'SELECT': () => 'combobox',
'OPTION': () => 'option',
'IMG': () => 'img',
'NAV': () => 'navigation',
'MAIN': () => 'main',
'HEADER': () => 'banner',
'FOOTER': () => 'contentinfo',
'ASIDE': () => 'complementary',
'ARTICLE': () => 'article',
'SECTION': (el) => el.getAttribute('aria-label') || el.getAttribute('aria-labelledby') ? 'region' : null,
'FORM': () => 'form',
'UL': () => 'list',
'OL': () => 'list',
'LI': () => 'listitem',
'H1': () => 'heading',
'H2': () => 'heading',
'H3': () => 'heading',
'H4': () => 'heading',
'H5': () => 'heading',
'H6': () => 'heading',
'TABLE': () => 'table',
'TR': () => 'row',
'TH': () => 'columnheader',
'TD': () => 'cell',
'DIALOG': () => 'dialog'
};
function getRole(el) {
// Explicit role takes precedence
const explicitRole = el.getAttribute('role');
if (explicitRole) return explicitRole;
// Check implicit role
const implicitFn = IMPLICIT_ROLES[el.tagName];
if (implicitFn) return implicitFn(el);
return null;
}
function getAccessibleName(el) {
// aria-label takes precedence
const ariaLabel = el.getAttribute('aria-label');
if (ariaLabel) return ariaLabel.trim();
// aria-labelledby
const labelledBy = el.getAttribute('aria-labelledby');
if (labelledBy) {
const labels = labelledBy.split(' ')
.map(id => document.getElementById(id)?.textContent?.trim())
.filter(Boolean)
.join(' ');
if (labels) return labels;
}
// Input associated label
if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT') {
if (el.id) {
const label = document.querySelector('label[for="' + el.id + '"]');
if (label) return label.textContent?.trim();
}
// Check parent label
const parentLabel = el.closest('label');
if (parentLabel) {
const labelText = parentLabel.textContent?.replace(el.value || '', '')?.trim();
if (labelText) return labelText;
}
}
// Button/link content
if (el.tagName === 'BUTTON' || el.tagName === 'A') {
const text = el.textContent?.trim();
if (text) return text.substring(0, 100);
}
// Alt text for images
if (el.tagName === 'IMG') {
return el.alt || null;
}
// Title attribute fallback
if (el.title) return el.title.trim();
// Placeholder for inputs
if (el.placeholder) return null; // Return null, will add as /placeholder
return null;
}
function getStateFlags(el) {
const flags = [];
// Checked state
if (el.checked || el.getAttribute('aria-checked') === 'true') {
flags.push('checked');
}
// Disabled state
if (el.disabled || el.getAttribute('aria-disabled') === 'true') {
flags.push('disabled');
}
// Expanded state
if (el.getAttribute('aria-expanded') === 'true') {
flags.push('expanded');
}
// Selected state
if (el.selected || el.getAttribute('aria-selected') === 'true') {
flags.push('selected');
}
// Pressed state
if (el.getAttribute('aria-pressed') === 'true') {
flags.push('pressed');
}
// Required state
if (el.required || el.getAttribute('aria-required') === 'true') {
flags.push('required');
}
return flags;
}
function isVisible(el) {
const style = window.getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden') return false;
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
}
function isInteractiveOrLandmark(role) {
return INTERACTIVE_ROLES.has(role) || LANDMARK_ROLES.has(role);
}
function shouldInclude(el) {
if (!isVisible(el)) return false;
const role = getRole(el);
if (!role) return false;
// Include interactive, landmarks, and structural elements
return isInteractiveOrLandmark(role) ||
role === 'heading' ||
role === 'img' ||
role === 'list' ||
role === 'listitem' ||
role === 'table' ||
role === 'row' ||
role === 'cell' ||
role === 'columnheader';
}
function assignRef(el, role) {
// Only assign refs to interactive elements
if (!INTERACTIVE_ROLES.has(role)) return null;
const ref = 'e' + refCounter++;
window.__chromeDevToolsRefs.set(ref, el);
return ref;
}
function buildYaml(el, indent = 0) {
const role = getRole(el);
if (!role) return '';
const prefix = ' '.repeat(indent) + '- ';
const lines = [];
// Build the line: role "name" [flags] [ref=eN]
let line = prefix + role;
const name = getAccessibleName(el);
if (name) {
line += ' "' + name.replace(/"/g, '\\\\"') + '"';
}
// Add heading level
if (role === 'heading') {
const level = el.tagName.match(/H(\\d)/)?.[1] || el.getAttribute('aria-level');
if (level) line += ' [level=' + level + ']';
}
// Add state flags
const flags = getStateFlags(el);
flags.forEach(flag => {
line += ' [' + flag + ']';
});
// Add ref for interactive elements
const ref = assignRef(el, role);
if (ref) {
line += ' [ref=' + ref + ']';
}
lines.push(line);
// Add metadata on subsequent lines
if (el.tagName === 'A' && el.href) {
lines.push(' '.repeat(indent + 1) + '/url: ' + el.href);
}
if (el.placeholder) {
lines.push(' '.repeat(indent + 1) + '/placeholder: "' + el.placeholder + '"');
}
if (el.tagName === 'INPUT' && el.value && el.type !== 'password') {
lines.push(' '.repeat(indent + 1) + '/value: "' + el.value.substring(0, 50) + '"');
}
// Process children
const children = Array.from(el.children);
children.forEach(child => {
const childYaml = buildYaml(child, indent + 1);
if (childYaml) lines.push(childYaml);
});
return lines.join('\\n');
}
function getSnapshot() {
const lines = [];
// Start from body
const children = Array.from(document.body.children);
children.forEach(child => {
const yaml = buildYaml(child, 0);
if (yaml) lines.push(yaml);
});
// Save ref counter for next snapshot
window.__chromeDevToolsRefCounter = refCounter;
return lines.join('\\n');
}
return getSnapshot();
})();
`;
}
async function ariaSnapshot() {
const args = parseArgs(process.argv.slice(2));
try {
const browser = await getBrowser({
headless: args.headless
});
const page = await getPage(browser);
// Navigate if URL provided
if (args.url) {
await page.goto(args.url, {
waitUntil: args['wait-until'] || 'networkidle2'
});
}
// Get ARIA snapshot
const snapshot = await page.evaluate(getAriaSnapshotScript());
// Build result
const result = {
success: true,
url: page.url(),
title: await page.title(),
format: 'yaml',
snapshot: snapshot
};
// Output to file or stdout
if (args.output) {
const outputPath = args.output;
// Ensure snapshots directory exists
const outputDir = path.dirname(outputPath);
await fs.mkdir(outputDir, { recursive: true });
// Write YAML snapshot
await fs.writeFile(outputPath, snapshot, 'utf8');
outputJSON({
success: true,
output: path.resolve(outputPath),
url: page.url()
});
} else {
// Output to stdout
outputJSON(result);
}
// Default: disconnect to keep browser running for session persistence
// Use --close true to fully close browser
if (args.close === 'true') {
await closeBrowser();
} else {
await disconnectBrowser();
}
process.exit(0);
} catch (error) {
outputError(error);
}
}
ariaSnapshot();