ally-a11y 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ACCESSIBILITY.md +205 -0
- package/LICENSE +21 -0
- package/README.md +940 -0
- package/dist/cli.d.ts +7 -0
- package/dist/cli.js +528 -0
- package/dist/commands/audit-palette.d.ts +18 -0
- package/dist/commands/audit-palette.js +613 -0
- package/dist/commands/auto-pr.d.ts +19 -0
- package/dist/commands/auto-pr.js +434 -0
- package/dist/commands/badge.d.ts +11 -0
- package/dist/commands/badge.js +143 -0
- package/dist/commands/completion.d.ts +4 -0
- package/dist/commands/completion.js +185 -0
- package/dist/commands/crawl.d.ts +12 -0
- package/dist/commands/crawl.js +249 -0
- package/dist/commands/doctor.d.ts +5 -0
- package/dist/commands/doctor.js +233 -0
- package/dist/commands/explain.d.ts +12 -0
- package/dist/commands/explain.js +233 -0
- package/dist/commands/fix.d.ts +13 -0
- package/dist/commands/fix.js +668 -0
- package/dist/commands/health.d.ts +11 -0
- package/dist/commands/health.js +367 -0
- package/dist/commands/history.d.ts +10 -0
- package/dist/commands/history.js +191 -0
- package/dist/commands/init.d.ts +9 -0
- package/dist/commands/init.js +164 -0
- package/dist/commands/learn.d.ts +8 -0
- package/dist/commands/learn.js +592 -0
- package/dist/commands/pr-check.d.ts +12 -0
- package/dist/commands/pr-check.js +270 -0
- package/dist/commands/report.d.ts +11 -0
- package/dist/commands/report.js +375 -0
- package/dist/commands/scan-storybook.d.ts +18 -0
- package/dist/commands/scan-storybook.js +402 -0
- package/dist/commands/scan.d.ts +25 -0
- package/dist/commands/scan.js +673 -0
- package/dist/commands/stats.d.ts +5 -0
- package/dist/commands/stats.js +137 -0
- package/dist/commands/tree.d.ts +12 -0
- package/dist/commands/tree.js +635 -0
- package/dist/commands/triage.d.ts +13 -0
- package/dist/commands/triage.js +327 -0
- package/dist/commands/watch.d.ts +17 -0
- package/dist/commands/watch.js +302 -0
- package/dist/types/index.d.ts +60 -0
- package/dist/types/index.js +4 -0
- package/dist/utils/baseline.d.ts +62 -0
- package/dist/utils/baseline.js +169 -0
- package/dist/utils/browser.d.ts +78 -0
- package/dist/utils/browser.js +239 -0
- package/dist/utils/cache.d.ts +76 -0
- package/dist/utils/cache.js +178 -0
- package/dist/utils/config.d.ts +102 -0
- package/dist/utils/config.js +237 -0
- package/dist/utils/converters.d.ts +77 -0
- package/dist/utils/converters.js +200 -0
- package/dist/utils/copilot.d.ts +36 -0
- package/dist/utils/copilot.js +139 -0
- package/dist/utils/detect.d.ts +22 -0
- package/dist/utils/detect.js +197 -0
- package/dist/utils/enhanced-errors.d.ts +46 -0
- package/dist/utils/enhanced-errors.js +295 -0
- package/dist/utils/errors.d.ts +31 -0
- package/dist/utils/errors.js +149 -0
- package/dist/utils/fix-patterns.d.ts +56 -0
- package/dist/utils/fix-patterns.js +529 -0
- package/dist/utils/history-tracking.d.ts +94 -0
- package/dist/utils/history-tracking.js +230 -0
- package/dist/utils/history.d.ts +42 -0
- package/dist/utils/history.js +255 -0
- package/dist/utils/impact-scores.d.ts +44 -0
- package/dist/utils/impact-scores.js +257 -0
- package/dist/utils/retry.d.ts +24 -0
- package/dist/utils/retry.js +76 -0
- package/dist/utils/scanner.d.ts +74 -0
- package/dist/utils/scanner.js +606 -0
- package/dist/utils/scanner.test.d.ts +4 -0
- package/dist/utils/scanner.test.js +162 -0
- package/dist/utils/ui.d.ts +44 -0
- package/dist/utils/ui.js +276 -0
- package/mcp-server/dist/index.d.ts +8 -0
- package/mcp-server/dist/index.js +1923 -0
- package/package.json +88 -0
|
@@ -0,0 +1,635 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ally tree command - Visualize accessibility tree for a URL
|
|
3
|
+
*/
|
|
4
|
+
import puppeteer from 'puppeteer';
|
|
5
|
+
import chalk from 'chalk';
|
|
6
|
+
import boxen from 'boxen';
|
|
7
|
+
import { spawn } from 'child_process';
|
|
8
|
+
import { platform } from 'os';
|
|
9
|
+
import { printBanner, createSpinner, printError, printWarning, printInfo, printSuccess, } from '../utils/ui.js';
|
|
10
|
+
// Tree drawing characters
|
|
11
|
+
const TREE_CHARS = {
|
|
12
|
+
pipe: '\u2502', // │
|
|
13
|
+
tee: '\u251C', // ├
|
|
14
|
+
elbow: '\u2514', // └
|
|
15
|
+
dash: '\u2500', // ─
|
|
16
|
+
space: ' ',
|
|
17
|
+
};
|
|
18
|
+
// Role categories for coloring and summary
|
|
19
|
+
const LANDMARK_ROLES = new Set([
|
|
20
|
+
'banner', 'main', 'contentinfo', 'navigation', 'complementary',
|
|
21
|
+
'region', 'search', 'form', 'application'
|
|
22
|
+
]);
|
|
23
|
+
const HEADING_ROLES = new Set(['heading']);
|
|
24
|
+
const INTERACTIVE_ROLES = new Set([
|
|
25
|
+
'link', 'button', 'textbox', 'checkbox', 'radio', 'combobox',
|
|
26
|
+
'listbox', 'menuitem', 'menuitemcheckbox', 'menuitemradio',
|
|
27
|
+
'option', 'searchbox', 'slider', 'spinbutton', 'switch', 'tab',
|
|
28
|
+
'treeitem', 'gridcell'
|
|
29
|
+
]);
|
|
30
|
+
/**
|
|
31
|
+
* Create an empty summary object
|
|
32
|
+
*/
|
|
33
|
+
function createSummary() {
|
|
34
|
+
return {
|
|
35
|
+
landmarks: new Map(),
|
|
36
|
+
headings: new Map(),
|
|
37
|
+
links: 0,
|
|
38
|
+
images: 0,
|
|
39
|
+
forms: 0,
|
|
40
|
+
buttons: 0,
|
|
41
|
+
textboxes: 0,
|
|
42
|
+
lists: 0,
|
|
43
|
+
tables: 0,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Update summary based on node
|
|
48
|
+
*/
|
|
49
|
+
function updateSummary(summary, node) {
|
|
50
|
+
const role = node.role;
|
|
51
|
+
if (LANDMARK_ROLES.has(role)) {
|
|
52
|
+
summary.landmarks.set(role, (summary.landmarks.get(role) || 0) + 1);
|
|
53
|
+
}
|
|
54
|
+
if (role === 'heading' && node.level !== undefined) {
|
|
55
|
+
summary.headings.set(node.level, (summary.headings.get(node.level) || 0) + 1);
|
|
56
|
+
}
|
|
57
|
+
switch (role) {
|
|
58
|
+
case 'link':
|
|
59
|
+
summary.links++;
|
|
60
|
+
break;
|
|
61
|
+
case 'img':
|
|
62
|
+
case 'image':
|
|
63
|
+
summary.images++;
|
|
64
|
+
break;
|
|
65
|
+
case 'form':
|
|
66
|
+
summary.forms++;
|
|
67
|
+
break;
|
|
68
|
+
case 'button':
|
|
69
|
+
summary.buttons++;
|
|
70
|
+
break;
|
|
71
|
+
case 'textbox':
|
|
72
|
+
case 'searchbox':
|
|
73
|
+
summary.textboxes++;
|
|
74
|
+
break;
|
|
75
|
+
case 'list':
|
|
76
|
+
summary.lists++;
|
|
77
|
+
break;
|
|
78
|
+
case 'table':
|
|
79
|
+
summary.tables++;
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Colorize role based on category
|
|
85
|
+
*/
|
|
86
|
+
function colorizeRole(role) {
|
|
87
|
+
if (LANDMARK_ROLES.has(role)) {
|
|
88
|
+
return chalk.blue(role);
|
|
89
|
+
}
|
|
90
|
+
if (HEADING_ROLES.has(role)) {
|
|
91
|
+
return chalk.yellow(role);
|
|
92
|
+
}
|
|
93
|
+
if (INTERACTIVE_ROLES.has(role)) {
|
|
94
|
+
return chalk.green(role);
|
|
95
|
+
}
|
|
96
|
+
return chalk.cyan(role);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Format node for display (role + optional level + name)
|
|
100
|
+
*/
|
|
101
|
+
function formatNodeDisplay(node) {
|
|
102
|
+
let display = colorizeRole(node.role);
|
|
103
|
+
// Add level info for headings
|
|
104
|
+
if (node.role === 'heading' && node.level !== undefined) {
|
|
105
|
+
display += chalk.dim(` (level ${node.level})`);
|
|
106
|
+
}
|
|
107
|
+
// Add name if present
|
|
108
|
+
if (node.name && node.name.trim()) {
|
|
109
|
+
display += ` ${chalk.white(`"${node.name}"`)}`;
|
|
110
|
+
}
|
|
111
|
+
return display;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Format node properties for display (e.g., [level=2], [checked], [disabled])
|
|
115
|
+
*/
|
|
116
|
+
function formatProperties(node) {
|
|
117
|
+
const props = [];
|
|
118
|
+
if (node.level !== undefined) {
|
|
119
|
+
props.push(`level=${node.level}`);
|
|
120
|
+
}
|
|
121
|
+
if (node.checked !== undefined) {
|
|
122
|
+
props.push(node.checked === 'mixed' ? 'checked=mixed' : (node.checked ? 'checked' : 'unchecked'));
|
|
123
|
+
}
|
|
124
|
+
if (node.pressed !== undefined) {
|
|
125
|
+
props.push(node.pressed === 'mixed' ? 'pressed=mixed' : (node.pressed ? 'pressed' : 'not pressed'));
|
|
126
|
+
}
|
|
127
|
+
if (node.selected) {
|
|
128
|
+
props.push('selected');
|
|
129
|
+
}
|
|
130
|
+
if (node.expanded !== undefined) {
|
|
131
|
+
props.push(node.expanded ? 'expanded' : 'collapsed');
|
|
132
|
+
}
|
|
133
|
+
if (node.disabled) {
|
|
134
|
+
props.push('disabled');
|
|
135
|
+
}
|
|
136
|
+
if (node.required) {
|
|
137
|
+
props.push('required');
|
|
138
|
+
}
|
|
139
|
+
if (node.invalid) {
|
|
140
|
+
props.push(typeof node.invalid === 'string' ? `invalid=${node.invalid}` : 'invalid');
|
|
141
|
+
}
|
|
142
|
+
if (node.focused) {
|
|
143
|
+
props.push('focused');
|
|
144
|
+
}
|
|
145
|
+
if (node.modal) {
|
|
146
|
+
props.push('modal');
|
|
147
|
+
}
|
|
148
|
+
if (node.readonly) {
|
|
149
|
+
props.push('readonly');
|
|
150
|
+
}
|
|
151
|
+
if (node.value !== undefined) {
|
|
152
|
+
const valueStr = typeof node.value === 'string' ? `"${node.value}"` : String(node.value);
|
|
153
|
+
props.push(`value=${valueStr}`);
|
|
154
|
+
}
|
|
155
|
+
return props.length > 0 ? ` [${props.join(', ')}]` : '';
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Print a single tree node with proper formatting
|
|
159
|
+
*/
|
|
160
|
+
function printNode(node, prefix, isLast, currentDepth, maxDepth, filterRole) {
|
|
161
|
+
// Check role filter
|
|
162
|
+
if (filterRole && node.role !== filterRole) {
|
|
163
|
+
// Still process children to find matching roles
|
|
164
|
+
let count = 0;
|
|
165
|
+
if (node.children && currentDepth < maxDepth) {
|
|
166
|
+
const childCount = node.children.length;
|
|
167
|
+
node.children.forEach((child, index) => {
|
|
168
|
+
count += printNode(child, prefix, index === childCount - 1, currentDepth + 1, maxDepth, filterRole);
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
return count;
|
|
172
|
+
}
|
|
173
|
+
// Build the tree prefix
|
|
174
|
+
const connector = isLast
|
|
175
|
+
? `${TREE_CHARS.elbow}${TREE_CHARS.dash}${TREE_CHARS.dash} `
|
|
176
|
+
: `${TREE_CHARS.tee}${TREE_CHARS.dash}${TREE_CHARS.dash} `;
|
|
177
|
+
// Format the node
|
|
178
|
+
const role = chalk.cyan(node.role);
|
|
179
|
+
const name = node.name ? chalk.white(` "${node.name}"`) : '';
|
|
180
|
+
const props = chalk.gray(formatProperties(node));
|
|
181
|
+
// Print the node
|
|
182
|
+
if (currentDepth === 0) {
|
|
183
|
+
console.log(`${role}${name}${props}`);
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
console.log(`${prefix}${connector}${role}${name}${props}`);
|
|
187
|
+
}
|
|
188
|
+
let nodeCount = 1;
|
|
189
|
+
// Process children
|
|
190
|
+
if (node.children && node.children.length > 0) {
|
|
191
|
+
if (currentDepth >= maxDepth) {
|
|
192
|
+
// Indicate there are more children but we're at max depth
|
|
193
|
+
const childPrefix = isLast
|
|
194
|
+
? `${prefix} `
|
|
195
|
+
: `${prefix}${TREE_CHARS.pipe} `;
|
|
196
|
+
console.log(`${childPrefix}${chalk.gray(`... ${node.children.length} more children`)}`);
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
const childPrefix = isLast
|
|
200
|
+
? `${prefix} `
|
|
201
|
+
: `${prefix}${TREE_CHARS.pipe} `;
|
|
202
|
+
const childCount = node.children.length;
|
|
203
|
+
node.children.forEach((child, index) => {
|
|
204
|
+
nodeCount += printNode(child, childPrefix, index === childCount - 1, currentDepth + 1, maxDepth, filterRole);
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return nodeCount;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Count total nodes in the tree
|
|
212
|
+
*/
|
|
213
|
+
function countNodes(node) {
|
|
214
|
+
let count = 1;
|
|
215
|
+
if (node.children) {
|
|
216
|
+
for (const child of node.children) {
|
|
217
|
+
count += countNodes(child);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return count;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Find the maximum depth of the tree
|
|
224
|
+
*/
|
|
225
|
+
function getMaxTreeDepth(node, currentDepth = 0) {
|
|
226
|
+
if (!node.children || node.children.length === 0) {
|
|
227
|
+
return currentDepth;
|
|
228
|
+
}
|
|
229
|
+
let maxChildDepth = currentDepth;
|
|
230
|
+
for (const child of node.children) {
|
|
231
|
+
const childDepth = getMaxTreeDepth(child, currentDepth + 1);
|
|
232
|
+
if (childDepth > maxChildDepth) {
|
|
233
|
+
maxChildDepth = childDepth;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return maxChildDepth;
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Filter tree to only include nodes with a specific role
|
|
240
|
+
*/
|
|
241
|
+
function filterByRole(node, role) {
|
|
242
|
+
const matches = [];
|
|
243
|
+
if (node.role === role) {
|
|
244
|
+
matches.push(node);
|
|
245
|
+
}
|
|
246
|
+
if (node.children) {
|
|
247
|
+
for (const child of node.children) {
|
|
248
|
+
matches.push(...filterByRole(child, role));
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return matches;
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Generate a screen reader announcement for a node
|
|
255
|
+
*/
|
|
256
|
+
function announceNode(node) {
|
|
257
|
+
const role = node.role;
|
|
258
|
+
const name = node.name?.trim() || '';
|
|
259
|
+
// Skip certain roles that don't typically announce
|
|
260
|
+
const skipRoles = new Set(['none', 'generic', 'RootWebArea', 'InlineTextBox', 'StaticText']);
|
|
261
|
+
if (skipRoles.has(role)) {
|
|
262
|
+
// For static text, just return the name
|
|
263
|
+
if (role === 'StaticText' && name) {
|
|
264
|
+
return name;
|
|
265
|
+
}
|
|
266
|
+
return '';
|
|
267
|
+
}
|
|
268
|
+
// Handle special cases
|
|
269
|
+
switch (role) {
|
|
270
|
+
case 'heading':
|
|
271
|
+
if (node.level !== undefined) {
|
|
272
|
+
return name ? `${name}, heading level ${node.level}` : `heading level ${node.level}`;
|
|
273
|
+
}
|
|
274
|
+
return name ? `${name}, heading` : 'heading';
|
|
275
|
+
case 'link':
|
|
276
|
+
return name ? `${name}, link` : 'link';
|
|
277
|
+
case 'button':
|
|
278
|
+
return name ? `${name}, button` : 'button';
|
|
279
|
+
case 'img':
|
|
280
|
+
case 'image':
|
|
281
|
+
return name ? `${name}, image` : 'image';
|
|
282
|
+
case 'navigation':
|
|
283
|
+
return name ? `${name}, navigation` : 'navigation';
|
|
284
|
+
case 'main':
|
|
285
|
+
return name ? `${name}, main` : 'main';
|
|
286
|
+
case 'banner':
|
|
287
|
+
return name ? `${name}, banner` : 'banner';
|
|
288
|
+
case 'contentinfo':
|
|
289
|
+
return name ? `${name}, content info` : 'content info';
|
|
290
|
+
case 'complementary':
|
|
291
|
+
return name ? `${name}, complementary` : 'complementary';
|
|
292
|
+
case 'region':
|
|
293
|
+
return name ? `${name}, region` : 'region';
|
|
294
|
+
case 'search':
|
|
295
|
+
return name ? `${name}, search` : 'search';
|
|
296
|
+
case 'form':
|
|
297
|
+
return name ? `${name}, form` : 'form';
|
|
298
|
+
case 'list':
|
|
299
|
+
const itemCount = node.children?.filter(c => c.role === 'listitem').length || 0;
|
|
300
|
+
if (itemCount > 0) {
|
|
301
|
+
return `list with ${itemCount} item${itemCount === 1 ? '' : 's'}`;
|
|
302
|
+
}
|
|
303
|
+
return 'list';
|
|
304
|
+
case 'listitem':
|
|
305
|
+
// List items typically announce their content, not themselves
|
|
306
|
+
return '';
|
|
307
|
+
case 'textbox':
|
|
308
|
+
case 'searchbox':
|
|
309
|
+
const textboxLabel = name ? `${name}, ` : '';
|
|
310
|
+
const textboxType = role === 'searchbox' ? 'search edit' : 'edit';
|
|
311
|
+
return `${textboxLabel}${textboxType}`;
|
|
312
|
+
case 'checkbox':
|
|
313
|
+
const checkState = node.checked ? 'checked' : 'not checked';
|
|
314
|
+
return name ? `${name}, checkbox, ${checkState}` : `checkbox, ${checkState}`;
|
|
315
|
+
case 'radio':
|
|
316
|
+
const radioState = node.checked ? 'selected' : 'not selected';
|
|
317
|
+
return name ? `${name}, radio button, ${radioState}` : `radio button, ${radioState}`;
|
|
318
|
+
case 'combobox':
|
|
319
|
+
return name ? `${name}, combo box` : 'combo box';
|
|
320
|
+
case 'menubar':
|
|
321
|
+
return name ? `${name}, menu bar` : 'menu bar';
|
|
322
|
+
case 'menu':
|
|
323
|
+
return name ? `${name}, menu` : 'menu';
|
|
324
|
+
case 'menuitem':
|
|
325
|
+
return name ? `${name}, menu item` : 'menu item';
|
|
326
|
+
case 'tab':
|
|
327
|
+
const tabState = node.selected ? 'selected' : '';
|
|
328
|
+
return name
|
|
329
|
+
? `${name}, tab${tabState ? ', ' + tabState : ''}`
|
|
330
|
+
: `tab${tabState ? ', ' + tabState : ''}`;
|
|
331
|
+
case 'tablist':
|
|
332
|
+
return name ? `${name}, tab list` : 'tab list';
|
|
333
|
+
case 'tabpanel':
|
|
334
|
+
return name ? `${name}, tab panel` : 'tab panel';
|
|
335
|
+
case 'table':
|
|
336
|
+
return name ? `${name}, table` : 'table';
|
|
337
|
+
case 'row':
|
|
338
|
+
return ''; // Rows don't typically announce themselves
|
|
339
|
+
case 'cell':
|
|
340
|
+
case 'gridcell':
|
|
341
|
+
return name || '';
|
|
342
|
+
case 'columnheader':
|
|
343
|
+
return name ? `${name}, column header` : 'column header';
|
|
344
|
+
case 'rowheader':
|
|
345
|
+
return name ? `${name}, row header` : 'row header';
|
|
346
|
+
case 'slider':
|
|
347
|
+
const sliderValue = node.valuetext || (node.value !== undefined ? String(node.value) : '');
|
|
348
|
+
return name
|
|
349
|
+
? `${name}, slider${sliderValue ? ', ' + sliderValue : ''}`
|
|
350
|
+
: `slider${sliderValue ? ', ' + sliderValue : ''}`;
|
|
351
|
+
case 'spinbutton':
|
|
352
|
+
const spinValue = node.valuetext || (node.value !== undefined ? String(node.value) : '');
|
|
353
|
+
return name
|
|
354
|
+
? `${name}, spin button${spinValue ? ', ' + spinValue : ''}`
|
|
355
|
+
: `spin button${spinValue ? ', ' + spinValue : ''}`;
|
|
356
|
+
case 'switch':
|
|
357
|
+
const switchState = node.checked ? 'on' : 'off';
|
|
358
|
+
return name ? `${name}, switch, ${switchState}` : `switch, ${switchState}`;
|
|
359
|
+
case 'progressbar':
|
|
360
|
+
const progress = node.valuetext || (node.value !== undefined ? `${node.value}%` : '');
|
|
361
|
+
return name
|
|
362
|
+
? `${name}, progress${progress ? ', ' + progress : ''}`
|
|
363
|
+
: `progress${progress ? ', ' + progress : ''}`;
|
|
364
|
+
case 'dialog':
|
|
365
|
+
return name ? `${name}, dialog` : 'dialog';
|
|
366
|
+
case 'alert':
|
|
367
|
+
return name ? `alert, ${name}` : 'alert';
|
|
368
|
+
case 'alertdialog':
|
|
369
|
+
return name ? `alert dialog, ${name}` : 'alert dialog';
|
|
370
|
+
case 'application':
|
|
371
|
+
return name ? `${name}, application` : 'application';
|
|
372
|
+
case 'article':
|
|
373
|
+
return name ? `${name}, article` : 'article';
|
|
374
|
+
case 'figure':
|
|
375
|
+
return name ? `${name}, figure` : 'figure';
|
|
376
|
+
case 'group':
|
|
377
|
+
return name ? `${name}, group` : '';
|
|
378
|
+
case 'separator':
|
|
379
|
+
return 'separator';
|
|
380
|
+
case 'tooltip':
|
|
381
|
+
return name ? `tooltip, ${name}` : 'tooltip';
|
|
382
|
+
case 'tree':
|
|
383
|
+
return name ? `${name}, tree` : 'tree';
|
|
384
|
+
case 'treeitem':
|
|
385
|
+
const expanded = node.expanded !== undefined
|
|
386
|
+
? (node.expanded ? ', expanded' : ', collapsed')
|
|
387
|
+
: '';
|
|
388
|
+
return name ? `${name}, tree item${expanded}` : `tree item${expanded}`;
|
|
389
|
+
default:
|
|
390
|
+
// For other roles, announce name and role if both exist
|
|
391
|
+
if (name) {
|
|
392
|
+
return `${name}, ${role}`;
|
|
393
|
+
}
|
|
394
|
+
return '';
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Walk the accessibility tree and generate announcement text
|
|
399
|
+
*/
|
|
400
|
+
function generateAnnouncement(node, depth = 0) {
|
|
401
|
+
const announcements = [];
|
|
402
|
+
// Get announcement for current node
|
|
403
|
+
const announcement = announceNode(node);
|
|
404
|
+
if (announcement) {
|
|
405
|
+
announcements.push(announcement);
|
|
406
|
+
}
|
|
407
|
+
// Process children
|
|
408
|
+
if (node.children) {
|
|
409
|
+
for (const child of node.children) {
|
|
410
|
+
const childAnnouncements = generateAnnouncement(child, depth + 1);
|
|
411
|
+
announcements.push(...childAnnouncements);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
return announcements;
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Speak text using system TTS
|
|
418
|
+
*/
|
|
419
|
+
async function speakText(text) {
|
|
420
|
+
return new Promise((resolve, reject) => {
|
|
421
|
+
const os = platform();
|
|
422
|
+
let command;
|
|
423
|
+
let args;
|
|
424
|
+
switch (os) {
|
|
425
|
+
case 'darwin':
|
|
426
|
+
// macOS: use 'say' command
|
|
427
|
+
command = 'say';
|
|
428
|
+
args = ['-r', '180', text]; // -r 180 sets a reasonable speaking rate
|
|
429
|
+
break;
|
|
430
|
+
case 'linux':
|
|
431
|
+
// Linux: try espeak first, then spd-say
|
|
432
|
+
// We'll try espeak as it's more commonly available
|
|
433
|
+
command = 'espeak';
|
|
434
|
+
args = ['-s', '150', text]; // -s 150 sets words per minute
|
|
435
|
+
break;
|
|
436
|
+
case 'win32':
|
|
437
|
+
// Windows: use PowerShell TTS
|
|
438
|
+
command = 'powershell';
|
|
439
|
+
args = [
|
|
440
|
+
'-Command',
|
|
441
|
+
`Add-Type -AssemblyName System.Speech; (New-Object System.Speech.Synthesis.SpeechSynthesizer).Speak('${text.replace(/'/g, "''")}')`
|
|
442
|
+
];
|
|
443
|
+
break;
|
|
444
|
+
default:
|
|
445
|
+
printWarning(`Text-to-speech not supported on ${os}`);
|
|
446
|
+
resolve();
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
const process = spawn(command, args, { stdio: 'inherit' });
|
|
450
|
+
process.on('close', (code) => {
|
|
451
|
+
if (code === 0) {
|
|
452
|
+
resolve();
|
|
453
|
+
}
|
|
454
|
+
else if (os === 'linux') {
|
|
455
|
+
// Try spd-say as fallback on Linux
|
|
456
|
+
const fallback = spawn('spd-say', ['-r', '-20', text], { stdio: 'inherit' });
|
|
457
|
+
fallback.on('close', (fallbackCode) => {
|
|
458
|
+
if (fallbackCode === 0) {
|
|
459
|
+
resolve();
|
|
460
|
+
}
|
|
461
|
+
else {
|
|
462
|
+
printWarning('TTS not available. Install espeak or speech-dispatcher.');
|
|
463
|
+
resolve();
|
|
464
|
+
}
|
|
465
|
+
});
|
|
466
|
+
fallback.on('error', () => {
|
|
467
|
+
printWarning('TTS not available. Install espeak or speech-dispatcher.');
|
|
468
|
+
resolve();
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
else {
|
|
472
|
+
printWarning(`TTS command failed with code ${code}`);
|
|
473
|
+
resolve();
|
|
474
|
+
}
|
|
475
|
+
});
|
|
476
|
+
process.on('error', (err) => {
|
|
477
|
+
if (os === 'linux') {
|
|
478
|
+
// Try spd-say as fallback on Linux
|
|
479
|
+
const fallback = spawn('spd-say', ['-r', '-20', text], { stdio: 'inherit' });
|
|
480
|
+
fallback.on('close', (fallbackCode) => {
|
|
481
|
+
if (fallbackCode === 0) {
|
|
482
|
+
resolve();
|
|
483
|
+
}
|
|
484
|
+
else {
|
|
485
|
+
printWarning('TTS not available. Install espeak or speech-dispatcher.');
|
|
486
|
+
resolve();
|
|
487
|
+
}
|
|
488
|
+
});
|
|
489
|
+
fallback.on('error', () => {
|
|
490
|
+
printWarning('TTS not available. Install espeak or speech-dispatcher.');
|
|
491
|
+
resolve();
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
else {
|
|
495
|
+
printWarning(`TTS not available: ${err.message}`);
|
|
496
|
+
resolve();
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
export async function treeCommand(url, options = {}) {
|
|
502
|
+
printBanner();
|
|
503
|
+
const { depth = 5, role, json = false, speak = false, noAudio = false } = options;
|
|
504
|
+
// Validate URL
|
|
505
|
+
let targetUrl = url;
|
|
506
|
+
if (!targetUrl.startsWith('http://') && !targetUrl.startsWith('https://')) {
|
|
507
|
+
targetUrl = `https://${targetUrl}`;
|
|
508
|
+
}
|
|
509
|
+
const spinner = createSpinner(`Fetching accessibility tree from ${targetUrl}...`);
|
|
510
|
+
spinner.start();
|
|
511
|
+
let browser = null;
|
|
512
|
+
try {
|
|
513
|
+
browser = await puppeteer.launch({
|
|
514
|
+
headless: true,
|
|
515
|
+
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
|
516
|
+
});
|
|
517
|
+
const page = await browser.newPage();
|
|
518
|
+
await page.goto(targetUrl, { waitUntil: 'networkidle2', timeout: 30000 });
|
|
519
|
+
// Get the accessibility tree
|
|
520
|
+
const snapshot = await page.accessibility.snapshot({
|
|
521
|
+
interestingOnly: false, // Get all nodes, not just interesting ones
|
|
522
|
+
});
|
|
523
|
+
await page.close();
|
|
524
|
+
if (!snapshot) {
|
|
525
|
+
spinner.fail('Failed to get accessibility tree');
|
|
526
|
+
printError('The page may not have any accessible content.');
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
spinner.succeed('Accessibility tree retrieved');
|
|
530
|
+
// Calculate tree stats
|
|
531
|
+
const totalNodes = countNodes(snapshot);
|
|
532
|
+
const actualDepth = getMaxTreeDepth(snapshot);
|
|
533
|
+
// JSON output mode
|
|
534
|
+
if (json) {
|
|
535
|
+
if (role) {
|
|
536
|
+
const filtered = filterByRole(snapshot, role);
|
|
537
|
+
console.log(JSON.stringify(filtered, null, 2));
|
|
538
|
+
}
|
|
539
|
+
else {
|
|
540
|
+
console.log(JSON.stringify(snapshot, null, 2));
|
|
541
|
+
}
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
// Print header
|
|
545
|
+
console.log();
|
|
546
|
+
console.log(boxen(`Accessibility Tree for ${chalk.cyan(targetUrl)}\n${chalk.dim(`${totalNodes} nodes, depth: ${actualDepth}`)}`, {
|
|
547
|
+
padding: { top: 0, bottom: 0, left: 1, right: 1 },
|
|
548
|
+
borderStyle: 'round',
|
|
549
|
+
borderColor: 'cyan',
|
|
550
|
+
}));
|
|
551
|
+
console.log();
|
|
552
|
+
// Show warning if tree is deep
|
|
553
|
+
if (actualDepth > depth) {
|
|
554
|
+
printWarning(`Tree depth (${actualDepth}) exceeds display limit (${depth}). Use --depth to show more.`);
|
|
555
|
+
console.log();
|
|
556
|
+
}
|
|
557
|
+
// Filter mode
|
|
558
|
+
if (role) {
|
|
559
|
+
const matches = filterByRole(snapshot, role);
|
|
560
|
+
if (matches.length === 0) {
|
|
561
|
+
printInfo(`No nodes found with role "${role}"`);
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
console.log(chalk.dim(`Found ${matches.length} node(s) with role "${role}":\n`));
|
|
565
|
+
for (const match of matches) {
|
|
566
|
+
printNode(match, '', true, 0, depth);
|
|
567
|
+
console.log();
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
else {
|
|
571
|
+
// Print full tree
|
|
572
|
+
printNode(snapshot, '', true, 0, depth);
|
|
573
|
+
}
|
|
574
|
+
console.log();
|
|
575
|
+
// Print legend
|
|
576
|
+
console.log(chalk.dim('Legend:'));
|
|
577
|
+
console.log(chalk.dim(` ${chalk.cyan('role')} ${chalk.white('"name"')} ${chalk.gray('[properties]')}`));
|
|
578
|
+
console.log();
|
|
579
|
+
// Screen reader simulation
|
|
580
|
+
if (speak) {
|
|
581
|
+
// Generate announcement from the tree
|
|
582
|
+
const treeToAnnounce = role
|
|
583
|
+
? filterByRole(snapshot, role)
|
|
584
|
+
: [snapshot];
|
|
585
|
+
const allAnnouncements = [];
|
|
586
|
+
for (const tree of treeToAnnounce) {
|
|
587
|
+
const announcements = generateAnnouncement(tree);
|
|
588
|
+
allAnnouncements.push(...announcements);
|
|
589
|
+
}
|
|
590
|
+
// Join announcements into readable text
|
|
591
|
+
const announcementText = allAnnouncements.join('. ').replace(/\.\./g, '.');
|
|
592
|
+
// Print the announcement
|
|
593
|
+
console.log(chalk.yellow.bold('Screen Reader Announcement:'));
|
|
594
|
+
console.log(boxen(chalk.white(`"${announcementText}"`), {
|
|
595
|
+
padding: { top: 0, bottom: 0, left: 1, right: 1 },
|
|
596
|
+
borderStyle: 'round',
|
|
597
|
+
borderColor: 'yellow',
|
|
598
|
+
}));
|
|
599
|
+
console.log();
|
|
600
|
+
// Play the announcement if audio is enabled
|
|
601
|
+
if (!noAudio) {
|
|
602
|
+
printInfo('Playing announcement...');
|
|
603
|
+
await speakText(announcementText);
|
|
604
|
+
printSuccess('Announcement complete.');
|
|
605
|
+
console.log();
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
catch (error) {
|
|
610
|
+
spinner.fail(`Failed to fetch accessibility tree`);
|
|
611
|
+
if (error instanceof Error) {
|
|
612
|
+
if (error.message.includes('net::ERR_NAME_NOT_RESOLVED')) {
|
|
613
|
+
printError(`Could not resolve hostname: ${targetUrl}`);
|
|
614
|
+
}
|
|
615
|
+
else if (error.message.includes('net::ERR_CONNECTION_REFUSED')) {
|
|
616
|
+
printError(`Connection refused: ${targetUrl}`);
|
|
617
|
+
}
|
|
618
|
+
else if (error.message.includes('Timeout')) {
|
|
619
|
+
printError(`Request timed out for: ${targetUrl}`);
|
|
620
|
+
}
|
|
621
|
+
else {
|
|
622
|
+
printError(error.message);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
else {
|
|
626
|
+
printError(String(error));
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
finally {
|
|
630
|
+
if (browser) {
|
|
631
|
+
await browser.close();
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
export default treeCommand;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ally triage command - Interactive issue categorization and prioritization
|
|
3
|
+
*/
|
|
4
|
+
export interface TriageResult {
|
|
5
|
+
fix: string[];
|
|
6
|
+
ignore: string[];
|
|
7
|
+
backlog: string[];
|
|
8
|
+
}
|
|
9
|
+
interface TriageOptions {
|
|
10
|
+
input?: string;
|
|
11
|
+
}
|
|
12
|
+
export declare function triageCommand(options?: TriageOptions): Promise<void>;
|
|
13
|
+
export default triageCommand;
|