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
@@ -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,189 @@ class SnapAllyReporter {
86
64
  totalA11yErrorCount: 0,
87
65
  browserSummaries: {},
88
66
  date: '',
67
+ colors: {},
89
68
  };
90
- this.options = options;
69
+ this.testRuleCounts = {};
70
+ this.testGlobalCounts = {};
71
+ this.options = { verbose: true, consoleLog: true, ...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;
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 && this.options.verbose) {
145
+ console.warn(`[SnapAlly] A11y attachment missing for test: ${test.title}. Available: ${result.attachments.map(a => a.name).join(', ')}`);
146
+ }
147
+ let a11yData = null;
148
+ if (a11yAttachment && a11yAttachment.body) {
149
+ try {
150
+ a11yData = JSON.parse(a11yAttachment.body.toString());
151
+ }
152
+ catch (err) {
153
+ console.error(`[SnapAlly] Failed to parse A11y attachment: ${err}. Body was: ${a11yAttachment.body.toString().substring(0, 100)}`);
154
+ }
155
+ }
156
+ // Handle cases where a11yData might be the direct ReportData or wrapped in a data property
157
+ const actualData = (a11yData && typeof a11yData === 'object' && 'data' in a11yData ? a11yData.data : a11yData);
158
+ const violations = (actualData === null || actualData === void 0 ? void 0 : actualData.a11yErrors) || (actualData === null || actualData === void 0 ? void 0 : actualData.violations) || [];
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
+ 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'];
162
+ const filtered = result.steps
163
+ .filter((s) => !blocklist.some(b => s.title.includes(b)))
164
+ .map((s) => s.title);
165
+ return filtered;
166
+ })();
167
+ const testResults = {
148
168
  num: index,
149
169
  folderName: testFolderName,
150
- executionReportPath: `${testFolderName}/${executionReportName}`,
151
170
  title: test.title,
152
- fileName: fileGroup,
171
+ fileName: path.relative(this.projectRoot, test.location.file),
172
+ duration: TimeUtils_1.TimeUtils.formatDuration(result.duration),
153
173
  timeDuration: result.duration,
154
- duration: A11yTimeUtils_1.A11yTimeUtils.formatDuration(result.duration),
155
- description: testMeta.description,
174
+ description: '', // Could extract from annotations if needed
156
175
  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,
176
+ statusIcon: this.getStatusIcon(result.status),
177
+ browser: (() => {
178
+ var _a, _b;
179
+ const bName = test._projectId ||
180
+ ((_b = (_a = test.parent) === null || _a === void 0 ? void 0 : _a.project()) === null || _b === void 0 ? void 0 : _b.name) ||
181
+ test.projectName ||
182
+ 'chromium';
183
+ return test.outcome() === 'skipped' ? 'n/a' : bName;
184
+ })(),
185
+ adoOrganization: ((_a = this.options.ado) === null || _a === void 0 ? void 0 : _a.organization) || (actualData === null || actualData === void 0 ? void 0 : actualData.adoOrganization),
186
+ adoProject: ((_b = this.options.ado) === null || _b === void 0 ? void 0 : _b.project) || (actualData === null || actualData === void 0 ? void 0 : actualData.adoProject),
187
+ adoAreaPath: ((_c = this.options.ado) === null || _c === void 0 ? void 0 : _c.areaPath) || (actualData === null || actualData === void 0 ? void 0 : actualData.adoAreaPath),
188
+ timestamp: new Date().toLocaleString(),
189
+ pageUrl: (actualData === null || actualData === void 0 ? void 0 : actualData.pageUrl) || (actualData === null || actualData === void 0 ? void 0 : actualData.pageKey) || 'Resource',
190
+ tags: [], // Extract from test tags if available
191
+ preConditions: [],
192
+ steps: filteredSteps,
193
+ postConditions: [],
194
+ videoPath: videoPath.length > 0 ? videoPath : null,
195
+ screenshotPaths,
196
+ attachments,
197
+ errors: result.errors.map((e) => this.renderer.ansiToHtml(e.message || '')),
198
+ a11yErrors: violations.map((v) => ({
199
+ ...v,
200
+ target: (v.target || []).map((t) => ({
201
+ ...t,
202
+ steps: (t.steps && t.steps.length > 0) ? t.steps : filteredSteps
203
+ }))
204
+ })),
205
+ a11yErrorCount: a11yErrorCount,
206
+ colors: this.colors,
172
207
  };
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: [] };
208
+ const reportFileName = 'report.html';
209
+ const a11yReportFileName = 'accessibility-report.html';
210
+ testResults.executionReportPath = `${testFolderName}/${reportFileName}`;
211
+ // Generate separate accessibility report if there are a11y errors
212
+ if (violations.length > 0) {
213
+ testResults.a11yReportPath = `${testFolderName}/${a11yReportFileName}`;
214
+ const a11yReportPath = path.join(testFolder, a11yReportFileName);
215
+ console.log(`[SnapAlly] Generating A11y report for ${test.title} (Browser: ${testResults.browser})`);
216
+ await this.renderer.render('accessibility-report.html', testResults, testFolder, a11yReportPath);
226
217
  }
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];
218
+ // Removed debug logging of internal data state
219
+ const reportPath = path.join(testFolder, reportFileName);
220
+ await this.renderer.render('test-execution-report.html', testResults, testFolder, reportPath);
221
+ this.updateSummary(test, testResults);
262
222
  }
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
- }
299
- }
300
- return `accessibility-${sanitizedTitle}${suffix}.html`;
301
- }
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++;
223
+ getStatusIcon(status) {
379
224
  switch (status) {
380
225
  case 'passed':
381
- bSummary.totalPassed++;
382
- break;
226
+ return models_1.TestStatusIcon.passed;
383
227
  case 'failed':
384
- bSummary.totalFailed++;
385
- break;
228
+ case 'timedOut':
229
+ return models_1.TestStatusIcon.failed;
386
230
  case 'skipped':
387
- bSummary.totalSkipped++;
388
- break;
231
+ return models_1.TestStatusIcon.skipped;
232
+ default:
233
+ return 'help';
389
234
  }
390
235
  }
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;
236
+ updateSummary(test, result) {
237
+ var _a, _b;
238
+ const browser = result.browser;
239
+ if (!this.executionSummary.groupedResults[browser]) {
240
+ this.executionSummary.groupedResults[browser] = [];
406
241
  }
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] = [];
242
+ this.executionSummary.groupedResults[browser].push(result);
243
+ // Initialize browser summary if needed
244
+ if (!this.executionSummary.browserSummaries) {
245
+ this.executionSummary.browserSummaries = {};
416
246
  }
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',
247
+ if (!this.executionSummary.browserSummaries[browser]) {
248
+ this.executionSummary.browserSummaries[browser] = {
249
+ duration: '',
424
250
  status: '',
425
251
  statusIcon: '',
426
252
  total: 0,
@@ -433,7 +259,63 @@ class SnapAllyReporter {
433
259
  totalA11yErrorCount: 0,
434
260
  };
435
261
  }
436
- return summaries[browser];
262
+ const bSummary = this.executionSummary.browserSummaries[browser];
263
+ this.executionSummary.total++;
264
+ bSummary.total++;
265
+ if (result.status === 'passed') {
266
+ this.executionSummary.totalPassed++;
267
+ bSummary.totalPassed++;
268
+ }
269
+ else if (result.status === 'failed' || result.status === 'timedOut') {
270
+ this.executionSummary.totalFailed++;
271
+ bSummary.totalFailed++;
272
+ }
273
+ else if (result.status === 'skipped') {
274
+ this.executionSummary.totalSkipped++;
275
+ bSummary.totalSkipped++;
276
+ }
277
+ const testKey = test.titlePath().join(' > ');
278
+ const violations = result.a11yErrors || result.violations;
279
+ if (violations && violations.length > 0) {
280
+ 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);
281
+ // De-duplicate global count across browsers for same test case
282
+ const prevTestGlobalCount = this.testGlobalCounts[testKey] || 0;
283
+ if (count > prevTestGlobalCount) {
284
+ this.executionSummary.totalA11yErrorCount += (count - prevTestGlobalCount);
285
+ this.testGlobalCounts[testKey] = count;
286
+ }
287
+ bSummary.totalA11yErrorCount += count;
288
+ for (const err of violations) {
289
+ const ruleId = err.id;
290
+ 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);
291
+ // Update global wcagErrors (de-duplicated)
292
+ if (!this.executionSummary.wcagErrors[ruleId]) {
293
+ this.executionSummary.wcagErrors[ruleId] = {
294
+ count: 0,
295
+ severity: err.severity || err.impact || 'minor',
296
+ helpUrl: err.helpUrl,
297
+ description: err.description,
298
+ };
299
+ }
300
+ if (!this.testRuleCounts[testKey])
301
+ this.testRuleCounts[testKey] = {};
302
+ const prevRuleOccCount = this.testRuleCounts[testKey][ruleId] || 0;
303
+ if (occCount > prevRuleOccCount) {
304
+ this.executionSummary.wcagErrors[ruleId].count += (occCount - prevRuleOccCount);
305
+ this.testRuleCounts[testKey][ruleId] = occCount;
306
+ }
307
+ // Update browser-specific wcagErrors (per browser, usually naturally unique)
308
+ if (!bSummary.wcagErrors[ruleId]) {
309
+ bSummary.wcagErrors[ruleId] = {
310
+ count: 0,
311
+ severity: err.severity || err.impact || 'minor',
312
+ helpUrl: err.helpUrl,
313
+ description: err.description,
314
+ };
315
+ }
316
+ bSummary.wcagErrors[ruleId].count += occCount;
317
+ }
318
+ }
437
319
  }
438
320
  }
439
321
  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
+ }