init
This commit is contained in:
363
.opencode/skills/chrome-devtools/scripts/aria-snapshot.js
Executable file
363
.opencode/skills/chrome-devtools/scripts/aria-snapshot.js
Executable 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();
|
||||
Reference in New Issue
Block a user