slik-report 1.0.2 → 1.0.3

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 (2) hide show
  1. package/dist/index.js +585 -327
  2. package/package.json +2 -3
package/dist/index.js CHANGED
@@ -25,19 +25,24 @@ function stripAnsi(str) {
25
25
  const ansiRegex = /[\u001b\u009b][[()#;?]*.{0,2}(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g;
26
26
  return str.replace(ansiRegex, '');
27
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
28
  function escapeJsonForHtml(jsonString) {
33
29
  return jsonString.replace(/<\/script>/g, '<\\/script>');
34
30
  }
31
+ function calculateStdDev(values) {
32
+ if (values.length < 2)
33
+ return 0;
34
+ const mean = values.reduce((sum, v) => sum + v, 0) / values.length;
35
+ const variance = values.reduce((sum, v) => sum + Math.pow(v - mean, 2), 0) / (values.length - 1);
36
+ return Math.sqrt(variance);
37
+ }
35
38
  class AggregatorReporter {
36
39
  constructor(opts) {
37
40
  this.options = opts || {};
38
41
  this.input = (this.options.input) || './playwright-report/report.json';
39
42
  this.outputHtml = (this.options.output) || './summary-report.html';
40
43
  this.historyDir = (this.options.history) || './reports/reports_history';
44
+ // New Option: Path to external HTML report
45
+ this.anotherHTML = (this.options.externalHtml) || './playwright-report/reports/index.html';
41
46
  }
42
47
  async onEnd(result) {
43
48
  try {
@@ -48,12 +53,21 @@ class AggregatorReporter {
48
53
  }
49
54
  console.log('AggregatorReporter: JSON file found. Reading file...');
50
55
  const report = JSON.parse(fs.readFileSync(this.input, 'utf8'));
51
- console.log('AggregatorReporter: JSON data read. Processing and generating report...');
56
+ // Check if external HTML exists
57
+ const hasExternalHtml = fs.existsSync(this.anotherHTML);
58
+ // Ensure we use a relative path for the HTML src attribute
59
+ const externalHtmlPath = this.anotherHTML;
60
+ console.log('AggregatorReporter: JSON data read. Processing...');
52
61
  const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
53
62
  const historyFile = path.join(this.historyDir, `run-${timestamp}.json`);
54
63
  writeFile(historyFile, safeJson(report));
55
64
  const history = await this.loadHistory();
56
65
  const processedReport = this.processReportWithHistory(report, history);
66
+ // Inject external HTML config
67
+ processedReport.config = {
68
+ hasExternalHtml: hasExternalHtml,
69
+ externalHtmlPath: externalHtmlPath
70
+ };
57
71
  const htmlContent = this.generateReportHtml(processedReport);
58
72
  writeFile(this.outputHtml, htmlContent);
59
73
  console.log('AggregatorReporter: Report generation successful!');
@@ -62,39 +76,22 @@ class AggregatorReporter {
62
76
  console.error('AggregatorReporter failed:', e);
63
77
  }
64
78
  }
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
79
  getTestSuites(report) {
70
80
  if (!report)
71
81
  return [];
72
- // 1. Standard Playwright structure
73
- if (report.suites && Array.isArray(report.suites)) {
82
+ if (report.suites && Array.isArray(report.suites))
74
83
  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
+ if (report.specs && Array.isArray(report.specs))
85
+ return [{ title: 'Root (Specs)', suites: [], specs: report.specs, file: 'N/A' }];
86
+ // Handle flattened 'tests' structure if strictly flat
87
+ if (report.tests && Array.isArray(report.tests)) {
84
88
  return [{
85
- title: 'Root Suite (Flat Tests)',
89
+ title: 'Root (Flat Tests)',
86
90
  suites: [],
87
- specs: report.tests.map(t => ({
88
- title: t.title,
89
- file: t.file,
90
- tests: [t]
91
- })),
91
+ specs: report.tests.map(t => ({ title: t.title, file: t.file, tests: [t] })),
92
92
  file: 'N/A'
93
93
  }];
94
94
  }
95
- if (report.stats) {
96
- console.warn('AggregatorReporter: Could not find "suites" or alternative test structure. Returning empty suites list.');
97
- }
98
95
  return [];
99
96
  }
100
97
  findAllSpecs(suites) {
@@ -104,9 +101,8 @@ class AggregatorReporter {
104
101
  for (const suite of suites) {
105
102
  if (suite.specs)
106
103
  specs.push(...suite.specs);
107
- if (suite.suites) {
104
+ if (suite.suites)
108
105
  specs.push(...this.findAllSpecs(suite.suites));
109
- }
110
106
  }
111
107
  return specs;
112
108
  }
@@ -122,16 +118,8 @@ class AggregatorReporter {
122
118
  const filePath = path.join(this.historyDir, file);
123
119
  const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
124
120
  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
- });
121
+ const validIso = dateStr.slice(0, 10) + 'T' + dateStr.slice(11, 13) + ':' + dateStr.slice(14, 16) + ':' + dateStr.slice(17, 19) + '.' + dateStr.slice(20);
122
+ history.push({ date: new Date(validIso).toLocaleDateString(), report: data });
135
123
  }
136
124
  catch (e) {
137
125
  console.warn(`Could not read history file: ${file}`, e);
@@ -141,56 +129,110 @@ class AggregatorReporter {
141
129
  }
142
130
  processReportWithHistory(report, history) {
143
131
  const stats = this.processStats(report);
132
+ const historyData = this.processHistoryData(history, report);
144
133
  const suites = this.processSuites(report, history);
145
134
  const categories = this.processCategories(report);
146
- const historyData = this.processHistoryData(history, report);
147
135
  const timeline = this.processTimeline(report);
148
136
  const metadata = this.processMetadata(report);
149
- return {
150
- stats,
151
- suites,
152
- categories,
153
- history: historyData,
154
- timeline,
155
- metadata
156
- };
137
+ const tagStats = this.processTags(report); // New Tag Processor
138
+ // KPI Calculations
139
+ const lastFivePassRates = historyData.slice(-5).map(h => h.passRate);
140
+ stats.passRateConsistency = calculateStdDev(lastFivePassRates).toFixed(2);
141
+ let flakyCount = 0;
142
+ let failedAndReproduced = 0;
143
+ suites.forEach(suite => {
144
+ suite.specs.forEach(spec => {
145
+ const test = spec.tests?.[0];
146
+ if (test) {
147
+ if (test.flaky)
148
+ flakyCount++;
149
+ if (test.status === 'failed' && test.previousStatus === 'failed')
150
+ failedAndReproduced++;
151
+ }
152
+ });
153
+ });
154
+ const totalTests = stats.total;
155
+ stats.flakyCount = flakyCount;
156
+ stats.flakyPercentage = totalTests > 0 ? ((flakyCount / totalTests) * 100).toFixed(2) + '%' : 'N/A';
157
+ const currentFailedTests = stats.failed;
158
+ stats.failureReproducibility = currentFailedTests > 0
159
+ ? ((failedAndReproduced / currentFailedTests) * 100).toFixed(2) + '%'
160
+ : 'N/A';
161
+ return { stats, suites, categories, history: historyData, timeline, metadata, tagStats };
157
162
  }
158
163
  processStats(report) {
159
164
  const stats = report.stats || {};
160
165
  const { expected = 0, unexpected = 0, skipped = 0, duration = 0 } = stats;
166
+ let retries = 0;
167
+ const allSpecs = this.findAllSpecs(this.getTestSuites(report));
168
+ allSpecs.forEach(spec => {
169
+ if (spec.tests) {
170
+ spec.tests.forEach(test => { if (test.results)
171
+ retries += Math.max(0, test.results.length - 1); });
172
+ }
173
+ });
161
174
  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
175
  return {
165
- total,
166
- passed: expected,
167
- failed: unexpected,
168
- skipped,
169
- passRate,
170
- avgDuration,
171
- totalDuration: `${(duration / 1000).toFixed(2)}s`
176
+ total, passed: expected, failed: unexpected, skipped, retries,
177
+ passRate: total > 0 ? `${((expected / total) * 100).toFixed(0)}%` : 'N/A',
178
+ avgDuration: total > 0 ? `${(duration / total / 1000).toFixed(2)}s` : 'N/A',
179
+ totalDuration: `${(duration / 1000).toFixed(2)}s`,
180
+ passRateConsistency: 'N/A', flakyPercentage: 'N/A', failureReproducibility: 'N/A',
172
181
  };
173
182
  }
174
183
  processMetadata(report) {
175
- // Safely access project name from the projects array
176
184
  const projectName = report.config?.projects?.[0]?.name || report.config?.metadata?.project || 'N/A';
177
185
  return {
178
186
  browser: projectName,
179
- os: report.config?.metadata?.platform || 'N/A', // Using 'platform' if available in ortoni/custom meta
187
+ os: report.config?.metadata?.platform || 'N/A',
180
188
  startTime: report.stats?.startTime ? new Date(report.stats.startTime).toLocaleString() : 'N/A',
181
189
  totalDuration: report.stats?.duration ? `${(report.stats.duration / 1000).toFixed(2)}s` : 'N/A'
182
190
  };
183
191
  }
192
+ processTags(report) {
193
+ const tagMap = {};
194
+ const allSpecs = this.findAllSpecs(this.getTestSuites(report));
195
+ allSpecs.forEach(spec => {
196
+ // Collect tags from spec or test level
197
+ const tags = spec.tags || [];
198
+ if (spec.tests && spec.tests[0] && spec.tests[0].tags) {
199
+ tags.push(...spec.tests[0].tags);
200
+ }
201
+ const uniqueTags = [...new Set(tags)]; // Dedup
202
+ const result = spec.tests?.[0]?.results?.[0];
203
+ const status = result?.status || 'skipped';
204
+ const duration = result?.duration || 0;
205
+ uniqueTags.forEach(tag => {
206
+ // Clean tag (remove @ if present for display)
207
+ const tagName = tag.startsWith('@') ? tag.substring(1) : tag;
208
+ if (!tagMap[tagName]) {
209
+ tagMap[tagName] = { name: tagName, total: 0, passed: 0, failed: 0, skipped: 0, duration: 0, tests: [] };
210
+ }
211
+ tagMap[tagName].total++;
212
+ if (status === 'passed')
213
+ tagMap[tagName].passed++;
214
+ else if (status === 'failed' || status === 'timedOut')
215
+ tagMap[tagName].failed++;
216
+ else
217
+ tagMap[tagName].skipped++;
218
+ tagMap[tagName].duration += duration;
219
+ tagMap[tagName].tests.push({
220
+ title: spec.title,
221
+ status: status,
222
+ duration: duration
223
+ });
224
+ });
225
+ });
226
+ return Object.values(tagMap);
227
+ }
184
228
  processSuites(report, history) {
185
229
  const historyDataMap = new Map();
186
230
  history.forEach(run => {
187
231
  const allSpecs = this.findAllSpecs(this.getTestSuites(run.report));
188
232
  allSpecs.forEach(spec => {
189
233
  const testId = `${spec.file}::${spec.title}`;
190
- if (!historyDataMap.has(testId)) {
234
+ if (!historyDataMap.has(testId))
191
235
  historyDataMap.set(testId, []);
192
- }
193
- // Safely get status, prioritizing unexpected over passed/skipped if multiple results exist
194
236
  const resultStatus = spec.tests?.[0]?.results?.[0]?.status || 'skipped';
195
237
  historyDataMap.get(testId).push(resultStatus);
196
238
  });
@@ -208,26 +250,31 @@ class AggregatorReporter {
208
250
  const currentTest = spec.tests?.[0];
209
251
  const currentResult = currentTest?.results?.[0];
210
252
  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);
253
+ const previousStatus = historyStatuses[historyStatuses.length - 1] || 'N/A';
254
+ const allRuns = [...historyStatuses, currentStatus];
255
+ const displayRuns = allRuns.slice(-5);
256
+ const isFlaky = new Set(displayRuns).size > 1;
257
+ const logs = (currentResult?.stdout || []).map(s => stripAnsi(s.text || s)).join('');
258
+ // Extract Stack Trace
259
+ let stack = '';
260
+ if (currentResult?.error?.stack) {
261
+ stack = stripAnsi(currentResult.error.stack);
216
262
  }
217
- else if (lastRuns.length > 5) {
218
- lastRuns.splice(0, lastRuns.length - 5);
263
+ else if (currentResult?.errors && currentResult.errors.length > 0) {
264
+ stack = currentResult.errors.map(e => stripAnsi(e.stack || e.message)).join('\n');
219
265
  }
220
- const isFlaky = new Set(lastRuns).size > 1;
221
- const logs = (currentResult?.stdout || []).map(s => stripAnsi(s.text || '')).join('');
222
266
  return {
223
267
  ...spec,
224
268
  tests: spec.tests.map(test => ({
225
269
  ...test,
226
- history: lastRuns,
270
+ history: displayRuns,
271
+ previousStatus: previousStatus,
227
272
  flaky: isFlaky,
228
273
  status: currentStatus,
229
274
  duration: currentResult?.duration || 0,
230
- logs: logs
275
+ logs: logs,
276
+ stack: stack, // Added Stack
277
+ results: test.results || [],
231
278
  }))
232
279
  };
233
280
  })
@@ -246,30 +293,19 @@ class AggregatorReporter {
246
293
  allSpecs.forEach(spec => {
247
294
  const result = spec.tests?.[0]?.results?.[0];
248
295
  if (result?.status === 'failed' && result.error) {
249
- // Use the raw error message to preserve newlines and ANSI for stripping/display
250
296
  const cleanMessage = stripAnsi(result.error.message || result.error.stack || 'Unknown error');
251
297
  let errorName;
252
298
  const expectMatch = cleanMessage.match(/Expected: (.*)\s*Received: (.*)/);
253
299
  if (expectMatch) {
254
- const expected = (expectMatch[1] || '').trim();
255
- const received = (expectMatch[2] || '').trim();
256
- errorName = `AssertionError: Expected '${expected}' but received '${received}'`;
300
+ errorName = `AssertionError: Expected '${expectMatch[1].trim()}' but received '${expectMatch[2].trim()}'`;
257
301
  }
258
302
  else {
259
303
  errorName = (cleanMessage.split('\n')[0] || 'Unknown Error').replace('Error: ', '').trim();
260
304
  }
261
- if (!categories[errorName]) {
262
- categories[errorName] = {
263
- name: errorName,
264
- count: 0,
265
- tests: []
266
- };
267
- }
305
+ if (!categories[errorName])
306
+ categories[errorName] = { name: errorName, count: 0, tests: [] };
268
307
  categories[errorName].count++;
269
- categories[errorName].tests.push({
270
- title: `${spec.file} -> ${spec.title}`,
271
- trace: cleanMessage
272
- });
308
+ categories[errorName].tests.push({ title: `${spec.file} -> ${spec.title}`, trace: cleanMessage });
273
309
  }
274
310
  });
275
311
  return Object.values(categories);
@@ -277,22 +313,15 @@ class AggregatorReporter {
277
313
  processHistoryData(history, currentReport) {
278
314
  const allRuns = [...history];
279
315
  const today = new Date().toLocaleDateString();
280
- // Add current report to historical runs for chart, checking for duplicates
281
316
  if (!history.find(h => h.report.stats?.startTime === currentReport.stats?.startTime)) {
282
- allRuns.push({
283
- date: today,
284
- report: currentReport,
285
- });
317
+ allRuns.push({ date: today, report: currentReport });
286
318
  }
287
319
  return allRuns.map(run => {
288
320
  const stats = run.report.stats || {};
289
321
  const { expected = 0, unexpected = 0, skipped = 0 } = stats;
290
322
  const total = expected + unexpected + skipped;
291
323
  return {
292
- date: run.date,
293
- passed: expected,
294
- failed: unexpected,
295
- skipped,
324
+ date: run.date, passed: expected, failed: unexpected, skipped,
296
325
  passRate: total > 0 ? (expected / total) * 100 : 0
297
326
  };
298
327
  });
@@ -304,27 +333,20 @@ class AggregatorReporter {
304
333
  allSpecs.forEach(spec => {
305
334
  const result = spec.tests?.[0]?.results?.[0];
306
335
  const duration = result?.duration || 0;
307
- timeline.push({
308
- name: spec.title,
309
- start: cumulativeDuration,
310
- duration: duration,
311
- status: result?.status || 'skipped'
312
- });
336
+ timeline.push({ name: spec.title, start: cumulativeDuration, duration: duration, status: result?.status || 'skipped' });
313
337
  cumulativeDuration += duration;
314
338
  });
315
339
  return timeline;
316
340
  }
317
341
  generateReportHtml(report) {
318
- // 1. Stringify the report object
319
342
  const reportJson = JSON.stringify(report);
320
- // 2. Escape the stringified JSON for safe HTML embedding (CRITICAL FIX)
321
343
  const safeReportJson = escapeJsonForHtml(reportJson);
322
344
  return `
323
345
  <!doctype html>
324
346
  <html>
325
347
  <head>
326
348
  <meta charset="utf-8"/>
327
- <title>DIS API Test Summary - Enhanced</title>
349
+ <title>DIS API Test Summary</title>
328
350
  <meta name="viewport" content="width=device-width,initial-scale=1"/>
329
351
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;700&display=swap" rel="stylesheet">
330
352
  <style>
@@ -333,59 +355,75 @@ class AggregatorReporter {
333
355
  --pass-bg:#e6ffed; --pass-text:#0a7a0a; --fail-bg:#fff0f0; --fail-text:#b00020;
334
356
  --skipped-bg:#f0f0f0; --skipped-text:#6B7280; --shadow: 0 6px 18px rgba(15,23,42,0.08);
335
357
  --table-header-bg: rgba(15,23,42,0.03); --table-border: rgba(15,23,42,0.04);
336
- --log-bg: #eef0f3;
358
+ --log-bg: #eef0f3; --retry-bg: #fffbe6; --retry-text: #8a6d3b;
359
+ --consistency-bg: #e0f7fa; --consistency-text: #00838f;
360
+ --flaky-bg: #fff3e0; --flaky-text: #ff9800;
361
+ --repro-bg: #f3e5f5; --repro-text: #4a148c;
337
362
  }
338
363
  [data-theme="dark"]{
339
364
  --bg:#1a202c; --card:#2d3748; --text:#e2e8f0; --muted:#a0aec0; --accent:#63b3ed;
340
365
  --pass-bg:#2A4838; --pass-text:#68D391; --fail-bg:#582C2C; --fail-text:#FC8181;
341
366
  --skipped-bg:#4a5568; --skipped-text:#e2e8f0; --shadow: 0 6px 18px rgba(0,0,0,0.4);
342
367
  --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;
368
+ --log-bg: #1e293b; --retry-bg: #4c3c13; --retry-text: #ffeb3b;
369
+ --consistency-bg: #1a4e58; --consistency-text: #80deea;
370
+ --flaky-bg: #5f4a13; --flaky-text: #ffc107;
371
+ --repro-bg: #40234a; --repro-text: #ce93d8;
348
372
  }
373
+ body { margin: 0; padding: 0; font-family: 'Inter', sans-serif; font-size: 14px; line-height: 1.6; color: var(--text); background-color: var(--bg); transition: background-color 0.4s, color 0.4s; }
349
374
  .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; }
375
+ .download-buttons{display:flex;gap:10px}.download-buttons button{padding:8px 14px;border:1px solid var(--table-border);background:linear-gradient(135deg,var(--card),color-mix(in srgb,var(--card) 90%,black));color:var(--text);border-radius:10px;cursor:pointer;transition:transform .2s,box-shadow .2s,background .25s}.download-buttons button:hover{transform:translateY(-2px);background:linear-gradient(135deg,var(--table-header-bg),var(--card));box-shadow:0 6px 16px rgba(0,0,0,.15)}.download-buttons button:active{transform:translateY(0);box-shadow:0 3px 8px rgba(0,0,0,.2) inset}
376
+ .sidebar { width: 200px; background-color: var(--card); padding: 24px; box-shadow: var(--shadow); z-index: 10; flex-shrink: 0; }
351
377
  .sidebar-header { font-weight: 700; font-size: 1.5em; margin-bottom: 30px; color: var(--accent); }
352
378
  .sidebar-menu { list-style: none; padding: 0; }
353
379
  .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); }
380
+ .sidebar-menu-item a { display: block; padding: 8px 16px; color: var(--text); text-decoration: none; border-radius: 8px; transition: background-color 0.2s, transform 0.1s; }
381
+ .sidebar-menu-item a:hover { background-color: var(--table-header-bg); transform: translateX(4px); }
356
382
  .sidebar-menu-item a.active { background-color: var(--accent); color: white; }
357
383
  .main-content { flex-grow: 1; padding: 32px; overflow-x: auto; }
358
384
  .header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; flex-wrap: wrap; gap: 16px;}
359
385
  .header h1 { font-size: 2em; font-weight: 600; margin: 0; }
360
386
  .filters, .actions { display: flex; gap: 16px; align-items: center; }
361
387
  .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; }
388
+ .card { background-color: var(--card); border-radius: 12px; padding: 20px; box-shadow: var(--shadow); margin-bottom: 24px; animation: fadeIn 0.5s ease-out; }
363
389
  h2 { font-size: 1.5em; font-weight: 600; margin: 0 0 20px; display: flex; justify-content: space-between; align-items: center;}
364
390
  h3 { font-size: 1.1em; font-weight: 600; margin: 0 0 12px; text-align: center; }
365
391
  .header-stats { display: flex; gap: 8px; font-size: 0.7em; font-weight: normal; }
366
392
  .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); }
393
+ .kpi-card { text-align: center; padding: 16px; border-radius: 8px; background-color: var(--bg); border: 1px solid var(--table-border); transition: transform 0.2s, box-shadow 0.2s; cursor: pointer; }
394
+ .kpi-card:hover { transform: translateY(-4px); box-shadow: 0 8px 25px rgba(15,23,42,0.1); }
368
395
  .kpi-value { font-size: 2em; font-weight: 700; color: var(--text); }
369
396
  .kpi-label { color: var(--muted); font-size: 0.9em; }
370
- .kpi-pass-rate .kpi-value { color: var(--pass-text); }
397
+ .kpi-pass-rate .kpi-value, .kpi-passed .kpi-value { color: var(--pass-text); }
371
398
  .kpi-failed .kpi-value { color: var(--fail-text); }
399
+ .kpi-retries .kpi-value { color: var(--retry-text); }
400
+ .kpi-retries { border-left: 5px solid var(--retry-text); }
401
+ .kpi-consistency { border-left: 5px solid var(--consistency-text); background-color: var(--consistency-bg); }
402
+ .kpi-flaky-percent { border-left: 5px solid var(--flaky-text); background-color: var(--flaky-bg); }
403
+ .kpi-reproducibility { border-left: 5px solid var(--repro-text); background-color: var(--repro-bg); }
404
+ .kpi-consistency .kpi-value { color: var(--consistency-text); }
405
+ .kpi-flaky-percent .kpi-value { color: var(--flaky-text); }
406
+ .kpi-reproducibility .kpi-value { color: var(--repro-text); }
372
407
  .status-badge { display: inline-block; padding: 4px 10px; border-radius: 12px; font-weight: 600; font-size: 0.8em; text-transform: uppercase; }
373
408
  .status-passed { background-color: var(--pass-bg); color: var(--pass-text); }
374
409
  .status-failed { background-color: var(--fail-bg); color: var(--fail-text); }
375
410
  .status-skipped { background-color: var(--skipped-bg); color: var(--skipped-text); }
411
+ .modal { display: none; position: fixed; z-index: 1000; left: 0; top: 0; width: 100%; height: 100%; overflow: hidden; background-color: rgba(0,0,0,0.6); animation: modalFadeIn 0.3s; }
412
+ .modal-content { background-color: var(--card); margin: 5vh auto; padding: 20px; border: 1px solid var(--table-border); width: 90%; max-width: 1200px; border-radius: 12px; position: relative; max-height: 90vh; display: flex; flex-direction: column; }
413
+ .close-button { color: var(--muted); float: right; font-size: 28px; font-weight: bold; cursor: pointer; position: absolute; top: 15px; right: 20px; }
414
+ .modal-body { overflow-y: auto; flex-grow: 1; }
415
+ .modal table { font-size: 0.9em; min-width: 100%; }
376
416
  table { width: 100%; border-collapse: collapse; }
377
417
  th, td { padding: 8px 12px; text-align: left; border-bottom: 1px solid var(--table-border); }
378
418
  th { background-color: var(--table-header-bg); font-weight: 600; }
379
- tr:last-child td { border-bottom: none; }
380
419
  .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
420
  .collapse-icon { width: 1em; height: 1em; text-align: center; transition: transform 0.2s; }
382
421
  .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;}
422
+ .test-card-header { cursor: pointer; display: grid; grid-template-columns: 1fr auto auto auto auto; gap: 16px; align-items: center; padding: 8px 12px;}
384
423
  .test-card-header:hover { background-color: var(--table-header-bg); }
385
424
  .test-card { border-bottom: 1px solid var(--table-border); }
386
- .log-row { background-color: var(--table-header-bg); }
425
+ .log-row, .stack-row { background-color: var(--table-header-bg); display: none; }
387
426
  .log-container { padding: 16px; }
388
- .log-container h4 { margin: 0 0 8px; text-align: left;}
389
427
  .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
428
  .dot { display: inline-block; width: 12px; height: 12px; border-radius: 50%; margin-right: 4px; }
391
429
  .dot.passed { background-color: #4CAF50; }
@@ -396,19 +434,19 @@ tr:last-child td { border-bottom: none; }
396
434
  .dark-mode-toggle:hover { background-color: var(--table-header-bg); }
397
435
  .tab-content { display: none; }
398
436
  .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
437
  .chart-container { position: relative; display: flex; align-items: center; justify-content: center; color: var(--muted); }
403
438
  .category-header { display: flex; justify-content: space-between; align-items: center; cursor: pointer; padding: 12px; border-bottom: 1px solid var(--table-border); }
404
439
  .category-content { padding: 0 12px; }
405
440
  .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
441
  .metadata-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-top: 20px;}
409
442
  .metadata-item { background-color: var(--bg); padding: 12px; border-radius: 8px; border: 1px solid var(--table-border); }
410
443
  .metadata-label { font-weight: 600; color: var(--muted); display: block; margin-bottom: 4px;}
411
- .metadata-value { font-size: 1.1em;}
444
+ .metadata-value { font-size: 1.0em;}
445
+ .action-btn { background: none; border: 1px solid var(--muted); border-radius: 4px; padding: 2px 6px; cursor: pointer; font-size: 0.8em; margin-left: 5px; color: var(--text); }
446
+ .action-btn:hover { background-color: var(--table-header-bg); }
447
+ iframe { width: 100%; height: 80vh; border: none; border-radius: 8px; box-shadow: var(--shadow); }
448
+ @keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
449
+ @keyframes modalFadeIn { from { opacity: 0; } to { opacity: 1; } }
412
450
  </style>
413
451
  </head>
414
452
  <body>
@@ -423,6 +461,8 @@ tr:last-child td { border-bottom: none; }
423
461
  <li class="sidebar-menu-item"><a href="#" data-tab="graphs">Graphs</a></li>
424
462
  <li class="sidebar-menu-item"><a href="#" data-tab="timeline">Timeline</a></li>
425
463
  <li class="sidebar-menu-item"><a href="#" data-tab="history">Historical Runs</a></li>
464
+ <li class="sidebar-menu-item" id="tagsMenu" style="display:none;"><a href="#" data-tab="tags">Tags</a></li>
465
+ <li class="sidebar-menu-item" id="htmlReportMenu" style="display:none;"><a href="#" data-tab="external-html">HTML Report</a></li>
426
466
  </ul>
427
467
  </div>
428
468
 
@@ -433,7 +473,6 @@ tr:last-child td { border-bottom: none; }
433
473
  <button id="downloadHtml">Download HTML</button>
434
474
  <button id="downloadCsv">Download CSV</button>
435
475
  <button id="downloadJson">Download JSON</button>
436
- <button id="downloadPdf">Download PDF</button>
437
476
  </div>
438
477
  <div class="actions">
439
478
  <div class="filters">
@@ -478,21 +517,18 @@ tr:last-child td { border-bottom: none; }
478
517
  <h3>Pass/Fail/Skip</h3>
479
518
  <div class="chart-container" style="height: 300px;">
480
519
  <canvas id="statusChart"></canvas>
481
- <div>Loading Chart...</div>
482
520
  </div>
483
521
  </div>
484
522
  <div style="flex: 1 1 400px; min-width: 400px;">
485
523
  <h3>Duration Distribution</h3>
486
524
  <div class="chart-container" style="height: 300px;">
487
525
  <canvas id="durationHistogram"></canvas>
488
- <div>Loading Chart...</div>
489
526
  </div>
490
527
  </div>
491
528
  <div style="flex: 1 1 100%; min-width: 400px;">
492
529
  <h3>Pass Rate Trend</h3>
493
530
  <div class="chart-container" style="height: 300px;">
494
531
  <canvas id="historyChart"></canvas>
495
- <div>Loading Chart...</div>
496
532
  </div>
497
533
  </div>
498
534
  </div>
@@ -504,7 +540,6 @@ tr:last-child td { border-bottom: none; }
504
540
  <h2>Test Execution Timeline</h2>
505
541
  <div id="timelineContainer" class="chart-container" style="height: 600px;">
506
542
  <canvas id="timelineChart"></canvas>
507
- <div>Loading Chart...</div>
508
543
  </div>
509
544
  </div>
510
545
  </div>
@@ -514,25 +549,69 @@ tr:last-child td { border-bottom: none; }
514
549
  <h2 id="historyRunsTitle">Historical Runs</h2>
515
550
  <div id="historyGraphContainer" class="chart-container" style="height: 400px;">
516
551
  <canvas id="historicalRunsChart"></canvas>
517
- <div>Loading Chart...</div>
518
552
  </div>
519
553
  </div>
520
554
  </div>
521
555
 
556
+ <div id="tags" class="tab-content">
557
+ <div class="card">
558
+ <h2>Tag Analysis</h2>
559
+ <div style="display: flex; flex-wrap: wrap; gap: 24px; margin-bottom: 24px;">
560
+ <div style="flex: 1 1 300px;">
561
+ <h3>Distribution by Tag</h3>
562
+ <div class="chart-container" style="height: 300px;">
563
+ <canvas id="tagsPieChart"></canvas>
564
+ </div>
565
+ </div>
566
+ <div style="flex: 1 1 300px;">
567
+ <h3>Duration by Tag (Total)</h3>
568
+ <div class="chart-container" style="height: 300px;">
569
+ <canvas id="tagsBarChart"></canvas>
570
+ </div>
571
+ </div>
572
+ </div>
573
+ <h3>Test List by Tag</h3>
574
+ <div id="tagsTableContainer"></div>
575
+ </div>
576
+ </div>
577
+
578
+ <div id="external-html" class="tab-content">
579
+ <div class="card">
580
+ <h2 style="margin-bottom: 10px;">Full HTML Report</h2>
581
+ <div id="iframeContainer" style="width:100%; height:80vh;"></div>
582
+ </div>
583
+ </div>
584
+
522
585
  </div>
523
586
  </div>
524
587
 
588
+ <div id="testModal" class="modal">
589
+ <div class="modal-content">
590
+ <span class="close-button">&times;</span>
591
+ <h2 id="modalTitle"></h2>
592
+ <div class="modal-body">
593
+ <table>
594
+ <thead id="modalTableHeader"></thead>
595
+ <tbody id="modalTableBody"></tbody>
596
+ </table>
597
+ </div>
598
+ </div>
599
+ </div>
525
600
  <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
526
601
  <script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
527
602
  <script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
528
603
 
529
604
  <script>
530
605
  const reportData = ${safeReportJson};
606
+ let allTests = [];
531
607
 
532
608
  (function() {
533
609
  const root = document.documentElement;
534
610
  const darkModeToggle = document.getElementById('darkModeToggle');
535
- const renderedTabs = new Set();
611
+ const modal = document.getElementById('testModal');
612
+ const closeBtn = document.querySelector('.close-button');
613
+ const modalBody = document.getElementById('modalTableBody');
614
+ const modalHeader = document.getElementById('modalTableHeader');
536
615
 
537
616
  function applyTheme(theme) {
538
617
  if (theme === 'dark') {
@@ -551,12 +630,21 @@ const reportData = ${safeReportJson};
551
630
  }
552
631
 
553
632
  darkModeToggle.addEventListener('click', toggleTheme);
554
-
633
+
555
634
  const savedTheme = localStorage.getItem('theme');
556
635
  if (savedTheme) {
557
636
  applyTheme(savedTheme);
558
637
  }
559
-
638
+
639
+ // --- Show/Hide Dynamic Menus ---
640
+ if(reportData.config.hasExternalHtml) {
641
+ document.getElementById('htmlReportMenu').style.display = 'block';
642
+ }
643
+
644
+ if(reportData.tagStats && reportData.tagStats.length > 0) {
645
+ document.getElementById('tagsMenu').style.display = 'block';
646
+ }
647
+
560
648
  const tabs = document.querySelectorAll('.sidebar-menu-item a');
561
649
  const tabContents = document.querySelectorAll('.tab-content');
562
650
 
@@ -568,32 +656,150 @@ const reportData = ${safeReportJson};
568
656
  e.target.classList.add('active');
569
657
  tabContents.forEach(c => c.classList.remove('active'));
570
658
  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); }
659
+
660
+ // Fix: Handle External HTML rendering specifically
661
+ if (targetId === 'external-html') {
662
+ renderIframe();
663
+ return;
664
+ }
665
+
666
+ if (targetId === 'graphs') { renderCharts(); }
667
+ if (targetId === 'timeline') { renderTimeline(); }
668
+ if (targetId === 'history') { renderHistoryGraph(); }
669
+ if (targetId === 'tags') { renderTags(); }
575
670
  });
576
671
  });
672
+
673
+ function getAllTestsFlat() {
674
+ if (allTests.length > 0) return allTests;
675
+
676
+ reportData.suites.forEach(suite => {
677
+ suite.specs.forEach(spec => {
678
+ spec.tests.forEach(test => {
679
+ const result = test.results[0];
680
+ const status = result?.status || 'skipped';
681
+ const isRetry = test.results.length > 1;
682
+
683
+ allTests.push({
684
+ suite: suite.title,
685
+ test: spec.title,
686
+ duration: result?.duration || 0,
687
+ status: status,
688
+ isRetry: isRetry
689
+ });
690
+ });
691
+ });
692
+ });
693
+ return allTests;
694
+ }
695
+
696
+ function openTestModal(filterStatus) {
697
+ const allTests = getAllTestsFlat();
698
+ let filteredTests = [];
699
+ let title = '';
700
+
701
+ if (filterStatus === 'total') {
702
+ filteredTests = allTests;
703
+ title = 'All Test Cases';
704
+ } else if (filterStatus === 'retries') {
705
+ filteredTests = allTests.filter(t => t.isRetry);
706
+ title = 'Tests With Retries (' + reportData.stats.retries + ')';
707
+ } else {
708
+ filteredTests = allTests.filter(t => t.status === filterStatus);
709
+ const count = reportData.stats[filterStatus];
710
+ title = filterStatus.charAt(0).toUpperCase() + filterStatus.slice(1) + ' Test Cases (' + count + ')';
711
+ }
712
+
713
+ document.getElementById('modalTitle').textContent = title;
714
+
715
+ // FIX: String concatenation for safety
716
+ modalHeader.innerHTML = '<tr><th>Suite</th><th>Test Case</th><th>Status</th><th>Duration (s)</th></tr>';
717
+
718
+ modalBody.innerHTML = filteredTests.map(t =>
719
+ '<tr>' +
720
+ '<td>' + t.suite + '</td>' +
721
+ '<td>' + t.test + '</td>' +
722
+ '<td><span class="status-badge status-' + t.status + '">' + t.status + '</span></td>' +
723
+ '<td>' + (t.duration / 1000).toFixed(2) + '</td>' +
724
+ '</tr>'
725
+ ).join('');
726
+
727
+ modal.style.display = 'block';
728
+ }
729
+
730
+ closeBtn.onclick = function() {
731
+ modal.style.display = 'none';
732
+ }
733
+ window.onclick = function(event) {
734
+ if (event.target == modal) {
735
+ modal.style.display = 'none';
736
+ }
737
+ }
577
738
 
578
739
  function renderOverview() {
740
+ getAllTestsFlat();
741
+
579
742
  const { stats, metadata } = reportData;
580
743
  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
- \`;
744
+
745
+ kpiGrid.innerHTML =
746
+ '<div class="kpi-card kpi-total" data-status="total"><div class="kpi-value">' + stats.total + '</div><div class="kpi-label">Total Tests</div></div>' +
747
+ '<div class="kpi-card kpi-passed" data-status="passed"><div class="kpi-value">' + stats.passed + '</div><div class="kpi-label">Passed</div></div>' +
748
+ '<div class="kpi-card kpi-failed" data-status="failed"><div class="kpi-value">' + stats.failed + '</div><div class="kpi-label">Failed</div></div>' +
749
+ '<div class="kpi-card kpi-retries" data-status="retries"><div class="kpi-value">' + stats.retries + '</div><div class="kpi-label">Retries</div></div>' +
750
+ '<div class="kpi-card kpi-skipped" data-status="skipped"><div class="kpi-value">' + stats.skipped + '</div><div class="kpi-label">Skipped</div></div>' +
751
+ '<div class="kpi-card kpi-pass-rate"><div class="kpi-value">' + stats.passRate + '</div><div class="kpi-label">Pass Rate</div></div>' +
752
+ '<div class="kpi-card kpi-duration"><div class="kpi-value">' + stats.totalDuration + '</div><div class="kpi-label">Total Duration</div></div>'+
753
+ '<div class="kpi-card kpi-consistency"><div class="kpi-value">' + stats.passRateConsistency + '</div><div class="kpi-label">Pass Rate Consistency (SD)</div></div>' +
754
+ '<div class="kpi-card kpi-flaky-percent"><div class="kpi-value">' + stats.flakyPercentage + '</div><div class="kpi-label">Flaky Test Percentage</div></div>' +
755
+ '<div class="kpi-card kpi-reproducibility"><div class="kpi-value">' + stats.failureReproducibility + '</div><div class="kpi-label">Failure Reproducibility</div></div>';
756
+
757
+
758
+ document.querySelectorAll('.kpi-card').forEach(card => {
759
+ const status = card.getAttribute('data-status');
760
+ if (status) {
761
+ card.addEventListener('click', () => openTestModal(status));
762
+ }
763
+ });
589
764
 
590
765
  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
- \`;
766
+ metadataGrid.innerHTML =
767
+ '<div class="metadata-item"><span class="metadata-label">Start Time</span><span class="metadata-value">' + metadata.startTime + '</span></div>' +
768
+ '<div class="metadata-item"><span class="metadata-label">Total Duration</span><span class="metadata-value">' + metadata.totalDuration + '</span></div>' +
769
+ '<div class="metadata-item"><span class="metadata-label">Browser</span><span class="metadata-value">' + metadata.browser + '</span></div>' +
770
+ '<div class="metadata-item"><span class="metadata-label">Operating System</span><span class="metadata-value">' + metadata.os + '</span></div>';
771
+
772
+ const kpiValues = kpiGrid.querySelectorAll('.kpi-value');
773
+ kpiValues.forEach(kpiValueEl => {
774
+ const targetText = kpiValueEl.textContent;
775
+ const isPercentage = targetText.includes('%');
776
+ const isDuration = targetText.includes('s');
777
+ const isDecimal = targetText.includes('.');
778
+ let targetValue = parseFloat(targetText.replace(/[^0-9.]/g, ''));
779
+ if (isNaN(targetValue)) targetValue = 0;
780
+ const startValue = 0;
781
+ const duration = 2000;
782
+ let startTime = null;
783
+ function animate(currentTime) {
784
+ if (!startTime) startTime = currentTime;
785
+ const progress = Math.min((currentTime - startTime) / duration, 1);
786
+ const easedProgress = 1 - Math.pow(1 - progress, 3);
787
+ let currentValue = startValue + (targetValue - startValue) * easedProgress;
788
+ if (progress < 1) {
789
+ if (isPercentage || isDecimal) {
790
+ kpiValueEl.textContent = currentValue.toFixed(2) + (isPercentage ? '%' : '');
791
+ } else if (isDuration) {
792
+ kpiValueEl.textContent = currentValue.toFixed(2) + 's';
793
+ } else {
794
+ kpiValueEl.textContent = Math.round(currentValue);
795
+ }
796
+ requestAnimationFrame(animate);
797
+ } else {
798
+ kpiValueEl.textContent = targetText;
799
+ }
800
+ }
801
+ requestAnimationFrame(animate);
802
+ });
597
803
  }
598
804
 
599
805
  function renderCategories() {
@@ -603,22 +809,22 @@ const reportData = ${safeReportJson};
603
809
  categoryList.innerHTML = '<p>No failed tests categorized.</p>';
604
810
  return;
605
811
  }
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('');
812
+ categoryList.innerHTML = categories.map(cat =>
813
+ '<div class="card">' +
814
+ '<div class="category-header">' +
815
+ '<span>' + cat.name + '</span>' +
816
+ '<span class="status-badge status-failed">' + cat.count + ' failed</span>' +
817
+ '</div>' +
818
+ '<div class="category-content" style="display: none;">' +
819
+ cat.tests.map(test =>
820
+ '<div class="category-test">' +
821
+ '<strong>' + test.title + '</strong>' +
822
+ '<pre>' + test.trace + '</pre>' +
823
+ '</div>'
824
+ ).join('') +
825
+ '</div>' +
826
+ '</div>'
827
+ ).join('');
622
828
 
623
829
  document.querySelectorAll('.category-header').forEach(header => {
624
830
  header.addEventListener('click', () => {
@@ -633,44 +839,54 @@ const reportData = ${safeReportJson};
633
839
  const suitesContainer = document.getElementById('suitesContainer');
634
840
  const suitesHeader = document.getElementById('suitesHeader');
635
841
 
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
- \`;
842
+ suitesHeader.innerHTML =
843
+ 'Suites & Tests' +
844
+ '<div class="header-stats">' +
845
+ '<span class="status-badge status-passed">' + stats.passed + ' Passed</span>' +
846
+ '<span class="status-badge status-failed">' + stats.failed + ' Failed</span>' +
847
+ '<span class="status-badge status-skipped">' + stats.skipped + ' Skipped</span>' +
848
+ '</div>';
644
849
 
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, "&lt;").replace(/>/g, "&gt;")}</pre>
666
- </div>
667
- </div>\` : ''}
668
- </div>
669
- \`)
670
- ).join('')}
671
- </div>
672
- </div>
673
- \`).join('');
850
+ suitesContainer.innerHTML = suites.map(suite =>
851
+ '<div class="card">' +
852
+ '<div class="suite-header">' +
853
+ '<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>' +
854
+ ' ' + suite.title +
855
+ '</div>' +
856
+ '<div class="suite-content" style="display: none;">' +
857
+ suite.specs.flatMap(spec =>
858
+ spec.tests.map(test =>
859
+ '<div class="test-card">' +
860
+ '<div class="test-card-header">' +
861
+ '<span style="font-weight: 500;">' + spec.title + '</span>' +
862
+ '<span class="status-badge status-' + test.status + '">' + test.status + ' ' + (test.flaky ? '<span class="flaky-badge">FLAKY</span>' : '') + '</span>' +
863
+ '<span>' + (test.duration / 1000).toFixed(2) + 's</span>' +
864
+ '<span>' + test.history.map(h => '<span class="dot ' + h + '"></span>').join('') + '</span>' +
865
+ '<span>' +
866
+ (test.logs ? '<button class="action-btn toggle-logs">Logs</button>' : '') +
867
+ (test.stack ? '<button class="action-btn toggle-stack">Stack</button>' : '') +
868
+ '</span>' +
869
+ '</div>' +
870
+ (test.logs ?
871
+ '<div class="log-row" style="display: none;">' +
872
+ '<div class="log-container">' +
873
+ '<h4>Logs</h4>' +
874
+ '<pre>' + test.logs.replace(/</g, "&lt;").replace(/>/g, "&gt;") + '</pre>' +
875
+ '</div>' +
876
+ '</div>' : '') +
877
+ (test.stack ?
878
+ '<div class="stack-row" style="display: none;">' +
879
+ '<div class="log-container">' +
880
+ '<h4>Error Stack</h4>' +
881
+ '<pre>' + test.stack.replace(/</g, "&lt;").replace(/>/g, "&gt;") + '</pre>' +
882
+ '</div>' +
883
+ '</div>' : '') +
884
+ '</div>'
885
+ )
886
+ ).join('') +
887
+ '</div>' +
888
+ '</div>'
889
+ ).join('');
674
890
 
675
891
  document.querySelectorAll('.suite-header').forEach(header => {
676
892
  header.addEventListener('click', () => {
@@ -682,22 +898,117 @@ const reportData = ${safeReportJson};
682
898
  });
683
899
  });
684
900
 
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
- }
901
+ // Toggle Logs
902
+ document.querySelectorAll('.toggle-logs').forEach(btn => {
903
+ btn.addEventListener('click', (e) => {
904
+ e.stopPropagation();
905
+ const card = btn.closest('.test-card');
906
+ const logRow = card.querySelector('.log-row');
907
+ if(logRow) logRow.style.display = logRow.style.display === 'none' ? 'block' : 'none';
691
908
  });
692
909
  });
910
+
911
+ // Toggle Stack
912
+ document.querySelectorAll('.toggle-stack').forEach(btn => {
913
+ btn.addEventListener('click', (e) => {
914
+ e.stopPropagation();
915
+ const card = btn.closest('.test-card');
916
+ const stackRow = card.querySelector('.stack-row');
917
+ if(stackRow) stackRow.style.display = stackRow.style.display === 'none' ? 'block' : 'none';
918
+ });
919
+ });
920
+
921
+ // Make entire test-card-header clickable to toggle both Logs and Stack
922
+ document.querySelectorAll('.test-card-header').forEach(header => {
923
+ header.addEventListener('click', (e) => {
924
+ const card = header.closest('.test-card');
925
+ if (!card) return;
926
+ const logRow = card.querySelector('.log-row');
927
+ const stackRow = card.querySelector('.stack-row');
928
+ if (logRow) logRow.style.display = logRow.style.display === 'none' ? 'block' : 'none';
929
+ if (stackRow) stackRow.style.display = stackRow.style.display === 'none' ? 'block' : 'none';
930
+ });
931
+ });
932
+ }
933
+
934
+ function renderIframe() {
935
+ const container = document.getElementById('iframeContainer');
936
+ // Always refresh if path exists to prevent blank display on tab switch
937
+ if (reportData.config.hasExternalHtml && reportData.config.externalHtmlPath) {
938
+ let src = reportData.config.externalHtmlPath;
939
+ // Optimization: only set innerHTML once, but ensure it's correct
940
+ if (container.innerHTML === "") {
941
+ container.innerHTML = '<iframe src="' + src + '" style="height:80vh; width:100%; border:none;"></iframe>';
942
+ }
943
+ } else {
944
+ container.innerHTML = '<p style="padding:20px; text-align:center; color:var(--muted);">External HTML report not found at ' + reportData.config.externalHtmlPath + '</p>';
945
+ }
946
+ }
947
+
948
+ function renderTags() {
949
+ const stats = reportData.tagStats;
950
+ if (!stats || stats.length === 0) return;
951
+
952
+ // 1. Pie Chart
953
+ const pieCtx = document.getElementById('tagsPieChart');
954
+ if (pieCtx && !pieCtx.chart) {
955
+ new Chart(pieCtx, {
956
+ type: 'pie',
957
+ data: {
958
+ labels: stats.map(s => s.name),
959
+ datasets: [{
960
+ data: stats.map(s => s.total),
961
+ backgroundColor: [
962
+ '#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0', '#9966FF', '#FF9F40'
963
+ ]
964
+ }]
965
+ },
966
+ options: { responsive: true, maintainAspectRatio: false }
967
+ });
968
+ pieCtx.chart = true; // Flag to prevent re-render
969
+ }
970
+
971
+ // 2. Bar Chart (Duration)
972
+ const barCtx = document.getElementById('tagsBarChart');
973
+ if (barCtx && !barCtx.chart) {
974
+ new Chart(barCtx, {
975
+ type: 'bar',
976
+ data: {
977
+ labels: stats.map(s => s.name),
978
+ datasets: [{
979
+ label: 'Total Duration (s)',
980
+ data: stats.map(s => s.duration / 1000),
981
+ backgroundColor: '#36A2EB'
982
+ }]
983
+ },
984
+ options: { responsive: true, maintainAspectRatio: false }
985
+ });
986
+ barCtx.chart = true;
987
+ }
988
+
989
+ // 3. Table
990
+ const tableContainer = document.getElementById('tagsTableContainer');
991
+ let tableHtml = '<table><thead><tr><th>Tag</th><th>Test Case</th><th>Status</th><th>Duration (s)</th></tr></thead><tbody>';
992
+
993
+ stats.forEach(tag => {
994
+ tag.tests.forEach(t => {
995
+ tableHtml += '<tr>' +
996
+ '<td><span class="status-badge" style="background:#eee; color:#333;">' + tag.name + '</span></td>' +
997
+ '<td>' + t.title + '</td>' +
998
+ '<td><span class="status-badge status-' + t.status + '">' + t.status + '</span></td>' +
999
+ '<td>' + (t.duration / 1000).toFixed(2) + '</td>' +
1000
+ '</tr>';
1001
+ });
1002
+ });
1003
+ tableHtml += '</tbody></table>';
1004
+ tableContainer.innerHTML = tableHtml;
693
1005
  }
694
1006
 
695
1007
  let statusChart, durationChart, historyChart, timelineChart, historicalRunsChart;
696
1008
 
697
1009
  function renderCharts() {
698
1010
  const statusCtx = document.getElementById('statusChart');
699
- statusCtx.nextElementSibling.style.display = 'none';
700
- statusChart = new Chart(statusCtx, {
1011
+ statusChart = new Chart(statusCtx, {
701
1012
  type: 'doughnut',
702
1013
  data: {
703
1014
  labels: ['Passed', 'Failed', 'Skipped'],
@@ -706,16 +1017,22 @@ const reportData = ${safeReportJson};
706
1017
  backgroundColor: ['#4CAF50', '#F44336', '#9E9E9E'],
707
1018
  }]
708
1019
  },
709
- options: { responsive: true, maintainAspectRatio: false, animation: { animateScale: true } }
1020
+ options: {
1021
+ responsive: true,
1022
+ maintainAspectRatio: false,
1023
+ animation: {
1024
+ animateRotate: true,
1025
+ animateScale: true,
1026
+ duration: 1500,
1027
+ easing: 'easeOutQuart'
1028
+ }
1029
+ }
710
1030
  });
711
1031
 
712
- // Duration Histogram
713
1032
  const histogramCtx = document.getElementById('durationHistogram');
714
- histogramCtx.nextElementSibling.style.display = 'none';
715
1033
  const durations = reportData.suites.flatMap(s => s.specs.flatMap(sp => sp.tests.map(t => t.duration / 1000)));
716
1034
  const labels = ['<1s', '1-2s', '2-5s', '5-10s', '10-30s', '30-60s', '>60s'];
717
1035
  const data = new Array(labels.length).fill(0);
718
-
719
1036
  durations.forEach(d => {
720
1037
  if (d < 1) data[0]++;
721
1038
  else if (d < 2) data[1]++;
@@ -725,7 +1042,6 @@ const reportData = ${safeReportJson};
725
1042
  else if (d < 60) data[5]++;
726
1043
  else data[6]++;
727
1044
  });
728
-
729
1045
  new Chart(histogramCtx, {
730
1046
  type: 'bar',
731
1047
  data: {
@@ -737,15 +1053,16 @@ const reportData = ${safeReportJson};
737
1053
  }]
738
1054
  },
739
1055
  options: {
740
- responsive: true,
741
- maintainAspectRatio: false,
1056
+ responsive: true, maintainAspectRatio: false,
1057
+ animation: {
1058
+ duration: 1200,
1059
+ easing: 'easeOutQuart'
1060
+ },
742
1061
  scales: { y: { beginAtZero: true, title: { display: true, text: 'Test Count' } } }
743
1062
  }
744
1063
  });
745
1064
 
746
-
747
1065
  const historyCtx = document.getElementById('historyChart');
748
- historyCtx.nextElementSibling.style.display = 'none';
749
1066
  historyChart = new Chart(historyCtx, {
750
1067
  type: 'line',
751
1068
  data: {
@@ -758,19 +1075,24 @@ const reportData = ${safeReportJson};
758
1075
  tension: 0.1
759
1076
  }]
760
1077
  },
761
- options: { responsive: true, maintainAspectRatio: false, scales: { y: { beginAtZero: true, max: 100 } } }
1078
+ options: {
1079
+ responsive: true,
1080
+ maintainAspectRatio: false,
1081
+ animation: {
1082
+ duration: 1200,
1083
+ easing: 'easeOutQuart'
1084
+ },
1085
+ scales: { y: { beginAtZero: true, max: 100 } }
1086
+ }
762
1087
  });
763
1088
  }
764
1089
 
765
1090
  function renderTimeline() {
766
1091
  const timelineCtx = document.getElementById('timelineChart');
767
- timelineCtx.nextElementSibling.style.display = 'none';
768
-
769
- // Transform timeline data for Chart.js gantt-like chart
770
1092
  const labels = reportData.timeline.map(t => t.name);
771
1093
  const datasets = [{
772
1094
  label: 'Execution Time',
773
- data: reportData.timeline.map(t => [t.start, t.start + t.duration]),
1095
+ data: reportData.timeline.map(t => [t.start / 1000, (t.start + t.duration) / 1000]),
774
1096
  backgroundColor: reportData.timeline.map(t => {
775
1097
  if (t.status === 'passed') return 'rgba(76, 175, 80, 0.8)';
776
1098
  if (t.status === 'failed') return 'rgba(244, 67, 54, 0.8)';
@@ -778,23 +1100,20 @@ const reportData = ${safeReportJson};
778
1100
  }),
779
1101
  }];
780
1102
 
781
- // Only render chart if there is data
782
1103
  if (reportData.timeline.length > 0) {
783
- timelineCtx.parentElement.style.height = (reportData.timeline.length * 30 + 100) + 'px'; // Dynamic height based on number of tests
1104
+ timelineCtx.parentElement.style.height = (reportData.timeline.length * 30 + 100) + 'px';
784
1105
  timelineChart = new Chart(timelineCtx, {
785
1106
  type: 'bar',
786
- data: {
787
- labels: labels,
788
- datasets: datasets
789
- },
1107
+ data: { labels: labels, datasets: datasets },
790
1108
  options: {
791
1109
  indexAxis: 'y',
792
- responsive: true,
793
- maintainAspectRatio: false,
1110
+ responsive: true, maintainAspectRatio: false,
794
1111
  plugins: { legend: { display: false } },
795
- scales: {
796
- x: { min: 0, title: { display: true, text: 'Time (ms)' } },
797
- }
1112
+ animation: {
1113
+ duration: 2000,
1114
+ easing: 'easeOutQuart'
1115
+ },
1116
+ scales: { x: { min: 0, title: { display: true, text: 'Time (s)' } } }
798
1117
  }
799
1118
  });
800
1119
  } else {
@@ -804,11 +1123,8 @@ const reportData = ${safeReportJson};
804
1123
 
805
1124
  function renderHistoryGraph() {
806
1125
  const history = reportData.history;
807
- document.getElementById('historyRunsTitle').textContent = \`Historical Runs (Count: \${history.length})\`;
1126
+ document.getElementById('historyRunsTitle').textContent = 'Historical Runs (Count: ' + history.length + ')';
808
1127
  const historyCtx = document.getElementById('historicalRunsChart');
809
- historyCtx.nextElementSibling.style.display = 'none';
810
-
811
- // Only render chart if there is data
812
1128
  if (history.length > 0) {
813
1129
  historicalRunsChart = new Chart(historyCtx, {
814
1130
  type: 'bar',
@@ -816,60 +1132,24 @@ const reportData = ${safeReportJson};
816
1132
  labels: history.map(h => h.date),
817
1133
  datasets: [
818
1134
  {
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
1135
+ type: 'line', label: 'Pass Rate', data: history.map(h => h.passRate),
1136
+ borderColor: 'rgb(255, 159, 64)', yAxisID: 'y1', tension: 0.1, pointRadius: 5, pointBackgroundColor: 'rgb(255, 159, 64)', order: 0,
828
1137
  },
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
- }
1138
+ { type: 'bar', label: 'Passed', data: history.map(h => h.passed), backgroundColor: '#4CAF50', order: 1 },
1139
+ { type: 'bar', label: 'Failed', data: history.map(h => h.failed), backgroundColor: 'rgba(255, 99, 132, 0.8)', order: 1 },
1140
+ { type: 'bar', label: 'Skipped', data: history.map(h => h.skipped), backgroundColor: 'rgba(158, 158, 158, 0.8)', order: 1 }
850
1141
  ]
851
1142
  },
852
1143
  options: {
853
- responsive: true,
854
- maintainAspectRatio: false,
1144
+ responsive: true, maintainAspectRatio: false,
1145
+ animation: {
1146
+ duration: 1200,
1147
+ easing: 'easeOutQuart'
1148
+ },
855
1149
  scales: {
856
1150
  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
1151
+ y: { stacked: true, beginAtZero: true, title: { display: true, text: 'Test Count' } },
1152
+ y1: { position: 'right', beginAtZero: true, max: 100, title: { display: true, text: 'Pass Rate (%)' }, grid: { drawOnChartArea: false } }
873
1153
  }
874
1154
  }
875
1155
  });
@@ -886,22 +1166,16 @@ const reportData = ${safeReportJson};
886
1166
  const showPassed = filterPassed.checked;
887
1167
  const showFailed = filterFailed.checked;
888
1168
  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
-
1169
+ document.querySelectorAll('.log-row').forEach(logRow => { logRow.style.display = 'none'; });
895
1170
  document.querySelectorAll('.test-card').forEach(card => {
896
1171
  const header = card.querySelector('.test-card-header');
897
1172
  const status = header.querySelector('.status-badge').textContent.toLowerCase();
898
1173
  let display = false;
899
- if (status === 'passed' && showPassed) display = true;
900
- if (status === 'failed' && showFailed) display = true;
901
- if (status === 'skipped' && showSkipped) display = true;
1174
+ if (status.includes('passed') && showPassed) display = true;
1175
+ if (status.includes('failed') && showFailed) display = true;
1176
+ if (status.includes('skipped') && showSkipped) display = true;
902
1177
  card.style.display = display ? '' : 'none';
903
1178
 
904
- // Hide the parent suite card if all its tests are hidden
905
1179
  let parentSuiteContent = card.closest('.suite-content');
906
1180
  if (parentSuiteContent) {
907
1181
  let visibleTests = Array.from(parentSuiteContent.querySelectorAll('.test-card')).filter(t => t.style.display !== 'none');
@@ -935,40 +1209,24 @@ const reportData = ${safeReportJson};
935
1209
  URL.revokeObjectURL(a.href);
936
1210
  });
937
1211
  document.getElementById('downloadCsv').addEventListener('click', () => {
938
- let csvContent = "data:text/csv;charset=utf-8,Suite,Test,Status,Duration (s),History\\n";
1212
+ let csvContent = "data:text/csv;charset=utf-8,Suite,Test,Status,Duration (s),Flaky,History\\n";
939
1213
  reportData.suites.forEach(suite => {
940
1214
  suite.specs.forEach(spec => {
941
1215
  const testData = spec.tests && spec.tests[0];
942
1216
  const status = testData ? testData.status : 'N/A';
943
1217
  const duration = testData ? (testData.duration / 1000).toFixed(2) : 'N/A';
1218
+ const isFlaky = testData?.flaky ? 'Yes' : 'No';
944
1219
  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(",");
1220
+ const safeSuiteTitle = '"' + suite.title.replace(/"/g,'""') + '"';
1221
+ const safeSpecTitle = '"' + spec.title.replace(/"/g,'""') + '"';
1222
+ const row = [safeSuiteTitle, safeSpecTitle, status, duration, isFlaky, history].join(",");
947
1223
  csvContent += row + '\\n';
948
1224
  });
949
1225
  });
950
1226
  const encodedUri = encodeURI(csvContent);
951
1227
  const a = document.createElement('a'); a.href = encodedUri; a.download = 'test_report.csv'; a.click();
952
1228
  });
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
-
1229
+
972
1230
  renderOverview();
973
1231
  renderCategories();
974
1232
  renderSuites();