@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,846 @@
1
+ import { createRequire } from "node:module";
2
+ import { execFileSync, execSync } from "child_process";
3
+ import * as fs from "fs";
4
+ import { existsSync, readFileSync } from "fs";
5
+ import * as path from "path";
6
+ import { resolve } from "path";
7
+ import * as readline from "readline";
8
+ import { fileURLToPath } from "url";
9
+ //#region \0rolldown/runtime.js
10
+ var __require = /* #__PURE__ */ (() => createRequire(import.meta.url))();
11
+ //#endregion
12
+ //#region src/lib/utils.ts
13
+ var customFsInstance = null;
14
+ function setFsModule(fsInstance) {
15
+ customFsInstance = fsInstance;
16
+ }
17
+ function getNodeFs() {
18
+ if (typeof window !== "undefined") return null;
19
+ if (customFsInstance) return customFsInstance;
20
+ try {
21
+ const proc = typeof process !== "undefined" ? process : void 0;
22
+ if (proc && typeof proc.getBuiltinModule === "function") {
23
+ const builtinFs = proc.getBuiltinModule("fs");
24
+ if (builtinFs) return builtinFs;
25
+ }
26
+ const g = globalThis;
27
+ if (typeof g.require === "function") return g.require("fs");
28
+ } catch {
29
+ return null;
30
+ }
31
+ return null;
32
+ }
33
+ var fileSnippetCache = /* @__PURE__ */ new Map();
34
+ function getStepCodeSnippet(location, fsModule) {
35
+ if (!location || !location.file || !location.line) return;
36
+ try {
37
+ const fs = fsModule || getNodeFs();
38
+ if (!fs) return void 0;
39
+ let filePath = location.file;
40
+ if (!fs.existsSync(filePath)) {
41
+ const resolved = typeof process !== "undefined" && process.cwd ? `${process.cwd()}/${filePath}` : filePath;
42
+ if (fs.existsSync(resolved)) filePath = resolved;
43
+ else return;
44
+ }
45
+ let lines = fileSnippetCache.get(filePath);
46
+ if (!lines) {
47
+ lines = fs.readFileSync(filePath, "utf8").split(/\r?\n/);
48
+ fileSnippetCache.set(filePath, lines);
49
+ }
50
+ const targetLine = location.line;
51
+ if (targetLine < 1 || targetLine > lines.length) return void 0;
52
+ const startLine = Math.max(1, targetLine - 1);
53
+ const endLine = Math.min(lines.length, targetLine + 1);
54
+ const snippetLines = [];
55
+ for (let l = startLine; l <= endLine; l++) {
56
+ const prefix = l === targetLine ? ">" : " ";
57
+ const lineNum = String(l).padStart(4, " ");
58
+ snippetLines.push(`${prefix} ${lineNum} | ${lines[l - 1]}`);
59
+ }
60
+ return snippetLines.join("\n");
61
+ } catch {}
62
+ }
63
+ function convertPlaywrightSteps(pwSteps, fsModule) {
64
+ const activeFs = fsModule || getNodeFs();
65
+ return pwSteps.map((step) => {
66
+ const rawStep = step;
67
+ const rawError = step.error;
68
+ const error = step.error ? {
69
+ name: "Error",
70
+ message: step.error.message ?? "",
71
+ stack: step.error.stack || "",
72
+ location: step.error.location ? {
73
+ file: step.error.location.file,
74
+ line: step.error.location.line,
75
+ column: step.error.location.column
76
+ } : null,
77
+ snippet: step.error.snippet || "",
78
+ cause: rawError?.cause || null
79
+ } : null;
80
+ const status = step.error ? "failed" : "passed";
81
+ const location = step.location ? {
82
+ file: step.location.file,
83
+ line: step.location.line,
84
+ column: step.location.column
85
+ } : null;
86
+ const snippet = (typeof rawStep.snippet === "string" ? rawStep.snippet : void 0) || getStepCodeSnippet(location, activeFs);
87
+ const rawParams = rawStep.params;
88
+ const params = rawParams && typeof rawParams === "object" ? rawParams : void 0;
89
+ return {
90
+ title: step.title,
91
+ subtitle: typeof rawStep.subtitle === "string" ? rawStep.subtitle : void 0,
92
+ location,
93
+ snippet,
94
+ params,
95
+ duration: step.duration,
96
+ status,
97
+ annotations: Array.isArray(rawStep.annotations) ? rawStep.annotations : [],
98
+ attachments: step.attachments ? step.attachments.map((att) => {
99
+ let bodyData = null;
100
+ if (att.body) bodyData = Buffer.isBuffer(att.body) ? att.body.toString("base64") : typeof att.body === "string" ? att.body : Buffer.from(att.body).toString("base64");
101
+ else if (att.path) {
102
+ if (activeFs && activeFs.existsSync(att.path)) try {
103
+ bodyData = activeFs.readFileSync(att.path).toString("base64");
104
+ } catch {}
105
+ }
106
+ return {
107
+ name: att.name,
108
+ contentType: att.contentType,
109
+ path: att.path || null,
110
+ body: bodyData
111
+ };
112
+ }) : [],
113
+ error,
114
+ subSteps: step.steps && step.steps.length > 0 ? convertPlaywrightSteps(step.steps, activeFs) : void 0
115
+ };
116
+ });
117
+ }
118
+ function sanitizeAnsi(str) {
119
+ return str.replace(/\x1b\[[0-9;]*m/g, "");
120
+ }
121
+ function buildHierarchyForFile(fileName, cases) {
122
+ const rootSuite = {
123
+ title: fileName,
124
+ cases: [],
125
+ subSuites: []
126
+ };
127
+ for (const tc of cases) {
128
+ const describePath = tc.describePath || (tc.parent && tc.parent !== tc.fileName ? [tc.parent] : []);
129
+ let currentSuite = rootSuite;
130
+ for (const title of describePath) {
131
+ if (!currentSuite.subSuites) currentSuite.subSuites = [];
132
+ let child = currentSuite.subSuites.find((s) => s.title === title);
133
+ if (!child) {
134
+ child = {
135
+ title,
136
+ cases: [],
137
+ subSuites: []
138
+ };
139
+ currentSuite.subSuites.push(child);
140
+ }
141
+ currentSuite = child;
142
+ }
143
+ currentSuite.cases.push(tc);
144
+ }
145
+ return rootSuite;
146
+ }
147
+ function buildSuitesFromCases(testCases) {
148
+ const fileGroups = {};
149
+ for (const tc of testCases) {
150
+ const key = tc.fileName || "unknown file";
151
+ if (!fileGroups[key]) fileGroups[key] = [];
152
+ fileGroups[key].push(tc);
153
+ }
154
+ const suites = [];
155
+ for (const [fileName, cases] of Object.entries(fileGroups)) suites.push(buildHierarchyForFile(fileName, cases));
156
+ return suites;
157
+ }
158
+ function formatDuration(ms) {
159
+ if (ms < 1e3) return `${ms}ms`;
160
+ const seconds = Math.floor(ms / 1e3);
161
+ const minutes = Math.floor(seconds / 60);
162
+ const secs = seconds % 60;
163
+ if (minutes > 0) return `${minutes}m ${secs}s`;
164
+ return `${secs}s`;
165
+ }
166
+ function generateTerminalSummaryTable(summary, projectName, testRunName) {
167
+ const total = summary.total || 0;
168
+ const passed = summary.passed || 0;
169
+ const failed = summary.failed || 0;
170
+ const timedOut = summary.timedOut || 0;
171
+ const skipped = summary.skipped || 0;
172
+ const interrupted = summary.interrupted || 0;
173
+ const statusStr = failed > 0 || timedOut > 0 || interrupted > 0 ? "FAILED" : "PASSED";
174
+ const passRate = total > 0 ? `${(passed / total * 100).toFixed(1).replace(/\.0$/, "")}%` : "0%";
175
+ const durationStr = formatDuration(summary.duration || 0);
176
+ const headers = [
177
+ "Status",
178
+ "Duration",
179
+ "Pass Rate",
180
+ "Total",
181
+ "Passed",
182
+ "Failed",
183
+ "Timed Out",
184
+ "Skipped"
185
+ ];
186
+ const values = [
187
+ statusStr,
188
+ durationStr,
189
+ passRate,
190
+ String(total),
191
+ String(passed),
192
+ String(failed),
193
+ String(timedOut),
194
+ String(skipped)
195
+ ];
196
+ if (interrupted > 0) {
197
+ headers.push("Interrupted");
198
+ values.push(String(interrupted));
199
+ }
200
+ const widths = headers.map((h, i) => Math.max(h.length, values[i].length));
201
+ const border = `+${widths.map((w) => "-".repeat(w + 2)).join("+")}+`;
202
+ const formatRow = (cells) => `| ${cells.map((c, i) => c.padEnd(widths[i])).join(" | ")} |`;
203
+ return `${`Test Summary: ${projectName || "Test Automation Project"} — ${testRunName || "Test Run"}`}\n${border}\n${formatRow(headers)}\n${border}\n${formatRow(values)}\n${border}`;
204
+ }
205
+ //#endregion
206
+ //#region src/lib/dataProcessor.ts
207
+ function detectPackageManager(cwd = process.cwd()) {
208
+ for (const [file, manager] of [
209
+ ["pnpm-lock.yaml", "pnpm"],
210
+ ["yarn.lock", "yarn"],
211
+ ["bun.lock", "bun"],
212
+ ["package-lock.json", "npm"]
213
+ ]) if (existsSync(resolve(cwd, file))) return manager;
214
+ if (existsSync(resolve(cwd, ".npmrc"))) return "npm";
215
+ try {
216
+ const pkgPath = resolve(cwd, "package.json");
217
+ if (existsSync(pkgPath)) {
218
+ const pkgJson = JSON.parse(readFileSync(pkgPath, "utf8"));
219
+ if (pkgJson.packageManager) return pkgJson.packageManager.split("@")[0];
220
+ }
221
+ } catch {}
222
+ return "npm";
223
+ }
224
+ function getViteBuildCommand(pm, cwd = process.cwd()) {
225
+ switch (pm || detectPackageManager(cwd)) {
226
+ case "pnpm": return "pnpm exec vite build";
227
+ case "yarn": return "yarn exec vite build";
228
+ case "bun": return "bunx vite build";
229
+ default: return "npx vite build";
230
+ }
231
+ }
232
+ //#endregion
233
+ //#region src/lib/runHistory.ts
234
+ /**
235
+ * Exact ordered field list for a single history row (single source of truth
236
+ * for the JSONL row shape that DuckDB's read_json consumes).
237
+ */
238
+ var RUN_ROW_FIELDS = [
239
+ "run_id",
240
+ "run_name",
241
+ "project_name",
242
+ "started_at",
243
+ "ended_at",
244
+ "run_duration_ms",
245
+ "run_sequential_duration_ms",
246
+ "run_interrupted",
247
+ "run_passed",
248
+ "run_failed",
249
+ "run_skipped",
250
+ "run_timed_out",
251
+ "run_interrupted",
252
+ "run_workers",
253
+ "run_projects",
254
+ "file",
255
+ "suite",
256
+ "title",
257
+ "project",
258
+ "status",
259
+ "duration_ms",
260
+ "attempts",
261
+ "failed_attempts",
262
+ "passed_on_retry",
263
+ "error_message",
264
+ "error_stack",
265
+ "error_file",
266
+ "error_line",
267
+ "tags"
268
+ ];
269
+ /** Lowercase, trim, every non-alphanumeric run -> single '-', strip edge '-'. */
270
+ function slugify(name) {
271
+ return name.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
272
+ }
273
+ /**
274
+ * Deterministic run identifier: ISO start time (colons/dots -> '-') + slugified
275
+ * test run name, plus a shard suffix when running sharded.
276
+ */
277
+ function buildRunId(startedAtIso, testRunName, shard) {
278
+ const base = `${startedAtIso.replace(/[:.]/g, "-")}__${slugify(testRunName)}`;
279
+ if (shard && shard.total > 1) return `${base}__shard${shard.current}of${shard.total}`;
280
+ return base;
281
+ }
282
+ /**
283
+ * One row per test case, each carrying the run-level fields plus the per-test
284
+ * fields, so DuckDB can group/join across runs on the uniform schema.
285
+ */
286
+ function flattenRunRows(runId, testRunName, projectName, summary, endedAt, testCases) {
287
+ const runLevel = {
288
+ run_id: runId,
289
+ run_name: testRunName,
290
+ project_name: projectName,
291
+ started_at: summary.startTime,
292
+ ended_at: endedAt,
293
+ run_duration_ms: summary.duration,
294
+ run_sequential_duration_ms: summary.totalSequentialDuration ?? null,
295
+ run_total: summary.total,
296
+ run_passed: summary.passed,
297
+ run_failed: summary.failed,
298
+ run_skipped: summary.skipped,
299
+ run_timed_out: summary.timedOut,
300
+ run_interrupted: summary.interrupted ?? 0,
301
+ run_workers: summary.workers ?? null,
302
+ run_projects: summary.numberOfProjects
303
+ };
304
+ if (testCases.length === 0) {
305
+ const row = { ...runLevel };
306
+ for (const field of RUN_ROW_FIELDS) if (!(field in row)) row[field] = null;
307
+ return [row];
308
+ }
309
+ return testCases.map((tc) => {
310
+ const row = { ...runLevel };
311
+ row.file = tc.fileName;
312
+ row.suite = (tc.describePath?.length ? tc.describePath.join(" > ") : tc.parent) || "";
313
+ row.title = tc.title;
314
+ row.project = tc.project;
315
+ row.status = tc.status;
316
+ row.duration_ms = Math.round(tc.duration);
317
+ row.attempts = tc.attempts ?? 0;
318
+ row.failed_attempts = tc.failedAttempts?.length ?? 0;
319
+ row.passed_on_retry = (tc.attempts ?? 0) > 1 && tc.status === "passed";
320
+ row.error_message = tc.errors?.[0]?.message ?? null;
321
+ row.error_stack = tc.errors?.[0]?.stack ?? null;
322
+ row.error_file = tc.errors?.[0]?.location?.file ?? null;
323
+ row.error_line = tc.errors?.[0]?.location?.line ?? null;
324
+ row.tags = tc.tags ?? [];
325
+ return row;
326
+ });
327
+ }
328
+ //#endregion
329
+ //#region src/lib/reporter.ts
330
+ setFsModule(fs);
331
+ function getDescribePath(test) {
332
+ const path = [];
333
+ let curr = test.parent;
334
+ while (curr) {
335
+ if (curr.type === "file" || curr.type === "project" || curr.type === "root") break;
336
+ if (curr.type === "describe" && curr.title) path.unshift(curr.title);
337
+ curr = curr.parent;
338
+ }
339
+ return path;
340
+ }
341
+ function resolveConfig(rawConfig, cwd = process.cwd()) {
342
+ const outputDir = rawConfig?.outputDir ? String(rawConfig.outputDir) : "zen-report";
343
+ const packageManager = detectPackageManager(cwd);
344
+ const projectName = rawConfig?.projectName ? String(rawConfig.projectName) : "Test Automation Project";
345
+ let runNumber = 1;
346
+ try {
347
+ const runsDir = path.resolve(cwd, outputDir, "runs");
348
+ if (fs.existsSync(runsDir)) runNumber = fs.readdirSync(runsDir).filter((f) => f.endsWith(".jsonl")).length + 1;
349
+ } catch {
350
+ runNumber = 1;
351
+ }
352
+ const testRunName = (rawConfig?.testRunName ? String(rawConfig.testRunName) : "Test Run #{N}").replaceAll("{N}", String(runNumber));
353
+ const singleSummaryFile = Boolean(rawConfig?.singleSummaryFile);
354
+ const theme = rawConfig?.theme !== void 0 ? String(rawConfig.theme) : "Cafe";
355
+ const darkMode = Boolean(rawConfig?.darkMode);
356
+ const minimalReport = Boolean(rawConfig?.minimalReport);
357
+ let enableHistory = "auto";
358
+ if (minimalReport) enableHistory = false;
359
+ else if (rawConfig?.enableHistory !== void 0) {
360
+ if (typeof rawConfig.enableHistory === "boolean") enableHistory = rawConfig.enableHistory;
361
+ else if (rawConfig.enableHistory === "auto") enableHistory = "auto";
362
+ }
363
+ let consoleProgress = "auto";
364
+ if (rawConfig?.consoleProgress !== void 0) {
365
+ if (typeof rawConfig.consoleProgress === "boolean") consoleProgress = rawConfig.consoleProgress;
366
+ else if (rawConfig.consoleProgress === "auto" || rawConfig.consoleProgress === "line" || rawConfig.consoleProgress === "dot") consoleProgress = rawConfig.consoleProgress;
367
+ }
368
+ return {
369
+ outputDir,
370
+ packageManager,
371
+ projectName,
372
+ testRunName,
373
+ singleSummaryFile,
374
+ theme,
375
+ darkMode,
376
+ enableHistory,
377
+ minimalReport,
378
+ consoleProgress
379
+ };
380
+ }
381
+ var ZenReporter = class {
382
+ constructor(options) {
383
+ this.testCaseMap = /* @__PURE__ */ new Map();
384
+ this.startTime = "";
385
+ this.testCases = [];
386
+ this.totalTests = 0;
387
+ this.completedTests = 0;
388
+ this.completedTestIds = /* @__PURE__ */ new Set();
389
+ this.passedCount = 0;
390
+ this.failedCount = 0;
391
+ this.timedOutCount = 0;
392
+ this.skippedCount = 0;
393
+ this.activeWorkers = /* @__PURE__ */ new Set();
394
+ this.lastCompletedTest = "";
395
+ this.dotLineLength = 0;
396
+ this.progressMode = "none";
397
+ this.options = options;
398
+ }
399
+ printLineProgress() {
400
+ if (this.progressMode !== "line") return;
401
+ if (!(typeof process !== "undefined" && process.stdout && Boolean(process.stdout.isTTY))) return;
402
+ const pct = this.totalTests > 0 ? Math.floor(this.completedTests / this.totalTests * 100) : 0;
403
+ const workersCount = this.activeWorkers.size;
404
+ const workerStr = `${workersCount} active worker${workersCount === 1 ? "" : "s"}`;
405
+ let line = `[ ${this.completedTests}/${this.totalTests} ] ${pct}% | āœ… ${this.passedCount} | āŒ ${this.failedCount + this.timedOutCount} | ā­ļø ${this.skippedCount} (${workerStr})`;
406
+ if (this.lastCompletedTest) line += ` | Last: ${this.lastCompletedTest}`;
407
+ const cols = process.stdout.columns || 100;
408
+ if (line.length > cols - 1) line = line.slice(0, Math.max(10, cols - 4)) + "...";
409
+ readline.clearLine(process.stdout, 0);
410
+ readline.cursorTo(process.stdout, 0);
411
+ process.stdout.write(line);
412
+ }
413
+ clearLineProgress() {
414
+ if (this.progressMode === "line" && typeof process !== "undefined" && process.stdout && Boolean(process.stdout.isTTY)) {
415
+ readline.clearLine(process.stdout, 0);
416
+ readline.cursorTo(process.stdout, 0);
417
+ }
418
+ }
419
+ handleDotProgress(status) {
420
+ if (this.progressMode !== "dot") return;
421
+ const symbol = status === "failed" || status === "timedOut" || status === "interrupted" ? "F" : status === "skipped" ? "s" : status === "flaky" ? "±" : ".";
422
+ process.stdout.write(symbol);
423
+ this.dotLineLength += 1;
424
+ if (this.dotLineLength >= 80 || this.completedTestIds.size === this.totalTests) {
425
+ process.stdout.write(` [ ${this.completedTestIds.size}/${this.totalTests} ]\n`);
426
+ this.dotLineLength = 0;
427
+ }
428
+ }
429
+ buildSuites() {
430
+ return buildSuitesFromCases(this.testCases);
431
+ }
432
+ /**
433
+ * Persist every run as a self-contained per-run JSONL file under
434
+ * <outputDir>/runs/ so historic runs survive report rebuilds.
435
+ * Failure policy: catch-and-log only — history writing must never break
436
+ * report/HTML generation.
437
+ */
438
+ writeRunHistory(summary, endedAt) {
439
+ try {
440
+ const runId = buildRunId(this.startTime, this.reportConfig.testRunName, this.config.shard);
441
+ const cwd = process.cwd();
442
+ const normalizedCwd = cwd.endsWith("/") ? cwd.slice(0, -1) : cwd;
443
+ const runsDir = path.resolve(normalizedCwd, this.reportConfig.outputDir, "runs");
444
+ let finalRunId = runId;
445
+ let suffix = 2;
446
+ while (fs.existsSync(path.join(runsDir, `${finalRunId}.jsonl`))) {
447
+ finalRunId = `${runId}-${suffix}`;
448
+ suffix += 1;
449
+ }
450
+ const rows = flattenRunRows(finalRunId, this.reportConfig.testRunName, this.reportConfig.projectName, summary, endedAt, this.testCases);
451
+ fs.mkdirSync(runsDir, { recursive: true });
452
+ fs.writeFileSync(path.join(runsDir, `${finalRunId}.jsonl`), rows.map((r) => JSON.stringify(r)).join("\n") + "\n", "utf8");
453
+ } catch (e) {
454
+ console.error("Run history write failed:", e);
455
+ }
456
+ }
457
+ /**
458
+ * Regenerate `history.json` by running the CLI's `history report`
459
+ * subcommand (the CLI owns the DuckDB aggregate queries — single source
460
+ * of truth) and inject the fresh snapshot into `<outputDir>/index.html`.
461
+ * Runs after the new run's JSONL has been written, so it includes the
462
+ * just-finished run. Best-effort: any failure (missing @duckdb/node-api,
463
+ * missing bin, spawn error) is caught and logged; the snapshot that was
464
+ * re-injected above remains in the report.
465
+ */
466
+ refreshHistory() {
467
+ try {
468
+ const moduleFile = typeof __filename !== "undefined" ? __filename : fileURLToPath(import.meta.url);
469
+ let dir = path.dirname(moduleFile);
470
+ let binPath = null;
471
+ for (let i = 0; i < 4; i++) {
472
+ const candidate = path.join(dir, "bin", "zen-reporter.js");
473
+ if (fs.existsSync(candidate)) {
474
+ binPath = candidate;
475
+ break;
476
+ }
477
+ const parent = path.dirname(dir);
478
+ if (parent === dir) break;
479
+ dir = parent;
480
+ }
481
+ if (!binPath) return;
482
+ execFileSync(process.execPath, [
483
+ binPath,
484
+ "history",
485
+ "report"
486
+ ], {
487
+ cwd: process.cwd(),
488
+ stdio: "inherit",
489
+ env: {
490
+ ...process.env,
491
+ PW_REPORTER_OUTPUT: this.reportConfig.outputDir
492
+ }
493
+ });
494
+ } catch {
495
+ console.warn("History refresh skipped (keeping existing history data).");
496
+ }
497
+ }
498
+ onBegin(config, suite) {
499
+ this.config = config;
500
+ this.reportConfig = resolveConfig(this.options || config.reporterConfig);
501
+ if (this.reportConfig.enableHistory === true) try {
502
+ __require.resolve("@duckdb/node-api");
503
+ } catch {
504
+ throw new Error("Zen Reporter: history is explicitly enabled (enableHistory: true), but \"@duckdb/node-api\" is not installed. Please install \"@duckdb/node-api\" or set enableHistory to \"auto\" or false.");
505
+ }
506
+ this.startTime = (/* @__PURE__ */ new Date()).toISOString();
507
+ this.testCaseMap.clear();
508
+ this.testCases = [];
509
+ const allTests = suite.allTests();
510
+ this.totalTests = allTests.length;
511
+ this.completedTests = 0;
512
+ this.completedTestIds.clear();
513
+ this.passedCount = 0;
514
+ this.failedCount = 0;
515
+ this.timedOutCount = 0;
516
+ this.skippedCount = 0;
517
+ this.activeWorkers.clear();
518
+ this.lastCompletedTest = "";
519
+ this.dotLineLength = 0;
520
+ const cp = this.reportConfig.consoleProgress;
521
+ if (cp === false) this.progressMode = "none";
522
+ else if (cp === "line") this.progressMode = "line";
523
+ else if (cp === "dot") this.progressMode = "dot";
524
+ else {
525
+ const isTTY = typeof process !== "undefined" && process.stdout && Boolean(process.stdout.isTTY);
526
+ this.progressMode = isTTY ? "line" : "dot";
527
+ }
528
+ if (this.progressMode === "dot") {
529
+ const workers = this.config.workers || 1;
530
+ const workerStr = workers === 1 ? "1 worker (serially)" : `${workers} workers (in parallel)`;
531
+ console.log(`Running total ${this.totalTests} tests, ${workerStr}\n\nLegend: . passed | F failed | ± flaky | s skipped\n`);
532
+ }
533
+ for (const test of allTests) {
534
+ const fileName = test.location?.file ? test.location.file.replace(process.cwd(), "") : "unknown file";
535
+ const testCase = {
536
+ title: test.title,
537
+ parent: test.parent.title,
538
+ project: test.parent.project()?.name || "unknown",
539
+ fileName,
540
+ status: "skipped",
541
+ duration: 0,
542
+ steps: [],
543
+ stdout: [],
544
+ stderr: [],
545
+ annotations: test.annotations ? test.annotations.map((a) => ({
546
+ type: a.type,
547
+ description: a.description || null,
548
+ location: a.location ? {
549
+ file: a.location.file,
550
+ line: a.location.line,
551
+ column: a.location.column
552
+ } : null
553
+ })) : [],
554
+ attachments: [],
555
+ tags: test.tags.flatMap((tag) => tag.replace("@", "")),
556
+ describePath: getDescribePath(test),
557
+ attempts: 0,
558
+ failedAttempts: []
559
+ };
560
+ testCase.tags?.push(testCase.project);
561
+ this.testCaseMap.set(test.id, testCase);
562
+ }
563
+ }
564
+ onTestBegin(test, _result) {
565
+ this.activeWorkers.add(test.id);
566
+ if (this.progressMode === "line") this.printLineProgress();
567
+ let existing = this.testCaseMap.get(test.id);
568
+ if (!existing) {
569
+ const fileName = test.parent.location?.file ? test.parent.location.file.replace(process.cwd(), "") : "unknown file";
570
+ existing = {
571
+ title: test.title,
572
+ parent: test.parent.title,
573
+ project: test.parent.project()?.name || "unknown",
574
+ fileName,
575
+ status: "passed",
576
+ duration: 0,
577
+ steps: [],
578
+ stdout: [],
579
+ stderr: [],
580
+ annotations: test.annotations ? test.annotations.map((a) => ({
581
+ type: a.type,
582
+ description: a.description || null,
583
+ location: a.location ? {
584
+ file: a.location.file,
585
+ line: a.location.line,
586
+ column: a.location.column
587
+ } : null
588
+ })) : [],
589
+ attachments: [],
590
+ tags: test.tags.flatMap((tag) => tag.replace("@", "")),
591
+ describePath: getDescribePath(test),
592
+ attempts: 0,
593
+ failedAttempts: []
594
+ };
595
+ existing.tags?.push(existing.project);
596
+ this.testCaseMap.set(test.id, existing);
597
+ }
598
+ existing.attempts = (existing.attempts ?? 0) + 1;
599
+ }
600
+ onTestEnd(test, result) {
601
+ const testCase = this.testCaseMap.get(test.id);
602
+ if (!testCase) return;
603
+ testCase.status = result.status === "passed" ? "passed" : result.status === "failed" ? "failed" : result.status === "skipped" ? "skipped" : result.status === "timedOut" ? "timedOut" : result.status === "interrupted" ? "interrupted" : "failed";
604
+ const attemptSteps = result.steps && result.steps.length > 0 ? convertPlaywrightSteps(result.steps, fs) : void 0;
605
+ const stepDurationSum = attemptSteps ? attemptSteps.reduce((sum, step) => sum + (step.duration || 0), 0) : 0;
606
+ const attemptDuration = typeof result.duration === "number" && result.duration > 0 ? result.duration : stepDurationSum;
607
+ testCase.duration = attemptDuration;
608
+ if (test.annotations) testCase.annotations = test.annotations.map((a) => ({
609
+ type: a.type,
610
+ description: a.description || null,
611
+ location: a.location ? {
612
+ file: a.location.file,
613
+ line: a.location.line,
614
+ column: a.location.column
615
+ } : null
616
+ }));
617
+ const attemptStdout = (result.stdout || []).map((entry) => typeof entry === "string" ? entry : entry.toString("utf8"));
618
+ const attemptStderr = (result.stderr || []).map((entry) => typeof entry === "string" ? entry : entry.toString("utf8"));
619
+ const cwd = process.cwd();
620
+ const normalizedCwd = cwd.endsWith("/") ? cwd.slice(0, -1) : cwd;
621
+ const outputDir = path.resolve(normalizedCwd, this.reportConfig.outputDir);
622
+ const attachmentsDir = path.join(outputDir, "attachments");
623
+ const attemptAttachments = (result.attachments || []).map((att, attIdx) => {
624
+ let bodyData = null;
625
+ let finalPath = att.path || null;
626
+ const isText = att.contentType?.startsWith("text/") || /\.(txt|log|json|csv|html|xml|md|yaml|yml|js|ts|jsx|tsx|css)$/i.test(att.name || att.path || "");
627
+ if (att.path && fs.existsSync(att.path)) try {
628
+ fs.mkdirSync(attachmentsDir, { recursive: true });
629
+ const safeName = path.basename(att.path).replace(/[^a-zA-Z0-9._-]/g, "_");
630
+ const targetFilename = `${test.id.replace(/[^a-zA-Z0-9_-]/g, "_")}_att${attIdx}_${safeName}`;
631
+ const targetPath = path.join(attachmentsDir, targetFilename);
632
+ fs.copyFileSync(att.path, targetPath);
633
+ finalPath = `./attachments/${targetFilename}`;
634
+ if (isText) bodyData = fs.readFileSync(att.path, "utf8");
635
+ } catch (e) {
636
+ console.error(`Failed to copy attachment file from ${att.path}:`, e);
637
+ }
638
+ else if (att.body) {
639
+ if (isText) bodyData = Buffer.isBuffer(att.body) ? att.body.toString("utf8") : typeof att.body === "string" ? att.body : String(att.body);
640
+ else try {
641
+ fs.mkdirSync(attachmentsDir, { recursive: true });
642
+ const ext = att.contentType === "image/png" ? ".png" : att.contentType === "image/jpeg" ? ".jpg" : ".bin";
643
+ const targetFilename = `${test.id.replace(/[^a-zA-Z0-9_-]/g, "_")}_att${attIdx}${ext}`;
644
+ const targetPath = path.join(attachmentsDir, targetFilename);
645
+ const buf = Buffer.isBuffer(att.body) ? att.body : Buffer.from(att.body);
646
+ fs.writeFileSync(targetPath, buf);
647
+ finalPath = `./attachments/${targetFilename}`;
648
+ } catch (e) {
649
+ console.error("Failed to write in-memory attachment to disk:", e);
650
+ }
651
+ }
652
+ return {
653
+ name: att.name,
654
+ contentType: att.contentType,
655
+ path: finalPath,
656
+ body: bodyData
657
+ };
658
+ });
659
+ testCase.attachments = attemptAttachments.length > 0 ? attemptAttachments : void 0;
660
+ testCase.errors = result.error ? [{
661
+ name: "Error",
662
+ message: sanitizeAnsi(result.error.message || ""),
663
+ stack: sanitizeAnsi(result.error.stack || ""),
664
+ location: result.error.location ? {
665
+ file: result.error.location.file,
666
+ line: result.error.location.line,
667
+ column: result.error.location.column
668
+ } : null,
669
+ snippet: result.error.snippet || "",
670
+ cause: result.error.cause || null
671
+ }] : void 0;
672
+ if (result.status === "failed" || result.status === "timedOut" || result.status === "interrupted") {
673
+ testCase.failedAttempts = testCase.failedAttempts ?? [];
674
+ testCase.failedAttempts.push({
675
+ status: result.status,
676
+ duration: attemptDuration,
677
+ error: testCase.errors && testCase.errors.length > 0 ? testCase.errors[0] : null,
678
+ steps: attemptSteps,
679
+ stdout: attemptStdout.length > 0 ? attemptStdout : void 0,
680
+ stderr: attemptStderr.length > 0 ? attemptStderr : void 0,
681
+ attachments: attemptAttachments.length > 0 ? attemptAttachments : void 0
682
+ });
683
+ }
684
+ testCase.steps = attemptSteps;
685
+ testCase.stdout = attemptStdout.length > 0 ? attemptStdout : void 0;
686
+ testCase.stderr = attemptStderr.length > 0 ? attemptStderr : void 0;
687
+ this.activeWorkers.delete(test.id);
688
+ this.completedTestIds.add(test.id);
689
+ this.completedTests = this.completedTestIds.size;
690
+ const status = testCase.status;
691
+ if (status === "passed") this.passedCount += 1;
692
+ else if (status === "failed") this.failedCount += 1;
693
+ else if (status === "timedOut") this.timedOutCount += 1;
694
+ else if (status === "skipped") this.skippedCount += 1;
695
+ const shortName = `${testCase.project && testCase.project !== "unknown" ? `[${testCase.project}] ` : ""}${testCase.fileName} › ${testCase.title}`;
696
+ this.lastCompletedTest = shortName;
697
+ if (this.progressMode === "line") {
698
+ if (status === "failed" || status === "timedOut" || status === "interrupted") {
699
+ this.clearLineProgress();
700
+ const durationStr = testCase.duration > 0 ? ` (${testCase.duration}ms)` : "";
701
+ console.log(`āŒ [FAILED] ${shortName}${durationStr}`);
702
+ }
703
+ this.printLineProgress();
704
+ } else if (this.progressMode === "dot") {
705
+ let dotStatus = status;
706
+ if (status === "passed" && (testCase.failedAttempts?.length ?? 0) > 0) dotStatus = "flaky";
707
+ this.handleDotProgress(dotStatus);
708
+ }
709
+ }
710
+ onEnd(result) {
711
+ if (this.progressMode === "line") this.clearLineProgress();
712
+ else if (this.progressMode === "dot") {
713
+ if (this.dotLineLength > 0) {
714
+ process.stdout.write(` [ ${this.completedTests}/${this.totalTests} ]\n`);
715
+ this.dotLineLength = 0;
716
+ }
717
+ }
718
+ if (result.status === "interrupted") {
719
+ for (const testCase of this.testCaseMap.values()) if ((testCase.attempts ?? 0) === 0) testCase.status = "interrupted";
720
+ }
721
+ this.testCases = Array.from(this.testCaseMap.values());
722
+ const endTime = (/* @__PURE__ */ new Date()).toISOString();
723
+ const startTimeMs = new Date(this.startTime).getTime();
724
+ const endTimeMs = new Date(endTime).getTime();
725
+ const wallClockDuration = Math.max(0, endTimeMs - startTimeMs);
726
+ const passed = this.testCases.filter((tc) => tc.status === "passed").length;
727
+ const failed = this.testCases.filter((tc) => tc.status === "failed").length;
728
+ const skipped = this.testCases.filter((tc) => tc.status === "skipped").length;
729
+ const timedOut = this.testCases.filter((tc) => tc.status === "timedOut").length;
730
+ const interrupted = this.testCases.filter((tc) => tc.status === "interrupted").length;
731
+ const numberOfProjects = this.config.projects.length;
732
+ const totalSequentialDuration = this.testCases.reduce((sum, tc) => sum + (tc.duration || 0), 0);
733
+ const summary = {
734
+ total: this.testCases.length,
735
+ passed,
736
+ failed,
737
+ skipped,
738
+ timedOut,
739
+ interrupted,
740
+ startTime: this.startTime,
741
+ endTime,
742
+ duration: wallClockDuration,
743
+ totalSequentialDuration,
744
+ numberOfProjects,
745
+ workers: this.config?.workers
746
+ };
747
+ const suites = this.buildSuites();
748
+ if (this.reportConfig.enableHistory !== false) this.writeRunHistory(summary, endTime);
749
+ const reportData = { testRun: {
750
+ summary,
751
+ suites,
752
+ projectName: this.reportConfig.projectName,
753
+ testRunName: this.reportConfig.testRunName,
754
+ theme: this.reportConfig.theme,
755
+ darkMode: this.reportConfig.darkMode,
756
+ enableHistory: this.reportConfig.enableHistory,
757
+ minimalReport: this.reportConfig.minimalReport
758
+ } };
759
+ const cwd = process.cwd();
760
+ const normalizedCwd = cwd.endsWith("/") ? cwd.slice(0, -1) : cwd;
761
+ const outputDir = path.resolve(normalizedCwd, this.reportConfig.outputDir);
762
+ fs.mkdirSync(outputDir, { recursive: true });
763
+ const jsonPath = path.join(outputDir, "report.json");
764
+ fs.writeFileSync(jsonPath, JSON.stringify(reportData, null, 2), "utf8");
765
+ if (this.progressMode === "dot") {
766
+ const failedCases = this.testCases.filter((tc) => tc.status === "failed" || tc.status === "timedOut" || tc.status === "interrupted");
767
+ if (failedCases.length > 0) {
768
+ console.log("\nFailed Tests:");
769
+ for (const tc of failedCases) {
770
+ const projStr = tc.project && tc.project !== "unknown" ? `[${tc.project}] ` : "";
771
+ const durationStr = tc.duration > 0 ? ` (${tc.duration}ms)` : "";
772
+ console.log(`āŒ [FAILED] ${projStr}${tc.fileName} › ${tc.title}${durationStr}`);
773
+ }
774
+ }
775
+ }
776
+ const summaryTable = generateTerminalSummaryTable(summary, this.reportConfig.projectName, this.reportConfig.testRunName);
777
+ console.log("\n" + summaryTable);
778
+ try {
779
+ const pm = this.reportConfig.packageManager;
780
+ const moduleFile = typeof __filename !== "undefined" ? __filename : fileURLToPath(import.meta.url);
781
+ const moduleDir = path.dirname(moduleFile);
782
+ const packageRoot = moduleDir.endsWith(path.join("src", "lib")) || moduleDir.endsWith("src/lib") ? path.resolve(moduleDir, "..", "..") : path.resolve(moduleDir, "..");
783
+ const possibleTemplatePaths = [
784
+ path.join(packageRoot, "assets", "template.html"),
785
+ path.join(packageRoot, "dist", "index.html"),
786
+ path.join(packageRoot, "dist", "template.html")
787
+ ];
788
+ let templateContent = null;
789
+ for (const p of possibleTemplatePaths) if (fs.existsSync(p)) try {
790
+ templateContent = fs.readFileSync(p, "utf8");
791
+ break;
792
+ } catch {}
793
+ if (!templateContent) {
794
+ const buildCmd = getViteBuildCommand(pm, packageRoot);
795
+ execSync(buildCmd, {
796
+ cwd: packageRoot,
797
+ stdio: "pipe",
798
+ env: {
799
+ ...process.env,
800
+ PW_REPORTER_OUTPUT: outputDir
801
+ }
802
+ });
803
+ for (const p of possibleTemplatePaths) if (fs.existsSync(p)) try {
804
+ templateContent = fs.readFileSync(p, "utf8");
805
+ break;
806
+ } catch {}
807
+ }
808
+ if (templateContent) {
809
+ const dataScript = `<script id="report-data" type="application/json">${JSON.stringify(reportData).replace(/</g, "\\u003c")}<\/script>`;
810
+ const cleanTemplate = templateContent.replace(/<script id="report-data" type="application\/json">[\s\S]*?<\/script>/, "");
811
+ let finalHtml = cleanTemplate.replace("</head>", `${dataScript}\n</head>`);
812
+ const historyPath = path.join(outputDir, "history.json");
813
+ if (fs.existsSync(historyPath)) try {
814
+ const history = JSON.parse(fs.readFileSync(historyPath, "utf8"));
815
+ const historyScript = `<script id="history-data" type="application/json">${JSON.stringify(history).replace(/</g, "\\u003c")}<\/script>`;
816
+ finalHtml = finalHtml.replace(/<script id="history-data" type="application\/json">[\s\S]*?<\/script>/, "").replace("</head>", `${historyScript}\n</head>`);
817
+ } catch (e) {
818
+ console.error("History data injection failed:", e);
819
+ }
820
+ const destHtml = path.join(outputDir, "index.html");
821
+ fs.writeFileSync(destHtml, finalHtml, "utf8");
822
+ console.debug(`\nāœ“ Report generated: ${destHtml}`);
823
+ console.log(`\nšŸ’” Run "${getShowReportCommand(pm)}" to view the report in web browser.\n`);
824
+ if (this.reportConfig.enableHistory !== false) this.refreshHistory();
825
+ if (this.reportConfig.singleSummaryFile) {
826
+ const summaryHtmlPath = path.join(outputDir, "summary.html");
827
+ const summaryHtml = cleanTemplate.replace("</head>", `${dataScript}\n<script>window.__ZEN_SUMMARY_ONLY__ = true;<\/script>\n</head>`);
828
+ fs.writeFileSync(summaryHtmlPath, summaryHtml, "utf8");
829
+ console.debug(`āœ“ Standalone Summary generated: ${summaryHtmlPath}`);
830
+ }
831
+ } else console.error("Report template not found and build failed.");
832
+ } catch (err) {
833
+ console.error("Report generation failed, keeping report.json:", err);
834
+ }
835
+ }
836
+ };
837
+ function getShowReportCommand(pm, cwd = process.cwd()) {
838
+ switch (pm || detectPackageManager(cwd)) {
839
+ case "pnpm": return "pnpm zr show";
840
+ case "yarn": return "yarn zr show";
841
+ case "bun": return "bunx zr show";
842
+ default: return "npx zr show";
843
+ }
844
+ }
845
+ //#endregion
846
+ export { ZenReporter as default, getShowReportCommand, resolveConfig };