snap-ally 0.0.4 → 0.1.1-beta

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
@@ -37,29 +37,29 @@ npm install snap-ally --save-dev
37
37
  Add `snap-ally` to your `playwright.config.ts`:
38
38
 
39
39
  ```typescript
40
- import { defineConfig } from "@playwright/test";
40
+ import { defineConfig } from '@playwright/test';
41
41
 
42
42
  export default defineConfig({
43
- reporter: [
44
- [
45
- "snap-ally",
46
- {
47
- outputFolder: "a11y-report",
48
- // Optional: Visual Customization
49
- colors: {
50
- critical: "#dc2626",
51
- serious: "#ea580c",
52
- moderate: "#f59e0b",
53
- minor: "#0ea5e9",
54
- },
55
- // Optional: Azure DevOps Integration
56
- ado: {
57
- organization: "your-org",
58
- project: "your-project",
59
- },
60
- },
43
+ reporter: [
44
+ [
45
+ 'snap-ally',
46
+ {
47
+ outputFolder: 'a11y-report',
48
+ // Optional: Visual Customization
49
+ colors: {
50
+ critical: '#dc2626',
51
+ serious: '#ea580c',
52
+ moderate: '#f59e0b',
53
+ minor: '#0ea5e9',
54
+ },
55
+ // Optional: Azure DevOps Integration
56
+ ado: {
57
+ organization: 'your-org',
58
+ project: 'your-project',
59
+ },
60
+ },
61
+ ],
61
62
  ],
62
- ],
63
63
  });
64
64
  ```
65
65
 
