snap-ally 0.2.7-beta → 0.3.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 (75) hide show
  1. package/dist/A11yReportAssets.d.ts +5 -12
  2. package/dist/A11yReportAssets.js +16 -82
  3. package/dist/A11yScanner.d.ts +2 -21
  4. package/dist/A11yScanner.js +16 -22
  5. package/dist/A11yTimeUtils.js +11 -23
  6. package/dist/A11yVisualReporter.d.ts +50 -0
  7. package/dist/A11yVisualReporter.js +188 -0
  8. package/dist/AccessibilityReporterOptions.d.ts +24 -0
  9. package/dist/AccessibilityReporterOptions.js +5 -0
  10. package/dist/ResolvedColors.d.ts +15 -0
  11. package/dist/ResolvedColors.js +20 -0
  12. package/dist/SnapAllyReporter.d.ts +12 -72
  13. package/dist/SnapAllyReporter.js +218 -329
  14. package/dist/core/A11yHtmlRenderer.d.ts +17 -0
  15. package/dist/core/A11yHtmlRenderer.js +118 -0
  16. package/dist/core/A11yReportAssets.d.ts +30 -0
  17. package/dist/core/A11yReportAssets.js +127 -0
  18. package/dist/core/A11yScanner.d.ts +8 -0
  19. package/dist/core/A11yScanner.js +178 -0
  20. package/dist/core/A11yVisualReporter.d.ts +50 -0
  21. package/dist/core/A11yVisualReporter.js +188 -0
  22. package/dist/core/HtmlRenderer.d.ts +14 -0
  23. package/dist/core/HtmlRenderer.js +106 -0
  24. package/dist/core/ReportAssets.d.ts +29 -0
  25. package/dist/core/ReportAssets.js +126 -0
  26. package/dist/core/Scanner.d.ts +7 -0
  27. package/dist/core/Scanner.js +162 -0
  28. package/dist/core/VisualReporter.d.ts +54 -0
  29. package/dist/core/VisualReporter.js +192 -0
  30. package/dist/index.d.ts +6 -6
  31. package/dist/index.js +13 -12
  32. package/dist/models/A11yDataSource.d.ts +15 -0
  33. package/dist/models/A11yDataSource.js +2 -0
  34. package/dist/models/A11yError.d.ts +34 -0
  35. package/dist/models/A11yError.js +11 -0
  36. package/dist/models/A11yScannerOptions.d.ts +24 -0
  37. package/dist/models/A11yScannerOptions.js +2 -0
  38. package/dist/models/AccessibilityReporterOptions.d.ts +24 -0
  39. package/dist/models/AccessibilityReporterOptions.js +5 -0
  40. package/dist/models/DataSource.d.ts +15 -0
  41. package/dist/models/DataSource.js +2 -0
  42. package/dist/models/ImagePath.d.ts +5 -0
  43. package/dist/models/ImagePath.js +3 -0
  44. package/dist/models/ReportData.d.ts +24 -0
  45. package/dist/models/ReportData.js +2 -0
  46. package/dist/models/ReporterOptions.d.ts +24 -0
  47. package/dist/models/ReporterOptions.js +5 -0
  48. package/dist/models/ResolvedColors.d.ts +16 -0
  49. package/dist/models/ResolvedColors.js +24 -0
  50. package/dist/models/ScannerOptions.d.ts +30 -0
  51. package/dist/models/ScannerOptions.js +2 -0
  52. package/dist/models/Severity.d.ts +7 -0
  53. package/dist/models/Severity.js +11 -0
  54. package/dist/models/Target.d.ts +10 -0
  55. package/dist/models/Target.js +3 -0
  56. package/dist/models/TestResults.d.ts +41 -0
  57. package/dist/models/TestResults.js +2 -0
  58. package/dist/models/TestStatusIcon.d.ts +8 -0
  59. package/dist/models/TestStatusIcon.js +12 -0
  60. package/dist/models/TestSummary.d.ts +34 -0
  61. package/dist/models/TestSummary.js +2 -0
  62. package/dist/models/Violation.d.ts +13 -0
  63. package/dist/models/Violation.js +2 -0
  64. package/dist/models/index.d.ts +12 -113
  65. package/dist/models/index.js +26 -16
  66. package/dist/templates/accessibility-report.html +62 -95
  67. package/dist/templates/execution-summary.html +37 -103
  68. package/dist/templates/global-report-styles.css +400 -9
  69. package/dist/templates/report-app.js +170 -72
  70. package/dist/templates/test-execution-report.html +84 -121
  71. package/dist/utils/A11yTimeUtils.d.ts +13 -0
  72. package/dist/utils/A11yTimeUtils.js +40 -0
  73. package/dist/utils/TimeUtils.d.ts +13 -0
  74. package/dist/utils/TimeUtils.js +39 -0
  75. package/package.json +2 -2
