snap-ally 0.2.7-beta → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +19 -12
  2. package/dist/A11yReportAssets.d.ts +5 -12
  3. package/dist/A11yReportAssets.js +16 -82
  4. package/dist/A11yScanner.d.ts +2 -21
  5. package/dist/A11yScanner.js +16 -22
  6. package/dist/A11yTimeUtils.js +11 -23
  7. package/dist/A11yVisualReporter.d.ts +50 -0
  8. package/dist/A11yVisualReporter.js +188 -0
  9. package/dist/AccessibilityReporterOptions.d.ts +24 -0
  10. package/dist/AccessibilityReporterOptions.js +5 -0
  11. package/dist/ResolvedColors.d.ts +15 -0
  12. package/dist/ResolvedColors.js +20 -0
  13. package/dist/SnapAllyReporter.d.ts +12 -72
  14. package/dist/SnapAllyReporter.js +212 -330
  15. package/dist/core/A11yHtmlRenderer.d.ts +17 -0
  16. package/dist/core/A11yHtmlRenderer.js +118 -0
  17. package/dist/core/A11yReportAssets.d.ts +30 -0
  18. package/dist/core/A11yReportAssets.js +127 -0
  19. package/dist/core/A11yScanner.d.ts +8 -0
  20. package/dist/core/A11yScanner.js +178 -0
  21. package/dist/core/A11yVisualReporter.d.ts +50 -0
  22. package/dist/core/A11yVisualReporter.js +188 -0
  23. package/dist/core/HtmlRenderer.d.ts +14 -0
  24. package/dist/core/HtmlRenderer.js +106 -0
  25. package/dist/core/ReportAssets.d.ts +29 -0
  26. package/dist/core/ReportAssets.js +126 -0
  27. package/dist/core/Scanner.d.ts +7 -0
  28. package/dist/core/Scanner.js +164 -0
  29. package/dist/core/VisualReporter.d.ts +54 -0
  30. package/dist/core/VisualReporter.js +192 -0
  31. package/dist/index.d.ts +6 -6
  32. package/dist/index.js +13 -12
  33. package/dist/models/A11yDataSource.d.ts +15 -0
  34. package/dist/models/A11yDataSource.js +2 -0
  35. package/dist/models/A11yError.d.ts +34 -0
  36. package/dist/models/A11yError.js +11 -0
  37. package/dist/models/A11yScannerOptions.d.ts +24 -0
  38. package/dist/models/A11yScannerOptions.js +2 -0
  39. package/dist/models/AccessibilityReporterOptions.d.ts +24 -0
  40. package/dist/models/AccessibilityReporterOptions.js +5 -0
  41. package/dist/models/DataSource.d.ts +15 -0
  42. package/dist/models/DataSource.js +2 -0
  43. package/dist/models/ImagePath.d.ts +5 -0
  44. package/dist/models/ImagePath.js +3 -0
  45. package/dist/models/ReportData.d.ts +24 -0
  46. package/dist/models/ReportData.js +2 -0
  47. package/dist/models/ReporterOptions.d.ts +34 -0
  48. package/dist/models/ReporterOptions.js +5 -0
  49. package/dist/models/ResolvedColors.d.ts +16 -0
  50. package/dist/models/ResolvedColors.js +24 -0
  51. package/dist/models/ScannerOptions.d.ts +30 -0
  52. package/dist/models/ScannerOptions.js +2 -0
  53. package/dist/models/Severity.d.ts +7 -0
  54. package/dist/models/Severity.js +11 -0
  55. package/dist/models/Target.d.ts +10 -0
  56. package/dist/models/Target.js +3 -0
  57. package/dist/models/TestResults.d.ts +41 -0
  58. package/dist/models/TestResults.js +2 -0
  59. package/dist/models/TestStatusIcon.d.ts +8 -0
  60. package/dist/models/TestStatusIcon.js +12 -0
  61. package/dist/models/TestSummary.d.ts +34 -0
  62. package/dist/models/TestSummary.js +2 -0
  63. package/dist/models/Violation.d.ts +13 -0
  64. package/dist/models/Violation.js +2 -0
  65. package/dist/models/index.d.ts +12 -113
  66. package/dist/models/index.js +26 -16
  67. package/dist/templates/accessibility-report.html +62 -95
  68. package/dist/templates/execution-summary.html +37 -103
  69. package/dist/templates/global-report-styles.css +400 -9
  70. package/dist/templates/report-app.js +174 -74
  71. package/dist/templates/test-execution-report.html +84 -121
  72. package/dist/utils/A11yTimeUtils.d.ts +13 -0
  73. package/dist/utils/A11yTimeUtils.js +40 -0
  74. package/dist/utils/TimeUtils.d.ts +13 -0
  75. package/dist/utils/TimeUtils.js +39 -0
  76. package/package.json +1 -1