@@ -70,25 +70,25 @@ export default defineConfig({
70
70
  Import and use `scanA11y` within your Playwright tests:
71
71
 
72
72
  ```typescript
73
- import { test } from "@playwright/test";
74
- import { scanA11y } from "snap-ally";
75
-
76
- test("verify page accessibility", async ({ page }, testInfo) => {
77
- await page.goto("https://example.com");
78
-
79
- // Basic scan
80
- await scanA11y(page, testInfo);
81
-
82
- // Advanced scan with configuration
83
- await scanA11y(page, testInfo, {
84
- verbose: true, // Log results to terminal
85
- consoleLog: true, // Log results to browser console
86
- pageKey: 'Homepage', // Custom name for the report file
87
- tags: ['wcag2a', 'wcag2aa'],
88
- rules: {
89
- 'color-contrast': { enabled: false },
90
- }
91
- });
73
+ import { test } from '@playwright/test';
74
+ import { scanA11y } from 'snap-ally';
75
+
76
+ test('verify page accessibility', async ({ page }, testInfo) => {
77
+ await page.goto('https://example.com');
78
+
79
+ // Basic scan
80
+ await scanA11y(page, testInfo);
81
+
82
+ // Advanced scan with configuration
83
+ await scanA11y(page, testInfo, {
84
+ verbose: true, // Log results to terminal
85
+ consoleLog: true, // Log results to browser console
86
+ pageKey: 'Homepage', // Custom name for the report file
87
+ tags: ['wcag2a', 'wcag2aa'],
88
+ rules: {
89
+ 'color-contrast': { enabled: false },
90
+ },
91
+ });
92
92
  });
93
93
  ```
94
94
 
@@ -108,14 +108,14 @@ test("verify page accessibility", async ({ page }, testInfo) => {
108
108
 
109
109
  ### `scanA11y` Options
110
110
 
111
- | Option | Type | Description |
112
- | --- | --- | --- |
113
- | `include` | `string` | CSS selector to limit the scan to a specific element. |
114
- | `verbose` | `boolean` | **Terminal Logs**: Print violations to terminal. Defaults to `true`. |
115
- | `consoleLog` | `boolean` | **Browser Logs**: Print violations to browser console. Defaults to `true`. |
116
- | `rules` | `object` | Axe-core rule configuration. |
117
- | `tags` | `string[]` | List of Axe-core tags to run (e.g., `['wcag2aa']`). |
118
- | `pageKey` | `string` | Custom identifier for the report file name. |
111
+ | Option | Type | Description |
112
+ | ------------ | ---------- | -------------------------------------------------------------------------- |
113
+ | `include` | `string` | CSS selector to limit the scan to a specific element. |
114
+ | `verbose` | `boolean` | **Terminal Logs**: Print violations to terminal. Defaults to `true`. |
115
+ | `consoleLog` | `boolean` | **Browser Logs**: Print violations to browser console. Defaults to `true`. |
116
+ | `rules` | `object` | Axe-core rule configuration. |
117
+ | `tags` | `string[]` | List of Axe-core tags to run (e.g., `['wcag2aa']`). |
118
+ | `pageKey` | `string` | Custom identifier for the report file name. |
119
119
 
120
120
  ---
121
121
 
@@ -24,7 +24,8 @@ class A11yAuditOverlay {
24
24
  if (!root) {
25
25
  root = document.createElement('div');
26
26
  root.id = rootId;
27
- root.style.cssText = 'position: absolute; top: 0; left: 0; width: 0; height: 0; z-index: 2147483647;';
27
+ root.style.cssText =
28
+ 'position: absolute; top: 0; left: 0; width: 0; height: 0; z-index: 2147483647;';
28
29
  document.body.appendChild(root);
29
30
  root.attachShadow({ mode: 'open' });
30
31
  }
@@ -76,8 +77,11 @@ class A11yAuditOverlay {
76
77
  container.id = 'a11y-banner';
77
78
  shadow.appendChild(container);
78
79
  }
79
- const alphaColor = color.includes('rgba') ? color :
80
- (color.includes('rgb') ? color.replace('rgb', 'rgba').replace(')', ', 0.85)') : color + 'E6');
80
+ const alphaColor = color.includes('rgba')
81
+ ? color
82
+ : color.includes('rgb')
83
+ ? color.replace('rgb', 'rgba').replace(')', ', 0.85)')
84
+ : color + 'E6';
81
85
  container.style.backgroundColor = alphaColor;
82
86
  container.innerHTML = `
83
87
  <div style="font-size: 20px;">⚠️</div>
@@ -106,7 +110,7 @@ class A11yAuditOverlay {
106
110
  async addTestAttachment(testInfo, name, description) {
107
111
  await testInfo.attach(name, {
108
112
  contentType: 'application/json',
109
- body: Buffer.from(description)
113
+ body: Buffer.from(description),
110
114
  });
111
115
  }
112
116
  getAuditAnnotations() {
@@ -134,7 +138,8 @@ class A11yAuditOverlay {
134
138
  if (!root) {
135
139
  root = document.createElement('div');
136
140
  root.id = rootId;
137
- root.style.cssText = 'position: absolute; top: 0; left: 0; width: 0; height: 0; z-index: 2147483647;';
141
+ root.style.cssText =
142
+ 'position: absolute; top: 0; left: 0; width: 0; height: 0; z-index: 2147483647;';
138
143
  document.body.appendChild(root);
139
144
  root.attachShadow({ mode: 'open' });
140
145
  }
@@ -1,11 +1,11 @@
1
1
  /**
2
- * Handles the rendering of HTML reports using EJS templates.
2
+ * Handles the rendering of HTML reports using static templates and JSON data injection.
3
3
  */
4
4
  export declare class A11yHtmlRenderer {
5
5
  /**
6
- * Renders an HTML template and saves it to the specified file.
6
+ * Renders a static HTML template by copying it and generating the accompanied data payload.
7
7
  * @param templateName The template file name in the templates folder.
8
- * @param data The data object to pass to EJS.
8
+ * @param data The data object to pass to the client-side JS app.
9
9
  * @param outputFolder The folder where the rendered file will be saved.
10
10
  * @param outputFileName The full path of the output file.
11
11
  */
@@ -36,40 +36,53 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.A11yHtmlRenderer = void 0;
37
37
  const fs = __importStar(require("fs"));
38
38
  const path = __importStar(require("path"));
39
- const ejs = __importStar(require("ejs"));
40
39
  /**
41
- * Handles the rendering of HTML reports using EJS templates.
40
+ * Handles the rendering of HTML reports using static templates and JSON data injection.
42
41
  */
43
42
  class A11yHtmlRenderer {
44
43
  /**
45
- * Renders an HTML template and saves it to the specified file.
44
+ * Renders a static HTML template by copying it and generating the accompanied data payload.
46
45
  * @param templateName The template file name in the templates folder.
47
- * @param data The data object to pass to EJS.
46
+ * @param data The data object to pass to the client-side JS app.
48
47
  * @param outputFolder The folder where the rendered file will be saved.
49
48
  * @param outputFileName The full path of the output file.
50
49
  */
51
50
  async render(templateName, data, outputFolder, outputFileName) {
52
51
  // Resolve path relative to this file (dist/A11yHtmlRenderer.js)
53
- const templatePath = path.join(__dirname, 'templates', templateName);
54
- let templateContent = '';
55
- try {
56
- templateContent = fs.readFileSync(templatePath, 'utf8');
57
- }
58
- catch {
52
+ const templatesDir = path.join(__dirname, 'templates');
53
+ const templatePath = path.join(templatesDir, templateName);
54
+ const cssPath = path.join(templatesDir, 'global-report-styles.css');
55
+ const jsPath = path.join(templatesDir, 'report-app.js');
56
+ if (!fs.existsSync(templatePath)) {
59
57
  throw new Error(`[A11yHtmlRenderer] Template not found: ${templatePath}`);
60
58
  }
61
- let html = '';
59
+ if (!fs.existsSync(outputFolder)) {
60
+ fs.mkdirSync(outputFolder, { recursive: true });
61
+ }
62
+ // 1. Copy the pure HTML template to the output location
63
+ fs.copyFileSync(templatePath, outputFileName);
64
+ // 2. Wrap the report data in a JS variable and write data.js next to the HTML file
65
+ const outputDir = path.dirname(outputFileName);
66
+ const dataJsPath = path.join(outputDir, 'data.js');
67
+ const jsContent = `window.snapAllyData = ${JSON.stringify(data)};`;
68
+ fs.writeFileSync(dataJsPath, jsContent, 'utf8');
69
+ // 3. Copy the global CSS and JS rendering engine next to the HTML file
70
+ const outCssPath = path.join(outputDir, 'global-report-styles.css');
71
+ const outJsPath = path.join(outputDir, 'report-app.js');
62
72
  try {
63
- html = ejs.render(templateContent, data);
73
+ if (fs.existsSync(cssPath))
74
+ fs.copyFileSync(cssPath, outCssPath);
64
75
  }
65
- catch (error) {
66
- console.error(`[A11yHtmlRenderer] EJS Render Error (${templateName}):`, error);
67
- throw error;
76
+ catch (e) {
77
+ console.error('Error copying CSS:', e);
68
78
  }
69
- if (!fs.existsSync(outputFolder)) {
70
- fs.mkdirSync(outputFolder, { recursive: true });
79
+ try {
80
+ if (fs.existsSync(jsPath))
81
+ fs.copyFileSync(jsPath, outJsPath);
82
+ }
83
+ catch (e) {
84
+ console.error('Error copying JS:', e);
71
85
  }
72
- fs.writeFileSync(outputFileName, html);
73
86
  }
74
87
  /**
75
88
  * Converts ANSI color codes to HTML spans for nicer error display.
@@ -89,10 +102,7 @@ class A11yHtmlRenderer {
89
102
  '\u001b[22m': '</span>',
90
103
  '\u001b[39m': '</span>',
91
104
  };
92
- let result = text
93
- .replace(/&/g, '&amp;')
94
- .replace(/</g, '&lt;')
95
- .replace(/>/g, '&gt;');
105
+ let result = text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
96
106
  for (const [code, tag] of Object.entries(map)) {
97
107
  result = result.split(code).join(tag);
98
108
  }
@@ -9,7 +9,7 @@ export declare class A11yReportAssets {
9
9
  copyToFolder(destFolder: string, srcPath: string, fileName?: string): string;
10
10
  /**
11
11
  * Copies the test video if available.
12
- * Includes a small retry to ensure Playwright has finished flushing the file.
12
+ * Includes a more robust retry to ensure Playwright has finished flushing the file.
13
13
  */
14
14
  copyTestVideo(result: TestResult, destFolder: string): Promise<string>;
15
15
  /**
@@ -57,31 +57,33 @@ class A11yReportAssets {
57
57
  }
58
58
  /**
59
59
  * Copies the test video if available.
60
- * Includes a small retry to ensure Playwright has finished flushing the file.
60
+ * Includes a more robust retry to ensure Playwright has finished flushing the file.
61
61
  */
62
62
  async copyTestVideo(result, destFolder) {
63
- const videoAttachments = result.attachments.filter(a => a.name === 'video');
63
+ // More flexible matching for video attachments
64
+ const videoAttachments = result.attachments.filter((a) => a.name === 'video' || (a.contentType || '').startsWith('video/'));
64
65
  let bestVideo = null;
65
66
  let maxSize = -1;
66
67
  for (const attachment of videoAttachments) {
67
68
  if (!attachment.path)
68
69
  continue;
69
- // Retry logic: Wait for file to exist and have non-zero size (up to 2 seconds)
70
+ // Retry logic: Wait for file to exist and have non-zero size (up to 3 seconds)
70
71
  let attempts = 0;
71
72
  let isReady = false;
72
- while (attempts < 10) {
73
+ while (attempts < 15) {
73
74
  if (fs.existsSync(attachment.path)) {
74
75
  try {
75
- if (fs.statSync(attachment.path).size > 0) {
76
+ const stats = fs.statSync(attachment.path);
77
+ if (stats.size > 0) {
76
78
  isReady = true;
77
79
  break;
78
80
  }
79
81
  }
80
- catch (e) {
82
+ catch {
81
83
  // statSync might fail if file is temporarily locked
82
84
  }
83
85
  }
84
- await new Promise(r => setTimeout(r, 200));
86
+ await new Promise((r) => setTimeout(r, 200));
85
87
  attempts++;
86
88
  }
87
89
  if (isReady) {
@@ -97,12 +99,12 @@ class A11yReportAssets {
97
99
  }
98
100
  }
99
101
  else {
100
- console.warn(`[SnapAlly] Video attachment found but file is missing or empty: ${attachment.path}`);
102
+ console.warn(`[SnapAlly] Video attachment found but file is missing or empty after retry: ${attachment.path}`);
101
103
  }
102
104
  }
103
105
  if (bestVideo) {
104
106
  try {
105
- return this.copyToFolder(destFolder, bestVideo);
107
+ return this.copyToFolder(destFolder, bestVideo, 'video.webm');
106
108
  }
107
109
  catch (e) {
108
110
  console.error(`[SnapAlly] Failed to copy video: ${e}`);
@@ -116,31 +118,39 @@ class A11yReportAssets {
116
118
  */
117
119
  copyScreenshots(result, destFolder) {
118
120
  return result.attachments
119
- .filter(a => a.name === 'screenshot')
120
- .map(a => {
121
+ .filter((a) => a.name === 'screenshot' || (a.contentType || '').startsWith('image/'))
122
+ .map((a) => {
121
123
  if (a.path) {
122
124
  return this.copyToFolder(destFolder, a.path);
123
125
  }
124
126
  else if (a.body) {
125
- return this.writeBuffer(destFolder, `screenshot-${Date.now()}.png`, a.body);
127
+ const timestamp = Date.now();
128
+ const name = a.name === 'screenshot'
129
+ ? `screenshot-${timestamp}.png`
130
+ : a.name.endsWith('.png')
131
+ ? a.name
132
+ : `${a.name}.png`;
133
+ return this.writeBuffer(destFolder, name, a.body);
126
134
  }
127
135
  return '';
128
136
  })
129
- .filter(path => path !== '');
137
+ .filter((path) => path !== '');
130
138
  }
131
139
  /**
132
140
  * Copies all PNG attachments to the report folder and returns their new names.
133
141
  */
134
142
  copyPngAttachments(result, destFolder) {
135
143
  return result.attachments
136
- .filter(a => a.name.endsWith('.png') && a.name !== 'screenshot')
137
- .map(a => {
144
+ .filter((a) => (a.name.endsWith('.png') || (a.contentType || '') === 'image/png') &&
145
+ a.name !== 'screenshot')
146
+ .map((a) => {
138
147
  let name = '';
139
148
  if (a.path) {
140
149
  name = this.copyToFolder(destFolder, a.path, a.name);
141
150
  }
142
151
  else if (a.body) {
143
- name = this.writeBuffer(destFolder, a.name, a.body);
152
+ const safeName = a.name.endsWith('.png') ? a.name : `${a.name}.png`;
153
+ name = this.writeBuffer(destFolder, safeName, a.body);
144
154
  }
145
155
  return name ? { path: name, name: a.name } : null;
146
156
  })
@@ -152,8 +162,11 @@ class A11yReportAssets {
152
162
  copyAllOtherAttachments(result, destFolder) {
153
163
  const excludedNames = ['screenshot', 'video', 'A11y'];
154
164
  return result.attachments
155
- .filter(a => !excludedNames.includes(a.name) && !a.name.endsWith('.png'))
156
- .map(a => {
165
+ .filter((a) => !excludedNames.includes(a.name) &&
166
+ !a.name.endsWith('.png') &&
167
+ !(a.contentType || '').startsWith('image/') &&
168
+ !(a.contentType || '').startsWith('video/'))
169
+ .map((a) => {
157
170
  let name = '';
158
171
  if (a.path) {
159
172
  name = this.copyToFolder(destFolder, a.path, a.name);
@@ -9,12 +9,13 @@ const playwright_1 = __importDefault(require("@axe-core/playwright"));
9
9
  const test_1 = require("@playwright/test");
10
10
  const A11yAuditOverlay_1 = require("./A11yAuditOverlay");
11
11
  const models_1 = require("./models");
12
+ const A11yTimeUtils_1 = require("./A11yTimeUtils");
12
13
  /**
13
14
  * Sanitizes a string to be safe for use in file paths and prevents path traversal attacks.
14
15
  * Removes or replaces dangerous characters and path separators.
15
16
  */
16
17
  function sanitizePageKey(input) {
17
- return input
18
+ return (input
18
19
  // Remove protocol
19
20
  .replace(/^https?:\/\//, '')
20
21
  // Remove or replace path separators and dangerous characters
@@ -28,7 +29,7 @@ function sanitizePageKey(input) {
28
29
  // Convert to lowercase for consistency
29
30
  .toLowerCase()
30
31
  // Limit length to prevent filesystem issues
31
- .substring(0, 200);
32
+ .substring(0, 200));
32
33
  }
33
34
  /**
34
35
  * Performs an accessibility audit using Axe and Lighthouse.
@@ -50,6 +51,7 @@ async function scanA11y(page, testInfo, options = {}) {
50
51
  }
51
52
  else {
52
53
  // AxeBuilder for playwright also supports locators/elements in include
54
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
53
55
  axeBuilder = axeBuilder.include(target);
54
56
  }
55
57
  }
@@ -62,7 +64,19 @@ async function scanA11y(page, testInfo, options = {}) {
62
64
  if (options.axeOptions) {
63
65
  axeBuilder = axeBuilder.options(options.axeOptions);
64
66
  }
65
- const axeResults = await axeBuilder.analyze();
67
+ let axeResults;
68
+ try {
69
+ axeResults = await axeBuilder.analyze();
70
+ }
71
+ catch (error) {
72
+ if (error instanceof Error &&
73
+ (error.message.includes('Test ended') ||
74
+ error.message.includes('Target page, context or browser has been closed'))) {
75
+ console.warn(`[SnapAlly] Accessibility scan skipped: ${error.message}`);
76
+ return;
77
+ }
78
+ throw error;
79
+ }
66
80
  const violationCount = axeResults.violations.length;
67
81
  if ((showTerminal || showBrowser) && violationCount > 0) {
68
82
  const mainMsg = `[A11yScanner] Violations found: ${violationCount}`;
@@ -71,7 +85,7 @@ async function scanA11y(page, testInfo, options = {}) {
71
85
  // Log to terminal
72
86
  if (showTerminal) {
73
87
  console.log(`\n${mainMsg}`);
74
- detailMessages.forEach(msg => console.log(msg));
88
+ detailMessages.forEach((msg) => console.log(msg));
75
89
  }
76
90
  // Batch log to Browser Console in a single evaluate call
77
91
  if (showBrowser) {
@@ -82,14 +96,16 @@ async function scanA11y(page, testInfo, options = {}) {
82
96
  }
83
97
  }
84
98
  // Fail the test if violations found (softly)
85
- test_1.expect.soft(violationCount, `Accessibility audit failed with ${violationCount} violations.`).toBe(0);
99
+ test_1.expect
100
+ .soft(violationCount, `Accessibility audit failed with ${violationCount} violations.`)
101
+ .toBe(0);
86
102
  // Run Axe Audit
87
103
  const errors = [];
88
104
  const colorMap = {
89
105
  minor: '#0ea5e9', // Ocean Blue
90
106
  moderate: '#f59e0b', // Amber/Honey
91
107
  serious: '#ea580c', // Deep Orange
92
- critical: '#dc2626' // Power Red
108
+ critical: '#dc2626', // Power Red
93
109
  };
94
110
  // Process violations for the report
95
111
  for (const violation of axeResults.violations) {
@@ -103,16 +119,21 @@ async function scanA11y(page, testInfo, options = {}) {
103
119
  await overlay.showViolationOverlay({ id: violation.id, help: violation.help }, severityColor);
104
120
  if (await locator.isVisible()) {
105
121
  await overlay.highlightElement(elementSelector, severityColor);
106
- // Allow time for video capture or manual inspection during debug
122
+ // Allow a small time for overlay highlight to be visible in video
107
123
  // eslint-disable-next-line playwright/no-wait-for-timeout
108
- await page.waitForTimeout(2000);
124
+ await page.waitForTimeout(100);
109
125
  const screenshotName = `a11y-${violation.id}-${errorIdx++}.png`;
110
126
  const buffer = await overlay.captureAndAttachScreenshot(screenshotName, testInfo);
111
127
  // Capture execution steps for context
112
- const excluded = new Set(['Pre Condition', 'Post Condition', 'Description', 'A11y']);
128
+ const excluded = new Set([
129
+ 'Pre Condition',
130
+ 'Post Condition',
131
+ 'Description',
132
+ 'A11y',
133
+ ]);
113
134
  const contextSteps = (testInfo.annotations || [])
114
- .filter(a => !excluded.has(a.type))
115
- .map(a => a.description || '');
135
+ .filter((a) => !excluded.has(a.type))
136
+ .map((a) => a.description || '');
116
137
  const nodeHtml = node.html || '';
117
138
  const friendlySnippet = elementSelector; // Use full CSS selector path from Axe core
118
139
  targets.push({
@@ -122,7 +143,7 @@ async function scanA11y(page, testInfo, options = {}) {
122
143
  screenshot: screenshotName,
123
144
  steps: contextSteps,
124
145
  stepsJson: JSON.stringify(contextSteps),
125
- screenshotBase64: buffer.toString('base64')
146
+ screenshotBase64: buffer.toString('base64'),
126
147
  });
127
148
  await overlay.unhighlightElement();
128
149
  }
@@ -135,23 +156,25 @@ async function scanA11y(page, testInfo, options = {}) {
135
156
  helpUrl: violation.helpUrl,
136
157
  help: violation.help,
137
158
  guideline: violation.tags[1] || 'N/A',
138
- wcagRule: violation.tags.find(t => t.startsWith('wcag')) || violation.tags[1] || 'N/A',
159
+ wcagRule: violation.tags.find((t) => t.startsWith('wcag')) || violation.tags[1] || 'N/A',
139
160
  total: targets.length || violation.nodes.length, // Fallback to node count if no screenshots
140
- target: targets
161
+ target: targets,
141
162
  });
142
163
  }
143
164
  // Prepare data for the reporter
144
165
  const reportData = {
145
166
  pageKey,
167
+ pageUrl: page.url(),
146
168
  accessibilityScore: 0, // No longer used, derivation from Lighthouse removed
147
- errors,
169
+ a11yErrors: errors,
148
170
  video: 'a11y-scan-video.webm', // Reference name for reporter
149
171
  criticalColor: models_1.Severity.critical,
150
172
  seriousColor: models_1.Severity.serious,
151
173
  moderateColor: models_1.Severity.moderate,
152
174
  minorColor: models_1.Severity.minor,
153
175
  adoOrganization: process.env.ADO_ORGANIZATION || '',
154
- adoProject: process.env.ADO_PROJECT || ''
176
+ adoProject: process.env.ADO_PROJECT || '',
177
+ timestamp: A11yTimeUtils_1.A11yTimeUtils.formatDate(new Date()),
155
178
  };
156
179
  await overlay.addTestAttachment(testInfo, 'A11y', JSON.stringify(reportData));
157
180
  await overlay.hideViolationOverlay();
@@ -6,4 +6,8 @@ export declare class A11yTimeUtils {
6
6
  * Formats milliseconds into a human-readable duration string.
7
7
  */
8
8
  static formatDuration(ms: number): string;
9
+ /**
10
+ * Formats a Date object into a human-readable string.
11
+ */
12
+ static formatDate(date: Date): string;
9
13
  }
@@ -20,5 +20,33 @@ class A11yTimeUtils {
20
20
  const remainingSeconds = seconds % 60;
21
21
  return `${minutes}m ${remainingSeconds.toFixed(0)}s`;
22
22
  }
23
+ /**
24
+ * Formats a Date object into a human-readable string.
25
+ */
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}`;
50
+ }
23
51
  }
24
52
  exports.A11yTimeUtils = A11yTimeUtils;