@@ -33,45 +33,23 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- const models_1 = require("./models");
37
- const A11yReportAssets_1 = require("./A11yReportAssets");
38
- const A11yHtmlRenderer_1 = require("./A11yHtmlRenderer");
39
- const A11yTimeUtils_1 = require("./A11yTimeUtils");
40
- const path = __importStar(require("path"));
41
36
  const fs = __importStar(require("fs"));
42
- /** Default severity colors used when the user doesn't override them. */
43
- const DEFAULT_COLORS = {
44
- critical: '#c92a2a',
45
- serious: '#e67700',
46
- moderate: '#ca8a04',
47
- minor: '#0891b2',
48
- };
49
- // ────────────────────────────────────────────────────────────────────────────
50
- // Reporter
51
- // ────────────────────────────────────────────────────────────────────────────
37
+ const path = __importStar(require("path"));
38
+ const HtmlRenderer_1 = require("./core/HtmlRenderer");
39
+ const ReportAssets_1 = require("./core/ReportAssets");
40
+ const TimeUtils_1 = require("./utils/TimeUtils");
41
+ const models_1 = require("./models");
52
42
  /**
53
43
  * Playwright reporter for accessibility audits and test steps.
54
- *
55
- * Generates:
56
- * - A per-test execution report (steps, video, screenshots, errors)
57
- * - A per-scan accessibility report (violations, evidence, ADO integration)
58
- * - A global execution summary with per-browser breakdowns
59
44
  */
