@arpanp/zen-reporter 0.10.1

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.
@@ -0,0 +1,673 @@
1
+ #!/usr/bin/env node
2
+ import { execSync } from 'child_process';
3
+ import { existsSync, readFileSync, readdirSync, writeFileSync } from 'fs';
4
+ import { resolve } from 'path';
5
+
6
+ function detectPackageManager(cwd = process.cwd()) {
7
+ const lockfiles = [
8
+ ['pnpm-lock.yaml', 'pnpm'],
9
+ ['yarn.lock', 'yarn'],
10
+ ['bun.lock', 'bun'],
11
+ ['package-lock.json', 'npm'],
12
+ ];
13
+
14
+ for (const [file, manager] of lockfiles) {
15
+ if (existsSync(resolve(cwd, file))) return manager;
16
+ }
17
+
18
+ if (existsSync(resolve(cwd, '.npmrc'))) return 'npm';
19
+
20
+ try {
21
+ const pkgPath = resolve(cwd, 'package.json');
22
+ if (existsSync(pkgPath)) {
23
+ const pkgJson = JSON.parse(readFileSync(pkgPath, 'utf8'));
24
+
25
+ if (pkgJson.packageManager) return pkgJson.packageManager.split('@')[0];
26
+ }
27
+ } catch {
28
+ /* ignore */
29
+ }
30
+
31
+ return 'npm';
32
+ }
33
+
34
+ const cwd = process.cwd();
35
+ const args = process.argv.slice(2);
36
+ const command = args[0] || 'show';
37
+
38
+ // ── `zr history` plumbing ────────────────────────────────────────────────
39
+
40
+ const historyDir = process.env.PW_REPORTER_OUTPUT || 'zen-report';
41
+ const runsDirPath = resolve(cwd, historyDir, 'runs');
42
+ const runsGlob = `${runsDirPath}/*.jsonl`;
43
+ // Canonical table expression: one row per test case per run, uniform schema.
44
+ const SRC = "read_json(?, format='newline_delimited')";
45
+
46
+ function findRunFiles() {
47
+ let files = [];
48
+ try {
49
+ files = readdirSync(runsDirPath).filter((f) => f.endsWith('.jsonl'));
50
+ } catch {
51
+ /* runs dir missing */
52
+ }
53
+ if (files.length === 0) {
54
+ console.error(
55
+ `✗ No historic runs found in "${historyDir}/runs". Run your Playwright tests first.`
56
+ );
57
+ process.exit(1);
58
+ }
59
+ return files;
60
+ }
61
+
62
+ async function openDuckDb() {
63
+ try {
64
+ const { DuckDBConnection } = await import('@duckdb/node-api');
65
+ return await DuckDBConnection.create();
66
+ } catch (e) {
67
+ if (e?.code === 'ERR_MODULE_NOT_FOUND') {
68
+ console.error('✗ @duckdb/node-api is not installed. Install it to use "zr history":');
69
+ console.error(' npm i -D @duckdb/node-api');
70
+ process.exit(1);
71
+ }
72
+ throw e;
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Run a query and return object-keyed rows (one object per row, keyed by
78
+ * column name) so both the table printer and history.json are uniform.
79
+ */
80
+ async function runQuery(conn, sql, params) {
81
+ const reader = await conn.runAndReadAll(sql, params);
82
+ const columns = reader.columnNames();
83
+ const rawRows = reader.getRowsJS();
84
+ const rows = rawRows.map((r) => {
85
+ const obj = {};
86
+ columns.forEach((c, i) => {
87
+ let v = r[i];
88
+ if (typeof v === 'bigint') v = Number(v);
89
+ obj[c] = v ?? null;
90
+ });
91
+ return obj;
92
+ });
93
+ return { columns, rows };
94
+ }
95
+
96
+ function formatDuration(ms) {
97
+ if (ms === null || ms === undefined || isNaN(ms)) return '';
98
+ const totalSeconds = Math.round(Number(ms) / 1000);
99
+ if (totalSeconds < 60) {
100
+ return `${totalSeconds}s`;
101
+ }
102
+ const minutes = Math.floor(totalSeconds / 60);
103
+ const seconds = totalSeconds % 60;
104
+ return `${minutes}m ${seconds}s`;
105
+ }
106
+
107
+ function toProperCase(str) {
108
+ return str
109
+ .split('_')
110
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
111
+ .join(' ');
112
+ }
113
+
114
+ function isNumericValue(val) {
115
+ if (val === null || val === undefined) return false;
116
+ if (typeof val === 'number') return true;
117
+ if (typeof val === 'string' && val.trim() !== '') {
118
+ // Check for numbers or formatted duration strings like "5s" or "1m 20s" or percentages "70.6%"
119
+ if (!isNaN(Number(val))) return true;
120
+ if (/^\d+s$/.test(val) || /^\d+m \d+s$/.test(val) || /^\d+(\.\d+)?%$/.test(val)) return true;
121
+ }
122
+ return false;
123
+ }
124
+
125
+ function formatDate(val) {
126
+ if (val === null || val === undefined || val === '') return '';
127
+ const d = val instanceof Date ? val : new Date(val);
128
+ if (isNaN(d.getTime())) return String(val);
129
+ const pad = (n) => String(n).padStart(2, '0');
130
+ const year = d.getFullYear();
131
+ const month = pad(d.getMonth() + 1);
132
+ const day = pad(d.getDate());
133
+ const hours = pad(d.getHours());
134
+ const mins = pad(d.getMinutes());
135
+ const secs = pad(d.getSeconds());
136
+ return `${year}-${month}-${day} ${hours}:${mins}:${secs}`;
137
+ }
138
+
139
+ function printTable({ columns, rows }, emptyMessage = 'No records found.', entityName = 'records') {
140
+ if (!columns || columns.length === 0) return;
141
+
142
+ // Header mapping: Proper Case without 'ms'
143
+ const displayHeaders = [
144
+ 'Sr. No.',
145
+ ...columns.map((c) => {
146
+ let header = c;
147
+ if (header.endsWith('_ms')) {
148
+ header = header.slice(0, -3);
149
+ } else {
150
+ header = header.replace('_ms_', '_');
151
+ }
152
+ return toProperCase(header);
153
+ }),
154
+ ];
155
+
156
+ const formattedRows = rows.map((r, idx) => [
157
+ idx + 1,
158
+ ...columns.map((c) => {
159
+ const val = r && c in r ? r[c] : null;
160
+ if (val === null || val === undefined) return '';
161
+ if (c.includes('ms')) {
162
+ return formatDuration(val);
163
+ }
164
+ if (c.endsWith('_at') || val instanceof Date) {
165
+ return formatDate(val);
166
+ }
167
+ return val;
168
+ }),
169
+ ]);
170
+
171
+ let stringRows = formattedRows.map((row) =>
172
+ row.map((cell) => {
173
+ if (cell instanceof Date) return formatDate(cell);
174
+ return String(cell);
175
+ })
176
+ );
177
+
178
+ // Determine if column is predominantly numeric for right-alignment
179
+ const isNumericColumn = [
180
+ true,
181
+ ...columns.map((_, colIdx) => {
182
+ return formattedRows.some((row) => isNumericValue(row[colIdx + 1]));
183
+ }),
184
+ ];
185
+
186
+ // Responsive column width calculation to prevent line-wrapping in terminals
187
+ const termWidth =
188
+ process.stdout.columns && process.stdout.columns > 20 ? process.stdout.columns : 0;
189
+
190
+ // Split a cell string into up to 2 lines matching the column width.
191
+ // If the string exceeds 2 * width, line 2 gets elided with '...'.
192
+ const wrapCell2Lines = (cellStr, width) => {
193
+ const s = String(cellStr).trim();
194
+ if (s.length <= width) {
195
+ return [s, ''];
196
+ }
197
+
198
+ // Try to find last space before width limit
199
+ let splitIdx = s.lastIndexOf(' ', width);
200
+ if (splitIdx <= Math.floor(width / 3)) {
201
+ splitIdx = width; // fall back to hard cut if no suitable space
202
+ }
203
+
204
+ const line1 = s.slice(0, splitIdx).trimEnd();
205
+ const remainder = s.slice(splitIdx).trimStart();
206
+
207
+ if (remainder.length <= width) {
208
+ return [line1, remainder];
209
+ }
210
+
211
+ const line2 = width > 3 ? `${remainder.slice(0, width - 3)}...` : remainder.slice(0, width);
212
+ return [line1, line2];
213
+ };
214
+
215
+ // Split headers into up to 2 lines naturally by word boundary
216
+ const header2Lines = displayHeaders.map((h, colIdx) => {
217
+ const s = h.trim();
218
+ const maxValLen = Math.max(
219
+ colIdx === 0 ? String(stringRows.length).length : 0,
220
+ ...stringRows.map((r) => r[colIdx].length)
221
+ );
222
+ // If header has multiple words and is longer than data values (or > 6 chars), split into 2 lines
223
+ if (s.includes(' ') && s.length > Math.max(maxValLen, 6)) {
224
+ const mid = Math.floor(s.length / 2);
225
+ let spaceIdx = s.lastIndexOf(' ', mid);
226
+ if (spaceIdx === -1) spaceIdx = s.indexOf(' ', mid);
227
+ if (spaceIdx !== -1) {
228
+ const line1 = s.slice(0, spaceIdx).trim();
229
+ const line2 = s.slice(spaceIdx + 1).trim();
230
+ return [line1, line2];
231
+ }
232
+ }
233
+ return [s, ''];
234
+ });
235
+
236
+ // Determine initial widths based on maximum of wrapped header lines and data rows
237
+ const naturalWidths = displayHeaders.map((_, colIdx) => {
238
+ const [h1, h2] = header2Lines[colIdx];
239
+ const maxValLen = Math.max(
240
+ colIdx === 0 ? String(stringRows.length).length : 0,
241
+ ...stringRows.map((row) => row[colIdx].length)
242
+ );
243
+ return Math.max(h1.length, h2.length, maxValLen);
244
+ });
245
+
246
+ let widths = [...naturalWidths];
247
+ const totalCols = displayHeaders.length;
248
+
249
+ if (termWidth > 0) {
250
+ // Total overhead = (| ) per column + starting | = columns * 3 + 1
251
+ const overhead = totalCols * 3 + 1;
252
+ const availableWidth = termWidth - overhead;
253
+ let totalNaturalWidth = naturalWidths.reduce((a, b) => a + b, 0);
254
+
255
+ if (totalNaturalWidth > availableWidth && availableWidth > totalCols * 5) {
256
+ const flexIndices = [];
257
+ let fixedWidthSum = 0;
258
+
259
+ displayHeaders.forEach((_, idx) => {
260
+ const origCol = idx > 0 ? columns[idx - 1] : '';
261
+ const isFlex =
262
+ idx > 0 && !isNumericColumn[idx] && !origCol.endsWith('_at') && naturalWidths[idx] > 15;
263
+ if (isFlex) {
264
+ flexIndices.push(idx);
265
+ } else {
266
+ fixedWidthSum += naturalWidths[idx];
267
+ }
268
+ });
269
+
270
+ if (flexIndices.length > 0) {
271
+ const flexAvailable = Math.max(flexIndices.length * 8, availableWidth - fixedWidthSum);
272
+ const flexNaturalSum = flexIndices.reduce((sum, i) => sum + naturalWidths[i], 0);
273
+
274
+ flexIndices.forEach((idx) => {
275
+ const share = Math.floor((naturalWidths[idx] / flexNaturalSum) * flexAvailable);
276
+ widths[idx] = Math.max(8, share);
277
+ });
278
+ }
279
+ }
280
+ }
281
+
282
+ const wrappedHeaders = displayHeaders.map((h, i) => {
283
+ const [h1, h2] = header2Lines[i];
284
+ const targetW = widths[i];
285
+ // If line1 or line2 exceed targetW, use wrapCell2Lines
286
+ if (h1.length > targetW || h2.length > targetW) {
287
+ return wrapCell2Lines(h, targetW);
288
+ }
289
+ return [h1, h2];
290
+ });
291
+
292
+ const hasHeaderLine2 = wrappedHeaders.some((lines) => lines[1].length > 0);
293
+
294
+ const formatRow = (cells) => {
295
+ const formattedCells = cells.map((cell, i) => {
296
+ const s = String(cell);
297
+ const w = widths[i];
298
+ if (isNumericColumn[i]) {
299
+ return s.padStart(w);
300
+ }
301
+ return s.padEnd(w);
302
+ });
303
+ return `| ${formattedCells.join(' | ')} |`;
304
+ };
305
+
306
+ const border = `+${widths.map((w) => '-'.repeat(w + 2)).join('+')}+`;
307
+
308
+ console.log(border);
309
+ console.log(formatRow(wrappedHeaders.map((lines) => lines[0])));
310
+ if (hasHeaderLine2) {
311
+ console.log(formatRow(wrappedHeaders.map((lines) => lines[1])));
312
+ }
313
+ console.log(border);
314
+
315
+ if (stringRows.length === 0) {
316
+ const totalInnerWidth = widths.reduce((sum, w) => sum + w + 3, 0) - 1;
317
+ const msg =
318
+ emptyMessage.length > totalInnerWidth - 2
319
+ ? emptyMessage.slice(0, totalInnerWidth - 5) + '...'
320
+ : emptyMessage;
321
+ const padTotal = totalInnerWidth - msg.length;
322
+ const padLeft = Math.floor(padTotal / 2);
323
+ const padRight = padTotal - padLeft;
324
+ console.log(`|${' '.repeat(padLeft)}${msg}${' '.repeat(padRight)}|`);
325
+ } else {
326
+ let isFirst = true;
327
+ for (const row of stringRows) {
328
+ if (!isFirst) {
329
+ console.log(border);
330
+ }
331
+ isFirst = false;
332
+
333
+ const wrappedCells = row.map((cell, i) => wrapCell2Lines(cell, widths[i]));
334
+ const hasLine2 = wrappedCells.some((lines) => lines[1].length > 0);
335
+
336
+ const line1Cells = wrappedCells.map((lines) => lines[0]);
337
+ console.log(formatRow(line1Cells));
338
+
339
+ if (hasLine2) {
340
+ const line2Cells = wrappedCells.map((lines) => lines[1]);
341
+ console.log(formatRow(line2Cells));
342
+ }
343
+ }
344
+ }
345
+ console.log(border);
346
+ const label = rows.length === 1 ? entityName.replace(/s$/, '') : entityName;
347
+ console.log(`Total ${rows.length} ${label}.`);
348
+ }
349
+
350
+ function generateMarkdownSummaryFromReport(reportData) {
351
+ const testRun = reportData.testRun || {};
352
+ const summary = testRun.summary || {};
353
+ const total = summary.total || 0;
354
+ const passed = summary.passed || 0;
355
+ const failed = summary.failed || 0;
356
+ const timedOut = summary.timedOut || 0;
357
+ const skipped = summary.skipped || 0;
358
+ const interrupted = summary.interrupted || 0;
359
+ const hasFailures = failed > 0 || timedOut > 0 || interrupted > 0;
360
+ const statusStr = hasFailures ? '❌ FAILED' : '✅ PASSED';
361
+ const passRate = total > 0 ? ((passed / total) * 100).toFixed(1).replace(/\.0$/, '') : '0';
362
+ const durationStr = formatDuration(summary.duration || 0);
363
+
364
+ const titleProject = testRun.projectName || 'Test Automation Project';
365
+ const titleRun = testRun.testRunName || 'Test Run';
366
+
367
+ let md = `### 📊 Test Run Summary: ${titleProject} — ${titleRun}\n\n`;
368
+ md += `**Status:** ${statusStr}\n`;
369
+ md += `**Duration:** ${durationStr}\n`;
370
+ md += `**Pass Rate:** ${passRate}%\n\n`;
371
+ md += `| Total | Passed | Failed | Timed Out | Skipped |\n`;
372
+ md += `| :---: | :---: | :---: | :-------: | :-----: |\n`;
373
+ md += `| ${total} | ${passed} | ${failed} | ${timedOut} | ${skipped} |\n`;
374
+
375
+ const failedCases = [];
376
+ function collectFailedCases(suites) {
377
+ if (!suites || !Array.isArray(suites)) return;
378
+ for (const suite of suites) {
379
+ if (suite.cases && Array.isArray(suite.cases)) {
380
+ for (const tc of suite.cases) {
381
+ if (tc.status === 'failed' || tc.status === 'timedOut' || tc.status === 'interrupted') {
382
+ failedCases.push(tc);
383
+ }
384
+ }
385
+ }
386
+ if (suite.subSuites) {
387
+ collectFailedCases(suite.subSuites);
388
+ }
389
+ }
390
+ }
391
+ collectFailedCases(testRun.suites);
392
+
393
+ if (failedCases.length > 0) {
394
+ md += `\n#### ❌ Failed Tests (${failedCases.length})\n`;
395
+ const limit = 15;
396
+ const displayed = failedCases.slice(0, limit);
397
+ for (const tc of displayed) {
398
+ const projectStr = tc.project && tc.project !== 'unknown' ? `**[${tc.project}]** ` : '';
399
+ const fileStr = tc.fileName ? `\`${tc.fileName}\` › ` : '';
400
+ md += `- ${projectStr}${fileStr}${tc.title}\n`;
401
+ }
402
+ if (failedCases.length > limit) {
403
+ md += `- ... and ${failedCases.length - limit} more failed tests\n`;
404
+ }
405
+ }
406
+
407
+ return md;
408
+ }
409
+
410
+ function printHelp() {
411
+ console.log(`
412
+ Zen Reporter CLI
413
+
414
+ Usage:
415
+ npx zr show Serve and view the HTML report
416
+ npx zr summary Output Markdown summary snippet for current run results
417
+ npx zr history List historic runs
418
+ npx zr history runs Same as above
419
+ npx zr history flaky Tests that failed in some runs and passed in others
420
+ npx zr history regressions Tests that passed in one run, failed in the next (latest regression per test)
421
+ npx zr history slow [--limit N] Slowest tests across runs (default 10)
422
+ npx zr history trend Per-run pass rate over time
423
+ npx zr history report Generate history.json and inject into index.html (History tab)
424
+ npx zr history query "<SQL>" Arbitrary SQL; table "runs" = read_json of runs/*.jsonl
425
+ `);
426
+ }
427
+
428
+ const RUNS_SQL = `SELECT DISTINCT run_name, started_at, run_duration_ms, run_total, run_passed, run_failed, run_timed_out, run_skipped, run_interrupted FROM ${SRC} ORDER BY started_at DESC`;
429
+
430
+ const FLAKY_SQL = `SELECT suite, file, title, project, count(*) FILTER (status IN ('failed', 'timedOut')) AS failed_runs, count(*) FILTER (status = 'passed') AS passed_runs, sum(passed_on_retry) AS recovered_by_retry, count(*) AS total_runs FROM ${SRC} GROUP BY suite, file, title, project HAVING failed_runs > 0 AND passed_runs > 0 ORDER BY failed_runs DESC, passed_runs DESC`;
431
+
432
+ const REGRESSIONS_SQL = `WITH run_tests AS (SELECT run_id, run_name, suite, file, title, project, max(started_at) AS started_at, CASE WHEN count(*) FILTER (status = 'failed') > 0 THEN 'failed' WHEN count(*) FILTER (status = 'timedOut') > 0 THEN 'timedOut' WHEN count(*) FILTER (status = 'interrupted') > 0 THEN 'interrupted' WHEN count(*) FILTER (status = 'skipped') > 0 THEN 'skipped' ELSE 'passed' END AS status FROM ${SRC} GROUP BY run_id, run_name, suite, file, title, project), ordered AS (SELECT run_id, run_name, started_at, suite, file, title, project, status, row_number() OVER (PARTITION BY suite, file, title, project ORDER BY started_at, run_id) AS seq FROM run_tests), regressions AS (SELECT b.run_name AS regressed_in, b.started_at AS regressed_at, b.suite AS suite, b.file AS file, b.title AS title, b.project AS project FROM ordered b JOIN ordered a ON a.suite = b.suite AND a.file = b.file AND a.title = b.title AND a.project = b.project AND b.seq = a.seq + 1 WHERE a.status = 'passed' AND b.status IN ('failed', 'timedOut', 'interrupted')), latest AS (SELECT run_id, run_name, started_at, status, suite, file, title, project, row_number() OVER (PARTITION BY suite, file, title, project ORDER BY started_at DESC, run_id DESC) AS rn FROM run_tests) SELECT r.suite, r.file, r.title, r.project, r.regressed_in, r.regressed_at, l.status AS last_status, l.started_at AS last_run_at FROM (SELECT r.*, row_number() OVER (PARTITION BY suite, file, title, project ORDER BY regressed_at DESC) AS rn FROM regressions r) r JOIN latest l ON l.suite = r.suite AND l.file = r.file AND l.title = r.title AND l.project = r.project AND l.rn = 1 WHERE r.rn = 1 ORDER BY l.started_at DESC`;
433
+
434
+ const SLOW_SQL = `SELECT suite, file, title, project, round(avg(duration_ms), 1) AS avg_duration_ms, max(duration_ms) AS max_duration_ms, arg_max(duration_ms, started_at) AS last_duration_ms, count(*) AS runs FROM ${SRC} GROUP BY suite, file, title, project ORDER BY avg(duration_ms) DESC LIMIT ?`;
435
+
436
+ const TREND_SQL = `SELECT run_name, started_at, run_total, run_passed, run_failed, round(100.0 * run_passed / nullif(run_total, 0), 1) AS pass_rate FROM ${SRC} GROUP BY run_id, run_name, started_at, run_total, run_passed, run_failed ORDER BY started_at`;
437
+
438
+ const PROJECT_DURATIONS_SQL = `SELECT run_id, started_at, project, round(sum(duration_ms) / 1000.0, 2) AS duration_sec FROM ${SRC} WHERE project IS NOT NULL AND project != '' GROUP BY run_id, started_at, project ORDER BY started_at ASC`;
439
+
440
+ const FILES_SQL = `SELECT run_id, started_at, file, count(*) AS total, count(*) FILTER (status = 'passed') AS passed, count(*) FILTER (status = 'failed') AS failed, count(*) FILTER (status = 'timedOut') AS timed_out, count(*) FILTER (status = 'interrupted') AS interrupted, count(*) FILTER (status = 'skipped') AS skipped FROM ${SRC} WHERE file IS NOT NULL AND file != '' GROUP BY run_id, started_at, file ORDER BY started_at ASC, file ASC`;
441
+
442
+ const FILES_SUMMARY_SQL = `SELECT file, count(DISTINCT run_id) AS runs, count(*) AS total_tests, count(*) FILTER (status = 'passed') AS passed, count(*) FILTER (status = 'failed') AS failed, count(*) FILTER (status = 'timedOut') AS timed_out, count(*) FILTER (status = 'interrupted') AS interrupted, count(*) FILTER (status = 'skipped') AS skipped, round(100.0 * count(*) FILTER (status = 'passed') / nullif(count(*), 0), 1) AS pass_rate FROM ${SRC} WHERE file IS NOT NULL AND file != '' GROUP BY file ORDER BY total_tests DESC`;
443
+
444
+ const TESTS_SQL = `SELECT run_id, started_at, suite, file, title, project, count(*) AS total, count(*) FILTER (status = 'passed') AS passed, count(*) FILTER (status = 'failed') AS failed, count(*) FILTER (status = 'timedOut') AS timed_out, count(*) FILTER (status = 'interrupted') AS interrupted, count(*) FILTER (status = 'skipped') AS skipped, round(avg(duration_ms), 1) AS avg_duration_ms FROM ${SRC} WHERE title IS NOT NULL AND title != '' GROUP BY run_id, started_at, suite, file, title, project ORDER BY started_at ASC, file ASC, title ASC`;
445
+
446
+ const TESTS_SUMMARY_SQL = `SELECT suite, file, title, project, count(DISTINCT run_id) AS runs, count(*) AS total_executions, count(*) FILTER (status = 'passed') AS passed, count(*) FILTER (status = 'failed') AS failed, count(*) FILTER (status = 'timedOut') AS timed_out, count(*) FILTER (status = 'interrupted') AS interrupted, count(*) FILTER (status = 'skipped') AS skipped, round(avg(duration_ms), 1) AS avg_duration_ms, round(100.0 * count(*) FILTER (status = 'passed') / nullif(count(*), 0), 1) AS pass_rate FROM ${SRC} WHERE title IS NOT NULL AND title != '' GROUP BY suite, file, title, project ORDER BY total_executions DESC`;
447
+
448
+ async function generateHistoryReport(conn) {
449
+ const runsSql = `SELECT DISTINCT run_id, run_name, started_at, run_duration_ms, coalesce(max(run_sequential_duration_ms), sum(duration_ms)) AS run_sequential_duration_ms, max(run_workers) AS run_workers, run_total, run_passed, run_failed, run_timed_out, run_skipped, run_interrupted, round(100.0 * run_passed / nullif(run_total, 0), 1) AS pass_rate FROM ${SRC} GROUP BY run_id, run_name, started_at, run_duration_ms, run_total, run_passed, run_failed, run_timed_out, run_skipped, run_interrupted ORDER BY started_at ASC`;
450
+
451
+ const [
452
+ runsResult,
453
+ flakyResult,
454
+ regressionsResult,
455
+ slowResult,
456
+ projectDurationsResult,
457
+ filesResult,
458
+ testsResult,
459
+ ] = await Promise.all([
460
+ runQuery(conn, runsSql, [runsGlob]),
461
+ runQuery(conn, FLAKY_SQL, [runsGlob]),
462
+ runQuery(conn, REGRESSIONS_SQL, [runsGlob]),
463
+ runQuery(conn, SLOW_SQL, [runsGlob, 10]),
464
+ runQuery(conn, PROJECT_DURATIONS_SQL, [runsGlob]),
465
+ runQuery(conn, FILES_SQL, [runsGlob]),
466
+ runQuery(conn, TESTS_SQL, [runsGlob]),
467
+ ]);
468
+
469
+ const projectDurationsByRun = {};
470
+ for (const row of projectDurationsResult.rows) {
471
+ if (!projectDurationsByRun[row.run_id]) {
472
+ projectDurationsByRun[row.run_id] = {};
473
+ }
474
+ projectDurationsByRun[row.run_id][row.project] = row.duration_sec;
475
+ }
476
+
477
+ const runs = runsResult.rows.map((run) => ({
478
+ ...run,
479
+ project_durations: projectDurationsByRun[run.run_id] || {},
480
+ }));
481
+
482
+ const history = {
483
+ generated_at: new Date().toISOString(),
484
+ runs,
485
+ flaky: flakyResult.rows,
486
+ regressions: regressionsResult.rows,
487
+ slowest: slowResult.rows,
488
+ files: filesResult.rows,
489
+ tests: testsResult.rows,
490
+ project_trends: projectDurationsResult.rows,
491
+ };
492
+
493
+ const historyPath = resolve(cwd, historyDir, 'history.json');
494
+ writeFileSync(historyPath, JSON.stringify(history, null, 2), 'utf8');
495
+
496
+ const indexPath = resolve(cwd, historyDir, 'index.html');
497
+ if (!existsSync(indexPath)) {
498
+ console.error('! index.html not found — history.json written but not injected.');
499
+ return;
500
+ }
501
+
502
+ const script = `<script id="history-data" type="application/json">${JSON.stringify(
503
+ history
504
+ ).replace(/</g, '\\u003c')}</script>`;
505
+ let html = readFileSync(indexPath, 'utf8');
506
+ html = html
507
+ .replace(/<script id="history-data" type="application\/json">[\s\S]*?<\/script>/, '')
508
+ .replace('</head>', `${script}\n</head>`);
509
+ writeFileSync(indexPath, html, 'utf8');
510
+ console.log(`✓ Execution history updated.`);
511
+ }
512
+
513
+ async function handleHistory(subArgs) {
514
+ const sub = subArgs[0] || 'runs';
515
+ const conn = await openDuckDb();
516
+ findRunFiles();
517
+ try {
518
+ switch (sub) {
519
+ case 'runs':
520
+ printTable(
521
+ await runQuery(conn, RUNS_SQL, [runsGlob]),
522
+ 'No historic test runs found.',
523
+ 'runs'
524
+ );
525
+ break;
526
+
527
+ case 'flaky':
528
+ printTable(
529
+ await runQuery(conn, FLAKY_SQL, [runsGlob]),
530
+ 'No flaky tests found.',
531
+ 'flaky tests'
532
+ );
533
+ break;
534
+
535
+ case 'regressions':
536
+ printTable(
537
+ await runQuery(conn, REGRESSIONS_SQL, [runsGlob]),
538
+ 'No test regressions found.',
539
+ 'regressions'
540
+ );
541
+ break;
542
+
543
+ case 'slow': {
544
+ const idx = subArgs.indexOf('--limit');
545
+ const rawLimit =
546
+ idx >= 0 ? subArgs[idx + 1] : /^\d+$/.test(subArgs[1] || '') ? subArgs[1] : '10';
547
+ const limit = Math.max(1, parseInt(rawLimit, 10) || 10);
548
+ printTable(
549
+ await runQuery(conn, SLOW_SQL, [runsGlob, limit]),
550
+ 'No slow tests found.',
551
+ 'slow tests'
552
+ );
553
+ break;
554
+ }
555
+
556
+ case 'trend':
557
+ printTable(await runQuery(conn, TREND_SQL, [runsGlob]), 'No trend data found.', 'runs');
558
+ break;
559
+
560
+ case 'files':
561
+ printTable(
562
+ await runQuery(conn, FILES_SUMMARY_SQL, [runsGlob]),
563
+ 'No spec files history found.',
564
+ 'spec files'
565
+ );
566
+ break;
567
+
568
+ case 'tests':
569
+ printTable(
570
+ await runQuery(conn, TESTS_SUMMARY_SQL, [runsGlob]),
571
+ 'No test history found.',
572
+ 'tests'
573
+ );
574
+ break;
575
+
576
+ case 'report':
577
+ await generateHistoryReport(conn);
578
+ break;
579
+
580
+ case 'query': {
581
+ const sqlText = subArgs.slice(1).join(' ').trim();
582
+ if (!sqlText) {
583
+ console.error('✗ Usage: zr history query "<SQL>"');
584
+ process.exit(1);
585
+ }
586
+ const usesReadJson = sqlText.includes('read_json');
587
+ const sql = usesReadJson
588
+ ? sqlText
589
+ : `WITH runs AS (SELECT * FROM read_json(?, format='newline_delimited')) ${sqlText}`;
590
+ const params = usesReadJson ? [] : [runsGlob];
591
+ try {
592
+ printTable(await runQuery(conn, sql, params), 'No matching records found.', 'records');
593
+ } catch (e) {
594
+ console.error('✗ Query failed:', e?.message || e);
595
+ process.exit(1);
596
+ }
597
+ break;
598
+ }
599
+
600
+ default:
601
+ printHelp();
602
+ }
603
+ } finally {
604
+ try {
605
+ conn.closeSync();
606
+ } catch {
607
+ /* ignore */
608
+ }
609
+ }
610
+ }
611
+
612
+ // ── dispatcher ───────────────────────────────────────────────────────────
613
+
614
+ if (command === 'show') {
615
+ const outputDir = process.env.PW_REPORTER_OUTPUT || 'zen-report';
616
+ const reportPath = resolve(cwd, outputDir, 'index.html');
617
+
618
+ if (!existsSync(reportPath)) {
619
+ console.error(`✗ Report file not found at "${outputDir}/index.html".`);
620
+ console.error(` Make sure you have run your Playwright tests first.`);
621
+ process.exit(1);
622
+ }
623
+
624
+ const pm = detectPackageManager(cwd);
625
+ const showCmd =
626
+ pm === 'pnpm'
627
+ ? `pnpm exec playwright show-report ${outputDir}`
628
+ : pm === 'yarn'
629
+ ? `yarn exec playwright show-report ${outputDir}`
630
+ : pm === 'bun'
631
+ ? `bunx playwright show-report ${outputDir}`
632
+ : `npx playwright show-report ${outputDir}`;
633
+
634
+ try {
635
+ execSync(showCmd, { cwd, stdio: 'inherit' });
636
+ } catch (err) {
637
+ if (
638
+ err &&
639
+ typeof err === 'object' &&
640
+ 'status' in err &&
641
+ err.status !== 130 &&
642
+ err.status !== 0
643
+ ) {
644
+ console.error('✗ Failed to launch report server:', err.message || err);
645
+ }
646
+ }
647
+ } else if (command === 'summary') {
648
+ const outputDir = process.env.PW_REPORTER_OUTPUT || 'zen-report';
649
+ const reportJsonPath = resolve(cwd, outputDir, 'report.json');
650
+
651
+ if (!existsSync(reportJsonPath)) {
652
+ console.error(`✗ Report file not found at "${outputDir}/report.json".`);
653
+ console.error(` Make sure you have run your Playwright tests first.`);
654
+ process.exit(1);
655
+ }
656
+
657
+ try {
658
+ const rawData = readFileSync(reportJsonPath, 'utf8');
659
+ const reportData = JSON.parse(rawData);
660
+ const summaryMd = generateMarkdownSummaryFromReport(reportData);
661
+ console.log(summaryMd);
662
+ } catch (err) {
663
+ console.error('✗ Failed to read report summary:', err.message || err);
664
+ process.exit(1);
665
+ }
666
+ } else if (command === 'history') {
667
+ handleHistory(args.slice(1)).catch((e) => {
668
+ console.error('✗ history command failed:', e?.message || e);
669
+ process.exit(1);
670
+ });
671
+ } else {
672
+ printHelp();
673
+ }