package/README.md CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/snap-ally.svg)](https://www.npmjs.com/package/snap-ally)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
+ [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/U7U1U2V3V)
5
6
 
6
7
  A powerful, developer-friendly Playwright reporter for **Accessibility testing** using Axe-core. Beyond just reporting, it provides visual evidence to help developers fix accessibility issues faster.
7
8
 
@@ -60,15 +61,18 @@ export default defineConfig({
60
61
  outputFolder: 'a11y-report',
61
62
  // Optional: Visual Customization
62
63
  colors: {
63
- critical: '#dc2626',
64
- serious: '#ea580c',
65
- moderate: '#f59e0b',
66
- minor: '#0ea5e9',
64
+ critical: '#b91c1c',
65
+ serious: '#c2410c',
66
+ moderate: '#a16207',
67
+ minor: '#1e40af',
67
68
  },
69
+ verbose: true, // Show in terminal
70
+ consoleLog: true, // Show in browser console
68
71
  // Optional: Azure DevOps Integration
69
72
  ado: {
70
73
  organization: 'your-org',
71
74
  project: 'your-project',
75
+ areaPath: 'your-project\\your-team', // Optional: Define where bugs should be created
72
76
  },
73
77
  },
74
78
  ],
@@ -80,20 +84,20 @@ export default defineConfig({
80
84
 
81
85
  ## <span aria-hidden="true">📖</span> Usage
82
86
 
83
- Import and use `scanA11y` within your Playwright tests:
87
+ Import and use `checkAccessibility` within your Playwright tests:
84
88
 
85
89
  ```typescript
86
90
  import { test } from '@playwright/test';
87
- import { scanA11y } from 'snap-ally';
91
+ import { checkAccessibility } from 'snap-ally';
88
92
 
89
93
  test('verify page accessibility', async ({ page }, testInfo) => {
90
94
  await page.goto('https://example.com');
91
95
 
92
96
  // Basic scan
93
- await scanA11y(page, testInfo);
97
+ await checkAccessibility(page, testInfo);
94
98
 
95
99
  // Advanced scan with configuration
96
- await scanA11y(page, testInfo, {
100
+ await checkAccessibility(page, testInfo, {
97
101
  verbose: true, // Log results to terminal
98
102
  consoleLog: true, // Log results to browser console
99
103
  pageKey: 'Homepage', // Custom name for the report file
@@ -113,13 +117,16 @@ test('verify page accessibility', async ({ page }, testInfo) => {
113
117
 
114
118
  | Option | Type | Description |
115
119
  | ------------------ | -------- | --------------------------------------------------------------- |
116
- | `outputFolder` | `string` | Where to save the reports. Defaults to `steps-report`. |
117
- | `colors` | `object` | Customize severity colors (critical, serious, moderate, minor). |
118
- | `ado` | `object` | Azure DevOps configuration for deep linking. |
120
+ | `outputFolder` | `string` | Where to save the reports. Defaults to `steps-report`. |
121
+ | `colors` | `object` | Customize severity colors (critical, serious, moderate, minor). |
122
+ | `verbose` | `boolean` | **Default**: `true`. Show violations in terminal. |
123
+ | `consoleLog` | `boolean` | **Default**: `true`. Show violations in browser console. |
124
+ | `ado` | `object` | Azure DevOps configuration for deep linking. |
119
125
  | `ado.organization` | `string` | Your Azure DevOps organization name. |
120
126
  | `ado.project` | `string` | Your Azure DevOps project name. |
127
+ | `ado.areaPath` | `string` | Optional: The Area Path where bugs should be created. |
121
128
 
122
- ### `scanA11y` Options
129
+ ### `checkAccessibility` Options
123
130
 
124
131
  | Option | Type | Description |
125
132
  | ------------ | ---------- | -------------------------------------------------------------------------- |
@@ -8,21 +8,14 @@ export declare class A11yReportAssets {
8
8
  */
9
9
  copyToFolder(destFolder: string, srcPath: string, fileName?: string): string;
10
10
  /**
11
- * Copies the test video if available.
12
- * Includes a more robust retry to ensure Playwright has finished flushing the file.
11
+ * Copies all video attachments to the report folder for portability.
12
+ * @returns An array of filenames written to the destination folder.
13
13
  */
14
- copyTestVideo(result: TestResult, destFolder: string): Promise<string>;
14
+ copyVideos(result: TestResult, destFolder: string): string[];
15
15
  /**
16
16
  * Copies all screenshots found in the test attachments.
17
17
  */
18
18
  copyScreenshots(result: TestResult, destFolder: string): string[];
19
- /**
20
- * Copies all PNG attachments to the report folder and returns their new names.
21
- */
22
- copyPngAttachments(result: TestResult, destFolder: string): {
23
- path: string;
24
- name: string;
25
- }[];
26
19
  /**
27
20
  * Copies all other attachments (traces, logs, etc.) to the report folder.
28
21
  */
@@ -31,7 +24,7 @@ export declare class A11yReportAssets {
31
24
  name: string;
32
25
  }[];
33
26
  /**
34
- * Writes a buffer to a file in the destination folder.
27
+ * Persists an in-memory buffer to a file in the destination folder.
35
28
  */
36
- writeBuffer(destFolder: string, fileName: string, buffer: Buffer): string;
29
+ saveBuffer(destFolder: string, fileName: string, buffer: Buffer): string;
37
30
  }
@@ -56,72 +56,26 @@ class A11yReportAssets {
56
56
  return name;
57
57
  }
58
58
  /**
59
- * Copies the test video if available.
60
- * Includes a more robust retry to ensure Playwright has finished flushing the file.
59
+ * Copies all video attachments to the report folder for portability.
60
+ * @returns An array of filenames written to the destination folder.
61
61
  */
62
- async copyTestVideo(result, destFolder) {
63
- // More flexible matching for video attachments
64
- const videoAttachments = result.attachments.filter((a) => a.name === 'video' || (a.contentType || '').startsWith('video/'));
65
- let bestVideo = null;
66
- let maxSize = -1;
67
- for (const attachment of videoAttachments) {
68
- if (!attachment.path)
69
- continue;
70
- // Retry logic: Wait for file to exist and have non-zero size (up to 3 seconds)
71
- let attempts = 0;
72
- let isReady = false;
73
- while (attempts < 15) {
74
- if (fs.existsSync(attachment.path)) {
75
- try {
76
- const stats = fs.statSync(attachment.path);
77
- if (stats.size > 0) {
78
- isReady = true;
79
- break;
80
- }
81
- }
82
- catch {
83
- // statSync might fail if file is temporarily locked
84
- }
85
- }
86
- await new Promise((r) => setTimeout(r, 200));
87
- attempts++;
88
- }
89
- if (isReady) {
90
- try {
91
- const size = fs.statSync(attachment.path).size;
92
- if (size > maxSize) {
93
- maxSize = size;
94
- bestVideo = attachment.path;
95
- }
96
- }
97
- catch (err) {
98
- console.error(`[SnapAlly] Error checking video stats: ${err}`);
99
- }
100
- }
101
- else {
102
- console.warn(`[SnapAlly] Video attachment found but file is missing or empty after retry: ${attachment.path}`);
103
- }
104
- }
105
- if (bestVideo) {
106
- try {
107
- return this.copyToFolder(destFolder, bestVideo, 'video.webm');
108
- }
109
- catch (e) {
110
- console.error(`[SnapAlly] Failed to copy video: ${e}`);
111
- return path.basename(bestVideo);
112
- }
113
- }
114
- return '';
62
+ copyVideos(result, destFolder) {
63
+ return result.attachments
64
+ .filter((a) => (a.name === 'video' || (a.contentType || '').startsWith('video/')) && a.path)
65
+ .map((attachment) => this.copyToFolder(destFolder, attachment.path))
66
+ .filter((p) => !!p);
115
67
  }
116
68
  /**
117
69
  * Copies all screenshots found in the test attachments.
118
70
  */
119
71
  copyScreenshots(result, destFolder) {
120
72
  return result.attachments
121
- .filter((a) => a.name === 'screenshot' || (a.contentType || '').startsWith('image/'))
73
+ .filter((a) => a.name === 'screenshot' ||
74
+ a.name.endsWith('.png') ||
75
+ (a.contentType || '').startsWith('image/'))
122
76
  .map((a) => {
123
77
  if (a.path) {
124
- return this.copyToFolder(destFolder, a.path);
78
+ return this.copyToFolder(destFolder, a.path, a.name !== 'screenshot' ? a.name : undefined);
125
79
  }
126
80
  else if (a.body) {
127
81
  const timestamp = Date.now();
@@ -130,32 +84,12 @@ class A11yReportAssets {
130
84
  : a.name.endsWith('.png')
131
85
  ? a.name
132
86
  : `${a.name}.png`;
133
- return this.writeBuffer(destFolder, name, a.body);
87
+ return this.saveBuffer(destFolder, name, a.body);
134
88
  }
135
89
  return '';
136
90
  })
137
91
  .filter((path) => path !== '');
138
92
  }
139
- /**
140
- * Copies all PNG attachments to the report folder and returns their new names.
141
- */
142
- copyPngAttachments(result, destFolder) {
143
- return result.attachments
144
- .filter((a) => (a.name.endsWith('.png') || (a.contentType || '') === 'image/png') &&
145
- a.name !== 'screenshot')
146
- .map((a) => {
147
- let name = '';
148
- if (a.path) {
149
- name = this.copyToFolder(destFolder, a.path, a.name);
150
- }
151
- else if (a.body) {
152
- const safeName = a.name.endsWith('.png') ? a.name : `${a.name}.png`;
153
- name = this.writeBuffer(destFolder, safeName, a.body);
154
- }
155
- return name ? { path: name, name: a.name } : null;
156
- })
157
- .filter((item) => item !== null);
158
- }
159
93
  /**
160
94
  * Copies all other attachments (traces, logs, etc.) to the report folder.
161
95
  */
@@ -163,7 +97,7 @@ class A11yReportAssets {
163
97
  const excludedNames = ['screenshot', 'video', 'A11y'];
164
98
  return result.attachments
165
99
  .filter((a) => !excludedNames.includes(a.name) &&
166
- !a.name.endsWith('.png') &&
100
+ !a.name.toLowerCase().endsWith('.png') &&
167
101
  !(a.contentType || '').startsWith('image/') &&
168
102
  !(a.contentType || '').startsWith('video/'))
169
103
  .map((a) => {
@@ -172,16 +106,16 @@ class A11yReportAssets {
172
106
  name = this.copyToFolder(destFolder, a.path, a.name);
173
107
  }
174
108
  else if (a.body) {
175
- name = this.writeBuffer(destFolder, a.name, a.body);
109
+ name = this.saveBuffer(destFolder, a.name, a.body);
176
110
  }
177
111
  return name ? { path: name, name: a.name } : null;
178
112
  })
179
113
  .filter((item) => item !== null);
180
114
  }
181
115
  /**
182
- * Writes a buffer to a file in the destination folder.
116
+ * Persists an in-memory buffer to a file in the destination folder.
183
117
  */
184
- writeBuffer(destFolder, fileName, buffer) {
118
+ saveBuffer(destFolder, fileName, buffer) {
185
119
  if (!fs.existsSync(destFolder)) {
186
120
  fs.mkdirSync(destFolder, { recursive: true });
187
121
  }
@@ -1,24 +1,5 @@
1
- import type { Page, Locator, TestInfo } from '@playwright/test';
2
- export interface A11yScannerOptions {
3
- /** Specific selector or locator to include in the scan. */
4
- include?: string | Locator;
5
- /** Alias for include. */
6
- box?: string | Locator;
7
- /** Whether to log violations to the console. @default true */
8
- verbose?: boolean;
9
- /** Alias for verbose. */
10
- consoleLog?: boolean;
11
- /** Specific Axe rules to enable or disable. */
12
- rules?: Record<string, {
13
- enabled: boolean;
14
- }>;
15
- /** Specific WCAG tags to check (e.g., ['wcag2a', 'wcag2aa']). */
16
- tags?: string[];
17
- /** Any other Axe-core options to pass to the builder. */
18
- axeOptions?: Record<string, unknown>;
19
- /** Custom identifier for the report file name. */
20
- pageKey?: string;
21
- }
1
+ import type { Page, TestInfo } from '@playwright/test';
2
+ import { A11yScannerOptions } from './models';
22
3
  /**
23
4
  * Performs an accessibility audit using Axe and Lighthouse.
24
5
  */
@@ -6,9 +6,9 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.checkAccessibility = void 0;
7
7
  exports.scanA11y = scanA11y;
8
8
  const playwright_1 = __importDefault(require("@axe-core/playwright"));
9
- const A11yAuditOverlay_1 = require("./A11yAuditOverlay");
10
- const models_1 = require("./models");
9
+ const A11yVisualReporter_1 = require("./A11yVisualReporter");
11
10
  const A11yTimeUtils_1 = require("./A11yTimeUtils");
11
+ const ResolvedColors_1 = require("./ResolvedColors");
12
12
  /**
13
13
  * Sanitizes a string to be safe for use in file paths and prevents path traversal attacks.
14
14
  * Removes or replaces dangerous characters and path separators.
@@ -40,7 +40,7 @@ async function scanA11y(page, testInfo, options = {}) {
40
40
  // Sanitize pageKey to prevent path traversal attacks
41
41
  const rawPageKey = options.pageKey || page.url();
42
42
  const pageKey = sanitizePageKey(rawPageKey);
43
- const overlay = new A11yAuditOverlay_1.A11yAuditOverlay(page);
43
+ const overlay = new A11yVisualReporter_1.A11yVisualReporter(page);
44
44
  // Configure Axe
45
45
  let axeBuilder = new playwright_1.default({ page });
46
46
  const target = options.include || options.box;
@@ -88,10 +88,10 @@ async function scanA11y(page, testInfo, options = {}) {
88
88
  }
89
89
  // Batch log to Browser Console in a single evaluate call
90
90
  if (showBrowser) {
91
- await page.evaluate(([mainMsg, details]) => {
92
- console.log(`%c ${mainMsg}`, 'color: #ea580c; font-weight: bold; font-size: 12px;');
91
+ await page.evaluate(([mainMsg, details, color]) => {
92
+ console.log(`%c ${mainMsg}`, `color: ${color}; font-weight: bold; font-size: 12px;`);
93
93
  details.forEach((msg) => console.log(msg));
94
- }, [mainMsg, detailMessages]);
94
+ }, [mainMsg, detailMessages, ResolvedColors_1.DEFAULT_COLORS.serious]);
95
95
  }
96
96
  }
97
97
  // Fail the test if violations found (softly)
@@ -103,29 +103,23 @@ async function scanA11y(page, testInfo, options = {}) {
103
103
  .toBe(0);
104
104
  // Run Axe Audit
105
105
  const errors = [];
106
- const colorMap = {
107
- minor: '#0ea5e9', // Ocean Blue
108
- moderate: '#f59e0b', // Amber/Honey
109
- serious: '#ea580c', // Deep Orange
110
- critical: '#dc2626', // Power Red
111
- };
112
106
  // Process violations for the report
113
107
  for (const violation of axeResults.violations) {
114
108
  let errorIdx = 0;
115
109
  const targets = [];
116
- const severityColor = colorMap[violation.impact || ''] || '#757575';
110
+ const severityColor = (0, ResolvedColors_1.getSeverityColor)(violation.impact);
117
111
  for (const node of violation.nodes) {
118
112
  for (const selector of node.target) {
119
113
  const elementSelector = selector.toString();
120
114
  const locator = page.locator(elementSelector);
121
- await overlay.showViolationOverlay({ id: violation.id, help: violation.help }, severityColor);
115
+ await overlay.showBanner({ id: violation.id, help: violation.help }, severityColor);
122
116
  if (await locator.isVisible()) {
123
117
  await overlay.highlightElement(elementSelector, severityColor);
124
118
  // Allow a small time for overlay highlight to be visible in video
125
119
  // eslint-disable-next-line playwright/no-wait-for-timeout
126
120
  await page.waitForTimeout(100);
127
121
  const screenshotName = `a11y-${violation.id}-${errorIdx++}.png`;
128
- const buffer = await overlay.captureAndAttachScreenshot(screenshotName, testInfo);
122
+ const buffer = await overlay.captureScreenshot(screenshotName, testInfo);
129
123
  // Capture execution steps for context
130
124
  const excluded = new Set([
131
125
  'Pre Condition',
@@ -147,7 +141,7 @@ async function scanA11y(page, testInfo, options = {}) {
147
141
  stepsJson: JSON.stringify(contextSteps),
148
142
  screenshotBase64: buffer.toString('base64'),
149
143
  });
150
- await overlay.unhighlightElement();
144
+ await overlay.removeHighlight();
151
145
  }
152
146
  }
153
147
  }
@@ -169,16 +163,16 @@ async function scanA11y(page, testInfo, options = {}) {
169
163
  pageUrl: page.url(),
170
164
  accessibilityScore: 0, // No longer used, derivation from Lighthouse removed
171
165
  a11yErrors: errors,
172
- criticalColor: models_1.Severity.critical,
173
- seriousColor: models_1.Severity.serious,
174
- moderateColor: models_1.Severity.moderate,
175
- minorColor: models_1.Severity.minor,
166
+ criticalColor: ResolvedColors_1.DEFAULT_COLORS.critical,
167
+ seriousColor: ResolvedColors_1.DEFAULT_COLORS.serious,
168
+ moderateColor: ResolvedColors_1.DEFAULT_COLORS.moderate,
169
+ minorColor: ResolvedColors_1.DEFAULT_COLORS.minor,
176
170
  adoOrganization: process.env.ADO_ORGANIZATION || '',
177
171
  adoProject: process.env.ADO_PROJECT || '',
178
172
  timestamp: A11yTimeUtils_1.A11yTimeUtils.formatDate(new Date()),
179
173
  };
180
- await overlay.addTestAttachment(testInfo, 'A11y', JSON.stringify(reportData));
181
- await overlay.hideViolationOverlay();
174
+ await overlay.attachData(testInfo, 'A11y', JSON.stringify(reportData));
175
+ await overlay.clean();
182
176
  }
183
177
  /** Alias for backward compatibility */
184
178
  exports.checkAccessibility = scanA11y;
@@ -24,29 +24,17 @@ class A11yTimeUtils {
24
24
  * Formats a Date object into a human-readable string.
25
25
  */
26
26
  static formatDate(date) {
27
- const day = String(date.getDate()).padStart(2, '0');
28
- const monthNames = [
29
- 'Jan',
30
- 'Feb',
31
- 'Mar',
32
- 'Apr',
33
- 'May',
34
- 'Jun',
35
- 'Jul',
36
- 'Aug',
37
- 'Sep',
38
- 'Oct',
39
- 'Nov',
40
- 'Dec',
41
- ];
42
- const month = monthNames[date.getMonth()];
43
- const year = date.getFullYear();
44
- let hours = date.getHours();
45
- const ampm = hours >= 12 ? 'PM' : 'AM';
46
- hours = hours % 12 || 12;
47
- const formattedHours = String(hours).padStart(2, '0');
48
- const minutes = String(date.getMinutes()).padStart(2, '0');
49
- return `${day} ${month} ${year}, ${formattedHours}:${minutes} ${ampm}`;
27
+ const parts = new Intl.DateTimeFormat('en-US', {
28
+ day: '2-digit',
29
+ month: 'short',
30
+ year: 'numeric',
31
+ hour: '2-digit',
32
+ minute: '2-digit',
33
+ hour12: true,
34
+ }).formatToParts(date);
35
+ const get = (type) => { var _a; return ((_a = parts.find((p) => p.type === type)) === null || _a === void 0 ? void 0 : _a.value) || ''; };
36
+ // Standard US Format: MMM DD, YYYY, HH:MM AM/PM
37
+ return `${get('month')} ${get('day')}, ${get('year')}, ${get('hour')}:${get('minute')} ${get('dayPeriod').toUpperCase()}`;
50
38
  }
51
39
  }
52
40
  exports.A11yTimeUtils = A11yTimeUtils;
@@ -0,0 +1,50 @@
1
+ import type { Page, TestInfo } from '@playwright/test';
2
+ /**
3
+ * Manages visual feedback on the page during an accessibility scan.
4
+ * Handles element highlights, violation banners, and report attachments.
5
+ */
6
+ export declare class A11yVisualReporter {
7
+ private readonly page;
8
+ private readonly rootId;
9
+ private static readonly BANNER_ID;
10
+ private static readonly HIGHLIGHT_ID;
11
+ constructor(page: Page);
12
+ /**
13
+ * Shows a violation banner at the bottom of the page.
14
+ */
15
+ showBanner(violation: {
16
+ id: string;
17
+ help: string;
18
+ }, color: string): Promise<void>;
19
+ /**
20
+ * Highlights an element on the page.
21
+ */
22
+ highlightElement(selector: string, color: string): Promise<void>;
23
+ /**
24
+ * Removes all visual feedback from the page.
25
+ */
26
+ clean(): Promise<void>;
27
+ /**
28
+ * Removes the element highlight.
29
+ */
30
+ removeHighlight(): Promise<void>;
31
+ /**
32
+ * Attaches JSON data to the test report.
33
+ */
34
+ attachData(testInfo: TestInfo, name: string, data: string): Promise<void>;
35
+ /**
36
+ * Captures and attaches a screenshot to the test report.
37
+ */
38
+ captureScreenshot(name: string, testInfo: TestInfo): Promise<Buffer>;
39
+ /**
40
+ * Injects helper functions into the page context.
41
+ */
42
+ private ensureHelpers;
43
+ private safeEvaluate;
44
+ }
45
+ declare global {
46
+ interface Window {
47
+ snapAllyGetRoot: (id: string) => ShadowRoot;
48
+ snapAllyToAlpha: (color: string, alpha: number) => string;
49
+ }
50
+ }
@@ -0,0 +1,188 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.A11yVisualReporter = void 0;
4
+ /**
5
+ * Manages visual feedback on the page during an accessibility scan.
6
+ * Handles element highlights, violation banners, and report attachments.
7
+ */
8
+ class A11yVisualReporter {
9
+ constructor(page) {
10
+ this.page = page;
11
+ this.rootId = 'snap-ally-visual-root';
12
+ }
13
+ /**
14
+ * Shows a violation banner at the bottom of the page.
15
+ */
16
+ async showBanner(violation, color) {
17
+ await this.safeEvaluate(([v, rawColor, rootId, bannerId]) => {
18
+ const shadow = window.snapAllyGetRoot(rootId);
19
+ let container = shadow.getElementById(bannerId);
20
+ if (!container) {
21
+ const style = document.createElement('style');
22
+ style.textContent = `
23
+ #${bannerId} {
24
+ position: fixed;
25
+ left: 50%;
26
+ top: 24px;
27
+ transform: translateX(-50%);
28
+ width: calc(100% - 40px);
29
+ max-width: 600px;
30
+ padding: 12px 18px;
31
+ border-radius: 12px;
32
+ color: white;
33
+ font-family: system-ui, -apple-system, sans-serif;
34
+ font-size: 14px;
35
+ display: flex;
36
+ align-items: center;
37
+ gap: 12px;
38
+ box-shadow: 0 12px 40px rgba(0,0,0,0.3);
39
+ backdrop-filter: blur(16px) saturate(180%);
40
+ -webkit-backdrop-filter: blur(16px) saturate(180%);
41
+ border: 1px solid rgba(255,255,255,0.15);
42
+ z-index: 10000;
43
+ transition: all 0.3s ease;
44
+ }
45
+ .badge {
46
+ background: rgba(255,255,255,0.2);
47
+ padding: 2px 8px;
48
+ border-radius: 6px;
49
+ font-size: 11px;
50
+ font-weight: 700;
51
+ text-transform: uppercase;
52
+ border: 1px solid rgba(255,255,255,0.2);
53
+ }
54
+ .content { flex: 1; line-height: 1.4; font-weight: 500; }
55
+ `;
56
+ shadow.appendChild(style);
57
+ container = document.createElement('div');
58
+ container.id = bannerId;
59
+ shadow.appendChild(container);
60
+ }
61
+ container.style.backgroundColor = window.snapAllyToAlpha(rawColor, 0.85);
62
+ container.innerHTML = `
63
+ <div style="font-size: 20px;">⚠️</div>
64
+ <div class="content">
65
+ <div style="margin-bottom:4px; display:flex; align-items:center; gap:8px;">
66
+ <span class="badge">${v.id}</span>
67
+ <span style="opacity: 0.9;">${v.help}</span>
68
+ </div>
69
+ </div>
70
+ `;
71
+ }, [violation, color, this.rootId, A11yVisualReporter.BANNER_ID]);
72
+ }
73
+ /**
74
+ * Highlights an element on the page.
75
+ */
76
+ async highlightElement(selector, color) {
77
+ await this.safeEvaluate(([sel, rawColor, rootId, highlightId]) => {
78
+ const target = document.querySelector(sel);
79
+ if (!target)
80
+ return;
81
+ target.scrollIntoView({ behavior: 'auto', block: 'center' });
82
+ const shadow = window.snapAllyGetRoot(rootId);
83
+ let highlight = shadow.getElementById(highlightId);
84
+ if (!highlight) {
85
+ const style = document.createElement('style');
86
+ style.textContent = `
87
+ #${highlightId} {
88
+ position: absolute;
89
+ pointer-events: none;
90
+ border-radius: 8px;
91
+ box-sizing: border-box;
92
+ z-index: 9999;
93
+ transition: all 0.2s ease;
94
+ box-shadow: 0 0 0 4px var(--c-alpha), 0 0 20px var(--c-alpha);
95
+ }
96
+ `;
97
+ shadow.appendChild(style);
98
+ highlight = document.createElement('div');
99
+ highlight.id = highlightId;
100
+ shadow.appendChild(highlight);
101
+ }
102
+ const pad = 4;
103
+ const rect = target.getBoundingClientRect();
104
+ highlight.style.left = `${rect.left + window.scrollX - pad}px`;
105
+ highlight.style.top = `${rect.top + window.scrollY - pad}px`;
106
+ highlight.style.width = `${rect.width + pad * 2}px`;
107
+ highlight.style.height = `${rect.height + pad * 2}px`;
108
+ highlight.style.border = `3px solid ${rawColor}`;
109
+ highlight.style.setProperty('--c-alpha', window.snapAllyToAlpha(rawColor, 0.3));
110
+ }, [selector, color, this.rootId, A11yVisualReporter.HIGHLIGHT_ID]);
111
+ }
112
+ /**
113
+ * Removes all visual feedback from the page.
114
+ */
115
+ async clean() {
116
+ await this.safeEvaluate((id) => { var _a; return (_a = document.getElementById(id)) === null || _a === void 0 ? void 0 : _a.remove(); }, this.rootId);
117
+ }
118
+ /**
119
+ * Removes the element highlight.
120
+ */
121
+ async removeHighlight() {
122
+ 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.rootId, A11yVisualReporter.HIGHLIGHT_ID]);
123
+ }
124
+ /**
125
+ * Attaches JSON data to the test report.
126
+ */
127
+ async attachData(testInfo, name, data) {
128
+ await testInfo.attach(name, {
129
+ contentType: 'application/json',
130
+ body: Buffer.from(data),
131
+ });
132
+ }
133
+ /**
134
+ * Captures and attaches a screenshot to the test report.
135
+ */
136
+ async captureScreenshot(name, testInfo) {
137
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
138
+ const { test } = require('@playwright/test');
139
+ return await test.step('Capture A11y screenshot', async () => {
140
+ const screenshot = await this.page.screenshot({ fullPage: false });
141
+ await testInfo.attach(name, { contentType: 'image/png', body: screenshot });
142
+ return screenshot;
143
+ });
144
+ }
145
+ /**
146
+ * Injects helper functions into the page context.
147
+ */
148
+ async ensureHelpers() {
149
+ await this.page.evaluate(() => {
150
+ if (typeof window.snapAllyGetRoot === 'function')
151
+ return;
152
+ window.snapAllyGetRoot = (id) => {
153
+ let root = document.getElementById(id);
154
+ if (!root) {
155
+ root = document.createElement('div');
156
+ root.id = id;
157
+ root.style.cssText = 'position:absolute;top:0;left:0;width:0;height:0;z-index:2147483647;';
158
+ document.body.appendChild(root);
159
+ root.attachShadow({ mode: 'open' });
160
+ }
161
+ return root.shadowRoot;
162
+ };
163
+ window.snapAllyToAlpha = (color, alpha) => {
164
+ if (color.startsWith('rgba'))
165
+ return color.replace(/[\d.]+\)$/, `${alpha})`);
166
+ if (color.startsWith('rgb'))
167
+ return color.replace('rgb', 'rgba').replace(')', `, ${alpha})`);
168
+ const hex = Math.round(alpha * 255).toString(16).padStart(2, '0');
169
+ return `${color}${hex}`;
170
+ };
171
+ });
172
+ }
173
+ async safeEvaluate(fn, arg) {
174
+ try {
175
+ await this.ensureHelpers();
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 (msg.includes('closed') || msg.includes('destroyed') || msg.includes('ended'))
181
+ return;
182
+ throw e;
183
+ }
184
+ }
185
+ }
186
+ exports.A11yVisualReporter = A11yVisualReporter;
187
+ A11yVisualReporter.BANNER_ID = 'snap-ally-banner';
188
+ A11yVisualReporter.HIGHLIGHT_ID = 'snap-ally-highlight';