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 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
@@ -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
- const jsData = `window.snapAllyData = ${JSON.stringify(data)};`;
66
- html = html.replace('<script src="data.js"></script>', `<script>\n${jsData}\n</script>`);
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>/, '');
@@ -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 Lighthouse.
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;
@@ -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
- * Performs an accessibility audit using Axe and Lighthouse.
35
+ * Reads the SnapAllyReporter options from the Playwright config so scans can
36
+ * fall back to the globally configured defaults.
28
37
  */
29
- async function scanA11y(page, testInfo, options = {}) {
30
- var _a, _b, _c, _d, _e, _f, _g, _h;
31
- // 1. Find reporter config for global defaults
32
- const reporterConfig = testInfo.config.reporter.find((r) => Array.isArray(r) &&
33
- (typeof r[0] === 'string' &&
34
- (r[0].includes('SnapAllyReporter') || r[0].endsWith('SnapAllyReporter.ts'))));
35
- const globalOptions = (Array.isArray(reporterConfig) ? ((_a = reporterConfig[1]) !== null && _a !== void 0 ? _a : {}) : {});
36
- // 2. Resolve final options (local > global > default)
37
- const showTerminal = (_c = (_b = options.verbose) !== null && _b !== void 0 ? _b : globalOptions.verbose) !== null && _c !== void 0 ? _c : true;
38
- const showBrowser = (_e = (_d = options.consoleLog) !== null && _d !== void 0 ? _d : globalOptions.consoleLog) !== null && _e !== void 0 ? _e : true;
39
- const rawPageKey = options.pageKey || page.url();
40
- const pageKey = sanitizePageKey(rawPageKey);
41
- const overlay = new VisualReporter_1.VisualReporter(page);
42
- let axeBuilder = new playwright_1.default({ page });
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
- if (typeof target === 'string') {
46
- axeBuilder = axeBuilder.include(target);
47
- }
48
- else {
49
- axeBuilder = axeBuilder.include(target);
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
- axeBuilder = axeBuilder.options({ rules: options.rules });
65
+ builder = builder.options({ rules: options.rules });
54
66
  }
55
67
  if (options.tags) {
56
- axeBuilder = axeBuilder.withTags(options.tags);
68
+ builder = builder.withTags(options.tags);
57
69
  }
58
70
  if (options.axeOptions) {
59
- axeBuilder = axeBuilder.options(options.axeOptions);
71
+ builder = builder.options(options.axeOptions);
60
72
  }
61
- let axeResults;
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
- axeResults = await axeBuilder.analyze();
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
- const violationCount = axeResults.violations.length;
75
- if ((showTerminal || showBrowser) && violationCount > 0) {
76
- const mainMsg = `[A11yScanner] Violations found: ${violationCount}`;
77
- const detailMessages = axeResults.violations.map(
78
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
79
- (v, i) => ` ${i + 1}. ${v.id} [${v.impact}] - ${v.help}`);
80
- if (showTerminal) {
81
- console.log(`\n${mainMsg}`);
82
- detailMessages.forEach((msg) => console.log(msg));
83
- }
84
- if (showBrowser) {
85
- await page.evaluate(([mainMsg, details, color]) => {
86
- console.log(`%c ${mainMsg}`, `color: ${color}; font-weight: bold; font-size: 12px;`);
87
- details.forEach((msg) => console.log(msg));
88
- }, [mainMsg, detailMessages, models_1.DEFAULT_COLORS.serious]);
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 customColors = globalOptions === null || globalOptions === void 0 ? void 0 : globalOptions.colors;
95
- const errors = [];
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
- for (const node of violation.nodes) {
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: errors,
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: ((_f = options.ado) === null || _f === void 0 ? void 0 : _f.organization) || process.env.ADO_ORGANIZATION || '',
157
- adoProject: ((_g = options.ado) === null || _g === void 0 ? void 0 : _g.project) || process.env.ADO_PROJECT || '',
158
- adoAreaPath: ((_h = options.ado) === null || _h === void 0 ? void 0 : _h.areaPath) || process.env.ADO_AREA_PATH || '',
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 overlay.attachJsonData(testInfo, 'A11y', JSON.stringify(reportData));
162
- await overlay.cleanupOverlay();
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 overlayHostId;
16
- private static readonly BANNER_ID;
17
- private static readonly HIGHLIGHT_ID;
18
- private static readonly HIGHLIGHT_PADDING;
19
- private static readonly BANNER_ALPHA;
20
- private static readonly HIGHLIGHT_SHADOW_ALPHA;
21
- private static readonly PAGE_LIFECYCLE_ERROR_PATTERNS;
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
- showBanner(violation: ViolationInfo, color: string): Promise<void>;
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.overlayHostId = 'snap-ally-visual-root';
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
- async showBanner(violation, color) {
15
- await this.safeEvaluate(([v, rawColor, rootId, bannerId, bannerAlpha]) => {
16
- const shadow = window.getOrCreateOverlayShadowRoot(rootId);
17
- let container = shadow.getElementById(bannerId);
18
- if (!container) {
19
- const style = document.createElement('style');
20
- style.textContent = `
21
- #${bannerId} {
22
- position: fixed;
23
- left: 50%;
24
- top: 24px;
25
- transform: translateX(-50%);
26
- width: calc(100% - 40px);
27
- max-width: 600px;
28
- padding: 12px 18px;
29
- border-radius: 12px;
30
- color: white;
31
- font-family: system-ui, -apple-system, sans-serif;
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
- await this.safeEvaluate(([sel, rawColor, rootId, highlightId, padding, shadowAlpha]) => {
73
- const target = document.querySelector(sel);
74
- if (!target)
75
- return;
76
- target.scrollIntoView({ behavior: 'auto', block: 'center' });
77
- const shadow = window.getOrCreateOverlayShadowRoot(rootId);
78
- let highlight = shadow.getElementById(highlightId);
79
- if (!highlight) {
80
- const style = document.createElement('style');
81
- style.textContent = `
82
- #${highlightId} {
83
- position: absolute;
84
- pointer-events: none;
85
- border-radius: 8px;
86
- box-sizing: border-box;
87
- z-index: 9999;
88
- transition: all 0.2s ease;
89
- box-shadow: 0 0 0 4px var(--c-alpha), 0 0 20px var(--c-alpha);
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
- await this.safeEvaluate((id) => { var _a; return (_a = document.getElementById(id)) === null || _a === void 0 ? void 0 : _a.remove(); }, this.overlayHostId);
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
- await this.safeEvaluate(([rootId, hId]) => { var _a, _b, _c; return (_c = (_b = (_a = document.getElementById(rootId)) === null || _a === void 0 ? void 0 : _a.shadowRoot) === null || _b === void 0 ? void 0 : _b.getElementById(hId)) === null || _c === void 0 ? void 0 : _c.remove(); }, [this.overlayHostId, VisualReporter.HIGHLIGHT_ID]);
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.BANNER_ID = 'snap-ally-banner';
188
- VisualReporter.HIGHLIGHT_ID = 'snap-ally-highlight';
189
- VisualReporter.HIGHLIGHT_PADDING = 4;
190
- VisualReporter.BANNER_ALPHA = 0.85;
191
- VisualReporter.HIGHLIGHT_SHADOW_ALPHA = 0.3;
192
- VisualReporter.PAGE_LIFECYCLE_ERROR_PATTERNS = ['closed', 'destroyed', 'ended'];
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
+ `;
@@ -0,0 +1,6 @@
1
+ /** Data needed to render a violation banner overlay on the page. */
2
+ export interface BannerInfo {
3
+ id: string;
4
+ help: string;
5
+ color: string;
6
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -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
- /** Specific selector or locator to include in the scan. */
7
- include?: string | Locator;
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 | Locator;
11
+ box?: string;
10
12
  /** Whether to log violations to the console. @default true */
11
13
  verbose?: boolean;
12
14
  /** Alias for verbose. */
@@ -1,4 +1,5 @@
1
1
  export * from './Violation';
2
+ export * from './BannerInfo';
2
3
  export * from './ReportData';
3
4
  export * from './TestResults';
4
5
  export * from './ScannerOptions';
@@ -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.1",
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.58.2",
37
- "playwright": "^1.58.2"
36
+ "@playwright/test": "^1.60.0",
37
+ "playwright": "^1.60.0"
38
38
  },
39
39
  "devDependencies": {
40
- "@playwright/test": "^1.58.2",
41
- "@types/node": "^20.0.0",
42
- "@typescript-eslint/eslint-plugin": "^8.56.1",
43
- "@typescript-eslint/parser": "^8.56.1",
44
- "eslint": "^10.0.2",
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.9.0",
47
- "jsdom": "^28.1.0",
48
- "prettier": "^3.8.1",
49
- "typescript": "^5.0.0"
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
+ }