60
45
  class SnapAllyReporter {
61
46
  constructor(options = {}) {
62
47
  var _a, _b, _c, _d;
63
- this.assetsManager = new A11yReportAssets_1.A11yReportAssets();
64
- this.renderer = new A11yHtmlRenderer_1.A11yHtmlRenderer();
48
+ this.assetsManager = new ReportAssets_1.ReportAssets();
49
+ this.renderer = new HtmlRenderer_1.HtmlRenderer();
65
50
  this.projectRoot = 'tests';
66
- /**
67
- * Monotonically increasing test counter.
68
- * Incremented synchronously in {@link onTestEnd} to avoid race conditions
69
- * when multiple async {@link processTestResult} calls run concurrently.
70
- */
71
51
  this.testIndex = 0;
72
- /** Async tasks queued by `onTestEnd`; drained in `onEnd`. */
73
52
  this.tasks = [];
74
- /** Aggregated data for the final summary report. */
75
53
  this.executionSummary = {
76
54
  duration: '',
77
55
  status: '',
@@ -86,341 +64,196 @@ class SnapAllyReporter {
86
64
  totalA11yErrorCount: 0,
87
65
  browserSummaries: {},
88
66
  date: '',
67
+ colors: {},
89
68
  };
69
+ this.testRuleCounts = {};
70
+ this.testGlobalCounts = {};
90
71
  this.options = options;
91
72
  this.outputFolder = path.resolve(process.cwd(), options.outputFolder || 'steps-report');
73
+ this.validateOutputFolder(this.outputFolder);
92
74
  this.colors = {
93
- critical: ((_a = options.colors) === null || _a === void 0 ? void 0 : _a.critical) || DEFAULT_COLORS.critical,
94
- serious: ((_b = options.colors) === null || _b === void 0 ? void 0 : _b.serious) || DEFAULT_COLORS.serious,
95
- moderate: ((_c = options.colors) === null || _c === void 0 ? void 0 : _c.moderate) || DEFAULT_COLORS.moderate,
96
- minor: ((_d = options.colors) === null || _d === void 0 ? void 0 : _d.minor) || DEFAULT_COLORS.minor,
75
+ critical: ((_a = options.colors) === null || _a === void 0 ? void 0 : _a.critical) || models_1.DEFAULT_COLORS.critical,
76
+ serious: ((_b = options.colors) === null || _b === void 0 ? void 0 : _b.serious) || models_1.DEFAULT_COLORS.serious,
77
+ moderate: ((_c = options.colors) === null || _c === void 0 ? void 0 : _c.moderate) || models_1.DEFAULT_COLORS.moderate,
78
+ minor: ((_d = options.colors) === null || _d === void 0 ? void 0 : _d.minor) || models_1.DEFAULT_COLORS.minor,
97
79
  };
80
+ this.executionSummary.colors = this.colors;
98
81
  }
99
82
  printsToStdio() {
100
- return false;
83
+ return true;
84
+ }
85
+ /**
86
+ * Validates that the output folder is safe to delete.
87
+ * Prevents accidental deletion of critical directories like repo root, parent dirs, or system paths.
88
+ */
89
+ validateOutputFolder(resolvedPath) {
90
+ const cwd = process.cwd();
91
+ const normalizedPath = path.normalize(resolvedPath);
92
+ const normalizedCwd = path.normalize(cwd);
93
+ // Prevent deletion of current working directory
94
+ if (normalizedPath === normalizedCwd) {
95
+ throw new Error(`[SnapAlly] Invalid outputFolder: Cannot delete the current working directory. ` +
96
+ `Resolved path: "${resolvedPath}"`);
97
+ }
98
+ // Prevent deletion of parent directories
99
+ if (normalizedCwd.startsWith(normalizedPath + path.sep) || normalizedCwd.startsWith(normalizedPath + '/')) {
100
+ throw new Error(`[SnapAlly] Invalid outputFolder: Cannot delete a parent directory of the current working directory. ` +
101
+ `Resolved path: "${resolvedPath}"`);
102
+ }
103
+ // Prevent deletion of root or near-root directories
104
+ const pathSegments = normalizedPath.split(path.sep).filter(s => s.length > 0);
105
+ if (pathSegments.length <= 1) {
106
+ throw new Error(`[SnapAlly] Invalid outputFolder: Path is too close to root directory. ` +
107
+ `Resolved path: "${resolvedPath}"`);
108
+ }
109
+ // Ensure the path is within the current working directory (safest approach)
110
+ if (!normalizedPath.startsWith(normalizedCwd + path.sep) && !normalizedPath.startsWith(normalizedCwd + '/')) {
111
+ throw new Error(`[SnapAlly] Invalid outputFolder: Path must be within the current working directory. ` +
112
+ `Resolved path: "${resolvedPath}", CWD: "${cwd}"`);
113
+ }
101
114
  }
102
115
  onBegin(config) {
103
- this.projectRoot = config.rootDir || 'tests';
116
+ this.projectRoot = config.rootDir;
117
+ if (fs.existsSync(this.outputFolder)) {
118
+ fs.rmSync(this.outputFolder, { recursive: true, force: true });
119
+ }
104
120
  }
105
121
  onTestEnd(test, result) {
106
- // Increment index synchronously so concurrent tasks never share an index.
107
122
  const index = ++this.testIndex;
108
123
  this.tasks.push(this.processTestResult(test, result, index));
109
124
  }
110
125
  async onEnd(result) {
111
126
  await Promise.all(this.tasks);
112
- this.executionSummary.duration = A11yTimeUtils_1.A11yTimeUtils.formatDuration(result.duration);
127
+ const summaryPath = path.join(this.outputFolder, 'summary.html');
113
128
  this.executionSummary.status = result.status;
114
129
  this.executionSummary.statusIcon =
115
- models_1.TestStatusIcon[result.status] || 'help';
116
- this.executionSummary.date = A11yTimeUtils_1.A11yTimeUtils.formatDate(new Date());
117
- const summaryFile = path.join(this.outputFolder, 'summary.html');
118
- await this.renderer.render('execution-summary.html', { ...this.executionSummary, colors: this.colors }, this.outputFolder, summaryFile);
119
- console.log(`\n[SnapAlly] Reports generated in: ${path.resolve(this.outputFolder)}`);
130
+ result.status === 'passed' ? models_1.TestStatusIcon.passed : models_1.TestStatusIcon.failed;
131
+ this.executionSummary.date = TimeUtils_1.TimeUtils.formatDate(new Date());
132
+ this.executionSummary.duration = TimeUtils_1.TimeUtils.formatDuration(result.duration);
133
+ await this.renderer.render('execution-summary.html', this.executionSummary, this.outputFolder, summaryPath);
134
+ console.log(`\n[SnapAlly] Report generated: ${summaryPath}`);
120
135
  }
121
- // ────────────────────────────────────────────────────────────────────────
122
- // Core per-test processing
123
- // ────────────────────────────────────────────────────────────────────────
124
136
  async processTestResult(test, result, index) {
125
- const sanitizedTitle = test.title.replace(/[^a-z0-9]+/gi, '-').toLowerCase();
126
- const testFolderName = `${index}-${sanitizedTitle}`;
127
- const testResultsFolder = path.join(this.outputFolder, testFolderName);
128
- const fileGroup = path.relative(this.projectRoot, test.location.file);
129
- this.ensureGroupExists(fileGroup);
130
- const browser = this.resolveBrowser(test);
131
- const testMeta = this.extractTestMetadata(test, result);
132
- // Copy assets
133
- const video = await this.assetsManager.copyTestVideo(result, testResultsFolder);
134
- const screenshots = this.assetsManager.copyScreenshots(result, testResultsFolder);
135
- const allAttachments = [
136
- ...this.assetsManager.copyPngAttachments(result, testResultsFolder),
137
- ...this.assetsManager.copyAllOtherAttachments(result, testResultsFolder),
138
- ];
139
- const errorLogs = this.extractErrorLogs(result);
140
- // Accessibility processing
141
- const a11yResult = await this.processAccessibilityData(test, result, sanitizedTitle, testResultsFolder, testMeta.steps, video, browser, errorLogs);
142
- // Update browser & global summary counts
143
- this.updateBrowserSummary(browser, result.status);
144
- this.updateGlobalSummary(test, result);
145
- // Build the test stats object
146
- const executionReportName = `execution-${sanitizedTitle}.html`;
147
- const testStats = {
137
+ var _a, _b, _c, _d;
138
+ const testFolderName = `test-${index}`;
139
+ const testFolder = path.join(this.outputFolder, testFolderName);
140
+ const videoPath = this.assetsManager.copyVideos(result, testFolder);
141
+ const screenshotPaths = this.assetsManager.copyScreenshots(result, testFolder);
142
+ const attachments = this.assetsManager.copyAllOtherAttachments(result, testFolder);
143
+ const a11yAttachment = result.attachments.find((a) => a.name === 'A11y');
144
+ if (a11yAttachment) {
145
+ console.log(`[SnapAlly] Found A11y attachment for project: ${test._projectId}`);
146
+ }
147
+ else {
148
+ console.warn(`[SnapAlly] A11y attachment missing for test: ${test.title}. Available: ${result.attachments.map(a => a.name).join(', ')}`);
149
+ }
150
+ let a11yData = null;
151
+ if (a11yAttachment && a11yAttachment.body) {
152
+ try {
153
+ a11yData = JSON.parse(a11yAttachment.body.toString());
154
+ }
155
+ catch (err) {
156
+ console.error(`[SnapAlly] Failed to parse A11y attachment: ${err}. Body was: ${a11yAttachment.body.toString().substring(0, 100)}`);
157
+ }
158
+ }
159
+ // Handle cases where a11yData might be the direct ReportData or wrapped in a data property
160
+ const actualData = (a11yData === null || a11yData === void 0 ? void 0 : a11yData.data) || a11yData;
161
+ const violations = (actualData === null || actualData === void 0 ? void 0 : actualData.a11yErrors) || (actualData === null || actualData === void 0 ? void 0 : actualData.violations) || [];
162
+ 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);
163
+ const filteredSteps = (() => {
164
+ const sRaw = result.steps.map(s => s.title);
165
+ const blocklist = ['Evaluate', 'Create page', 'Close page', 'Before Hooks', 'After Hooks', 'Worker Teardown', 'Worker Cleanup', 'Attach', 'Wait for timeout', 'Capture A11y screenshot'];
166
+ const filtered = result.steps
167
+ .filter((s) => !blocklist.some(b => s.title.includes(b)))
168
+ .map((s) => s.title);
169
+ console.log(`[SnapAlly] Steps for ${test.title}: Raw=${sRaw.length}, Filtered=${filtered.length}`);
170
+ return filtered;
171
+ })();
172
+ const testResults = {
148
173
  num: index,
149
174
  folderName: testFolderName,
150
- executionReportPath: `${testFolderName}/${executionReportName}`,
151
175
  title: test.title,
152
- fileName: fileGroup,
176
+ fileName: path.relative(this.projectRoot, test.location.file),
177
+ duration: TimeUtils_1.TimeUtils.formatDuration(result.duration),
153
178
  timeDuration: result.duration,
154
- duration: A11yTimeUtils_1.A11yTimeUtils.formatDuration(result.duration),
155
- description: testMeta.description,
179
+ description: '', // Could extract from annotations if needed
156
180
  status: result.status,
157
- browser,
158
- tags: testMeta.tags,
159
- preConditions: testMeta.preConditions,
160
- steps: testMeta.steps,
161
- postConditions: testMeta.postConditions,
162
- statusIcon: testMeta.statusIcon,
163
- pageUrl: a11yResult.pageUrl,
164
- videoPath: video,
165
- screenshotPaths: screenshots,
166
- attachments: allAttachments,
167
- errors: errorLogs,
168
- a11yReportPath: a11yResult.reportPath,
169
- a11yErrorCount: a11yResult.errorCount,
170
- a11yErrors: a11yResult.errors,
171
- colors: this.options.colors,
181
+ statusIcon: this.getStatusIcon(result.status),
182
+ browser: (() => {
183
+ var _a, _b;
184
+ if (test.outcome() === 'skipped')
185
+ return 'n/a';
186
+ const bName = test._projectId ||
187
+ ((_b = (_a = test.parent) === null || _a === void 0 ? void 0 : _a.project()) === null || _b === void 0 ? void 0 : _b.name) ||
188
+ test.projectName ||
189
+ 'chromium';
190
+ return bName;
191
+ })(),
192
+ adoOrganization: ((_a = this.options.ado) === null || _a === void 0 ? void 0 : _a.organization) || (actualData === null || actualData === void 0 ? void 0 : actualData.adoOrganization),
193
+ adoProject: ((_b = this.options.ado) === null || _b === void 0 ? void 0 : _b.project) || (actualData === null || actualData === void 0 ? void 0 : actualData.adoProject),
194
+ adoAreaPath: ((_c = this.options.ado) === null || _c === void 0 ? void 0 : _c.areaPath) || (actualData === null || actualData === void 0 ? void 0 : actualData.adoAreaPath),
195
+ timestamp: new Date().toLocaleString(),
196
+ pageUrl: (actualData === null || actualData === void 0 ? void 0 : actualData.pageUrl) || (actualData === null || actualData === void 0 ? void 0 : actualData.pageKey) || 'Resource',
197
+ tags: [], // Extract from test tags if available
198
+ preConditions: [],
199
+ steps: filteredSteps,
200
+ postConditions: [],
201
+ videoPath: videoPath.length > 0 ? videoPath : null,
202
+ screenshotPaths,
203
+ attachments,
204
+ errors: result.errors.map((e) => this.renderer.ansiToHtml(e.message || '')),
205
+ a11yErrors: violations.map((v) => ({
206
+ ...v,
207
+ target: (v.target || []).map((t) => ({
208
+ ...t,
209
+ steps: (t.steps && t.steps.length > 0) ? t.steps : filteredSteps
210
+ }))
211
+ })),
212
+ a11yErrorCount: a11yErrorCount,
213
+ colors: this.colors,
172
214
  };
173
- this.executionSummary.groupedResults[fileGroup].push(testStats);
174
- // Render the per-test execution report
175
- const indexFile = path.join(testResultsFolder, executionReportName);
176
- await this.renderer.render('test-execution-report.html', { ...testStats, colors: this.colors }, testResultsFolder, indexFile);
177
- }
178
- // ────────────────────────────────────────────────────────────────────────
179
- // Metadata extraction
180
- // ────────────────────────────────────────────────────────────────────────
181
- /** Extracts structured metadata from test annotations and result steps. */
182
- extractTestMetadata(test, result) {
183
- const tags = test.tags.map((t) => t.replace('@', ''));
184
- const statusIcon = models_1.TestStatusIcon[result.status] || 'help';
185
- const descAnnotation = test.annotations.find((a) => a.type === 'Description');
186
- const description = (descAnnotation === null || descAnnotation === void 0 ? void 0 : descAnnotation.description) || 'No Description';
187
- const steps = result.steps
188
- .filter((s) => s.category === 'test.step')
189
- .map((s) => s.title);
190
- const preConditions = test.annotations
191
- .filter((a) => a.type === 'Pre Condition')
192
- .map((a) => a.description || '');
193
- const postConditions = test.annotations
194
- .filter((a) => a.type === 'Post Condition')
195
- .map((a) => a.description || '');
196
- return { tags, statusIcon, description, steps, preConditions, postConditions };
197
- }
198
- /** Determines the browser name for the current test. */
199
- resolveBrowser(test) {
200
- const project = test.parent.project();
201
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
202
- const projectUse = (project === null || project === void 0 ? void 0 : project.use) || {};
203
- return ((project === null || project === void 0 ? void 0 : project.name) ||
204
- projectUse.browserName ||
205
- projectUse.defaultBrowserType ||
206
- 'chromium');
207
- }
208
- /** Converts Playwright error objects into HTML-safe strings. */
209
- extractErrorLogs(result) {
210
- return (result.errors || []).map((err) => {
211
- const fullMsg = err.stack
212
- ? `${err.message}\n${err.stack}`
213
- : err.message || 'Error occurred';
214
- return this.renderer.ansiToHtml(fullMsg);
215
- });
216
- }
217
- // ────────────────────────────────────────────────────────────────────────
218
- // Accessibility data processing
219
- // ────────────────────────────────────────────────────────────────────────
220
- /** Return value for {@link processAccessibilityData}. */
221
- async processAccessibilityData(test, result, sanitizedTitle, testResultsFolder, steps, video, browser, errorLogs) {
222
- var _a;
223
- const sources = this.collectA11yDataSources(test, result);
224
- if (sources.length === 0) {
225
- return { errorCount: 0, errors: [] };
226
- }
227
- let reportPath;
228
- let errorCount = 0;
229
- const aggregatedErrors = [];
230
- let pageUrl;
231
- for (const [index, source] of sources.entries()) {
232
- const reportData = this.parseA11ySource(source, errorLogs);
233
- if (!reportData)
234
- continue;
235
- const reportName = this.buildA11yReportName(sanitizedTitle, reportData.pageKey, index, sources.length);
236
- reportPath = reportName;
237
- this.applyReportConfig(reportData, video);
238
- this.backfillSteps(reportData, steps);
239
- const auditFile = path.join(testResultsFolder, reportName);
240
- await this.renderer.render('accessibility-report.html', { data: reportData, folderTest: testResultsFolder }, testResultsFolder, auditFile);
241
- if (reportData.pageUrl && !pageUrl) {
242
- pageUrl = reportData.pageUrl;
243
- }
244
- // Aggregate a11y errors into browser & global summaries
245
- if ((_a = reportData.a11yErrors) === null || _a === void 0 ? void 0 : _a.length) {
246
- const scanCount = this.aggregateA11yErrors(reportData.a11yErrors, browser);
247
- errorCount += scanCount;
248
- aggregatedErrors.push(...reportData.a11yErrors);
249
- }
250
- }
251
- return { reportPath, errorCount, errors: aggregatedErrors, pageUrl };
252
- }
253
- /** Collects all A11y data sources (attachments + annotations) for a test. */
254
- collectA11yDataSources(test, result) {
255
- const attachments = (result.attachments || [])
256
- .filter((a) => a.name === 'A11y')
257
- .map((a) => ({ type: 'attachment', data: a }));
258
- const annotations = (test.annotations || [])
259
- .filter((a) => a.type === 'A11y')
260
- .map((a) => ({ type: 'annotation', data: a }));
261
- return [...attachments, ...annotations];
262
- }
263
- /** Attempts to parse a single A11y data source into a ReportData object. */
264
- parseA11ySource(source, errorLogs) {
265
- try {
266
- if (source.type === 'attachment') {
267
- const attach = source.data;
268
- if (attach.body) {
269
- return JSON.parse(attach.body.toString());
270
- }
271
- if (attach.path && fs.existsSync(attach.path)) {
272
- return JSON.parse(fs.readFileSync(attach.path, 'utf-8'));
273
- }
274
- return null;
275
- }
276
- // annotation
277
- const annot = source.data;
278
- return JSON.parse(annot.description || '{}');
279
- }
280
- catch (e) {
281
- console.error(`[SnapAlly] Failed to parse A11y ${source.type}: ${e}`);
282
- errorLogs.push(this.renderer.ansiToHtml(`[SnapAlly] Internal error parsing accessibility data from ${source.type}: ${e}`));
283
- return null;
284
- }
285
- }
286
- /** Generates a sanitized HTML filename for an accessibility report. */
287
- buildA11yReportName(sanitizedTitle, pageKey, index, totalScans) {
288
- const hasMultiple = totalScans > 1;
289
- const suffix = hasMultiple ? `-${index + 1}` : '';
290
- if (pageKey) {
291
- const sanitizedKey = pageKey
292
- .replace(/https?:\/\//, '')
293
- .replace(/[^a-z0-9]+/gi, '-')
294
- .replace(/^-+|-+$/g, '')
295
- .toLowerCase();
296
- if (sanitizedKey) {
297
- return `${sanitizedKey}${suffix}.html`;
298
- }
215
+ const reportFileName = 'report.html';
216
+ const a11yReportFileName = 'accessibility-report.html';
217
+ testResults.executionReportPath = `${testFolderName}/${reportFileName}`;
218
+ // Generate separate accessibility report if there are a11y errors
219
+ if (violations.length > 0) {
220
+ testResults.a11yReportPath = `${testFolderName}/${a11yReportFileName}`;
221
+ const a11yReportPath = path.join(testFolder, a11yReportFileName);
222
+ console.log(`[SnapAlly] Generating A11y report for ${test.title} (Browser: ${testResults.browser})`);
223
+ await this.renderer.render('accessibility-report.html', testResults, testFolder, a11yReportPath);
299
224
  }
300
- return `accessibility-${sanitizedTitle}${suffix}.html`;
225
+ console.log(`[SnapAlly] Data state for "${test.title}": browser=${testResults.browser}, a11yErrors=${((_d = testResults.a11yErrors) === null || _d === void 0 ? void 0 : _d.length) || 0}`);
226
+ const reportPath = path.join(testFolder, reportFileName);
227
+ await this.renderer.render('test-execution-report.html', testResults, testFolder, reportPath);
228
+ this.updateSummary(test, testResults);
301
229
  }
302
- /** Applies reporter-level configuration (colors, ADO, video) to a ReportData object. */
303
- applyReportConfig(reportData, video) {
304
- reportData.criticalColor = this.colors.critical;
305
- reportData.seriousColor = this.colors.serious;
306
- reportData.moderateColor = this.colors.moderate;
307
- reportData.minorColor = this.colors.minor;
308
- if (this.options.ado) {
309
- reportData.adoOrganization =
310
- this.options.ado.organization || reportData.adoOrganization;
311
- reportData.adoProject = this.options.ado.project || reportData.adoProject;
312
- if (this.options.ado.areaPath) {
313
- reportData.adoAreaPath = this.options.ado.areaPath;
314
- }
315
- }
316
- if (video) {
317
- reportData.video = video;
318
- }
319
- }
320
- /**
321
- * Backfills reproduction steps from `test.step` calls into a11y targets
322
- * that have no steps recorded (e.g. violations found via static scan).
323
- */
324
- backfillSteps(reportData, steps) {
325
- const filteredSteps = steps.filter((s) => !s.includes('Capture A11y screenshot'));
326
- if (filteredSteps.length === 0)
327
- return;
328
- for (const err of reportData.a11yErrors) {
329
- for (const target of err.target) {
330
- if (!target.steps || target.steps.length === 0) {
331
- target.steps = filteredSteps;
332
- target.stepsJson = JSON.stringify(filteredSteps);
333
- }
334
- }
335
- }
336
- }
337
- // ──────────────────────────────���─────────────────────────────────────────
338
- // Summary aggregation
339
- // ────────────────────────────────────────────────────────────────────────
340
- /**
341
- * Aggregates a11y error counts into both the browser-specific and global
342
- * summaries. Returns the total error count for this scan.
343
- */
344
- aggregateA11yErrors(errors, browser) {
345
- const bSummary = this.getOrCreateBrowserSummary(browser);
346
- let scanErrorCount = 0;
347
- for (const err of errors) {
348
- const count = err.total || 0;
349
- scanErrorCount += count;
350
- // Browser-level aggregation
351
- if (!bSummary.wcagErrors[err.id]) {
352
- bSummary.wcagErrors[err.id] = {
353
- count: 0,
354
- severity: err.severity,
355
- helpUrl: err.helpUrl,
356
- description: err.description,
357
- };
358
- }
359
- bSummary.wcagErrors[err.id].count += count;
360
- // Global aggregation
361
- if (!this.executionSummary.wcagErrors[err.id]) {
362
- this.executionSummary.wcagErrors[err.id] = {
363
- count: 0,
364
- severity: err.severity,
365
- helpUrl: err.helpUrl,
366
- description: err.description,
367
- };
368
- }
369
- this.executionSummary.wcagErrors[err.id].count += count;
370
- }
371
- bSummary.totalA11yErrorCount += scanErrorCount;
372
- this.executionSummary.totalA11yErrorCount += scanErrorCount;
373
- return scanErrorCount;
374
- }
375
- /** Updates the browser-specific test counts (passed/failed/skipped). */
376
- updateBrowserSummary(browser, status) {
377
- const bSummary = this.getOrCreateBrowserSummary(browser);
378
- bSummary.total++;
230
+ getStatusIcon(status) {
379
231
  switch (status) {
380
232
  case 'passed':
381
- bSummary.totalPassed++;
382
- break;
233
+ return models_1.TestStatusIcon.passed;
383
234
  case 'failed':
384
- bSummary.totalFailed++;
385
- break;
235
+ case 'timedOut':
236
+ return models_1.TestStatusIcon.failed;
386
237
  case 'skipped':
387
- bSummary.totalSkipped++;
388
- break;
238
+ return models_1.TestStatusIcon.skipped;
239
+ default:
240
+ return 'help';
389
241
  }
390
242
  }
391
- /** Updates the global execution summary counts. */
392
- updateGlobalSummary(test, result) {
393
- const isFlaky = test.results.length > 1 && result.status === 'passed';
394
- if (isFlaky)
395
- this.executionSummary.totalFlaky++;
396
- switch (result.status) {
397
- case 'passed':
398
- this.executionSummary.totalPassed++;
399
- break;
400
- case 'failed':
401
- this.executionSummary.totalFailed++;
402
- break;
403
- case 'skipped':
404
- this.executionSummary.totalSkipped++;
405
- break;
243
+ updateSummary(test, result) {
244
+ var _a, _b;
245
+ const browser = result.browser;
246
+ if (!this.executionSummary.groupedResults[browser]) {
247
+ this.executionSummary.groupedResults[browser] = [];
406
248
  }
407
- this.executionSummary.total++;
408
- }
409
- // ────────────────────────────────────────────────────────────────────────
410
- // Helpers
411
- // ────────────────────────────────────────────────────────────────────────
412
- /** Ensures a file group key exists in the grouped results map. */
413
- ensureGroupExists(fileGroup) {
414
- if (!this.executionSummary.groupedResults[fileGroup]) {
415
- this.executionSummary.groupedResults[fileGroup] = [];
249
+ this.executionSummary.groupedResults[browser].push(result);
250
+ // Initialize browser summary if needed
251
+ if (!this.executionSummary.browserSummaries) {
252
+ this.executionSummary.browserSummaries = {};
416
253
  }
417
- }
418
- /** Lazily initialises and returns the browser summary for the given browser name. */
419
- getOrCreateBrowserSummary(browser) {
420
- const summaries = this.executionSummary.browserSummaries;
421
- if (!summaries[browser]) {
422
- summaries[browser] = {
423
- duration: '0s',
254
+ if (!this.executionSummary.browserSummaries[browser]) {
255
+ this.executionSummary.browserSummaries[browser] = {
256
+ duration: '',
424
257
  status: '',
425
258
  statusIcon: '',
426
259
  total: 0,
@@ -433,7 +266,63 @@ class SnapAllyReporter {
433
266
  totalA11yErrorCount: 0,
434
267
  };
435
268
  }
436
- return summaries[browser];
269
+ const bSummary = this.executionSummary.browserSummaries[browser];
270
+ this.executionSummary.total++;
271
+ bSummary.total++;
272
+ if (result.status === 'passed') {
273
+ this.executionSummary.totalPassed++;
274
+ bSummary.totalPassed++;
275
+ }
276
+ else if (result.status === 'failed' || result.status === 'timedOut') {
277
+ this.executionSummary.totalFailed++;
278
+ bSummary.totalFailed++;
279
+ }
280
+ else if (result.status === 'skipped') {
281
+ this.executionSummary.totalSkipped++;
282
+ bSummary.totalSkipped++;
283
+ }
284
+ const testKey = test.titlePath().join(' > ');
285
+ const violations = result.a11yErrors || result.violations;
286
+ if (violations && violations.length > 0) {
287
+ const count = result.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);
288
+ // De-duplicate global count across browsers for same test case
289
+ const prevTestGlobalCount = this.testGlobalCounts[testKey] || 0;
290
+ if (count > prevTestGlobalCount) {
291
+ this.executionSummary.totalA11yErrorCount += (count - prevTestGlobalCount);
292
+ this.testGlobalCounts[testKey] = count;
293
+ }
294
+ bSummary.totalA11yErrorCount += count;
295
+ for (const err of violations) {
296
+ const ruleId = err.id;
297
+ const occCount = (err.total || ((_a = err.target) === null || _a === void 0 ? void 0 : _a.length) || ((_b = err.nodes) === null || _b === void 0 ? void 0 : _b.length) || 0);
298
+ // Update global wcagErrors (de-duplicated)
299
+ if (!this.executionSummary.wcagErrors[ruleId]) {
300
+ this.executionSummary.wcagErrors[ruleId] = {
301
+ count: 0,
302
+ severity: err.severity || err.impact,
303
+ helpUrl: err.helpUrl,
304
+ description: err.description,
305
+ };
306
+ }
307
+ if (!this.testRuleCounts[testKey])
308
+ this.testRuleCounts[testKey] = {};
309
+ const prevRuleOccCount = this.testRuleCounts[testKey][ruleId] || 0;
310
+ if (occCount > prevRuleOccCount) {
311
+ this.executionSummary.wcagErrors[ruleId].count += (occCount - prevRuleOccCount);
312
+ this.testRuleCounts[testKey][ruleId] = occCount;
313
+ }
314
+ // Update browser-specific wcagErrors (per browser, usually naturally unique)
315
+ if (!bSummary.wcagErrors[ruleId]) {
316
+ bSummary.wcagErrors[ruleId] = {
317
+ count: 0,
318
+ severity: err.severity || err.impact,
319
+ helpUrl: err.helpUrl,
320
+ description: err.description,
321
+ };
322
+ }
323
+ bSummary.wcagErrors[ruleId].count += occCount;
324
+ }
325
+ }
437
326
  }
438
327
  }
439
328
  exports.default = SnapAllyReporter;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Handles the rendering of HTML reports using static templates and JSON data injection.
3
+ */
4
+ export declare class A11yHtmlRenderer {
5
+ /**
6
+ * Renders a static HTML template by copying it and generating the accompanied data payload.
7
+ * @param templateName The template file name in the templates folder.
8
+ * @param data The data object to pass to the client-side JS app.
9
+ * @param outputFolder The folder where the rendered file will be saved.
10
+ * @param outputFileName The full path of the output file.
11
+ */
12
+ render(templateName: string, data: Record<string, unknown>, outputFolder: string, outputFileName: string): Promise<void>;
13
+ /**
14
+ * Converts ANSI color codes to HTML spans for nicer error display.
15
+ */
16
+ ansiToHtml(text: string): string;
17
+ }