snap-ally 1.0.1 → 1.0.2
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/README.md +4 -0
- package/dist/SnapAllyReporter.js +1 -1
- package/dist/core/HtmlRenderer.js +10 -6
- package/dist/core/Scanner.d.ts +2 -1
- package/dist/core/Scanner.js +136 -97
- package/dist/core/VisualReporter.d.ts +14 -40
- package/dist/core/VisualReporter.js +77 -158
- package/dist/models/BannerInfo.d.ts +6 -0
- package/dist/models/BannerInfo.js +2 -0
- package/dist/models/ScannerOptions.d.ts +6 -4
- package/dist/models/index.d.ts +1 -0
- package/dist/models/index.js +1 -0
- package/dist/templates/global-report-styles.css +7 -2
- package/package.json +13 -13
package/README.md
CHANGED
|
@@ -164,6 +164,10 @@ Automated accessibility testing with Axe-core detects approximately **30–40% o
|
|
|
164
164
|
|
|
165
165
|
snap-ally automates the "easy wins" in your CI/CD pipeline so your team can focus manual effort on the complex interactions that tools cannot evaluate.
|
|
166
166
|
|
|
167
|
+
### Video missing in the VS Code Playwright extension
|
|
168
|
+
|
|
169
|
+
If the report has no video when you run a test from the **VS Code Playwright extension**, uncheck **Show browser** in the Testing sidebar and run again. Playwright saves the video only when the browser context closes, and the extension's "Show browser" mode keeps a persistent context alive, so no video is produced (a [known Playwright behavior](https://github.com/microsoft/playwright/issues/33155), not a snap-ally bug). Running from the terminal (`npx playwright test`) and CI are unaffected.
|
|
170
|
+
|
|
167
171
|
---
|
|
168
172
|
|
|
169
173
|
## <span aria-hidden="true">🤝</span> Contributing
|
package/dist/SnapAllyReporter.js
CHANGED
|
@@ -158,7 +158,7 @@ class SnapAllyReporter {
|
|
|
158
158
|
const violations = (actualData === null || actualData === void 0 ? void 0 : actualData.a11yErrors) || (actualData === null || actualData === void 0 ? void 0 : actualData.violations) || [];
|
|
159
159
|
const a11yErrorCount = violations.reduce((acc, curr) => { var _a, _b; return acc + (curr.total || ((_a = curr.target) === null || _a === void 0 ? void 0 : _a.length) || ((_b = curr.nodes) === null || _b === void 0 ? void 0 : _b.length) || 0); }, 0);
|
|
160
160
|
const filteredSteps = (() => {
|
|
161
|
-
const blocklist = ['Evaluate', 'Create page', 'Close page', 'Before Hooks', 'After Hooks', 'Worker Teardown', 'Worker Cleanup', 'Attach', 'Wait for timeout', 'Capture A11y screenshot'];
|
|
161
|
+
const blocklist = ['Evaluate', 'Create page', 'Close page', 'Before Hooks', 'After Hooks', 'Worker Teardown', 'Worker Cleanup', 'Attach', 'Wait for timeout', 'Capture A11y screenshot', 'Scroll into view', 'Bounding box'];
|
|
162
162
|
const filtered = result.steps
|
|
163
163
|
.filter((s) => !blocklist.some(b => s.title.includes(b)))
|
|
164
164
|
.map((s) => s.title);
|
|
@@ -54,20 +54,24 @@ class HtmlRenderer {
|
|
|
54
54
|
throw new Error(`[HtmlRenderer] Template not found: ${templatePath}`);
|
|
55
55
|
}
|
|
56
56
|
let html = fs.readFileSync(templatePath, 'utf8');
|
|
57
|
-
// Inline CSS
|
|
57
|
+
// Inline CSS. Use a replacement function so any `$` sequences in the
|
|
58
|
+
// injected content are inserted literally (a replacement *string* would
|
|
59
|
+
// interpret `$$`, `$&`, `` $` ``, `$'`, `$n` as special patterns).
|
|
58
60
|
if (fs.existsSync(cssPath)) {
|
|
59
61
|
const css = fs.readFileSync(cssPath, 'utf8');
|
|
60
|
-
html = html.replace('</head>', `<style>\n${css}\n</style>\n</head>`);
|
|
62
|
+
html = html.replace('</head>', () => `<style>\n${css}\n</style>\n</head>`);
|
|
61
63
|
// Remove the link tag if it exists
|
|
62
64
|
html = html.replace(/<link[^>]*global-report-styles\.css[^>]*>/, '');
|
|
63
65
|
}
|
|
64
|
-
// Inline Data
|
|
65
|
-
|
|
66
|
-
|
|
66
|
+
// Inline Data. Escape `</script` so violation HTML/text containing that
|
|
67
|
+
// sequence can't prematurely close the inlined <script> tag.
|
|
68
|
+
const jsonData = JSON.stringify(data).replace(/<\/script/gi, '<\\/script');
|
|
69
|
+
const jsData = `window.snapAllyData = ${jsonData};`;
|
|
70
|
+
html = html.replace('<script src="data.js"></script>', () => `<script>\n${jsData}\n</script>`);
|
|
67
71
|
// Inline main logic
|
|
68
72
|
if (fs.existsSync(jsPath)) {
|
|
69
73
|
const js = fs.readFileSync(jsPath, 'utf8');
|
|
70
|
-
html = html.replace('<script src="report-app.js"></script>', `<script>\n${js}\n</script>`);
|
|
74
|
+
html = html.replace('<script src="report-app.js"></script>', () => `<script>\n${js}\n</script>`);
|
|
71
75
|
}
|
|
72
76
|
// Final cleanup of any potential remaining dummy scripts
|
|
73
77
|
html = html.replace(/<script src="data-[^>]*\.js"><\/script>/, '');
|
package/dist/core/Scanner.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { Page, TestInfo } from '@playwright/test';
|
|
2
2
|
import { ScannerOptions } from '../models';
|
|
3
3
|
/**
|
|
4
|
-
* Performs an accessibility audit using Axe and
|
|
4
|
+
* Performs an accessibility audit using Axe and attaches the results as the
|
|
5
|
+
* 'A11y' attachment consumed by SnapAllyReporter.
|
|
5
6
|
*/
|
|
6
7
|
export declare function scanA11y(page: Page, testInfo: TestInfo, options?: ScannerOptions): Promise<void>;
|
|
7
8
|
export declare const checkAccessibility: typeof scanA11y;
|
package/dist/core/Scanner.js
CHANGED
|
@@ -10,6 +10,14 @@ const test_1 = require("@playwright/test");
|
|
|
10
10
|
const VisualReporter_1 = require("./VisualReporter");
|
|
11
11
|
const models_1 = require("../models");
|
|
12
12
|
const TimeUtils_1 = require("../utils/TimeUtils");
|
|
13
|
+
/** Annotation types rendered separately in the report, so they must not repeat as context steps. */
|
|
14
|
+
const EXCLUDED_ANNOTATION_TYPES = new Set(['Pre Condition', 'Post Condition', 'Description', 'A11y']);
|
|
15
|
+
/**
|
|
16
|
+
* Matches reporter ids that refer to SnapAllyReporter: the file path used
|
|
17
|
+
* inside this repo (e.g. './src/SnapAllyReporter.ts') or the published
|
|
18
|
+
* package name ('snap-ally').
|
|
19
|
+
*/
|
|
20
|
+
const REPORTER_ID_PATTERN = /snap-?ally/i;
|
|
13
21
|
/**
|
|
14
22
|
* Sanitizes a string to be safe for use in file paths and prevents path traversal attacks.
|
|
15
23
|
*/
|
|
@@ -24,141 +32,172 @@ function sanitizePageKey(input) {
|
|
|
24
32
|
.substring(0, 200));
|
|
25
33
|
}
|
|
26
34
|
/**
|
|
27
|
-
*
|
|
35
|
+
* Reads the SnapAllyReporter options from the Playwright config so scans can
|
|
36
|
+
* fall back to the globally configured defaults.
|
|
28
37
|
*/
|
|
29
|
-
|
|
30
|
-
var _a
|
|
31
|
-
//
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
38
|
+
function getReporterOptions(testInfo) {
|
|
39
|
+
var _a;
|
|
40
|
+
// A reporter entry is normally a [name, options?] tuple, but Playwright also
|
|
41
|
+
// accepts a bare string (e.g. reporter: 'snap-ally'), so handle both shapes.
|
|
42
|
+
const reporters = testInfo.config.reporter;
|
|
43
|
+
const entry = reporters.find((descriptor) => {
|
|
44
|
+
const name = typeof descriptor === 'string' ? descriptor : descriptor[0];
|
|
45
|
+
return REPORTER_ID_PATTERN.test(name);
|
|
46
|
+
});
|
|
47
|
+
if (!entry || typeof entry === 'string') {
|
|
48
|
+
return {};
|
|
49
|
+
}
|
|
50
|
+
return ((_a = entry[1]) !== null && _a !== void 0 ? _a : {});
|
|
51
|
+
}
|
|
52
|
+
function buildAxe(page, options) {
|
|
53
|
+
let builder = new playwright_1.default({ page });
|
|
43
54
|
const target = options.include || options.box;
|
|
44
55
|
if (target) {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
56
|
+
// AxeBuilder.include only accepts selector strings (axe-core SerialFrameSelector),
|
|
57
|
+
// never a Playwright Locator. Guard JS callers that bypass the type with a clear error.
|
|
58
|
+
if (typeof target !== 'string') {
|
|
59
|
+
throw new Error('[SnapAlly] "include"/"box" must be a CSS selector string; ' +
|
|
60
|
+
'Playwright Locators are not supported by AxeBuilder.');
|
|
50
61
|
}
|
|
62
|
+
builder = builder.include(target);
|
|
51
63
|
}
|
|
52
64
|
if (options.rules) {
|
|
53
|
-
|
|
65
|
+
builder = builder.options({ rules: options.rules });
|
|
54
66
|
}
|
|
55
67
|
if (options.tags) {
|
|
56
|
-
|
|
68
|
+
builder = builder.withTags(options.tags);
|
|
57
69
|
}
|
|
58
70
|
if (options.axeOptions) {
|
|
59
|
-
|
|
71
|
+
builder = builder.options(options.axeOptions);
|
|
60
72
|
}
|
|
61
|
-
|
|
73
|
+
return builder;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Runs the Axe analysis, returning null when the page closed before the scan
|
|
77
|
+
* could finish (e.g. the test already ended).
|
|
78
|
+
*/
|
|
79
|
+
async function runAxe(builder) {
|
|
62
80
|
try {
|
|
63
|
-
|
|
81
|
+
return await builder.analyze();
|
|
64
82
|
}
|
|
65
83
|
catch (error) {
|
|
66
84
|
if (error instanceof Error &&
|
|
67
85
|
(error.message.includes('Test ended') ||
|
|
68
86
|
error.message.includes('Target page, context or browser has been closed'))) {
|
|
69
87
|
console.warn(`[SnapAlly] Accessibility scan skipped: ${error.message}`);
|
|
70
|
-
return;
|
|
88
|
+
return null;
|
|
71
89
|
}
|
|
72
90
|
throw error;
|
|
73
91
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
92
|
+
}
|
|
93
|
+
async function logViolations(page, violations, showTerminal, showBrowser) {
|
|
94
|
+
if (violations.length === 0 || (!showTerminal && !showBrowser)) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const mainMsg = `[A11yScanner] Violations found: ${violations.length}`;
|
|
98
|
+
const detailMessages = violations.map((violation, index) => ` ${index + 1}. ${violation.id} [${violation.impact}] - ${violation.help}`);
|
|
99
|
+
if (showTerminal) {
|
|
100
|
+
console.log(`\n${mainMsg}`);
|
|
101
|
+
detailMessages.forEach((msg) => console.log(msg));
|
|
102
|
+
}
|
|
103
|
+
if (showBrowser) {
|
|
104
|
+
await page.evaluate(([msg, details, color]) => {
|
|
105
|
+
console.log(`%c ${msg}`, `color: ${color}; font-weight: bold; font-size: 12px;`);
|
|
106
|
+
details.forEach((line) => console.log(line));
|
|
107
|
+
}, [mainMsg, detailMessages, models_1.DEFAULT_COLORS.serious]);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Highlights each visible violating element, captures a screenshot per element,
|
|
112
|
+
* and returns the violation enriched with that visual evidence.
|
|
113
|
+
*/
|
|
114
|
+
async function collectViolationEvidence(page, testInfo, visualReporter, violation, severityColor, contextSteps) {
|
|
115
|
+
const targets = [];
|
|
116
|
+
let screenshotIndex = 0;
|
|
117
|
+
await visualReporter.showBanner({ id: violation.id, help: violation.help, color: severityColor });
|
|
118
|
+
for (const node of violation.nodes) {
|
|
119
|
+
for (const selector of node.target) {
|
|
120
|
+
const elementSelector = selector.toString();
|
|
121
|
+
if (!(await page.locator(elementSelector).isVisible())) {
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
await visualReporter.highlightElement(elementSelector, severityColor);
|
|
125
|
+
// Let the highlight transition settle before the screenshot.
|
|
126
|
+
// eslint-disable-next-line playwright/no-wait-for-timeout
|
|
127
|
+
await page.waitForTimeout(100);
|
|
128
|
+
const screenshotName = `a11y-${violation.id}-${screenshotIndex++}.png`;
|
|
129
|
+
const buffer = await visualReporter.captureScreenshot(testInfo, screenshotName);
|
|
130
|
+
targets.push({
|
|
131
|
+
element: elementSelector,
|
|
132
|
+
snippet: elementSelector,
|
|
133
|
+
html: node.html || '',
|
|
134
|
+
screenshot: screenshotName,
|
|
135
|
+
steps: contextSteps,
|
|
136
|
+
stepsJson: JSON.stringify(contextSteps),
|
|
137
|
+
screenshotBase64: buffer.toString('base64'),
|
|
138
|
+
});
|
|
139
|
+
await visualReporter.removeHighlight();
|
|
89
140
|
}
|
|
90
141
|
}
|
|
142
|
+
// Remove this violation's banner so banners don't stack across violations.
|
|
143
|
+
await visualReporter.cleanupOverlay();
|
|
144
|
+
return {
|
|
145
|
+
id: violation.id,
|
|
146
|
+
description: violation.description,
|
|
147
|
+
severity: violation.impact || 'unknown',
|
|
148
|
+
helpUrl: violation.helpUrl,
|
|
149
|
+
help: violation.help,
|
|
150
|
+
guideline: violation.tags[1] || 'N/A',
|
|
151
|
+
wcagRule: violation.tags.find((tag) => tag.startsWith('wcag')) || violation.tags[1] || 'N/A',
|
|
152
|
+
total: targets.length || violation.nodes.length,
|
|
153
|
+
target: targets,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Performs an accessibility audit using Axe and attaches the results as the
|
|
158
|
+
* 'A11y' attachment consumed by SnapAllyReporter.
|
|
159
|
+
*/
|
|
160
|
+
async function scanA11y(page, testInfo, options = {}) {
|
|
161
|
+
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k;
|
|
162
|
+
const globalOptions = getReporterOptions(testInfo);
|
|
163
|
+
// Resolve final options (local > global > default)
|
|
164
|
+
const showTerminal = (_b = (_a = options.verbose) !== null && _a !== void 0 ? _a : globalOptions.verbose) !== null && _b !== void 0 ? _b : true;
|
|
165
|
+
const showBrowser = (_d = (_c = options.consoleLog) !== null && _c !== void 0 ? _c : globalOptions.consoleLog) !== null && _d !== void 0 ? _d : true;
|
|
166
|
+
const pageKey = sanitizePageKey(options.pageKey || page.url());
|
|
167
|
+
const customColors = globalOptions.colors;
|
|
168
|
+
const axeResults = await runAxe(buildAxe(page, options));
|
|
169
|
+
if (!axeResults) {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
await logViolations(page, axeResults.violations, showTerminal, showBrowser);
|
|
173
|
+
const violationCount = axeResults.violations.length;
|
|
91
174
|
await test_1.test.step('Check Accessibility', async () => {
|
|
92
175
|
test_1.expect.soft(violationCount).toBe(0);
|
|
93
176
|
});
|
|
94
|
-
const
|
|
95
|
-
const
|
|
177
|
+
const visualReporter = new VisualReporter_1.VisualReporter(page);
|
|
178
|
+
const contextSteps = (testInfo.annotations || [])
|
|
179
|
+
.filter((annotation) => !EXCLUDED_ANNOTATION_TYPES.has(annotation.type))
|
|
180
|
+
.map((annotation) => annotation.description || '');
|
|
181
|
+
const violations = [];
|
|
96
182
|
for (const violation of axeResults.violations) {
|
|
97
|
-
let errorIdx = 0;
|
|
98
|
-
const targets = [];
|
|
99
183
|
const severityColor = (0, models_1.getSeverityColor)(violation.impact, customColors);
|
|
100
|
-
|
|
101
|
-
for (const selector of node.target) {
|
|
102
|
-
const elementSelector = selector.toString();
|
|
103
|
-
const locator = page.locator(elementSelector);
|
|
104
|
-
await overlay.showBanner({ id: violation.id, help: violation.help }, severityColor);
|
|
105
|
-
if (await locator.isVisible()) {
|
|
106
|
-
await overlay.highlightElement(elementSelector, severityColor);
|
|
107
|
-
// eslint-disable-next-line playwright/no-wait-for-timeout
|
|
108
|
-
await page.waitForTimeout(100);
|
|
109
|
-
const screenshotName = `a11y-${violation.id}-${errorIdx++}.png`;
|
|
110
|
-
const buffer = await overlay.captureScreenshot(testInfo, screenshotName);
|
|
111
|
-
const excluded = new Set([
|
|
112
|
-
'Pre Condition',
|
|
113
|
-
'Post Condition',
|
|
114
|
-
'Description',
|
|
115
|
-
'A11y',
|
|
116
|
-
]);
|
|
117
|
-
const contextSteps = (testInfo.annotations || [])
|
|
118
|
-
.filter((a) => !excluded.has(a.type))
|
|
119
|
-
.map((a) => a.description || '');
|
|
120
|
-
const nodeHtml = node.html || '';
|
|
121
|
-
const friendlySnippet = elementSelector;
|
|
122
|
-
targets.push({
|
|
123
|
-
element: elementSelector,
|
|
124
|
-
snippet: friendlySnippet,
|
|
125
|
-
html: nodeHtml,
|
|
126
|
-
screenshot: screenshotName,
|
|
127
|
-
steps: contextSteps,
|
|
128
|
-
stepsJson: JSON.stringify(contextSteps),
|
|
129
|
-
screenshotBase64: buffer.toString('base64'),
|
|
130
|
-
});
|
|
131
|
-
await overlay.removeHighlight();
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
errors.push({
|
|
136
|
-
id: violation.id,
|
|
137
|
-
description: violation.description,
|
|
138
|
-
severity: violation.impact || 'unknown',
|
|
139
|
-
helpUrl: violation.helpUrl,
|
|
140
|
-
help: violation.help,
|
|
141
|
-
guideline: violation.tags[1] || 'N/A',
|
|
142
|
-
wcagRule: violation.tags.find((t) => t.startsWith('wcag')) || violation.tags[1] || 'N/A',
|
|
143
|
-
total: targets.length || violation.nodes.length,
|
|
144
|
-
target: targets,
|
|
145
|
-
});
|
|
184
|
+
violations.push(await collectViolationEvidence(page, testInfo, visualReporter, violation, severityColor, contextSteps));
|
|
146
185
|
}
|
|
147
186
|
const reportData = {
|
|
148
187
|
pageKey,
|
|
149
188
|
pageUrl: page.url(),
|
|
150
189
|
accessibilityScore: 0,
|
|
151
|
-
a11yErrors:
|
|
190
|
+
a11yErrors: violations,
|
|
152
191
|
criticalColor: (customColors === null || customColors === void 0 ? void 0 : customColors.critical) || models_1.DEFAULT_COLORS.critical,
|
|
153
192
|
seriousColor: (customColors === null || customColors === void 0 ? void 0 : customColors.serious) || models_1.DEFAULT_COLORS.serious,
|
|
154
193
|
moderateColor: (customColors === null || customColors === void 0 ? void 0 : customColors.moderate) || models_1.DEFAULT_COLORS.moderate,
|
|
155
194
|
minorColor: (customColors === null || customColors === void 0 ? void 0 : customColors.minor) || models_1.DEFAULT_COLORS.minor,
|
|
156
|
-
adoOrganization: ((
|
|
157
|
-
adoProject: ((_g = options.ado) === null || _g === void 0 ? void 0 : _g.project) || process.env.ADO_PROJECT || '',
|
|
158
|
-
adoAreaPath: ((
|
|
195
|
+
adoOrganization: ((_e = options.ado) === null || _e === void 0 ? void 0 : _e.organization) || ((_f = globalOptions.ado) === null || _f === void 0 ? void 0 : _f.organization) || process.env.ADO_ORGANIZATION || '',
|
|
196
|
+
adoProject: ((_g = options.ado) === null || _g === void 0 ? void 0 : _g.project) || ((_h = globalOptions.ado) === null || _h === void 0 ? void 0 : _h.project) || process.env.ADO_PROJECT || '',
|
|
197
|
+
adoAreaPath: ((_j = options.ado) === null || _j === void 0 ? void 0 : _j.areaPath) || ((_k = globalOptions.ado) === null || _k === void 0 ? void 0 : _k.areaPath) || process.env.ADO_AREA_PATH || '',
|
|
159
198
|
timestamp: TimeUtils_1.TimeUtils.formatDate(new Date()),
|
|
160
199
|
};
|
|
161
|
-
await
|
|
162
|
-
await
|
|
200
|
+
await visualReporter.attachJsonData(testInfo, 'A11y', JSON.stringify(reportData));
|
|
201
|
+
await visualReporter.cleanupOverlay();
|
|
163
202
|
}
|
|
164
203
|
exports.checkAccessibility = scanA11y;
|
|
@@ -1,54 +1,28 @@
|
|
|
1
1
|
import { type Page, type TestInfo } from '@playwright/test';
|
|
2
|
-
|
|
3
|
-
* Information about an accessibility violation to display.
|
|
4
|
-
*/
|
|
5
|
-
interface ViolationInfo {
|
|
6
|
-
id: string;
|
|
7
|
-
help: string;
|
|
8
|
-
}
|
|
2
|
+
import { BannerInfo } from '../models';
|
|
9
3
|
/**
|
|
10
4
|
* Manages visual feedback on the page during an accessibility scan.
|
|
11
5
|
* Handles element highlights, violation banners, and report attachments.
|
|
12
6
|
*/
|
|
13
7
|
export declare class VisualReporter {
|
|
14
8
|
private readonly page;
|
|
15
|
-
private readonly
|
|
16
|
-
private
|
|
17
|
-
private static readonly
|
|
18
|
-
private static readonly
|
|
19
|
-
private static readonly
|
|
20
|
-
private
|
|
21
|
-
private
|
|
9
|
+
private readonly HIGHLIGHT_PADDING;
|
|
10
|
+
private readonly BANNER_ALPHA;
|
|
11
|
+
private static readonly BANNER_STYLE;
|
|
12
|
+
private static readonly BADGE_STYLE;
|
|
13
|
+
private static readonly HIGHLIGHT_STYLE;
|
|
14
|
+
private bannerOverlay;
|
|
15
|
+
private actionsOverlay;
|
|
16
|
+
private highlightOverlay;
|
|
22
17
|
constructor(page: Page);
|
|
23
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Applies alpha transparency to a CSS color string (hex, rgb, hsl, or named color).
|
|
20
|
+
*/
|
|
21
|
+
private addAlphaToColor;
|
|
22
|
+
showBanner(violation: BannerInfo): Promise<void>;
|
|
24
23
|
highlightElement(selector: string, color: string): Promise<void>;
|
|
25
24
|
cleanupOverlay(): Promise<void>;
|
|
26
25
|
removeHighlight(): Promise<void>;
|
|
27
26
|
attachJsonData(testInfo: TestInfo, name: string, data: string): Promise<void>;
|
|
28
27
|
captureScreenshot(testInfo: TestInfo, name: string): Promise<Buffer>;
|
|
29
|
-
/**
|
|
30
|
-
* Injects helper functions into the page context for overlay management.
|
|
31
|
-
* These helpers are used by visual feedback operations (banner, highlight).
|
|
32
|
-
*/
|
|
33
|
-
private ensurePageHelpers;
|
|
34
|
-
/**
|
|
35
|
-
* Checks if an error message indicates a page lifecycle issue.
|
|
36
|
-
*/
|
|
37
|
-
private isPageLifecycleError;
|
|
38
|
-
/**
|
|
39
|
-
* Executes a function in the page context with safety guarantees:
|
|
40
|
-
* 1. Ensures overlay helper functions are injected before execution
|
|
41
|
-
* 2. Gracefully handles page lifecycle errors (closed/destroyed pages)
|
|
42
|
-
*
|
|
43
|
-
* @param fn - Function to execute in the page context
|
|
44
|
-
* @param arg - Optional argument to pass to the function
|
|
45
|
-
*/
|
|
46
|
-
private safeEvaluate;
|
|
47
|
-
}
|
|
48
|
-
declare global {
|
|
49
|
-
interface Window {
|
|
50
|
-
getOrCreateOverlayShadowRoot: (id: string) => ShadowRoot;
|
|
51
|
-
addAlphaToColor: (color: string, alpha: number) => string;
|
|
52
|
-
}
|
|
53
28
|
}
|
|
54
|
-
export {};
|
|
@@ -9,105 +9,62 @@ const test_1 = require("@playwright/test");
|
|
|
9
9
|
class VisualReporter {
|
|
10
10
|
constructor(page) {
|
|
11
11
|
this.page = page;
|
|
12
|
-
this.
|
|
12
|
+
this.HIGHLIGHT_PADDING = 4;
|
|
13
|
+
this.BANNER_ALPHA = 0.85;
|
|
14
|
+
this.bannerOverlay = null;
|
|
15
|
+
this.actionsOverlay = null;
|
|
16
|
+
this.highlightOverlay = null;
|
|
13
17
|
}
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
font-size: 14px;
|
|
33
|
-
display: flex;
|
|
34
|
-
align-items: center;
|
|
35
|
-
gap: 12px;
|
|
36
|
-
box-shadow: 0 12px 40px rgba(0,0,0,0.3);
|
|
37
|
-
backdrop-filter: blur(16px) saturate(180%);
|
|
38
|
-
-webkit-backdrop-filter: blur(16px) saturate(180%);
|
|
39
|
-
border: 1px solid rgba(255,255,255,0.15);
|
|
40
|
-
z-index: 10000;
|
|
41
|
-
transition: all 0.3s ease;
|
|
42
|
-
}
|
|
43
|
-
.badge {
|
|
44
|
-
background: rgba(255,255,255,0.2);
|
|
45
|
-
padding: 2px 8px;
|
|
46
|
-
border-radius: 6px;
|
|
47
|
-
font-size: 11px;
|
|
48
|
-
font-weight: 700;
|
|
49
|
-
text-transform: uppercase;
|
|
50
|
-
border: 1px solid rgba(255,255,255,0.2);
|
|
51
|
-
}
|
|
52
|
-
.content { flex: 1; line-height: 1.4; font-weight: 500; }
|
|
53
|
-
`;
|
|
54
|
-
shadow.appendChild(style);
|
|
55
|
-
container = document.createElement('div');
|
|
56
|
-
container.id = bannerId;
|
|
57
|
-
shadow.appendChild(container);
|
|
58
|
-
}
|
|
59
|
-
container.style.backgroundColor = window.addAlphaToColor(rawColor, bannerAlpha);
|
|
60
|
-
container.innerHTML = `
|
|
61
|
-
<div style="font-size: 20px;">⚠️</div>
|
|
62
|
-
<div class="content">
|
|
63
|
-
<div style="margin-bottom:4px; display:flex; align-items:center; gap:8px;">
|
|
64
|
-
<span class="badge">${v.id}</span>
|
|
65
|
-
<span style="opacity: 0.9;">${v.help}</span>
|
|
66
|
-
</div>
|
|
67
|
-
</div>
|
|
68
|
-
`;
|
|
69
|
-
}, [violation, color, this.overlayHostId, VisualReporter.BANNER_ID, VisualReporter.BANNER_ALPHA]);
|
|
18
|
+
/**
|
|
19
|
+
* Applies alpha transparency to a CSS color string (hex, rgb, hsl, or named color).
|
|
20
|
+
*/
|
|
21
|
+
addAlphaToColor(color, alpha) {
|
|
22
|
+
return `color-mix(in srgb, ${color} ${alpha * 100}%, transparent)`;
|
|
23
|
+
}
|
|
24
|
+
async showBanner(violation) {
|
|
25
|
+
this.actionsOverlay = await this.page.screencast.showActions({ position: 'top' });
|
|
26
|
+
const backgroundColor = this.addAlphaToColor(violation.color, this.BANNER_ALPHA);
|
|
27
|
+
const bannerHtml = `
|
|
28
|
+
<div style="${VisualReporter.BANNER_STYLE} background-color: ${backgroundColor};">
|
|
29
|
+
<div style="flex: 1; line-height: 1.4; font-weight: 500; display: flex; align-items: center; gap: 8px;">
|
|
30
|
+
<span style="${VisualReporter.BADGE_STYLE}">${violation.id}</span>
|
|
31
|
+
<span style="opacity: 0.9;">${violation.help}</span>
|
|
32
|
+
</div>
|
|
33
|
+
</div>
|
|
34
|
+
`;
|
|
35
|
+
this.bannerOverlay = await this.page.screencast.showOverlay(bannerHtml);
|
|
70
36
|
}
|
|
71
37
|
async highlightElement(selector, color) {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
}
|
|
91
|
-
`;
|
|
92
|
-
shadow.appendChild(style);
|
|
93
|
-
highlight = document.createElement('div');
|
|
94
|
-
highlight.id = highlightId;
|
|
95
|
-
shadow.appendChild(highlight);
|
|
96
|
-
}
|
|
97
|
-
const rect = target.getBoundingClientRect();
|
|
98
|
-
highlight.style.left = `${rect.left + window.scrollX - padding}px`;
|
|
99
|
-
highlight.style.top = `${rect.top + window.scrollY - padding}px`;
|
|
100
|
-
highlight.style.width = `${rect.width + padding * 2}px`;
|
|
101
|
-
highlight.style.height = `${rect.height + padding * 2}px`;
|
|
102
|
-
highlight.style.border = `3px solid ${rawColor}`;
|
|
103
|
-
highlight.style.setProperty('--c-alpha', window.addAlphaToColor(rawColor, shadowAlpha));
|
|
104
|
-
}, [selector, color, this.overlayHostId, VisualReporter.HIGHLIGHT_ID, VisualReporter.HIGHLIGHT_PADDING, VisualReporter.HIGHLIGHT_SHADOW_ALPHA]);
|
|
38
|
+
const locator = this.page.locator(selector);
|
|
39
|
+
await locator.scrollIntoViewIfNeeded();
|
|
40
|
+
const box = await locator.boundingBox();
|
|
41
|
+
if (!box)
|
|
42
|
+
return;
|
|
43
|
+
const padding = this.HIGHLIGHT_PADDING;
|
|
44
|
+
const highlightHtml = `
|
|
45
|
+
<div style="
|
|
46
|
+
position: fixed;
|
|
47
|
+
left: ${box.x - padding}px;
|
|
48
|
+
top: ${box.y - padding}px;
|
|
49
|
+
width: ${box.width + padding * 2}px;
|
|
50
|
+
height: ${box.height + padding * 2}px;
|
|
51
|
+
border: 3px solid ${color};
|
|
52
|
+
${VisualReporter.HIGHLIGHT_STYLE}
|
|
53
|
+
"></div>
|
|
54
|
+
`;
|
|
55
|
+
this.highlightOverlay = await this.page.screencast.showOverlay(highlightHtml);
|
|
105
56
|
}
|
|
106
57
|
async cleanupOverlay() {
|
|
107
|
-
|
|
58
|
+
var _a, _b;
|
|
59
|
+
await ((_a = this.bannerOverlay) === null || _a === void 0 ? void 0 : _a.dispose());
|
|
60
|
+
await ((_b = this.actionsOverlay) === null || _b === void 0 ? void 0 : _b.dispose());
|
|
61
|
+
this.bannerOverlay = null;
|
|
62
|
+
this.actionsOverlay = null;
|
|
108
63
|
}
|
|
109
64
|
async removeHighlight() {
|
|
110
|
-
|
|
65
|
+
var _a;
|
|
66
|
+
await ((_a = this.highlightOverlay) === null || _a === void 0 ? void 0 : _a.dispose());
|
|
67
|
+
this.highlightOverlay = null;
|
|
111
68
|
}
|
|
112
69
|
async attachJsonData(testInfo, name, data) {
|
|
113
70
|
await testInfo.attach(name, {
|
|
@@ -122,71 +79,33 @@ class VisualReporter {
|
|
|
122
79
|
return screenshot;
|
|
123
80
|
});
|
|
124
81
|
}
|
|
125
|
-
/**
|
|
126
|
-
* Injects helper functions into the page context for overlay management.
|
|
127
|
-
* These helpers are used by visual feedback operations (banner, highlight).
|
|
128
|
-
*/
|
|
129
|
-
async ensurePageHelpers() {
|
|
130
|
-
await this.page.evaluate(() => {
|
|
131
|
-
const w = window;
|
|
132
|
-
// Check if both helpers are already defined
|
|
133
|
-
if (typeof w.getOrCreateOverlayShadowRoot === 'function' && typeof w.addAlphaToColor === 'function') {
|
|
134
|
-
return;
|
|
135
|
-
}
|
|
136
|
-
w.getOrCreateOverlayShadowRoot = (id) => {
|
|
137
|
-
let root = document.getElementById(id);
|
|
138
|
-
if (!root) {
|
|
139
|
-
root = document.createElement('div');
|
|
140
|
-
root.id = id;
|
|
141
|
-
root.style.cssText = 'position:absolute;top:0;left:0;width:0;height:0;z-index:2147483647;';
|
|
142
|
-
document.body.appendChild(root);
|
|
143
|
-
root.attachShadow({ mode: 'open' });
|
|
144
|
-
}
|
|
145
|
-
return root.shadowRoot;
|
|
146
|
-
};
|
|
147
|
-
w.addAlphaToColor = (color, alpha) => {
|
|
148
|
-
if (color.startsWith('rgba'))
|
|
149
|
-
return color.replace(/[\d.]+\)$/, `${alpha})`);
|
|
150
|
-
if (color.startsWith('rgb'))
|
|
151
|
-
return color.replace('rgb', 'rgba').replace(')', `, ${alpha})`);
|
|
152
|
-
const hex = Math.round(alpha * 255).toString(16).padStart(2, '0');
|
|
153
|
-
return `${color}${hex}`;
|
|
154
|
-
};
|
|
155
|
-
});
|
|
156
|
-
}
|
|
157
|
-
/**
|
|
158
|
-
* Checks if an error message indicates a page lifecycle issue.
|
|
159
|
-
*/
|
|
160
|
-
isPageLifecycleError(message) {
|
|
161
|
-
const lowerMsg = message.toLowerCase();
|
|
162
|
-
return VisualReporter.PAGE_LIFECYCLE_ERROR_PATTERNS.some(pattern => lowerMsg.includes(pattern));
|
|
163
|
-
}
|
|
164
|
-
/**
|
|
165
|
-
* Executes a function in the page context with safety guarantees:
|
|
166
|
-
* 1. Ensures overlay helper functions are injected before execution
|
|
167
|
-
* 2. Gracefully handles page lifecycle errors (closed/destroyed pages)
|
|
168
|
-
*
|
|
169
|
-
* @param fn - Function to execute in the page context
|
|
170
|
-
* @param arg - Optional argument to pass to the function
|
|
171
|
-
*/
|
|
172
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
173
|
-
async safeEvaluate(fn, arg) {
|
|
174
|
-
try {
|
|
175
|
-
await this.ensurePageHelpers();
|
|
176
|
-
await (arg !== undefined ? this.page.evaluate(fn, arg) : this.page.evaluate(fn));
|
|
177
|
-
}
|
|
178
|
-
catch (e) {
|
|
179
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
180
|
-
if (this.isPageLifecycleError(msg))
|
|
181
|
-
return;
|
|
182
|
-
throw e;
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
82
|
}
|
|
186
83
|
exports.VisualReporter = VisualReporter;
|
|
187
|
-
VisualReporter.
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
84
|
+
VisualReporter.BANNER_STYLE = `
|
|
85
|
+
position: absolute;
|
|
86
|
+
top: 16px;
|
|
87
|
+
left: 50%;
|
|
88
|
+
transform: translateX(-50%);
|
|
89
|
+
width: fit-content;
|
|
90
|
+
max-width: 600px;
|
|
91
|
+
padding: 12px 18px;
|
|
92
|
+
border-radius: 12px;
|
|
93
|
+
color: white;
|
|
94
|
+
font-family: system-ui, -apple-system, sans-serif;
|
|
95
|
+
font-size: 14px;
|
|
96
|
+
z-index: 10000;
|
|
97
|
+
`;
|
|
98
|
+
VisualReporter.BADGE_STYLE = `
|
|
99
|
+
background: rgba(255,255,255,0.2);
|
|
100
|
+
padding: 2px 8px;
|
|
101
|
+
border-radius: 6px;
|
|
102
|
+
font-size: 11px;
|
|
103
|
+
font-weight: 700;
|
|
104
|
+
text-transform: uppercase;
|
|
105
|
+
`;
|
|
106
|
+
VisualReporter.HIGHLIGHT_STYLE = `
|
|
107
|
+
border-radius: 8px;
|
|
108
|
+
box-sizing: border-box;
|
|
109
|
+
pointer-events: none;
|
|
110
|
+
z-index: 9999;
|
|
111
|
+
`;
|
|
@@ -1,12 +1,14 @@
|
|
|
1
|
-
import type { Locator } from '@playwright/test';
|
|
2
1
|
/**
|
|
3
2
|
* Options for the accessibility scanner.
|
|
4
3
|
*/
|
|
5
4
|
export interface ScannerOptions {
|
|
6
|
-
/**
|
|
7
|
-
|
|
5
|
+
/**
|
|
6
|
+
* CSS selector to limit the scan to. Must be a string: AxeBuilder only
|
|
7
|
+
* accepts selector strings, not Playwright Locators.
|
|
8
|
+
*/
|
|
9
|
+
include?: string;
|
|
8
10
|
/** Alias for include. */
|
|
9
|
-
box?: string
|
|
11
|
+
box?: string;
|
|
10
12
|
/** Whether to log violations to the console. @default true */
|
|
11
13
|
verbose?: boolean;
|
|
12
14
|
/** Alias for verbose. */
|
package/dist/models/index.d.ts
CHANGED
package/dist/models/index.js
CHANGED
|
@@ -15,6 +15,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
15
15
|
};
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
17
|
__exportStar(require("./Violation"), exports);
|
|
18
|
+
__exportStar(require("./BannerInfo"), exports);
|
|
18
19
|
__exportStar(require("./ReportData"), exports);
|
|
19
20
|
__exportStar(require("./TestResults"), exports);
|
|
20
21
|
__exportStar(require("./ScannerOptions"), exports);
|
|
@@ -186,8 +186,8 @@ header {
|
|
|
186
186
|
right: 0;
|
|
187
187
|
z-index: 1000;
|
|
188
188
|
background: rgba(255, 255, 255, 0.8);
|
|
189
|
-
backdrop-filter: blur(20px) saturate(180%);
|
|
190
189
|
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
|
190
|
+
backdrop-filter: blur(20px) saturate(180%);
|
|
191
191
|
border-bottom: 1px solid var(--glass-border);
|
|
192
192
|
padding: 14px 40px;
|
|
193
193
|
display: flex;
|
|
@@ -230,8 +230,8 @@ header {
|
|
|
230
230
|
/* Footer */
|
|
231
231
|
footer {
|
|
232
232
|
background: rgba(255, 255, 255, 0.7);
|
|
233
|
-
backdrop-filter: blur(12px);
|
|
234
233
|
-webkit-backdrop-filter: blur(12px);
|
|
234
|
+
backdrop-filter: blur(12px);
|
|
235
235
|
border-top: 1px solid var(--glass-border);
|
|
236
236
|
padding: 24px 40px;
|
|
237
237
|
margin-top: auto;
|
|
@@ -485,6 +485,7 @@ footer {
|
|
|
485
485
|
justify-content: center;
|
|
486
486
|
background: rgba(255, 255, 255, 0.2);
|
|
487
487
|
border-radius: 30px;
|
|
488
|
+
-webkit-backdrop-filter: blur(10px);
|
|
488
489
|
backdrop-filter: blur(10px);
|
|
489
490
|
}
|
|
490
491
|
|
|
@@ -550,6 +551,7 @@ footer {
|
|
|
550
551
|
border-radius: 12px;
|
|
551
552
|
font-size: 0.85rem;
|
|
552
553
|
font-weight: 700;
|
|
554
|
+
-webkit-backdrop-filter: blur(5px);
|
|
553
555
|
backdrop-filter: blur(5px);
|
|
554
556
|
color: white;
|
|
555
557
|
}
|
|
@@ -1293,6 +1295,7 @@ video {
|
|
|
1293
1295
|
/* Charts */
|
|
1294
1296
|
.chart-box {
|
|
1295
1297
|
background: var(--card-bg);
|
|
1298
|
+
-webkit-backdrop-filter: blur(12px);
|
|
1296
1299
|
backdrop-filter: blur(12px);
|
|
1297
1300
|
border: 1px solid var(--glass-border);
|
|
1298
1301
|
border-radius: 24px;
|
|
@@ -1424,6 +1427,7 @@ video {
|
|
|
1424
1427
|
/* Violation Cards */
|
|
1425
1428
|
.violation-card {
|
|
1426
1429
|
background: var(--glass-bg);
|
|
1430
|
+
-webkit-backdrop-filter: blur(8px);
|
|
1427
1431
|
backdrop-filter: blur(8px);
|
|
1428
1432
|
border-radius: var(--card-radius);
|
|
1429
1433
|
border: 1px solid var(--glass-border);
|
|
@@ -1757,6 +1761,7 @@ video {
|
|
|
1757
1761
|
border: 1px solid var(--glass-border);
|
|
1758
1762
|
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.15);
|
|
1759
1763
|
background: rgba(255, 255, 255, 0.98);
|
|
1764
|
+
-webkit-backdrop-filter: blur(10px);
|
|
1760
1765
|
backdrop-filter: blur(10px);
|
|
1761
1766
|
overflow: hidden;
|
|
1762
1767
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "snap-ally",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"description": "A custom Playwright reporter for Accessibility testing using Axe, with HTML reporting and Azure DevOps integration.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -33,19 +33,19 @@
|
|
|
33
33
|
"@axe-core/playwright": "^4.11.1"
|
|
34
34
|
},
|
|
35
35
|
"peerDependencies": {
|
|
36
|
-
"@playwright/test": "^1.
|
|
37
|
-
"playwright": "^1.
|
|
36
|
+
"@playwright/test": "^1.60.0",
|
|
37
|
+
"playwright": "^1.60.0"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
|
-
"@playwright/test": "^1.
|
|
41
|
-
"@types/node": "^
|
|
42
|
-
"@typescript-eslint/eslint-plugin": "^8.
|
|
43
|
-
"@typescript-eslint/parser": "^8.
|
|
44
|
-
"eslint": "^10.
|
|
40
|
+
"@playwright/test": "^1.60.0",
|
|
41
|
+
"@types/node": "^25.9.3",
|
|
42
|
+
"@typescript-eslint/eslint-plugin": "^8.61.0",
|
|
43
|
+
"@typescript-eslint/parser": "^8.61.0",
|
|
44
|
+
"eslint": "^10.4.1",
|
|
45
45
|
"eslint-config-prettier": "^10.1.8",
|
|
46
|
-
"eslint-plugin-playwright": "^2.
|
|
47
|
-
"jsdom": "^
|
|
48
|
-
"prettier": "^3.8.
|
|
49
|
-
"typescript": "^
|
|
46
|
+
"eslint-plugin-playwright": "^2.10.4",
|
|
47
|
+
"jsdom": "^29.1.1",
|
|
48
|
+
"prettier": "^3.8.4",
|
|
49
|
+
"typescript": "^6.0.3"
|
|
50
50
|
}
|
|
51
|
-
}
|
|
51
|
+
}
|