Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/test/automation/src/playwrightDriver.ts
5238 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import * as playwright from '@playwright/test';
7
import type { Protocol } from 'playwright-core/types/protocol';
8
import { dirname, join } from 'path';
9
import { promises, readFileSync } from 'fs';
10
import { IWindowDriver } from './driver';
11
import { measureAndLog } from './logger';
12
import { LaunchOptions } from './code';
13
import { teardown } from './processes';
14
import { ChildProcess } from 'child_process';
15
import type { AxeResults, RunOptions } from 'axe-core';
16
17
// Load axe-core source for injection into pages (works with Electron)
18
let axeSource = '';
19
try {
20
const axePath = require.resolve('axe-core/axe.min.js');
21
axeSource = readFileSync(axePath, 'utf-8');
22
} catch {
23
// axe-core may not be installed; keep axeSource empty to avoid failing module initialization
24
axeSource = '';
25
}
26
27
type PageFunction<Arg, T> = (arg: Arg) => T | Promise<T>;
28
29
export interface AccessibilityScanOptions {
30
/** Specific selector to scan. If not provided, scans the entire page. */
31
selector?: string;
32
/** WCAG tags to include (e.g., 'wcag2a', 'wcag2aa', 'wcag21aa'). Defaults to WCAG 2.1 AA. */
33
tags?: string[];
34
/** Rule IDs to disable for this scan. */
35
disableRules?: string[];
36
/**
37
* Patterns to exclude from specific rules. Keys are rule IDs, values are strings to match against element target or HTML.
38
*
39
* **IMPORTANT**: Adding exclusions here bypasses accessibility checks. Before adding an exclusion:
40
* 1. File an issue to track the accessibility problem
41
* 2. Ensure there's a plan to fix the underlying issue (e.g., hover/focus states that axe can't detect)
42
* 3. Get approval from @anthropics/accessibility team
43
*/
44
excludeRules?: { [ruleId: string]: string[] };
45
}
46
47
export class PlaywrightDriver {
48
49
private static traceCounter = 1;
50
private static screenShotCounter = 1;
51
52
private static readonly vscodeToPlaywrightKey: { [key: string]: string } = {
53
cmd: 'Meta',
54
ctrl: 'Control',
55
shift: 'Shift',
56
enter: 'Enter',
57
escape: 'Escape',
58
right: 'ArrowRight',
59
up: 'ArrowUp',
60
down: 'ArrowDown',
61
left: 'ArrowLeft',
62
home: 'Home',
63
esc: 'Escape'
64
};
65
66
constructor(
67
private readonly application: playwright.Browser | playwright.ElectronApplication,
68
private readonly context: playwright.BrowserContext,
69
private _currentPage: playwright.Page,
70
private readonly serverProcess: ChildProcess | undefined,
71
private readonly whenLoaded: Promise<unknown>,
72
private readonly options: LaunchOptions
73
) {
74
}
75
76
get browserContext(): playwright.BrowserContext {
77
return this.context;
78
}
79
80
private get page(): playwright.Page {
81
return this._currentPage;
82
}
83
84
get currentPage(): playwright.Page {
85
return this._currentPage;
86
}
87
88
/**
89
* Get all open windows/pages.
90
* For Electron apps, returns all Electron windows.
91
* For browser contexts, returns all pages.
92
*/
93
getAllWindows(): playwright.Page[] {
94
if ('windows' in this.application) {
95
return (this.application as playwright.ElectronApplication).windows();
96
}
97
return this.context.pages();
98
}
99
100
/**
101
* Switch to a different window by index or URL pattern.
102
* @param indexOrUrl - Window index (0-based) or a string to match against the URL.
103
* When using a string, it first tries to find an exact URL match,
104
* then falls back to finding the first URL that contains the pattern.
105
* @returns The switched-to page, or undefined if not found
106
* @note When switching windows, any existing CDP session will be cleared since it
107
* remains attached to the previous page and cannot be used with the new page.
108
*/
109
switchToWindow(indexOrUrl: number | string): playwright.Page | undefined {
110
const windows = this.getAllWindows();
111
if (typeof indexOrUrl === 'number') {
112
if (indexOrUrl >= 0 && indexOrUrl < windows.length) {
113
this._currentPage = windows[indexOrUrl];
114
// Clear CDP session as it's attached to the previous page
115
this._cdpSession = undefined;
116
return this._currentPage;
117
}
118
} else {
119
// First try exact match, then fall back to substring match
120
let found = windows.find(w => w.url() === indexOrUrl);
121
if (!found) {
122
found = windows.find(w => w.url().includes(indexOrUrl));
123
}
124
if (found) {
125
this._currentPage = found;
126
// Clear CDP session as it's attached to the previous page
127
this._cdpSession = undefined;
128
return this._currentPage;
129
}
130
}
131
return undefined;
132
}
133
134
/**
135
* Get information about all windows.
136
*/
137
getWindowsInfo(): { index: number; url: string; isCurrent: boolean }[] {
138
const windows = this.getAllWindows();
139
return windows.map((p, index) => ({
140
index,
141
url: p.url(),
142
isCurrent: p === this._currentPage
143
}));
144
}
145
146
/**
147
* Take a screenshot of the current window.
148
* @param fullPage - Whether to capture the full scrollable page
149
* @returns Screenshot as a Buffer
150
*/
151
async screenshotBuffer(fullPage: boolean = false): Promise<Buffer> {
152
return await this.page.screenshot({
153
type: 'png',
154
fullPage
155
});
156
}
157
158
/**
159
* Get the accessibility snapshot of the current window.
160
*/
161
async getAccessibilitySnapshot(): Promise<playwright.Accessibility['snapshot'] extends () => Promise<infer T> ? T : never> {
162
return await this.page.accessibility.snapshot();
163
}
164
165
/**
166
* Click on an element using CSS selector with options.
167
*/
168
async clickSelector(selector: string, options?: { button?: 'left' | 'right' | 'middle'; clickCount?: number }): Promise<void> {
169
await this.page.click(selector, {
170
button: options?.button ?? 'left',
171
clickCount: options?.clickCount ?? 1
172
});
173
}
174
175
/**
176
* Type text into an element.
177
* @param selector - CSS selector for the element
178
* @param text - Text to type
179
* @param slowly - Whether to type character by character (triggers key events)
180
*/
181
async typeText(selector: string, text: string, slowly: boolean = false): Promise<void> {
182
if (slowly) {
183
await this.page.type(selector, text, { delay: 50 });
184
} else {
185
await this.page.fill(selector, text);
186
}
187
}
188
189
/**
190
* Evaluate a JavaScript expression in the current window.
191
*/
192
async evaluateExpression<T = unknown>(expression: string): Promise<T> {
193
return await this.page.evaluate(expression) as T;
194
}
195
196
/**
197
* Get information about elements matching a selector.
198
*/
199
async getLocatorInfo(selector: string, action?: 'count' | 'textContent' | 'innerHTML' | 'boundingBox' | 'isVisible'): Promise<
200
number | string[] | { x: number; y: number; width: number; height: number } | null | boolean | { count: number; firstVisible: boolean }
201
> {
202
const locator = this.page.locator(selector);
203
204
switch (action) {
205
case 'count':
206
return await locator.count();
207
case 'textContent':
208
return await locator.allTextContents();
209
case 'innerHTML':
210
return await locator.allInnerTexts();
211
case 'boundingBox':
212
return await locator.first().boundingBox();
213
case 'isVisible':
214
return await locator.first().isVisible();
215
default:
216
return {
217
count: await locator.count(),
218
firstVisible: await locator.first().isVisible().catch(() => false)
219
};
220
}
221
}
222
223
/**
224
* Wait for an element to reach a specific state.
225
*/
226
async waitForElement(selector: string, options?: { state?: 'attached' | 'detached' | 'visible' | 'hidden'; timeout?: number }): Promise<void> {
227
await this.page.waitForSelector(selector, {
228
state: options?.state ?? 'visible',
229
timeout: options?.timeout ?? 30000
230
});
231
}
232
233
/**
234
* Hover over an element.
235
*/
236
async hoverSelector(selector: string): Promise<void> {
237
await this.page.hover(selector);
238
}
239
240
/**
241
* Drag from one element to another.
242
*/
243
async dragSelector(sourceSelector: string, targetSelector: string): Promise<void> {
244
await this.page.dragAndDrop(sourceSelector, targetSelector);
245
}
246
247
/**
248
* Press a key or key combination.
249
*/
250
async pressKey(key: string): Promise<void> {
251
await this.page.keyboard.press(key);
252
}
253
254
/**
255
* Move mouse to a specific position.
256
*/
257
async mouseMove(x: number, y: number): Promise<void> {
258
await this.page.mouse.move(x, y);
259
}
260
261
/**
262
* Click at a specific position.
263
*/
264
async mouseClick(x: number, y: number, options?: { button?: 'left' | 'right' | 'middle'; clickCount?: number }): Promise<void> {
265
await this.page.mouse.click(x, y, {
266
button: options?.button ?? 'left',
267
clickCount: options?.clickCount ?? 1
268
});
269
}
270
271
/**
272
* Drag from one position to another.
273
*/
274
async mouseDrag(startX: number, startY: number, endX: number, endY: number): Promise<void> {
275
await this.page.mouse.move(startX, startY);
276
await this.page.mouse.down();
277
await this.page.mouse.move(endX, endY);
278
await this.page.mouse.up();
279
}
280
281
/**
282
* Select an option in a dropdown.
283
*/
284
async selectOption(selector: string, value: string | string[]): Promise<string[]> {
285
return await this.page.selectOption(selector, value);
286
}
287
288
/**
289
* Fill multiple form fields at once.
290
*/
291
async fillForm(fields: { selector: string; value: string }[]): Promise<void> {
292
for (const field of fields) {
293
await this.page.fill(field.selector, field.value);
294
}
295
}
296
297
/**
298
* Get console messages from the current window.
299
*/
300
async getConsoleMessages(): Promise<{ type: string; text: string }[]> {
301
const messages = await this.page.consoleMessages();
302
return messages.map(m => ({
303
type: m.type(),
304
text: m.text()
305
}));
306
}
307
308
/**
309
* Wait for text to appear, disappear, or a specified time to pass.
310
*/
311
async waitForText(options: { text?: string; textGone?: string; timeout?: number }): Promise<void> {
312
const { text, textGone, timeout = 30000 } = options;
313
314
if (text) {
315
await this.page.getByText(text).first().waitFor({ state: 'visible', timeout });
316
}
317
if (textGone) {
318
await this.page.getByText(textGone).first().waitFor({ state: 'hidden', timeout });
319
}
320
}
321
322
/**
323
* Wait for a specified time in milliseconds.
324
*/
325
async waitForTime(ms: number): Promise<void> {
326
await new Promise(resolve => setTimeout(resolve, ms));
327
}
328
329
/**
330
* Verify an element is visible.
331
*/
332
async verifyElementVisible(selector: string): Promise<boolean> {
333
try {
334
await this.page.locator(selector).first().waitFor({ state: 'visible', timeout: 5000 });
335
return true;
336
} catch {
337
return false;
338
}
339
}
340
341
/**
342
* Verify text is visible on the page.
343
*/
344
async verifyTextVisible(text: string): Promise<boolean> {
345
try {
346
await this.page.getByText(text).first().waitFor({ state: 'visible', timeout: 5000 });
347
return true;
348
} catch {
349
return false;
350
}
351
}
352
353
/**
354
* Get the value of an input element.
355
*/
356
async getInputValue(selector: string): Promise<string> {
357
return await this.page.inputValue(selector);
358
}
359
360
async startTracing(name?: string): Promise<void> {
361
if (!this.options.tracing) {
362
return; // tracing disabled
363
}
364
365
try {
366
await measureAndLog(() => this.context.tracing.startChunk({ title: name }), `startTracing${name ? ` for ${name}` : ''}`, this.options.logger);
367
} catch (error) {
368
// Ignore
369
}
370
}
371
372
async stopTracing(name?: string, persist: boolean = false): Promise<void> {
373
if (!this.options.tracing) {
374
return; // tracing disabled
375
}
376
377
try {
378
let persistPath: string | undefined = undefined;
379
if (persist) {
380
const nameSuffix = name ? `-${name.replace(/\s+/g, '-')}` : '';
381
persistPath = join(this.options.logsPath, `playwright-trace-${PlaywrightDriver.traceCounter++}${nameSuffix}.zip`);
382
}
383
384
await measureAndLog(() => this.context.tracing.stopChunk({ path: persistPath }), `stopTracing${name ? ` for ${name}` : ''}`, this.options.logger);
385
386
// To ensure we have a screenshot at the end where
387
// it failed, also trigger one explicitly. Tracing
388
// does not guarantee to give us a screenshot unless
389
// some driver action ran before.
390
if (persist) {
391
await this.takeScreenshot(name);
392
}
393
} catch (error) {
394
// Ignore
395
}
396
}
397
398
async didFinishLoad(): Promise<void> {
399
await this.whenLoaded;
400
}
401
402
private _cdpSession: playwright.CDPSession | undefined;
403
404
async startCDP() {
405
if (this._cdpSession) {
406
return;
407
}
408
409
this._cdpSession = await this.page.context().newCDPSession(this.page);
410
}
411
412
async collectGarbage() {
413
if (!this._cdpSession) {
414
throw new Error('CDP not started');
415
}
416
417
await this._cdpSession.send('HeapProfiler.collectGarbage');
418
}
419
420
async evaluate(options: Protocol.Runtime.evaluateParameters): Promise<Protocol.Runtime.evaluateReturnValue> {
421
if (!this._cdpSession) {
422
throw new Error('CDP not started');
423
}
424
425
return await this._cdpSession.send('Runtime.evaluate', options);
426
}
427
428
async releaseObjectGroup(parameters: Protocol.Runtime.releaseObjectGroupParameters): Promise<void> {
429
if (!this._cdpSession) {
430
throw new Error('CDP not started');
431
}
432
433
await this._cdpSession.send('Runtime.releaseObjectGroup', parameters);
434
}
435
436
async queryObjects(parameters: Protocol.Runtime.queryObjectsParameters): Promise<Protocol.Runtime.queryObjectsReturnValue> {
437
if (!this._cdpSession) {
438
throw new Error('CDP not started');
439
}
440
441
return await this._cdpSession.send('Runtime.queryObjects', parameters);
442
}
443
444
async callFunctionOn(parameters: Protocol.Runtime.callFunctionOnParameters): Promise<Protocol.Runtime.callFunctionOnReturnValue> {
445
if (!this._cdpSession) {
446
throw new Error('CDP not started');
447
}
448
449
return await this._cdpSession.send('Runtime.callFunctionOn', parameters);
450
}
451
452
async takeHeapSnapshot(): Promise<string> {
453
if (!this._cdpSession) {
454
throw new Error('CDP not started');
455
}
456
457
let snapshot = '';
458
const listener = (c: { chunk: string }) => {
459
snapshot += c.chunk;
460
};
461
462
this._cdpSession.addListener('HeapProfiler.addHeapSnapshotChunk', listener);
463
464
await this._cdpSession.send('HeapProfiler.takeHeapSnapshot');
465
466
this._cdpSession.removeListener('HeapProfiler.addHeapSnapshotChunk', listener);
467
return snapshot;
468
}
469
470
async getProperties(parameters: Protocol.Runtime.getPropertiesParameters): Promise<Protocol.Runtime.getPropertiesReturnValue> {
471
if (!this._cdpSession) {
472
throw new Error('CDP not started');
473
}
474
475
return await this._cdpSession.send('Runtime.getProperties', parameters);
476
}
477
478
private async takeScreenshot(name?: string): Promise<void> {
479
try {
480
const nameSuffix = name ? `-${name.replace(/\s+/g, '-')}` : '';
481
const persistPath = join(this.options.logsPath, `playwright-screenshot-${PlaywrightDriver.screenShotCounter++}${nameSuffix}.png`);
482
483
await measureAndLog(() => this.page.screenshot({ path: persistPath, type: 'png' }), 'takeScreenshot', this.options.logger);
484
} catch (error) {
485
// Ignore
486
}
487
}
488
489
async reload() {
490
await this.page.reload();
491
}
492
493
async close() {
494
495
// Stop tracing
496
try {
497
if (this.options.tracing) {
498
await measureAndLog(() => this.context.tracing.stop(), 'stop tracing', this.options.logger);
499
}
500
} catch (error) {
501
// Ignore
502
}
503
504
// Web: Extract client logs
505
if (this.options.web) {
506
try {
507
await measureAndLog(() => this.saveWebClientLogs(), 'saveWebClientLogs()', this.options.logger);
508
} catch (error) {
509
this.options.logger.log(`Error saving web client logs (${error})`);
510
}
511
}
512
513
// exit via `close` method
514
try {
515
await measureAndLog(() => this.application.close(), 'playwright.close()', this.options.logger);
516
} catch (error) {
517
this.options.logger.log(`Error closing application (${error})`);
518
}
519
520
// Server: via `teardown`
521
if (this.serverProcess) {
522
await measureAndLog(() => teardown(this.serverProcess!, this.options.logger), 'teardown server process', this.options.logger);
523
}
524
}
525
526
private async saveWebClientLogs(): Promise<void> {
527
const logs = await this.getLogs();
528
529
for (const log of logs) {
530
const absoluteLogsPath = join(this.options.logsPath, log.relativePath);
531
532
await promises.mkdir(dirname(absoluteLogsPath), { recursive: true });
533
await promises.writeFile(absoluteLogsPath, log.contents);
534
}
535
}
536
537
async sendKeybinding(keybinding: string, accept?: () => Promise<void> | void) {
538
const chords = keybinding.split(' ');
539
for (let i = 0; i < chords.length; i++) {
540
const chord = chords[i];
541
if (i > 0) {
542
await this.wait(100);
543
}
544
545
if (keybinding.startsWith('Alt') || keybinding.startsWith('Control') || keybinding.startsWith('Backspace')) {
546
await this.page.keyboard.press(keybinding);
547
return;
548
}
549
550
const keys = chord.split('+');
551
const keysDown: string[] = [];
552
for (let i = 0; i < keys.length; i++) {
553
if (keys[i] in PlaywrightDriver.vscodeToPlaywrightKey) {
554
keys[i] = PlaywrightDriver.vscodeToPlaywrightKey[keys[i]];
555
}
556
await this.page.keyboard.down(keys[i]);
557
keysDown.push(keys[i]);
558
}
559
while (keysDown.length > 0) {
560
await this.page.keyboard.up(keysDown.pop()!);
561
}
562
}
563
564
await accept?.();
565
}
566
567
async click(selector: string, xoffset?: number | undefined, yoffset?: number | undefined) {
568
const { x, y } = await this.getElementXY(selector, xoffset, yoffset);
569
await this.page.mouse.click(x + (xoffset ? xoffset : 0), y + (yoffset ? yoffset : 0));
570
}
571
572
async setValue(selector: string, text: string) {
573
return this.page.evaluate(([driver, selector, text]) => driver.setValue(selector, text), [await this.getDriverHandle(), selector, text] as const);
574
}
575
576
async getTitle() {
577
return this.page.title();
578
}
579
580
async isActiveElement(selector: string) {
581
return this.page.evaluate(([driver, selector]) => driver.isActiveElement(selector), [await this.getDriverHandle(), selector] as const);
582
}
583
584
async getElements(selector: string, recursive: boolean = false) {
585
return this.page.evaluate(([driver, selector, recursive]) => driver.getElements(selector, recursive), [await this.getDriverHandle(), selector, recursive] as const);
586
}
587
588
async getElementXY(selector: string, xoffset?: number, yoffset?: number) {
589
return this.page.evaluate(([driver, selector, xoffset, yoffset]) => driver.getElementXY(selector, xoffset, yoffset), [await this.getDriverHandle(), selector, xoffset, yoffset] as const);
590
}
591
592
async typeInEditor(selector: string, text: string) {
593
return this.page.evaluate(([driver, selector, text]) => driver.typeInEditor(selector, text), [await this.getDriverHandle(), selector, text] as const);
594
}
595
596
async getEditorSelection(selector: string) {
597
return this.page.evaluate(([driver, selector]) => driver.getEditorSelection(selector), [await this.getDriverHandle(), selector] as const);
598
}
599
600
async getTerminalBuffer(selector: string) {
601
return this.page.evaluate(([driver, selector]) => driver.getTerminalBuffer(selector), [await this.getDriverHandle(), selector] as const);
602
}
603
604
async writeInTerminal(selector: string, text: string) {
605
return this.page.evaluate(([driver, selector, text]) => driver.writeInTerminal(selector, text), [await this.getDriverHandle(), selector, text] as const);
606
}
607
608
async getLocaleInfo() {
609
return this.evaluateWithDriver(([driver]) => driver.getLocaleInfo());
610
}
611
612
async getLocalizedStrings() {
613
return this.evaluateWithDriver(([driver]) => driver.getLocalizedStrings());
614
}
615
616
async getLogs() {
617
return this.page.evaluate(([driver]) => driver.getLogs(), [await this.getDriverHandle()] as const);
618
}
619
620
private async evaluateWithDriver<T>(pageFunction: PageFunction<IWindowDriver[], T>) {
621
return this.page.evaluate(pageFunction, [await this.getDriverHandle()]);
622
}
623
624
wait(ms: number): Promise<void> {
625
return wait(ms);
626
}
627
628
whenWorkbenchRestored(): Promise<void> {
629
return this.evaluateWithDriver(([driver]) => driver.whenWorkbenchRestored());
630
}
631
632
private async getDriverHandle(): Promise<playwright.JSHandle<IWindowDriver>> {
633
return this.page.evaluateHandle('window.driver');
634
}
635
636
async isAlive(): Promise<boolean> {
637
try {
638
await this.getDriverHandle();
639
return true;
640
} catch (error) {
641
return false;
642
}
643
}
644
645
/**
646
* Run an accessibility scan on the current page using axe-core.
647
* Uses direct script injection to work with Electron.
648
* @param options Configuration options for the accessibility scan.
649
* @returns The axe-core scan results including any violations found.
650
*/
651
async runAccessibilityScan(options?: AccessibilityScanOptions): Promise<AxeResults> {
652
// Inject axe-core into the page if not already present
653
await this.page.evaluate(axeSource);
654
655
// Build axe-core run options
656
const runOptions: RunOptions = {
657
runOnly: {
658
type: 'tag',
659
values: options?.tags ?? ['wcag2a', 'wcag2aa', 'wcag21aa']
660
}
661
};
662
663
// Disable specific rules if requested
664
if (options?.disableRules && options.disableRules.length > 0) {
665
runOptions.rules = {};
666
for (const ruleId of options.disableRules) {
667
runOptions.rules[ruleId] = { enabled: false };
668
}
669
}
670
671
// Build context for axe.run
672
const context: { include?: string[]; exclude?: string[][] } = {};
673
674
if (options?.selector) {
675
context.include = [options.selector];
676
}
677
678
// Exclude known problematic areas
679
context.exclude = [
680
['.monaco-editor .view-lines'],
681
['.xterm-screen canvas']
682
];
683
684
// Run axe-core analysis
685
const results = await measureAndLog(
686
() => this.page.evaluate(
687
([ctx, opts]) => {
688
// @ts-expect-error axe is injected globally
689
return window.axe.run(ctx, opts);
690
},
691
[context, runOptions] as const
692
),
693
'runAccessibilityScan',
694
this.options.logger
695
);
696
697
return results as AxeResults;
698
}
699
700
/**
701
* Run an accessibility scan and throw an error if any violations are found.
702
* @param options Configuration options for the accessibility scan.
703
* @throws Error if accessibility violations are detected.
704
*/
705
async assertNoAccessibilityViolations(options?: AccessibilityScanOptions): Promise<void> {
706
const results = await this.runAccessibilityScan(options);
707
708
// Filter out violations for specific elements based on excludeRules
709
let filteredViolations = results.violations;
710
if (options?.excludeRules) {
711
filteredViolations = results.violations.map((violation: AxeResults['violations'][number]) => {
712
const excludePatterns = options.excludeRules![violation.id];
713
if (!excludePatterns) {
714
return violation;
715
}
716
// Filter out nodes that match any of the exclude patterns
717
const filteredNodes = violation.nodes.filter((node: AxeResults['violations'][number]['nodes'][number]) => {
718
const target = node.target.join(' ');
719
const html = node.html || '';
720
// Check if any exclude pattern appears in target or HTML
721
return !excludePatterns.some(pattern => target.includes(pattern) || html.includes(pattern));
722
});
723
return { ...violation, nodes: filteredNodes };
724
}).filter((violation: AxeResults['violations'][number]) => violation.nodes.length > 0);
725
}
726
727
if (filteredViolations.length > 0) {
728
const violationMessages = filteredViolations.map((violation: AxeResults['violations'][number]) => {
729
const nodes = violation.nodes.map((node: AxeResults['violations'][number]['nodes'][number]) => {
730
const target = node.target.join(' > ');
731
const html = node.html || 'N/A';
732
// Extract class from HTML for easier identification
733
const classMatch = html.match(/class="([^"]+)"/);
734
const className = classMatch ? classMatch[1] : 'no class';
735
return [
736
` Element: ${target}`,
737
` Class: ${className}`,
738
` HTML: ${html}`,
739
` Issue: ${node.failureSummary}`
740
].join('\n');
741
}).join('\n\n');
742
return [
743
`[${violation.id}] ${violation.help} (${violation.impact})`,
744
` Help URL: ${violation.helpUrl}`,
745
nodes
746
].join('\n');
747
}).join('\n\n---\n\n');
748
749
throw new Error(
750
`Accessibility violations found:\n\n${violationMessages}\n\n` +
751
`Total: ${filteredViolations.length} violation(s) affecting ${filteredViolations.reduce((sum: number, v: AxeResults['violations'][number]) => sum + v.nodes.length, 0)} element(s)`
752
);
753
}
754
}
755
}
756
757
export function wait(ms: number): Promise<void> {
758
return new Promise<void>(resolve => setTimeout(resolve, ms));
759
}
760
761
export type { AxeResults };
762
763