slik-report 1.0.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +982 -1
- package/package.json +13 -4
- package/dist/index.js.map +0 -1
package/README.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# slik-report
|
|
2
|
+
|
|
3
|
+
A powerful Playwright reporter for aggregating test results, flakiness analysis, and historical trend tracking.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install slik-report --save-dev
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## How to Use
|
|
12
|
+
|
|
13
|
+
To use `slik-report` with Playwright, you need to configure it in your `playwright.config.ts` file.
|
|
14
|
+
|
|
15
|
+
### Configuration Example
|
|
16
|
+
|
|
17
|
+
Add `slik-report` to the `reporter` array in your `playwright.config.ts`:
|
|
18
|
+
|
|
19
|
+
```typescript
|
|
20
|
+
// playwright.config.ts
|
|
21
|
+
import { defineConfig } from '@playwright/test';
|
|
22
|
+
|
|
23
|
+
export default defineConfig({
|
|
24
|
+
reporter: [
|
|
25
|
+
['json', { outputFile: 'playwright-report/report.json' }],
|
|
26
|
+
// Use the Slik Report reporter and pass options
|
|
27
|
+
['slik-report', {
|
|
28
|
+
input: 'playwright-report/report.json', // Path to the JSON report generated by Playwright
|
|
29
|
+
output: 'slik-report.html', // Output file name for the Slik report
|
|
30
|
+
history: './slik_history', // Directory to store history data
|
|
31
|
+
}]
|
|
32
|
+
],
|
|
33
|
+
});
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Options
|
|
37
|
+
|
|
38
|
+
| Option | Type | Description | Default |
|
|
39
|
+
| :-------- | :----- | :----------------------------------------------- | :-------------------------- |
|
|
40
|
+
| `input` | string | Path to the JSON report file generated by Playwright. | `playwright-report/report.json` |
|
|
41
|
+
| `output` | string | Path where the HTML report will be generated. | `slik-report.html` |
|
|
42
|
+
| `history` | string | Directory path to store historical run data. | `./slik_history` |
|
|
43
|
+
|
|
44
|
+
## License
|
|
45
|
+
|
|
46
|
+
ISC
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,984 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
|
|
3
|
+
// @ts-nocheck
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
function ensureDir(p) {
|
|
7
|
+
if (!fs.existsSync(p))
|
|
8
|
+
fs.mkdirSync(p, { recursive: true });
|
|
9
|
+
}
|
|
10
|
+
function writeFile(filePath, content) {
|
|
11
|
+
ensureDir(path.dirname(filePath));
|
|
12
|
+
fs.writeFileSync(filePath, content, 'utf8');
|
|
13
|
+
}
|
|
14
|
+
function safeJson(v) {
|
|
15
|
+
try {
|
|
16
|
+
return JSON.stringify(v);
|
|
17
|
+
}
|
|
18
|
+
catch (e) {
|
|
19
|
+
return '[]';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function stripAnsi(str) {
|
|
23
|
+
if (!str)
|
|
24
|
+
return '';
|
|
25
|
+
const ansiRegex = /[\u001b\u009b][[()#;?]*.{0,2}(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g;
|
|
26
|
+
return str.replace(ansiRegex, '');
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Escapes a JSON string for safe embedding within an HTML <script> tag.
|
|
30
|
+
* This prevents the JSON from prematurely closing the script block (e.g., if it contains </script>).
|
|
31
|
+
*/
|
|
32
|
+
function escapeJsonForHtml(jsonString) {
|
|
33
|
+
return jsonString.replace(/<\/script>/g, '<\\/script>');
|
|
34
|
+
}
|
|
35
|
+
class AggregatorReporter {
|
|
36
|
+
constructor(opts) {
|
|
37
|
+
this.options = opts || {};
|
|
38
|
+
this.input = (this.options.input) || './playwright-report/report.json';
|
|
39
|
+
this.outputHtml = (this.options.output) || './summary-report.html';
|
|
40
|
+
this.historyDir = (this.options.history) || './reports/reports_history';
|
|
41
|
+
}
|
|
42
|
+
async onEnd(result) {
|
|
43
|
+
try {
|
|
44
|
+
console.log('AggregatorReporter: Starting report generation...');
|
|
45
|
+
if (!fs.existsSync(this.input)) {
|
|
46
|
+
console.warn('AggregatorReporter: JSON report not found at', this.input);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
console.log('AggregatorReporter: JSON file found. Reading file...');
|
|
50
|
+
const report = JSON.parse(fs.readFileSync(this.input, 'utf8'));
|
|
51
|
+
console.log('AggregatorReporter: JSON data read. Processing and generating report...');
|
|
52
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
53
|
+
const historyFile = path.join(this.historyDir, `run-${timestamp}.json`);
|
|
54
|
+
writeFile(historyFile, safeJson(report));
|
|
55
|
+
const history = await this.loadHistory();
|
|
56
|
+
const processedReport = this.processReportWithHistory(report, history);
|
|
57
|
+
const htmlContent = this.generateReportHtml(processedReport);
|
|
58
|
+
writeFile(this.outputHtml, htmlContent);
|
|
59
|
+
console.log('AggregatorReporter: Report generation successful!');
|
|
60
|
+
}
|
|
61
|
+
catch (e) {
|
|
62
|
+
console.error('AggregatorReporter failed:', e);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Helper function to robustly get the array of top-level test suites.
|
|
67
|
+
* This handles standard Playwright and various custom/merged report formats.
|
|
68
|
+
*/
|
|
69
|
+
getTestSuites(report) {
|
|
70
|
+
if (!report)
|
|
71
|
+
return [];
|
|
72
|
+
// 1. Standard Playwright structure
|
|
73
|
+
if (report.suites && Array.isArray(report.suites)) {
|
|
74
|
+
return report.suites;
|
|
75
|
+
}
|
|
76
|
+
// 2. Common alternative in merged reports (a flat array of specs)
|
|
77
|
+
if (report.specs && Array.isArray(report.specs) && report.specs.length > 0) {
|
|
78
|
+
console.log('AggregatorReporter: Using flat "specs" array as report root.');
|
|
79
|
+
return [{ title: 'Root Suite (Flat Specs)', suites: [], specs: report.specs, file: 'N/A' }];
|
|
80
|
+
}
|
|
81
|
+
// 3. Check for a Playwright V2/aggregator structure where tests are flatly listed
|
|
82
|
+
if (report.tests && Array.isArray(report.tests) && report.tests.length > 0) {
|
|
83
|
+
console.log('AggregatorReporter: Using flat "tests" array as report root. (Playwright V2 or custom)');
|
|
84
|
+
return [{
|
|
85
|
+
title: 'Root Suite (Flat Tests)',
|
|
86
|
+
suites: [],
|
|
87
|
+
specs: report.tests.map(t => ({
|
|
88
|
+
title: t.title,
|
|
89
|
+
file: t.file,
|
|
90
|
+
tests: [t]
|
|
91
|
+
})),
|
|
92
|
+
file: 'N/A'
|
|
93
|
+
}];
|
|
94
|
+
}
|
|
95
|
+
if (report.stats) {
|
|
96
|
+
console.warn('AggregatorReporter: Could not find "suites" or alternative test structure. Returning empty suites list.');
|
|
97
|
+
}
|
|
98
|
+
return [];
|
|
99
|
+
}
|
|
100
|
+
findAllSpecs(suites) {
|
|
101
|
+
let specs = [];
|
|
102
|
+
if (!suites)
|
|
103
|
+
return specs;
|
|
104
|
+
for (const suite of suites) {
|
|
105
|
+
if (suite.specs)
|
|
106
|
+
specs.push(...suite.specs);
|
|
107
|
+
if (suite.suites) {
|
|
108
|
+
specs.push(...this.findAllSpecs(suite.suites));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return specs;
|
|
112
|
+
}
|
|
113
|
+
async loadHistory(n = 21) {
|
|
114
|
+
const history = [];
|
|
115
|
+
ensureDir(this.historyDir);
|
|
116
|
+
const files = fs.readdirSync(this.historyDir)
|
|
117
|
+
.filter(f => f.startsWith('run-') && f.endsWith('.json'))
|
|
118
|
+
.sort()
|
|
119
|
+
.slice(-n);
|
|
120
|
+
for (const file of files) {
|
|
121
|
+
try {
|
|
122
|
+
const filePath = path.join(this.historyDir, file);
|
|
123
|
+
const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
124
|
+
const dateStr = file.replace('run-', '').replace('.json', '');
|
|
125
|
+
// The file name format is ISO-like but uses hyphens/underscores instead of colons/dots. We reconstruct a valid ISO string.
|
|
126
|
+
const validIso = dateStr.slice(0, 10) + 'T' +
|
|
127
|
+
dateStr.slice(11, 13) + ':' +
|
|
128
|
+
dateStr.slice(14, 16) + ':' +
|
|
129
|
+
dateStr.slice(17, 19) + '.' +
|
|
130
|
+
dateStr.slice(20) + 'Z';
|
|
131
|
+
history.push({
|
|
132
|
+
date: new Date(validIso).toLocaleDateString(),
|
|
133
|
+
report: data
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
catch (e) {
|
|
137
|
+
console.warn(`Could not read history file: ${file}`, e);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return history;
|
|
141
|
+
}
|
|
142
|
+
processReportWithHistory(report, history) {
|
|
143
|
+
const stats = this.processStats(report);
|
|
144
|
+
const suites = this.processSuites(report, history);
|
|
145
|
+
const categories = this.processCategories(report);
|
|
146
|
+
const historyData = this.processHistoryData(history, report);
|
|
147
|
+
const timeline = this.processTimeline(report);
|
|
148
|
+
const metadata = this.processMetadata(report);
|
|
149
|
+
return {
|
|
150
|
+
stats,
|
|
151
|
+
suites,
|
|
152
|
+
categories,
|
|
153
|
+
history: historyData,
|
|
154
|
+
timeline,
|
|
155
|
+
metadata
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
processStats(report) {
|
|
159
|
+
const stats = report.stats || {};
|
|
160
|
+
const { expected = 0, unexpected = 0, skipped = 0, duration = 0 } = stats;
|
|
161
|
+
const total = expected + unexpected + skipped;
|
|
162
|
+
const passRate = total > 0 ? `${((expected / total) * 100).toFixed(0)}%` : 'N/A';
|
|
163
|
+
const avgDuration = total > 0 ? `${(duration / total / 1000).toFixed(2)}s` : 'N/A';
|
|
164
|
+
return {
|
|
165
|
+
total,
|
|
166
|
+
passed: expected,
|
|
167
|
+
failed: unexpected,
|
|
168
|
+
skipped,
|
|
169
|
+
passRate,
|
|
170
|
+
avgDuration,
|
|
171
|
+
totalDuration: `${(duration / 1000).toFixed(2)}s`
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
processMetadata(report) {
|
|
175
|
+
// Safely access project name from the projects array
|
|
176
|
+
const projectName = report.config?.projects?.[0]?.name || report.config?.metadata?.project || 'N/A';
|
|
177
|
+
return {
|
|
178
|
+
browser: projectName,
|
|
179
|
+
os: report.config?.metadata?.platform || 'N/A', // Using 'platform' if available in ortoni/custom meta
|
|
180
|
+
startTime: report.stats?.startTime ? new Date(report.stats.startTime).toLocaleString() : 'N/A',
|
|
181
|
+
totalDuration: report.stats?.duration ? `${(report.stats.duration / 1000).toFixed(2)}s` : 'N/A'
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
processSuites(report, history) {
|
|
185
|
+
const historyDataMap = new Map();
|
|
186
|
+
history.forEach(run => {
|
|
187
|
+
const allSpecs = this.findAllSpecs(this.getTestSuites(run.report));
|
|
188
|
+
allSpecs.forEach(spec => {
|
|
189
|
+
const testId = `${spec.file}::${spec.title}`;
|
|
190
|
+
if (!historyDataMap.has(testId)) {
|
|
191
|
+
historyDataMap.set(testId, []);
|
|
192
|
+
}
|
|
193
|
+
// Safely get status, prioritizing unexpected over passed/skipped if multiple results exist
|
|
194
|
+
const resultStatus = spec.tests?.[0]?.results?.[0]?.status || 'skipped';
|
|
195
|
+
historyDataMap.get(testId).push(resultStatus);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
const finalSuites = [];
|
|
199
|
+
const traverseSuites = (suite, parentTitle = '') => {
|
|
200
|
+
const currentTitle = parentTitle ? `${parentTitle} > ${suite.title}` : suite.title;
|
|
201
|
+
if (suite.specs && suite.specs.length > 0) {
|
|
202
|
+
finalSuites.push({
|
|
203
|
+
...suite,
|
|
204
|
+
title: currentTitle,
|
|
205
|
+
specs: suite.specs.map(spec => {
|
|
206
|
+
const testId = `${spec.file}::${spec.title}`;
|
|
207
|
+
const historyStatuses = historyDataMap.get(testId) || [];
|
|
208
|
+
const currentTest = spec.tests?.[0];
|
|
209
|
+
const currentResult = currentTest?.results?.[0];
|
|
210
|
+
const currentStatus = currentResult?.status || 'skipped';
|
|
211
|
+
// We only want the history statuses from previous runs + the current one
|
|
212
|
+
const lastRuns = historyStatuses.slice(-4);
|
|
213
|
+
// Ensure the current run is included, and limit to 5 total runs
|
|
214
|
+
if (lastRuns.length === 0 || lastRuns[lastRuns.length - 1] !== currentStatus) {
|
|
215
|
+
lastRuns.push(currentStatus);
|
|
216
|
+
}
|
|
217
|
+
else if (lastRuns.length > 5) {
|
|
218
|
+
lastRuns.splice(0, lastRuns.length - 5);
|
|
219
|
+
}
|
|
220
|
+
const isFlaky = new Set(lastRuns).size > 1;
|
|
221
|
+
const logs = (currentResult?.stdout || []).map(s => stripAnsi(s.text || '')).join('');
|
|
222
|
+
return {
|
|
223
|
+
...spec,
|
|
224
|
+
tests: spec.tests.map(test => ({
|
|
225
|
+
...test,
|
|
226
|
+
history: lastRuns,
|
|
227
|
+
flaky: isFlaky,
|
|
228
|
+
status: currentStatus,
|
|
229
|
+
duration: currentResult?.duration || 0,
|
|
230
|
+
logs: logs
|
|
231
|
+
}))
|
|
232
|
+
};
|
|
233
|
+
})
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
if (suite.suites) {
|
|
237
|
+
suite.suites.forEach(nested => traverseSuites(nested, currentTitle));
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
this.getTestSuites(report).forEach(s => traverseSuites(s));
|
|
241
|
+
return finalSuites;
|
|
242
|
+
}
|
|
243
|
+
processCategories(report) {
|
|
244
|
+
const categories = {};
|
|
245
|
+
const allSpecs = this.findAllSpecs(this.getTestSuites(report));
|
|
246
|
+
allSpecs.forEach(spec => {
|
|
247
|
+
const result = spec.tests?.[0]?.results?.[0];
|
|
248
|
+
if (result?.status === 'failed' && result.error) {
|
|
249
|
+
// Use the raw error message to preserve newlines and ANSI for stripping/display
|
|
250
|
+
const cleanMessage = stripAnsi(result.error.message || result.error.stack || 'Unknown error');
|
|
251
|
+
let errorName;
|
|
252
|
+
const expectMatch = cleanMessage.match(/Expected: (.*)\s*Received: (.*)/);
|
|
253
|
+
if (expectMatch) {
|
|
254
|
+
const expected = (expectMatch[1] || '').trim();
|
|
255
|
+
const received = (expectMatch[2] || '').trim();
|
|
256
|
+
errorName = `AssertionError: Expected '${expected}' but received '${received}'`;
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
errorName = (cleanMessage.split('\n')[0] || 'Unknown Error').replace('Error: ', '').trim();
|
|
260
|
+
}
|
|
261
|
+
if (!categories[errorName]) {
|
|
262
|
+
categories[errorName] = {
|
|
263
|
+
name: errorName,
|
|
264
|
+
count: 0,
|
|
265
|
+
tests: []
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
categories[errorName].count++;
|
|
269
|
+
categories[errorName].tests.push({
|
|
270
|
+
title: `${spec.file} -> ${spec.title}`,
|
|
271
|
+
trace: cleanMessage
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
return Object.values(categories);
|
|
276
|
+
}
|
|
277
|
+
processHistoryData(history, currentReport) {
|
|
278
|
+
const allRuns = [...history];
|
|
279
|
+
const today = new Date().toLocaleDateString();
|
|
280
|
+
// Add current report to historical runs for chart, checking for duplicates
|
|
281
|
+
if (!history.find(h => h.report.stats?.startTime === currentReport.stats?.startTime)) {
|
|
282
|
+
allRuns.push({
|
|
283
|
+
date: today,
|
|
284
|
+
report: currentReport,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
return allRuns.map(run => {
|
|
288
|
+
const stats = run.report.stats || {};
|
|
289
|
+
const { expected = 0, unexpected = 0, skipped = 0 } = stats;
|
|
290
|
+
const total = expected + unexpected + skipped;
|
|
291
|
+
return {
|
|
292
|
+
date: run.date,
|
|
293
|
+
passed: expected,
|
|
294
|
+
failed: unexpected,
|
|
295
|
+
skipped,
|
|
296
|
+
passRate: total > 0 ? (expected / total) * 100 : 0
|
|
297
|
+
};
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
processTimeline(report) {
|
|
301
|
+
const timeline = [];
|
|
302
|
+
let cumulativeDuration = 0;
|
|
303
|
+
const allSpecs = this.findAllSpecs(this.getTestSuites(report));
|
|
304
|
+
allSpecs.forEach(spec => {
|
|
305
|
+
const result = spec.tests?.[0]?.results?.[0];
|
|
306
|
+
const duration = result?.duration || 0;
|
|
307
|
+
timeline.push({
|
|
308
|
+
name: spec.title,
|
|
309
|
+
start: cumulativeDuration,
|
|
310
|
+
duration: duration,
|
|
311
|
+
status: result?.status || 'skipped'
|
|
312
|
+
});
|
|
313
|
+
cumulativeDuration += duration;
|
|
314
|
+
});
|
|
315
|
+
return timeline;
|
|
316
|
+
}
|
|
317
|
+
generateReportHtml(report) {
|
|
318
|
+
// 1. Stringify the report object
|
|
319
|
+
const reportJson = JSON.stringify(report);
|
|
320
|
+
// 2. Escape the stringified JSON for safe HTML embedding (CRITICAL FIX)
|
|
321
|
+
const safeReportJson = escapeJsonForHtml(reportJson);
|
|
322
|
+
return `
|
|
323
|
+
<!doctype html>
|
|
324
|
+
<html>
|
|
325
|
+
<head>
|
|
326
|
+
<meta charset="utf-8"/>
|
|
327
|
+
<title>DIS API Test Summary - Enhanced</title>
|
|
328
|
+
<meta name="viewport" content="width=device-width,initial-scale=1"/>
|
|
329
|
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;700&display=swap" rel="stylesheet">
|
|
330
|
+
<style>
|
|
331
|
+
:root{
|
|
332
|
+
--bg:#ffffff; --card:#ffffff; --text:#172B4D; --muted:#6B7280; --accent:#2563EB;
|
|
333
|
+
--pass-bg:#e6ffed; --pass-text:#0a7a0a; --fail-bg:#fff0f0; --fail-text:#b00020;
|
|
334
|
+
--skipped-bg:#f0f0f0; --skipped-text:#6B7280; --shadow: 0 6px 18px rgba(15,23,42,0.08);
|
|
335
|
+
--table-header-bg: rgba(15,23,42,0.03); --table-border: rgba(15,23,42,0.04);
|
|
336
|
+
--log-bg: #eef0f3;
|
|
337
|
+
}
|
|
338
|
+
[data-theme="dark"]{
|
|
339
|
+
--bg:#1a202c; --card:#2d3748; --text:#e2e8f0; --muted:#a0aec0; --accent:#63b3ed;
|
|
340
|
+
--pass-bg:#2A4838; --pass-text:#68D391; --fail-bg:#582C2C; --fail-text:#FC8181;
|
|
341
|
+
--skipped-bg:#4a5568; --skipped-text:#e2e8f0; --shadow: 0 6px 18px rgba(0,0,0,0.4);
|
|
342
|
+
--table-header-bg: rgba(255,255,255,0.05); --table-border: rgba(255,255,255,0.08);
|
|
343
|
+
--log-bg: #1e293b;
|
|
344
|
+
}
|
|
345
|
+
body {
|
|
346
|
+
margin: 0; padding: 0; font-family: 'Inter', sans-serif; font-size: 14px;
|
|
347
|
+
line-height: 1.6; color: var(--text); background-color: var(--bg); transition: background-color 0.3s, color 0.3s;
|
|
348
|
+
}
|
|
349
|
+
.app-container { display: flex; min-height: 100vh; }
|
|
350
|
+
.sidebar { width: 250px; background-color: var(--card); padding: 24px; box-shadow: var(--shadow); z-index: 10; flex-shrink: 0; }
|
|
351
|
+
.sidebar-header { font-weight: 700; font-size: 1.5em; margin-bottom: 30px; color: var(--accent); }
|
|
352
|
+
.sidebar-menu { list-style: none; padding: 0; }
|
|
353
|
+
.sidebar-menu-item { margin-bottom: 12px; }
|
|
354
|
+
.sidebar-menu-item a { display: block; padding: 12px 16px; color: var(--text); text-decoration: none; border-radius: 8px; transition: background-color 0.2s, color 0.2s; }
|
|
355
|
+
.sidebar-menu-item a:hover { background-color: var(--table-header-bg); }
|
|
356
|
+
.sidebar-menu-item a.active { background-color: var(--accent); color: white; }
|
|
357
|
+
.main-content { flex-grow: 1; padding: 32px; overflow-x: auto; }
|
|
358
|
+
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; flex-wrap: wrap; gap: 16px;}
|
|
359
|
+
.header h1 { font-size: 2em; font-weight: 600; margin: 0; }
|
|
360
|
+
.filters, .actions { display: flex; gap: 16px; align-items: center; }
|
|
361
|
+
.filters label { display: flex; align-items: center; gap: 8px; cursor: pointer; }
|
|
362
|
+
.card { background-color: var(--card); border-radius: 12px; padding: 24px; box-shadow: var(--shadow); margin-bottom: 24px; }
|
|
363
|
+
h2 { font-size: 1.5em; font-weight: 600; margin: 0 0 20px; display: flex; justify-content: space-between; align-items: center;}
|
|
364
|
+
h3 { font-size: 1.1em; font-weight: 600; margin: 0 0 12px; text-align: center; }
|
|
365
|
+
.header-stats { display: flex; gap: 8px; font-size: 0.7em; font-weight: normal; }
|
|
366
|
+
.kpi-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 16px; }
|
|
367
|
+
.kpi-card { text-align: center; padding: 16px; border-radius: 8px; background-color: var(--bg); border: 1px solid var(--table-border); }
|
|
368
|
+
.kpi-value { font-size: 2em; font-weight: 700; color: var(--text); }
|
|
369
|
+
.kpi-label { color: var(--muted); font-size: 0.9em; }
|
|
370
|
+
.kpi-pass-rate .kpi-value { color: var(--pass-text); }
|
|
371
|
+
.kpi-failed .kpi-value { color: var(--fail-text); }
|
|
372
|
+
.status-badge { display: inline-block; padding: 4px 10px; border-radius: 12px; font-weight: 600; font-size: 0.8em; text-transform: uppercase; }
|
|
373
|
+
.status-passed { background-color: var(--pass-bg); color: var(--pass-text); }
|
|
374
|
+
.status-failed { background-color: var(--fail-bg); color: var(--fail-text); }
|
|
375
|
+
.status-skipped { background-color: var(--skipped-bg); color: var(--skipped-text); }
|
|
376
|
+
table { width: 100%; border-collapse: collapse; }
|
|
377
|
+
th, td { padding: 8px 12px; text-align: left; border-bottom: 1px solid var(--table-border); }
|
|
378
|
+
th { background-color: var(--table-header-bg); font-weight: 600; }
|
|
379
|
+
tr:last-child td { border-bottom: none; }
|
|
380
|
+
.suite-header { cursor: pointer; padding: 12px 16px; background-color: var(--table-header-bg); border-bottom: 1px solid var(--table-border); display: flex; align-items: center; gap: 8px; font-weight: 600; }
|
|
381
|
+
.collapse-icon { width: 1em; height: 1em; text-align: center; transition: transform 0.2s; }
|
|
382
|
+
.collapse-icon.expanded { transform: rotate(90deg); }
|
|
383
|
+
.test-card-header { cursor: pointer; display: grid; grid-template-columns: 1fr auto auto auto; gap: 16px; align-items: center; padding: 8px 12px;}
|
|
384
|
+
.test-card-header:hover { background-color: var(--table-header-bg); }
|
|
385
|
+
.test-card { border-bottom: 1px solid var(--table-border); }
|
|
386
|
+
.log-row { background-color: var(--table-header-bg); }
|
|
387
|
+
.log-container { padding: 16px; }
|
|
388
|
+
.log-container h4 { margin: 0 0 8px; text-align: left;}
|
|
389
|
+
.log-container pre { white-space: pre-wrap; word-wrap: break-word; background: var(--log-bg); padding: 12px; border-radius: 8px; max-height: 300px; overflow-y: auto; color: var(--muted); font-family: monospace; }
|
|
390
|
+
.dot { display: inline-block; width: 12px; height: 12px; border-radius: 50%; margin-right: 4px; }
|
|
391
|
+
.dot.passed { background-color: #4CAF50; }
|
|
392
|
+
.dot.failed { background-color: #F44336; }
|
|
393
|
+
.dot.skipped { background-color: #9E9E9E; }
|
|
394
|
+
.flaky-badge { background-color: #ff9900; color: white; padding: 2px 6px; border-radius: 4px; font-size: 0.7em; font-weight: bold; }
|
|
395
|
+
.dark-mode-toggle { cursor: pointer; font-size: 24px; padding: 8px; border-radius: 50%; transition: background-color 0.2s; line-height: 1; }
|
|
396
|
+
.dark-mode-toggle:hover { background-color: var(--table-header-bg); }
|
|
397
|
+
.tab-content { display: none; }
|
|
398
|
+
.tab-content.active { display: block; }
|
|
399
|
+
.download-buttons { display: flex; gap: 10px; }
|
|
400
|
+
.download-buttons button { padding: 10px 16px; border: 1px solid var(--table-border); background-color: var(--card); color: var(--text); border-radius: 8px; cursor: pointer; transition: background-color 0.2s; }
|
|
401
|
+
.download-buttons button:hover { background-color: var(--table-header-bg); }
|
|
402
|
+
.chart-container { position: relative; display: flex; align-items: center; justify-content: center; color: var(--muted); }
|
|
403
|
+
.category-header { display: flex; justify-content: space-between; align-items: center; cursor: pointer; padding: 12px; border-bottom: 1px solid var(--table-border); }
|
|
404
|
+
.category-content { padding: 0 12px; }
|
|
405
|
+
.category-test { border-bottom: 1px solid var(--table-border); padding: 12px 0; }
|
|
406
|
+
.category-test:last-child { border-bottom: none; }
|
|
407
|
+
.category-test pre { background-color: var(--bg); padding: 8px; border-radius: 6px; margin-top: 8px; font-family: monospace; }
|
|
408
|
+
.metadata-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-top: 20px;}
|
|
409
|
+
.metadata-item { background-color: var(--bg); padding: 12px; border-radius: 8px; border: 1px solid var(--table-border); }
|
|
410
|
+
.metadata-label { font-weight: 600; color: var(--muted); display: block; margin-bottom: 4px;}
|
|
411
|
+
.metadata-value { font-size: 1.1em;}
|
|
412
|
+
</style>
|
|
413
|
+
</head>
|
|
414
|
+
<body>
|
|
415
|
+
|
|
416
|
+
<div class="app-container">
|
|
417
|
+
<div class="sidebar">
|
|
418
|
+
<div class="sidebar-header">Test Report</div>
|
|
419
|
+
<ul class="sidebar-menu">
|
|
420
|
+
<li class="sidebar-menu-item"><a href="#" class="active" data-tab="overview">Overview</a></li>
|
|
421
|
+
<li class="sidebar-menu-item"><a href="#" data-tab="categories">Categories</a></li>
|
|
422
|
+
<li class="sidebar-menu-item"><a href="#" data-tab="suites">Suites</a></li>
|
|
423
|
+
<li class="sidebar-menu-item"><a href="#" data-tab="graphs">Graphs</a></li>
|
|
424
|
+
<li class="sidebar-menu-item"><a href="#" data-tab="timeline">Timeline</a></li>
|
|
425
|
+
<li class="sidebar-menu-item"><a href="#" data-tab="history">Historical Runs</a></li>
|
|
426
|
+
</ul>
|
|
427
|
+
</div>
|
|
428
|
+
|
|
429
|
+
<div class="main-content">
|
|
430
|
+
<div class="header">
|
|
431
|
+
<h1>Test Execution Report</h1>
|
|
432
|
+
<div class="download-buttons">
|
|
433
|
+
<button id="downloadHtml">Download HTML</button>
|
|
434
|
+
<button id="downloadCsv">Download CSV</button>
|
|
435
|
+
<button id="downloadJson">Download JSON</button>
|
|
436
|
+
<button id="downloadPdf">Download PDF</button>
|
|
437
|
+
</div>
|
|
438
|
+
<div class="actions">
|
|
439
|
+
<div class="filters">
|
|
440
|
+
<label><input type="checkbox" id="filterPassed" checked> Passed</label>
|
|
441
|
+
<label><input type="checkbox" id="filterFailed" checked> Failed</label>
|
|
442
|
+
<label><input type="checkbox" id="filterSkipped" checked> Skipped</label>
|
|
443
|
+
</div>
|
|
444
|
+
<div class="dark-mode-toggle" id="darkModeToggle">☀️</div>
|
|
445
|
+
</div>
|
|
446
|
+
</div>
|
|
447
|
+
|
|
448
|
+
<div id="overview" class="tab-content active">
|
|
449
|
+
<div class="card">
|
|
450
|
+
<h2>Key Performance Indicators</h2>
|
|
451
|
+
<div class="kpi-grid" id="kpiGrid"></div>
|
|
452
|
+
</div>
|
|
453
|
+
<div class="card">
|
|
454
|
+
<h2>Execution Metadata</h2>
|
|
455
|
+
<div class="metadata-grid" id="metadataGrid"></div>
|
|
456
|
+
</div>
|
|
457
|
+
</div>
|
|
458
|
+
|
|
459
|
+
<div id="categories" class="tab-content">
|
|
460
|
+
<div class="card">
|
|
461
|
+
<h2>Failed Test Categories</h2>
|
|
462
|
+
<div id="categoryList"></div>
|
|
463
|
+
</div>
|
|
464
|
+
</div>
|
|
465
|
+
|
|
466
|
+
<div id="suites" class="tab-content">
|
|
467
|
+
<div class="card">
|
|
468
|
+
<h2 id="suitesHeader">Suites & Tests</h2>
|
|
469
|
+
<div id="suitesContainer"></div>
|
|
470
|
+
</div>
|
|
471
|
+
</div>
|
|
472
|
+
|
|
473
|
+
<div id="graphs" class="tab-content">
|
|
474
|
+
<div class="card">
|
|
475
|
+
<h2>Test Graphs & Trends</h2>
|
|
476
|
+
<div style="display: flex; flex-wrap: wrap; gap: 24px; align-items: flex-start;">
|
|
477
|
+
<div style="flex: 1 1 300px; min-width: 300px;">
|
|
478
|
+
<h3>Pass/Fail/Skip</h3>
|
|
479
|
+
<div class="chart-container" style="height: 300px;">
|
|
480
|
+
<canvas id="statusChart"></canvas>
|
|
481
|
+
<div>Loading Chart...</div>
|
|
482
|
+
</div>
|
|
483
|
+
</div>
|
|
484
|
+
<div style="flex: 1 1 400px; min-width: 400px;">
|
|
485
|
+
<h3>Duration Distribution</h3>
|
|
486
|
+
<div class="chart-container" style="height: 300px;">
|
|
487
|
+
<canvas id="durationHistogram"></canvas>
|
|
488
|
+
<div>Loading Chart...</div>
|
|
489
|
+
</div>
|
|
490
|
+
</div>
|
|
491
|
+
<div style="flex: 1 1 100%; min-width: 400px;">
|
|
492
|
+
<h3>Pass Rate Trend</h3>
|
|
493
|
+
<div class="chart-container" style="height: 300px;">
|
|
494
|
+
<canvas id="historyChart"></canvas>
|
|
495
|
+
<div>Loading Chart...</div>
|
|
496
|
+
</div>
|
|
497
|
+
</div>
|
|
498
|
+
</div>
|
|
499
|
+
</div>
|
|
500
|
+
</div>
|
|
501
|
+
|
|
502
|
+
<div id="timeline" class="tab-content">
|
|
503
|
+
<div class="card">
|
|
504
|
+
<h2>Test Execution Timeline</h2>
|
|
505
|
+
<div id="timelineContainer" class="chart-container" style="height: 600px;">
|
|
506
|
+
<canvas id="timelineChart"></canvas>
|
|
507
|
+
<div>Loading Chart...</div>
|
|
508
|
+
</div>
|
|
509
|
+
</div>
|
|
510
|
+
</div>
|
|
511
|
+
|
|
512
|
+
<div id="history" class="tab-content">
|
|
513
|
+
<div class="card">
|
|
514
|
+
<h2 id="historyRunsTitle">Historical Runs</h2>
|
|
515
|
+
<div id="historyGraphContainer" class="chart-container" style="height: 400px;">
|
|
516
|
+
<canvas id="historicalRunsChart"></canvas>
|
|
517
|
+
<div>Loading Chart...</div>
|
|
518
|
+
</div>
|
|
519
|
+
</div>
|
|
520
|
+
</div>
|
|
521
|
+
|
|
522
|
+
</div>
|
|
523
|
+
</div>
|
|
524
|
+
|
|
525
|
+
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
526
|
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
|
|
527
|
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
|
|
528
|
+
|
|
529
|
+
<script>
|
|
530
|
+
const reportData = ${safeReportJson};
|
|
531
|
+
|
|
532
|
+
(function() {
|
|
533
|
+
const root = document.documentElement;
|
|
534
|
+
const darkModeToggle = document.getElementById('darkModeToggle');
|
|
535
|
+
const renderedTabs = new Set();
|
|
536
|
+
|
|
537
|
+
function applyTheme(theme) {
|
|
538
|
+
if (theme === 'dark') {
|
|
539
|
+
root.setAttribute('data-theme', 'dark');
|
|
540
|
+
darkModeToggle.textContent = '🌙';
|
|
541
|
+
} else {
|
|
542
|
+
root.setAttribute('data-theme', 'light');
|
|
543
|
+
darkModeToggle.textContent = '☀️';
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function toggleTheme() {
|
|
548
|
+
const currentTheme = root.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
|
|
549
|
+
localStorage.setItem('theme', currentTheme);
|
|
550
|
+
applyTheme(currentTheme);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
darkModeToggle.addEventListener('click', toggleTheme);
|
|
554
|
+
|
|
555
|
+
const savedTheme = localStorage.getItem('theme');
|
|
556
|
+
if (savedTheme) {
|
|
557
|
+
applyTheme(savedTheme);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const tabs = document.querySelectorAll('.sidebar-menu-item a');
|
|
561
|
+
const tabContents = document.querySelectorAll('.tab-content');
|
|
562
|
+
|
|
563
|
+
tabs.forEach(tab => {
|
|
564
|
+
tab.addEventListener('click', (e) => {
|
|
565
|
+
e.preventDefault();
|
|
566
|
+
const targetId = e.target.getAttribute('data-tab');
|
|
567
|
+
tabs.forEach(t => t.classList.remove('active'));
|
|
568
|
+
e.target.classList.add('active');
|
|
569
|
+
tabContents.forEach(c => c.classList.remove('active'));
|
|
570
|
+
document.getElementById(targetId).classList.add('active');
|
|
571
|
+
if (renderedTabs.has(targetId)) return;
|
|
572
|
+
if (targetId === 'graphs') { renderCharts(); renderedTabs.add(targetId); }
|
|
573
|
+
if (targetId === 'timeline') { renderTimeline(); renderedTabs.add(targetId); }
|
|
574
|
+
if (targetId === 'history') { renderHistoryGraph(); renderedTabs.add(targetId); }
|
|
575
|
+
});
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
function renderOverview() {
|
|
579
|
+
const { stats, metadata } = reportData;
|
|
580
|
+
const kpiGrid = document.getElementById('kpiGrid');
|
|
581
|
+
kpiGrid.innerHTML = \`
|
|
582
|
+
<div class="kpi-card kpi-total"><div class="kpi-value">\${stats.total}</div><div class="kpi-label">Total Tests</div></div>
|
|
583
|
+
<div class="kpi-card kpi-passed"><div class="kpi-value">\${stats.passed}</div><div class="kpi-label">Passed</div></div>
|
|
584
|
+
<div class="kpi-card kpi-failed"><div class="kpi-value">\${stats.failed}</div><div class="kpi-label">Failed</div></div>
|
|
585
|
+
<div class="kpi-card kpi-skipped"><div class="kpi-value">\${stats.skipped}</div><div class="kpi-label">Skipped</div></div>
|
|
586
|
+
<div class="kpi-card kpi-pass-rate"><div class="kpi-value">\${stats.passRate}</div><div class="kpi-label">Pass Rate</div></div>
|
|
587
|
+
<div class="kpi-card kpi-duration"><div class="kpi-value">\${stats.totalDuration}</div><div class="kpi-label">Total Duration</div></div>
|
|
588
|
+
\`;
|
|
589
|
+
|
|
590
|
+
const metadataGrid = document.getElementById('metadataGrid');
|
|
591
|
+
metadataGrid.innerHTML = \`
|
|
592
|
+
<div class="metadata-item"><span class="metadata-label">Start Time</span><span class="metadata-value">\${metadata.startTime}</span></div>
|
|
593
|
+
<div class="metadata-item"><span class="metadata-label">Total Duration</span><span class="metadata-value">\${metadata.totalDuration}</span></div>
|
|
594
|
+
<div class="metadata-item"><span class="metadata-label">Browser</span><span class="metadata-value">\${metadata.browser}</span></div>
|
|
595
|
+
<div class="metadata-item"><span class="metadata-label">Operating System</span><span class="metadata-value">\${metadata.os}</span></div>
|
|
596
|
+
\`;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function renderCategories() {
|
|
600
|
+
const { categories } = reportData;
|
|
601
|
+
const categoryList = document.getElementById('categoryList');
|
|
602
|
+
if (!categories || categories.length === 0) {
|
|
603
|
+
categoryList.innerHTML = '<p>No failed tests categorized.</p>';
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
categoryList.innerHTML = categories.map(cat => \`
|
|
607
|
+
<div class="card">
|
|
608
|
+
<div class="category-header">
|
|
609
|
+
<span>\${cat.name}</span>
|
|
610
|
+
<span class="status-badge status-failed">\${cat.count} failed</span>
|
|
611
|
+
</div>
|
|
612
|
+
<div class="category-content" style="display: none;">
|
|
613
|
+
\${cat.tests.map(test => \`
|
|
614
|
+
<div class="category-test">
|
|
615
|
+
<strong>\${test.title}</strong>
|
|
616
|
+
<pre>\${test.trace}</pre>
|
|
617
|
+
</div>
|
|
618
|
+
\`).join('')}
|
|
619
|
+
</div>
|
|
620
|
+
</div>
|
|
621
|
+
\`).join('');
|
|
622
|
+
|
|
623
|
+
document.querySelectorAll('.category-header').forEach(header => {
|
|
624
|
+
header.addEventListener('click', () => {
|
|
625
|
+
const content = header.nextElementSibling;
|
|
626
|
+
content.style.display = content.style.display === 'none' ? 'block' : 'none';
|
|
627
|
+
});
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function renderSuites() {
|
|
632
|
+
const { suites, stats } = reportData;
|
|
633
|
+
const suitesContainer = document.getElementById('suitesContainer');
|
|
634
|
+
const suitesHeader = document.getElementById('suitesHeader');
|
|
635
|
+
|
|
636
|
+
suitesHeader.innerHTML = \`
|
|
637
|
+
Suites & Tests
|
|
638
|
+
<div class="header-stats">
|
|
639
|
+
<span class="status-badge status-passed">\${stats.passed} Passed</span>
|
|
640
|
+
<span class="status-badge status-failed">\${stats.failed} Failed</span>
|
|
641
|
+
<span class="status-badge status-skipped">\${stats.skipped} Skipped</span>
|
|
642
|
+
</div>
|
|
643
|
+
\`;
|
|
644
|
+
|
|
645
|
+
suitesContainer.innerHTML = suites.map(suite => \`
|
|
646
|
+
<div class="card">
|
|
647
|
+
<div class="suite-header">
|
|
648
|
+
<svg class="collapse-icon" fill="currentColor" viewBox="0 0 20 20" style="width:20px; height:20px;"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"></path></svg>
|
|
649
|
+
\${suite.title}
|
|
650
|
+
</div>
|
|
651
|
+
<div class="suite-content" style="display: none;">
|
|
652
|
+
\${suite.specs.flatMap(spec =>
|
|
653
|
+
spec.tests.map(test => \`
|
|
654
|
+
<div class="test-card">
|
|
655
|
+
<div class="test-card-header">
|
|
656
|
+
<span style="font-weight: 500;">\${spec.title}</span>
|
|
657
|
+
<span class="status-badge status-\${test.status}">\${test.status}</span>
|
|
658
|
+
<span>\${(test.duration / 1000).toFixed(2)}s</span>
|
|
659
|
+
<span>\${test.history.map(h => \`<span class="dot \${h}"></span>\`).join('')}</span>
|
|
660
|
+
</div>
|
|
661
|
+
\${test.logs ? \`
|
|
662
|
+
<div class="log-row" style="display: none;">
|
|
663
|
+
<div class="log-container">
|
|
664
|
+
<h4>Logs</h4>
|
|
665
|
+
<pre>\${test.logs.replace(/</g, "<").replace(/>/g, ">")}</pre>
|
|
666
|
+
</div>
|
|
667
|
+
</div>\` : ''}
|
|
668
|
+
</div>
|
|
669
|
+
\`)
|
|
670
|
+
).join('')}
|
|
671
|
+
</div>
|
|
672
|
+
</div>
|
|
673
|
+
\`).join('');
|
|
674
|
+
|
|
675
|
+
document.querySelectorAll('.suite-header').forEach(header => {
|
|
676
|
+
header.addEventListener('click', () => {
|
|
677
|
+
const content = header.nextElementSibling;
|
|
678
|
+
const icon = header.querySelector('.collapse-icon');
|
|
679
|
+
const isExpanded = content.style.display === 'block';
|
|
680
|
+
content.style.display = isExpanded ? 'none' : 'block';
|
|
681
|
+
icon.classList.toggle('expanded', !isExpanded);
|
|
682
|
+
});
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
document.querySelectorAll('.test-card-header').forEach(row => {
|
|
686
|
+
row.addEventListener('click', (e) => {
|
|
687
|
+
const logRow = row.nextElementSibling;
|
|
688
|
+
if (logRow && logRow.classList.contains('log-row')) {
|
|
689
|
+
logRow.style.display = logRow.style.display === 'none' ? 'block' : 'none';
|
|
690
|
+
}
|
|
691
|
+
});
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
let statusChart, durationChart, historyChart, timelineChart, historicalRunsChart;
|
|
696
|
+
|
|
697
|
+
function renderCharts() {
|
|
698
|
+
const statusCtx = document.getElementById('statusChart');
|
|
699
|
+
statusCtx.nextElementSibling.style.display = 'none';
|
|
700
|
+
statusChart = new Chart(statusCtx, {
|
|
701
|
+
type: 'doughnut',
|
|
702
|
+
data: {
|
|
703
|
+
labels: ['Passed', 'Failed', 'Skipped'],
|
|
704
|
+
datasets: [{
|
|
705
|
+
data: [reportData.stats.passed, reportData.stats.failed, reportData.stats.skipped],
|
|
706
|
+
backgroundColor: ['#4CAF50', '#F44336', '#9E9E9E'],
|
|
707
|
+
}]
|
|
708
|
+
},
|
|
709
|
+
options: { responsive: true, maintainAspectRatio: false, animation: { animateScale: true } }
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
// Duration Histogram
|
|
713
|
+
const histogramCtx = document.getElementById('durationHistogram');
|
|
714
|
+
histogramCtx.nextElementSibling.style.display = 'none';
|
|
715
|
+
const durations = reportData.suites.flatMap(s => s.specs.flatMap(sp => sp.tests.map(t => t.duration / 1000)));
|
|
716
|
+
const labels = ['<1s', '1-2s', '2-5s', '5-10s', '10-30s', '30-60s', '>60s'];
|
|
717
|
+
const data = new Array(labels.length).fill(0);
|
|
718
|
+
|
|
719
|
+
durations.forEach(d => {
|
|
720
|
+
if (d < 1) data[0]++;
|
|
721
|
+
else if (d < 2) data[1]++;
|
|
722
|
+
else if (d < 5) data[2]++;
|
|
723
|
+
else if (d < 10) data[3]++;
|
|
724
|
+
else if (d < 30) data[4]++;
|
|
725
|
+
else if (d < 60) data[5]++;
|
|
726
|
+
else data[6]++;
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
new Chart(histogramCtx, {
|
|
730
|
+
type: 'bar',
|
|
731
|
+
data: {
|
|
732
|
+
labels: labels,
|
|
733
|
+
datasets: [{
|
|
734
|
+
label: 'Number of Tests',
|
|
735
|
+
data: data,
|
|
736
|
+
backgroundColor: 'rgba(153, 102, 255, 0.6)',
|
|
737
|
+
}]
|
|
738
|
+
},
|
|
739
|
+
options: {
|
|
740
|
+
responsive: true,
|
|
741
|
+
maintainAspectRatio: false,
|
|
742
|
+
scales: { y: { beginAtZero: true, title: { display: true, text: 'Test Count' } } }
|
|
743
|
+
}
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
const historyCtx = document.getElementById('historyChart');
|
|
748
|
+
historyCtx.nextElementSibling.style.display = 'none';
|
|
749
|
+
historyChart = new Chart(historyCtx, {
|
|
750
|
+
type: 'line',
|
|
751
|
+
data: {
|
|
752
|
+
labels: reportData.history.map(h => h.date),
|
|
753
|
+
datasets: [{
|
|
754
|
+
label: 'Pass Rate (%)',
|
|
755
|
+
data: reportData.history.map(h => h.passRate),
|
|
756
|
+
borderColor: 'rgba(75, 192, 192, 1)',
|
|
757
|
+
fill: false,
|
|
758
|
+
tension: 0.1
|
|
759
|
+
}]
|
|
760
|
+
},
|
|
761
|
+
options: { responsive: true, maintainAspectRatio: false, scales: { y: { beginAtZero: true, max: 100 } } }
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
function renderTimeline() {
|
|
766
|
+
const timelineCtx = document.getElementById('timelineChart');
|
|
767
|
+
timelineCtx.nextElementSibling.style.display = 'none';
|
|
768
|
+
|
|
769
|
+
// Transform timeline data for Chart.js gantt-like chart
|
|
770
|
+
const labels = reportData.timeline.map(t => t.name);
|
|
771
|
+
const datasets = [{
|
|
772
|
+
label: 'Execution Time',
|
|
773
|
+
data: reportData.timeline.map(t => [t.start, t.start + t.duration]),
|
|
774
|
+
backgroundColor: reportData.timeline.map(t => {
|
|
775
|
+
if (t.status === 'passed') return 'rgba(76, 175, 80, 0.8)';
|
|
776
|
+
if (t.status === 'failed') return 'rgba(244, 67, 54, 0.8)';
|
|
777
|
+
return 'rgba(158, 158, 158, 0.8)';
|
|
778
|
+
}),
|
|
779
|
+
}];
|
|
780
|
+
|
|
781
|
+
// Only render chart if there is data
|
|
782
|
+
if (reportData.timeline.length > 0) {
|
|
783
|
+
timelineCtx.parentElement.style.height = (reportData.timeline.length * 30 + 100) + 'px'; // Dynamic height based on number of tests
|
|
784
|
+
timelineChart = new Chart(timelineCtx, {
|
|
785
|
+
type: 'bar',
|
|
786
|
+
data: {
|
|
787
|
+
labels: labels,
|
|
788
|
+
datasets: datasets
|
|
789
|
+
},
|
|
790
|
+
options: {
|
|
791
|
+
indexAxis: 'y',
|
|
792
|
+
responsive: true,
|
|
793
|
+
maintainAspectRatio: false,
|
|
794
|
+
plugins: { legend: { display: false } },
|
|
795
|
+
scales: {
|
|
796
|
+
x: { min: 0, title: { display: true, text: 'Time (ms)' } },
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
});
|
|
800
|
+
} else {
|
|
801
|
+
timelineCtx.parentElement.innerHTML = '<div>No timeline data available.</div>';
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
function renderHistoryGraph() {
|
|
806
|
+
const history = reportData.history;
|
|
807
|
+
document.getElementById('historyRunsTitle').textContent = \`Historical Runs (Count: \${history.length})\`;
|
|
808
|
+
const historyCtx = document.getElementById('historicalRunsChart');
|
|
809
|
+
historyCtx.nextElementSibling.style.display = 'none';
|
|
810
|
+
|
|
811
|
+
// Only render chart if there is data
|
|
812
|
+
if (history.length > 0) {
|
|
813
|
+
historicalRunsChart = new Chart(historyCtx, {
|
|
814
|
+
type: 'bar',
|
|
815
|
+
data: {
|
|
816
|
+
labels: history.map(h => h.date),
|
|
817
|
+
datasets: [
|
|
818
|
+
{
|
|
819
|
+
type: 'line',
|
|
820
|
+
label: 'Pass Rate',
|
|
821
|
+
data: history.map(h => h.passRate),
|
|
822
|
+
borderColor: 'rgb(255, 159, 64)',
|
|
823
|
+
yAxisID: 'y1',
|
|
824
|
+
tension: 0.1,
|
|
825
|
+
pointRadius: 5,
|
|
826
|
+
pointBackgroundColor: 'rgb(255, 159, 64)',
|
|
827
|
+
order: 0, // Draw line on top of bars
|
|
828
|
+
},
|
|
829
|
+
{
|
|
830
|
+
type: 'bar',
|
|
831
|
+
label: 'Passed',
|
|
832
|
+
data: history.map(h => h.passed),
|
|
833
|
+
backgroundColor: 'rgba(75, 192, 192, 0.8)',
|
|
834
|
+
order: 1,
|
|
835
|
+
},
|
|
836
|
+
{
|
|
837
|
+
type: 'bar',
|
|
838
|
+
label: 'Failed',
|
|
839
|
+
data: history.map(h => h.failed),
|
|
840
|
+
backgroundColor: 'rgba(255, 99, 132, 0.8)',
|
|
841
|
+
order: 1,
|
|
842
|
+
},
|
|
843
|
+
{
|
|
844
|
+
type: 'bar',
|
|
845
|
+
label: 'Skipped',
|
|
846
|
+
data: history.map(h => h.skipped),
|
|
847
|
+
backgroundColor: 'rgba(158, 158, 158, 0.8)',
|
|
848
|
+
order: 1,
|
|
849
|
+
}
|
|
850
|
+
]
|
|
851
|
+
},
|
|
852
|
+
options: {
|
|
853
|
+
responsive: true,
|
|
854
|
+
maintainAspectRatio: false,
|
|
855
|
+
scales: {
|
|
856
|
+
x: { stacked: true, },
|
|
857
|
+
y: {
|
|
858
|
+
stacked: true,
|
|
859
|
+
beginAtZero: true,
|
|
860
|
+
title: { display: true, text: 'Test Count' }
|
|
861
|
+
},
|
|
862
|
+
y1: {
|
|
863
|
+
position: 'right',
|
|
864
|
+
beginAtZero: true,
|
|
865
|
+
max: 100,
|
|
866
|
+
title: { display: true, text: 'Pass Rate (%)' },
|
|
867
|
+
grid: { drawOnChartArea: false }
|
|
868
|
+
}
|
|
869
|
+
},
|
|
870
|
+
tooltips: {
|
|
871
|
+
mode: 'index',
|
|
872
|
+
intersect: false
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
});
|
|
876
|
+
} else {
|
|
877
|
+
historyCtx.parentElement.innerHTML = '<div>No historical runs data available.</div>';
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
const filterPassed = document.getElementById('filterPassed');
|
|
882
|
+
const filterFailed = document.getElementById('filterFailed');
|
|
883
|
+
const filterSkipped = document.getElementById('filterSkipped');
|
|
884
|
+
|
|
885
|
+
function applyFilters() {
|
|
886
|
+
const showPassed = filterPassed.checked;
|
|
887
|
+
const showFailed = filterFailed.checked;
|
|
888
|
+
const showSkipped = filterSkipped.checked;
|
|
889
|
+
|
|
890
|
+
// Hide all log rows initially when filters change
|
|
891
|
+
document.querySelectorAll('.log-row').forEach(logRow => {
|
|
892
|
+
logRow.style.display = 'none';
|
|
893
|
+
});
|
|
894
|
+
|
|
895
|
+
document.querySelectorAll('.test-card').forEach(card => {
|
|
896
|
+
const header = card.querySelector('.test-card-header');
|
|
897
|
+
const status = header.querySelector('.status-badge').textContent.toLowerCase();
|
|
898
|
+
let display = false;
|
|
899
|
+
if (status === 'passed' && showPassed) display = true;
|
|
900
|
+
if (status === 'failed' && showFailed) display = true;
|
|
901
|
+
if (status === 'skipped' && showSkipped) display = true;
|
|
902
|
+
card.style.display = display ? '' : 'none';
|
|
903
|
+
|
|
904
|
+
// Hide the parent suite card if all its tests are hidden
|
|
905
|
+
let parentSuiteContent = card.closest('.suite-content');
|
|
906
|
+
if (parentSuiteContent) {
|
|
907
|
+
let visibleTests = Array.from(parentSuiteContent.querySelectorAll('.test-card')).filter(t => t.style.display !== 'none');
|
|
908
|
+
let parentCard = parentSuiteContent.closest('.card');
|
|
909
|
+
if (parentCard) {
|
|
910
|
+
parentCard.style.display = visibleTests.length > 0 ? '' : 'none';
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
filterPassed.addEventListener('change', applyFilters);
|
|
916
|
+
filterFailed.addEventListener('change', applyFilters);
|
|
917
|
+
filterSkipped.addEventListener('change', applyFilters);
|
|
918
|
+
|
|
919
|
+
document.getElementById('downloadHtml').addEventListener('click', () => {
|
|
920
|
+
const htmlContent = '<!DOCTYPE html>' + document.documentElement.outerHTML;
|
|
921
|
+
const blob = new Blob([htmlContent], { type: 'text/html' });
|
|
922
|
+
const a = document.createElement('a');
|
|
923
|
+
a.href = URL.createObjectURL(blob);
|
|
924
|
+
a.download = 'report.html';
|
|
925
|
+
a.click();
|
|
926
|
+
URL.revokeObjectURL(a.href);
|
|
927
|
+
});
|
|
928
|
+
document.getElementById('downloadJson').addEventListener('click', () => {
|
|
929
|
+
const jsonContent = JSON.stringify(reportData, null, 2);
|
|
930
|
+
const blob = new Blob([jsonContent], { type: 'application/json' });
|
|
931
|
+
const a = document.createElement('a');
|
|
932
|
+
a.href = URL.createObjectURL(blob);
|
|
933
|
+
a.download = 'report-data.json';
|
|
934
|
+
a.click();
|
|
935
|
+
URL.revokeObjectURL(a.href);
|
|
936
|
+
});
|
|
937
|
+
document.getElementById('downloadCsv').addEventListener('click', () => {
|
|
938
|
+
let csvContent = "data:text/csv;charset=utf-8,Suite,Test,Status,Duration (s),History\\n";
|
|
939
|
+
reportData.suites.forEach(suite => {
|
|
940
|
+
suite.specs.forEach(spec => {
|
|
941
|
+
const testData = spec.tests && spec.tests[0];
|
|
942
|
+
const status = testData ? testData.status : 'N/A';
|
|
943
|
+
const duration = testData ? (testData.duration / 1000).toFixed(2) : 'N/A';
|
|
944
|
+
const history = testData && testData.history ? testData.history.join(" | ") : 'N/A';
|
|
945
|
+
|
|
946
|
+
const row = [\`"\${suite.title.replace(/"/g,'""')}"\`,\`"\${spec.title.replace(/"/g,'""')}"\`,status,duration,history].join(",");
|
|
947
|
+
csvContent += row + '\\n';
|
|
948
|
+
});
|
|
949
|
+
});
|
|
950
|
+
const encodedUri = encodeURI(csvContent);
|
|
951
|
+
const a = document.createElement('a'); a.href = encodedUri; a.download = 'test_report.csv'; a.click();
|
|
952
|
+
});
|
|
953
|
+
document.getElementById('downloadPdf').addEventListener('click', () => {
|
|
954
|
+
const doc = new window.jspdf.jsPDF();
|
|
955
|
+
const element = document.querySelector('.main-content');
|
|
956
|
+
html2canvas(element, { scale: 2 }).then(canvas => {
|
|
957
|
+
const imgData = canvas.toDataURL('image/png');
|
|
958
|
+
const imgWidth = 210; const pageHeight = 295;
|
|
959
|
+
const imgHeight = canvas.height * imgWidth / canvas.width;
|
|
960
|
+
let heightLeft = imgHeight; let position = 0;
|
|
961
|
+
doc.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight);
|
|
962
|
+
heightLeft -= pageHeight;
|
|
963
|
+
while (heightLeft >= 0) {
|
|
964
|
+
position = heightLeft - imgHeight;
|
|
965
|
+
doc.addPage();
|
|
966
|
+
doc.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight);
|
|
967
|
+
}
|
|
968
|
+
doc.save('report.pdf');
|
|
969
|
+
});
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
renderOverview();
|
|
973
|
+
renderCategories();
|
|
974
|
+
renderSuites();
|
|
975
|
+
applyFilters();
|
|
976
|
+
|
|
977
|
+
})();
|
|
978
|
+
</script>
|
|
979
|
+
</body>
|
|
980
|
+
</html>
|
|
981
|
+
`;
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
module.exports = AggregatorReporter;
|
package/package.json
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "slik-report",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"description": "A powerful Playwright reporter for aggregating test results, flakiness analysis, and historical trend tracking.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
7
7
|
"scripts": {
|
|
8
8
|
"build": "npx tsc",
|
|
9
|
+
"prebuild": "npm run test",
|
|
10
|
+
"test": "jest",
|
|
9
11
|
"prepublishOnly": "npm run build"
|
|
10
12
|
},
|
|
11
13
|
"keywords": [
|
|
@@ -18,13 +20,20 @@
|
|
|
18
20
|
"author": "Your Name Here",
|
|
19
21
|
"license": "ISC",
|
|
20
22
|
"files": [
|
|
21
|
-
"dist"
|
|
23
|
+
"dist/index.js",
|
|
24
|
+
"dist/index.d.ts",
|
|
25
|
+
"README.md"
|
|
22
26
|
],
|
|
23
27
|
"devDependencies": {
|
|
24
|
-
"@
|
|
28
|
+
"@playwright/test": "^1.57.0",
|
|
29
|
+
"@types/jest": "^30.0.0",
|
|
30
|
+
"@types/node": "^20.19.25",
|
|
31
|
+
"jest": "^30.2.0",
|
|
32
|
+
"slik-report": "^1.0.1",
|
|
33
|
+
"ts-jest": "^29.4.5",
|
|
25
34
|
"typescript": "^5.3.3"
|
|
26
35
|
},
|
|
27
36
|
"peerDependencies": {
|
|
28
|
-
"playwright": "
|
|
37
|
+
"playwright": "^1.57.0"
|
|
29
38
|
}
|
|
30
39
|
}
|
package/dist/index.js.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":""}
|