@testrelic/playwright-analytics 2.2.7 → 2.3.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.
package/dist/cli.cjs CHANGED
@@ -1,14 +1,514 @@
1
1
  #!/usr/bin/env node
2
2
  "use strict";
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __esm = (fn, res) => function __init() {
6
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
7
+ };
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+
13
+ // src/report-server-routes.ts
14
+ function sendJson(res, status, body) {
15
+ res.writeHead(status, { "Content-Type": "application/json" });
16
+ res.end(JSON.stringify(body));
17
+ }
18
+ function setCorsHeaders(res) {
19
+ res.setHeader("Access-Control-Allow-Origin", "*");
20
+ res.setHeader("Access-Control-Allow-Methods", "GET, DELETE, OPTIONS");
21
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
22
+ }
23
+ function readJsonFile(filePath) {
24
+ try {
25
+ if (!(0, import_node_fs2.existsSync)(filePath)) return null;
26
+ return JSON.parse((0, import_node_fs2.readFileSync)(filePath, "utf-8"));
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+ function getDirSize(dirPath) {
32
+ let size = 0;
33
+ try {
34
+ const entries = (0, import_node_fs2.readdirSync)(dirPath, { withFileTypes: true });
35
+ for (const entry of entries) {
36
+ const fullPath = (0, import_node_path2.join)(dirPath, entry.name);
37
+ if (entry.isFile()) {
38
+ size += (0, import_node_fs2.statSync)(fullPath).size;
39
+ } else if (entry.isDirectory()) {
40
+ size += getDirSize(fullPath);
41
+ }
42
+ }
43
+ } catch {
44
+ }
45
+ return size;
46
+ }
47
+ function handleHealth(_req, res, reportDir, startTime) {
48
+ const index = readJsonFile((0, import_node_path2.join)(reportDir, "index.json"));
49
+ sendJson(res, 200, {
50
+ status: "ok",
51
+ reportMode: "streaming",
52
+ testCount: index?.length ?? 0,
53
+ uptime: Math.floor((Date.now() - startTime) / 1e3)
54
+ });
55
+ }
56
+ function handleSummary(_req, res, reportDir) {
57
+ const summary = readJsonFile((0, import_node_path2.join)(reportDir, "summary.json"));
58
+ if (!summary) {
59
+ sendJson(res, 404, { error: "Summary not found" });
60
+ return;
61
+ }
62
+ sendJson(res, 200, summary);
63
+ }
64
+ function handleTests(req, res, reportDir) {
65
+ const index = readJsonFile((0, import_node_path2.join)(reportDir, "index.json"));
66
+ if (!index) {
67
+ sendJson(res, 404, { error: "Test index not found" });
68
+ return;
69
+ }
70
+ const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
71
+ const params = url.searchParams;
72
+ const page = Math.max(1, parseInt(params.get("page") ?? "1", 10) || 1);
73
+ const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, parseInt(params.get("pageSize") ?? "100", 10) || 100));
74
+ const statusFilter = params.get("status")?.split(",").filter(Boolean) ?? null;
75
+ const fileFilter = params.get("file") ?? null;
76
+ const searchQuery = params.get("search")?.toLowerCase() ?? null;
77
+ const tagFilter = params.get("tag")?.split(",").filter(Boolean) ?? null;
78
+ const sortField = params.get("sort") ?? "file";
79
+ const sortOrder = params.get("order") === "desc" ? -1 : 1;
80
+ let filtered = index;
81
+ if (statusFilter && statusFilter.length > 0) {
82
+ filtered = filtered.filter((t) => statusFilter.includes(t.status));
83
+ }
84
+ if (fileFilter) {
85
+ filtered = filtered.filter((t) => t.filePath === fileFilter || t.filePath.startsWith(fileFilter + "/"));
86
+ }
87
+ if (searchQuery) {
88
+ filtered = filtered.filter(
89
+ (t) => t.title.toLowerCase().includes(searchQuery) || t.filePath.toLowerCase().includes(searchQuery)
90
+ );
91
+ }
92
+ if (tagFilter && tagFilter.length > 0) {
93
+ filtered = filtered.filter((t) => tagFilter.some((tag) => t.tags.includes(tag)));
94
+ }
95
+ filtered = [...filtered].sort((a, b) => {
96
+ let cmp = 0;
97
+ switch (sortField) {
98
+ case "duration":
99
+ cmp = a.duration - b.duration;
100
+ break;
101
+ case "status":
102
+ cmp = a.status.localeCompare(b.status);
103
+ break;
104
+ case "title":
105
+ cmp = a.title.localeCompare(b.title);
106
+ break;
107
+ case "file":
108
+ default:
109
+ cmp = a.filePath.localeCompare(b.filePath);
110
+ break;
111
+ }
112
+ return cmp * sortOrder;
113
+ });
114
+ const totalItems = filtered.length;
115
+ const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
116
+ const startIdx = (page - 1) * pageSize;
117
+ const tests = filtered.slice(startIdx, startIdx + pageSize);
118
+ sendJson(res, 200, {
119
+ tests,
120
+ pagination: { page, pageSize, totalItems, totalPages },
121
+ filters: {
122
+ status: statusFilter,
123
+ file: fileFilter,
124
+ search: searchQuery,
125
+ tag: tagFilter
126
+ }
127
+ });
128
+ }
129
+ function handleTestDetail(_req, res, reportDir, testId) {
130
+ if (!SAFE_ID_PATTERN.test(testId)) {
131
+ sendJson(res, 400, { error: "Invalid test ID format" });
132
+ return;
133
+ }
134
+ const filePath = (0, import_node_path2.join)(reportDir, "tests", `${testId}.json`);
135
+ if (!(0, import_node_fs2.existsSync)(filePath)) {
136
+ sendJson(res, 404, { error: `Test not found: ${testId}` });
137
+ return;
138
+ }
139
+ try {
140
+ const data = (0, import_node_fs2.readFileSync)(filePath, "utf-8");
141
+ res.writeHead(200, { "Content-Type": "application/json" });
142
+ res.end(data);
143
+ } catch (err) {
144
+ sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
145
+ }
146
+ }
147
+ function handleFiles(_req, res, reportDir) {
148
+ const index = readJsonFile((0, import_node_path2.join)(reportDir, "index.json"));
149
+ if (!index) {
150
+ sendJson(res, 404, { error: "Test index not found" });
151
+ return;
152
+ }
153
+ const fileMap = /* @__PURE__ */ new Map();
154
+ for (const t of index) {
155
+ if (t.isRetry) continue;
156
+ let stats = fileMap.get(t.filePath);
157
+ if (!stats) {
158
+ stats = { total: 0, passed: 0, failed: 0, flaky: 0, skipped: 0, timedOut: 0 };
159
+ fileMap.set(t.filePath, stats);
160
+ }
161
+ stats.total++;
162
+ switch (t.status) {
163
+ case "passed":
164
+ stats.passed++;
165
+ break;
166
+ case "failed":
167
+ stats.failed++;
168
+ break;
169
+ case "flaky":
170
+ stats.flaky++;
171
+ break;
172
+ case "skipped":
173
+ stats.skipped++;
174
+ break;
175
+ case "timedout":
176
+ stats.timedOut++;
177
+ break;
178
+ }
179
+ }
180
+ const files = Array.from(fileMap.entries()).map(([filePath, s]) => ({ filePath, ...s })).sort((a, b) => a.filePath.localeCompare(b.filePath));
181
+ sendJson(res, 200, { files });
182
+ }
183
+ function handleArtifactsList(_req, res, artifactsDir) {
184
+ if (!(0, import_node_fs2.existsSync)(artifactsDir)) {
185
+ sendJson(res, 200, { runs: [], totalSizeBytes: 0 });
186
+ return;
187
+ }
188
+ try {
189
+ const runs = [];
190
+ let totalSizeBytes = 0;
191
+ const entries = (0, import_node_fs2.readdirSync)(artifactsDir, { withFileTypes: true });
192
+ for (const entry of entries) {
193
+ if (!entry.isDirectory() || !TIMESTAMP_PATTERN.test(entry.name)) continue;
194
+ const runDir = (0, import_node_path2.join)(artifactsDir, entry.name);
195
+ const size = getDirSize(runDir);
196
+ const testDirs = (0, import_node_fs2.readdirSync)(runDir, { withFileTypes: true }).filter((e) => e.isDirectory());
197
+ runs.push({ folderName: entry.name, totalSizeBytes: size, testCount: testDirs.length });
198
+ totalSizeBytes += size;
199
+ }
200
+ runs.sort((a, b) => b.folderName.localeCompare(a.folderName));
201
+ sendJson(res, 200, { runs, totalSizeBytes });
202
+ } catch (err) {
203
+ sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
204
+ }
205
+ }
206
+ function handleArtifactsDeleteAll(_req, res, artifactsDir) {
207
+ try {
208
+ let deletedCount = 0;
209
+ let freedBytes = 0;
210
+ if ((0, import_node_fs2.existsSync)(artifactsDir)) {
211
+ const entries = (0, import_node_fs2.readdirSync)(artifactsDir, { withFileTypes: true });
212
+ for (const entry of entries) {
213
+ if (!entry.isDirectory() || !TIMESTAMP_PATTERN.test(entry.name)) continue;
214
+ const runDir = (0, import_node_path2.join)(artifactsDir, entry.name);
215
+ const size = getDirSize(runDir);
216
+ (0, import_node_fs2.rmSync)(runDir, { recursive: true, force: true });
217
+ freedBytes += size;
218
+ deletedCount++;
219
+ }
220
+ }
221
+ sendJson(res, 200, { deletedCount, freedBytes });
222
+ } catch (err) {
223
+ sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
224
+ }
225
+ }
226
+ function handleArtifactDelete(_req, res, artifactsDir, folderName) {
227
+ if (!TIMESTAMP_PATTERN.test(folderName)) {
228
+ sendJson(res, 400, { error: "Invalid folder name" });
229
+ return;
230
+ }
231
+ const runDir = (0, import_node_path2.join)(artifactsDir, folderName);
232
+ try {
233
+ const stat = (0, import_node_fs2.statSync)(runDir);
234
+ if (!stat.isDirectory()) {
235
+ sendJson(res, 404, { error: "Not found" });
236
+ return;
237
+ }
238
+ } catch {
239
+ sendJson(res, 404, { error: "Not found" });
240
+ return;
241
+ }
242
+ try {
243
+ const freedBytes = getDirSize(runDir);
244
+ (0, import_node_fs2.rmSync)(runDir, { recursive: true, force: true });
245
+ sendJson(res, 200, { deleted: folderName, freedBytes });
246
+ } catch (err) {
247
+ sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
248
+ }
249
+ }
250
+ function handleShutdown(_req, res, server) {
251
+ sendJson(res, 200, { status: "shutting_down" });
252
+ server.close();
253
+ }
254
+ function serveStaticFile(_req, res, filePath) {
255
+ if (!(0, import_node_fs2.existsSync)(filePath)) {
256
+ sendJson(res, 404, { error: "File not found" });
257
+ return;
258
+ }
259
+ try {
260
+ const data = (0, import_node_fs2.readFileSync)(filePath);
261
+ const ext = (0, import_node_path2.extname)(filePath).toLowerCase();
262
+ const contentType = MIME_TYPES[ext] ?? "application/octet-stream";
263
+ res.writeHead(200, { "Content-Type": contentType });
264
+ res.end(data);
265
+ } catch (err) {
266
+ sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
267
+ }
268
+ }
269
+ var import_node_fs2, import_node_path2, SAFE_ID_PATTERN, TIMESTAMP_PATTERN, MAX_PAGE_SIZE, MIME_TYPES;
270
+ var init_report_server_routes = __esm({
271
+ "src/report-server-routes.ts"() {
272
+ "use strict";
273
+ import_node_fs2 = require("fs");
274
+ import_node_path2 = require("path");
275
+ SAFE_ID_PATTERN = /^[a-f0-9]{12}$/;
276
+ TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}(-\d+)?$/;
277
+ MAX_PAGE_SIZE = 500;
278
+ MIME_TYPES = {
279
+ ".png": "image/png",
280
+ ".jpg": "image/jpeg",
281
+ ".jpeg": "image/jpeg",
282
+ ".gif": "image/gif",
283
+ ".webp": "image/webp",
284
+ ".webm": "video/webm",
285
+ ".mp4": "video/mp4",
286
+ ".json": "application/json",
287
+ ".html": "text/html",
288
+ ".css": "text/css",
289
+ ".js": "text/javascript",
290
+ ".svg": "image/svg+xml"
291
+ };
292
+ }
293
+ });
294
+
295
+ // src/report-server.ts
296
+ var report_server_exports = {};
297
+ __export(report_server_exports, {
298
+ startReportServer: () => startReportServer
299
+ });
300
+ function startReportServer(reportDir, options) {
301
+ return new Promise((resolve2, reject) => {
302
+ const startPort = options?.port ?? DEFAULT_PORT;
303
+ const startTime = Date.now();
304
+ let inactivityTimer;
305
+ let currentAttempt = 0;
306
+ const artifactsDir = (0, import_node_fs3.existsSync)((0, import_node_path3.join)(reportDir, "artifacts")) ? (0, import_node_path3.join)(reportDir, "artifacts") : (0, import_node_fs3.existsSync)((0, import_node_path3.join)(reportDir, "..", "artifacts")) ? (0, import_node_path3.join)(reportDir, "..", "artifacts") : (0, import_node_path3.join)(reportDir, "artifacts");
307
+ function resetTimer() {
308
+ clearTimeout(inactivityTimer);
309
+ inactivityTimer = setTimeout(() => {
310
+ server.close();
311
+ }, INACTIVITY_TIMEOUT_MS);
312
+ }
313
+ let htmlReportPath = options?.htmlPath ?? null;
314
+ if (!htmlReportPath) {
315
+ const parentDir = (0, import_node_path3.dirname)(reportDir);
316
+ try {
317
+ const htmlFile = (0, import_node_fs3.readdirSync)(parentDir).find((f) => f.endsWith(".html"));
318
+ if (htmlFile) htmlReportPath = (0, import_node_path3.join)(parentDir, htmlFile);
319
+ } catch {
320
+ }
321
+ }
322
+ const server = (0, import_node_http.createServer)((req, res) => {
323
+ resetTimer();
324
+ setCorsHeaders(res);
325
+ if (req.method === "OPTIONS") {
326
+ res.writeHead(204);
327
+ res.end();
328
+ return;
329
+ }
330
+ let pathname;
331
+ try {
332
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
333
+ pathname = url.pathname;
334
+ } catch {
335
+ sendJson(res, 400, { error: "Invalid URL" });
336
+ return;
337
+ }
338
+ if (req.method === "GET" && (pathname === "/" || pathname === "/index.html")) {
339
+ if (htmlReportPath && (0, import_node_fs3.existsSync)(htmlReportPath)) {
340
+ serveStaticFile(req, res, htmlReportPath);
341
+ return;
342
+ }
343
+ sendJson(res, 404, { error: "HTML report not found" });
344
+ return;
345
+ }
346
+ if (req.method === "GET" && pathname === "/api/health") {
347
+ handleHealth(req, res, reportDir, startTime);
348
+ return;
349
+ }
350
+ if (req.method === "GET" && pathname === "/api/summary") {
351
+ handleSummary(req, res, reportDir);
352
+ return;
353
+ }
354
+ if (req.method === "GET" && pathname === "/api/tests") {
355
+ handleTests(req, res, reportDir);
356
+ return;
357
+ }
358
+ if (req.method === "GET" && pathname === "/api/files") {
359
+ handleFiles(req, res, reportDir);
360
+ return;
361
+ }
362
+ const testMatch = pathname.match(/^\/api\/tests\/([a-f0-9]+)$/);
363
+ if (req.method === "GET" && testMatch) {
364
+ handleTestDetail(req, res, reportDir, testMatch[1]);
365
+ return;
366
+ }
367
+ if (req.method === "GET" && pathname === "/api/artifacts") {
368
+ handleArtifactsList(req, res, artifactsDir);
369
+ return;
370
+ }
371
+ if (req.method === "DELETE" && pathname === "/api/artifacts") {
372
+ handleArtifactsDeleteAll(req, res, artifactsDir);
373
+ return;
374
+ }
375
+ const deleteMatch = pathname.match(/^\/api\/artifacts\/(.+)$/);
376
+ if (req.method === "DELETE" && deleteMatch) {
377
+ handleArtifactDelete(req, res, artifactsDir, decodeURIComponent(deleteMatch[1]));
378
+ return;
379
+ }
380
+ if (req.method === "GET" && pathname.startsWith("/artifacts/")) {
381
+ const relPath = decodeURIComponent(pathname.slice("/artifacts/".length));
382
+ if (relPath.includes("..") || relPath.includes("\0")) {
383
+ sendJson(res, 400, { error: "Invalid path" });
384
+ return;
385
+ }
386
+ serveStaticFile(req, res, (0, import_node_path3.join)(artifactsDir, relPath));
387
+ return;
388
+ }
389
+ if (req.method === "POST" && pathname === "/api/shutdown") {
390
+ handleShutdown(req, res, server);
391
+ return;
392
+ }
393
+ sendJson(res, 404, { error: "Not found" });
394
+ });
395
+ function tryListen(port) {
396
+ const errorHandler = (err) => {
397
+ if (err.code === "EADDRINUSE" && currentAttempt < MAX_PORT_ATTEMPTS) {
398
+ currentAttempt++;
399
+ tryListen(port + 1);
400
+ } else {
401
+ reject(err);
402
+ }
403
+ };
404
+ server.once("error", errorHandler);
405
+ server.listen(port, "127.0.0.1", () => {
406
+ server.removeListener("error", errorHandler);
407
+ const addr = server.address();
408
+ if (!addr || typeof addr === "string") {
409
+ reject(new Error("Failed to get server address"));
410
+ return;
411
+ }
412
+ resetTimer();
413
+ resolve2({
414
+ port: addr.port,
415
+ dispose: () => new Promise((res) => {
416
+ clearTimeout(inactivityTimer);
417
+ server.close(() => res());
418
+ })
419
+ });
420
+ });
421
+ }
422
+ tryListen(startPort);
423
+ });
424
+ }
425
+ var import_node_http, import_node_path3, import_node_fs3, DEFAULT_PORT, MAX_PORT_ATTEMPTS, INACTIVITY_TIMEOUT_MS;
426
+ var init_report_server = __esm({
427
+ "src/report-server.ts"() {
428
+ "use strict";
429
+ import_node_http = require("http");
430
+ import_node_path3 = require("path");
431
+ import_node_fs3 = require("fs");
432
+ init_report_server_routes();
433
+ DEFAULT_PORT = 9323;
434
+ MAX_PORT_ATTEMPTS = 10;
435
+ INACTIVITY_TIMEOUT_MS = 30 * 60 * 1e3;
436
+ }
437
+ });
438
+
439
+ // src/browser-open.ts
440
+ var browser_open_exports = {};
441
+ __export(browser_open_exports, {
442
+ openInBrowser: () => openInBrowser
443
+ });
444
+ function openInBrowser(filePath) {
445
+ try {
446
+ const platform = process.platform;
447
+ let command;
448
+ if (platform === "darwin") {
449
+ command = `open "${filePath}"`;
450
+ } else if (platform === "win32") {
451
+ command = `start "" "${filePath}"`;
452
+ } else {
453
+ command = `xdg-open "${filePath}"`;
454
+ }
455
+ (0, import_node_child_process.exec)(command, (err) => {
456
+ if (err) {
457
+ process.stderr.write(
458
+ `[testrelic] Failed to open browser: ${err.message}
459
+ `
460
+ );
461
+ }
462
+ });
463
+ } catch {
464
+ }
465
+ }
466
+ var import_node_child_process;
467
+ var init_browser_open = __esm({
468
+ "src/browser-open.ts"() {
469
+ "use strict";
470
+ import_node_child_process = require("child_process");
471
+ }
472
+ });
3
473
 
4
474
  // src/merge.ts
5
475
  var import_node_crypto = require("crypto");
6
476
  var import_node_fs = require("fs");
7
477
  var import_node_path = require("path");
8
478
  var import_core = require("@testrelic/core");
479
+ function isStreamingDir(path) {
480
+ try {
481
+ const stat = (0, import_node_fs.statSync)(path);
482
+ return stat.isDirectory() && (0, import_node_fs.existsSync)((0, import_node_path.join)(path, "index.json"));
483
+ } catch {
484
+ return false;
485
+ }
486
+ }
487
+ function loadStreamingReport(dir) {
488
+ const summaryPath = (0, import_node_path.join)(dir, "summary.json");
489
+ const indexPath = (0, import_node_path.join)(dir, "index.json");
490
+ let summary;
491
+ try {
492
+ summary = JSON.parse((0, import_node_fs.readFileSync)(summaryPath, "utf-8"));
493
+ } catch (err) {
494
+ throw (0, import_core.createError)(import_core.ErrorCode.MERGE_READ_FAILED, `Failed to read summary.json from: ${dir}`, err);
495
+ }
496
+ let index;
497
+ try {
498
+ index = JSON.parse((0, import_node_fs.readFileSync)(indexPath, "utf-8"));
499
+ } catch (err) {
500
+ throw (0, import_core.createError)(import_core.ErrorCode.MERGE_READ_FAILED, `Failed to read index.json from: ${dir}`, err);
501
+ }
502
+ return { summary, index, dir };
503
+ }
9
504
  async function mergeReports(files, options) {
10
505
  const reports = [];
506
+ const streamingReports = [];
11
507
  for (const file of files) {
508
+ if (isStreamingDir(file)) {
509
+ streamingReports.push(loadStreamingReport(file));
510
+ continue;
511
+ }
12
512
  let raw;
13
513
  try {
14
514
  raw = (0, import_node_fs.readFileSync)(file, "utf-8");
@@ -47,7 +547,37 @@ async function mergeReports(files, options) {
47
547
  const timeB = "visitedAt" in b ? b.visitedAt : b.timestamp;
48
548
  return new Date(timeA).getTime() - new Date(timeB).getTime();
49
549
  });
50
- const summary = recalculateSummary(reports, allTimelines.length);
550
+ if (streamingReports.length > 0) {
551
+ const outputDir = (0, import_node_path.dirname)(options.output);
552
+ const mergedReportDir = (0, import_node_path.join)(outputDir, ".testrelic-report");
553
+ const mergedTestsDir = (0, import_node_path.join)(mergedReportDir, "tests");
554
+ (0, import_node_fs.mkdirSync)(mergedTestsDir, { recursive: true });
555
+ const seenTestIds = /* @__PURE__ */ new Set();
556
+ const mergedIndex = [];
557
+ for (const sr of streamingReports) {
558
+ const srcTestsDir = (0, import_node_path.join)(sr.dir, "tests");
559
+ if ((0, import_node_fs.existsSync)(srcTestsDir)) {
560
+ const testFiles = (0, import_node_fs.readdirSync)(srcTestsDir);
561
+ for (const f of testFiles) {
562
+ if (f.endsWith(".json")) {
563
+ (0, import_node_fs.cpSync)((0, import_node_path.join)(srcTestsDir, f), (0, import_node_path.join)(mergedTestsDir, f));
564
+ }
565
+ }
566
+ }
567
+ for (const entry of sr.index) {
568
+ if (!seenTestIds.has(entry.id)) {
569
+ seenTestIds.add(entry.id);
570
+ mergedIndex.push(entry);
571
+ }
572
+ }
573
+ }
574
+ (0, import_node_fs.writeFileSync)((0, import_node_path.join)(mergedReportDir, "index.json"), JSON.stringify(mergedIndex), "utf-8");
575
+ }
576
+ const allSummaries = [
577
+ ...reports.map((r) => r.summary),
578
+ ...streamingReports.map((sr) => sr.summary)
579
+ ];
580
+ const summary = recalculateSummaryFromList(allSummaries, allTimelines.length);
51
581
  const startedAt = reports.reduce(
52
582
  (earliest, r) => r.startedAt < earliest ? r.startedAt : earliest,
53
583
  reports[0]?.startedAt ?? (/* @__PURE__ */ new Date()).toISOString()
@@ -74,7 +604,7 @@ async function mergeReports(files, options) {
74
604
  (0, import_node_fs.writeFileSync)(options.output, JSON.stringify(merged, null, 2), "utf-8");
75
605
  return merged;
76
606
  }
77
- function recalculateSummary(reports, timelineLength) {
607
+ function recalculateSummaryFromList(summaries, timelineLength) {
78
608
  let total = 0;
79
609
  let passed = 0;
80
610
  let failed = 0;
@@ -86,23 +616,18 @@ function recalculateSummary(reports, timelineLength) {
86
616
  let passedAssertions = 0;
87
617
  let failedAssertions = 0;
88
618
  let totalNavigations = 0;
89
- const apiUrlSet = /* @__PURE__ */ new Set();
90
- const navUrlSet = /* @__PURE__ */ new Set();
91
- const methodCounts = {};
92
- const statusRanges = { "2xx": 0, "3xx": 0, "4xx": 0, "5xx": 0, error: 0 };
93
- const allResponseTimes = [];
94
- for (const report of reports) {
95
- total += report.summary.total;
96
- passed += report.summary.passed;
97
- failed += report.summary.failed;
98
- flaky += report.summary.flaky;
99
- skipped += report.summary.skipped;
100
- timedout += report.summary.timedout ?? 0;
101
- totalApiCalls += report.summary.totalApiCalls ?? 0;
102
- totalAssertions += report.summary.totalAssertions ?? 0;
103
- passedAssertions += report.summary.passedAssertions ?? 0;
104
- failedAssertions += report.summary.failedAssertions ?? 0;
105
- totalNavigations += report.summary.totalNavigations ?? 0;
619
+ for (const s of summaries) {
620
+ total += s.total;
621
+ passed += s.passed;
622
+ failed += s.failed;
623
+ flaky += s.flaky;
624
+ skipped += s.skipped;
625
+ timedout += s.timedout ?? 0;
626
+ totalApiCalls += s.totalApiCalls ?? 0;
627
+ totalAssertions += s.totalAssertions ?? 0;
628
+ passedAssertions += s.passedAssertions ?? 0;
629
+ failedAssertions += s.failedAssertions ?? 0;
630
+ totalNavigations += s.totalNavigations ?? 0;
106
631
  }
107
632
  return {
108
633
  total,
@@ -112,15 +637,15 @@ function recalculateSummary(reports, timelineLength) {
112
637
  skipped,
113
638
  timedout,
114
639
  totalApiCalls,
115
- uniqueApiUrls: apiUrlSet.size,
116
- apiCallsByMethod: methodCounts,
117
- apiCallsByStatusRange: statusRanges,
640
+ uniqueApiUrls: 0,
641
+ apiCallsByMethod: {},
642
+ apiCallsByStatusRange: { "2xx": 0, "3xx": 0, "4xx": 0, "5xx": 0, error: 0 },
118
643
  apiResponseTime: null,
119
644
  totalAssertions,
120
645
  passedAssertions,
121
646
  failedAssertions,
122
647
  totalNavigations,
123
- uniqueNavigationUrls: navUrlSet.size,
648
+ uniqueNavigationUrls: 0,
124
649
  totalTimelineSteps: timelineLength,
125
650
  totalActionSteps: 0,
126
651
  actionStepsByCategory: {}
@@ -128,19 +653,27 @@ function recalculateSummary(reports, timelineLength) {
128
653
  }
129
654
 
130
655
  // src/cli.ts
656
+ var import_node_path4 = require("path");
657
+ var import_node_fs4 = require("fs");
131
658
  async function main() {
132
659
  const args = process.argv.slice(2);
133
660
  if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
134
661
  printUsage();
135
662
  process.exit(0);
136
663
  }
137
- if (args[0] !== "merge") {
138
- process.stderr.write(`Unknown command: ${args[0]}
664
+ const command = args[0];
665
+ if (command === "merge") {
666
+ await handleMerge(args.slice(1));
667
+ } else if (command === "serve" || command === "open") {
668
+ await handleServe(args.slice(1));
669
+ } else {
670
+ process.stderr.write(`Unknown command: ${command}
139
671
  `);
140
672
  printUsage();
141
673
  process.exit(1);
142
674
  }
143
- const mergeArgs = args.slice(1);
675
+ }
676
+ async function handleMerge(mergeArgs) {
144
677
  const outputIndex = mergeArgs.findIndex((a) => a === "-o" || a === "--output");
145
678
  if (outputIndex === -1 || outputIndex === mergeArgs.length - 1) {
146
679
  process.stderr.write("Error: -o <output> is required\n");
@@ -171,14 +704,72 @@ async function main() {
171
704
  process.exit(1);
172
705
  }
173
706
  }
707
+ async function handleServe(serveArgs) {
708
+ const portIndex = serveArgs.findIndex((a) => a === "--port" || a === "-p");
709
+ let port;
710
+ let dir;
711
+ if (portIndex !== -1 && portIndex < serveArgs.length - 1) {
712
+ port = parseInt(serveArgs[portIndex + 1], 10);
713
+ if (isNaN(port) || port < 1 || port > 65535) {
714
+ process.stderr.write("Error: Invalid port number\n");
715
+ process.exit(1);
716
+ }
717
+ const rest = [...serveArgs.slice(0, portIndex), ...serveArgs.slice(portIndex + 2)];
718
+ dir = rest[0];
719
+ } else {
720
+ dir = serveArgs[0];
721
+ }
722
+ if (!dir) {
723
+ process.stderr.write("Error: Report directory is required\n");
724
+ process.stderr.write("Usage: testrelic serve <dir> [--port <port>]\n");
725
+ process.exit(1);
726
+ }
727
+ const reportDir = (0, import_node_path4.resolve)(dir);
728
+ if (!(0, import_node_fs4.existsSync)(reportDir)) {
729
+ process.stderr.write(`Error: Directory not found: ${reportDir}
730
+ `);
731
+ process.exit(1);
732
+ }
733
+ try {
734
+ const { startReportServer: startReportServer2 } = await Promise.resolve().then(() => (init_report_server(), report_server_exports));
735
+ const { openInBrowser: openInBrowser2 } = await Promise.resolve().then(() => (init_browser_open(), browser_open_exports));
736
+ const handle = await startReportServer2(reportDir, port ? { port } : void 0);
737
+ const url = `http://127.0.0.1:${handle.port}`;
738
+ process.stderr.write(`
739
+ Report server running at: ${url}
740
+ Press Ctrl+C to stop
741
+
742
+ `);
743
+ openInBrowser2(url);
744
+ process.on("SIGINT", () => {
745
+ handle.dispose();
746
+ process.exit(0);
747
+ });
748
+ process.on("SIGTERM", () => {
749
+ handle.dispose();
750
+ process.exit(0);
751
+ });
752
+ } catch (err) {
753
+ process.stderr.write(
754
+ `Error: ${err instanceof Error ? err.message : String(err)}
755
+ `
756
+ );
757
+ process.exit(1);
758
+ }
759
+ }
174
760
  function printUsage() {
175
761
  process.stderr.write(
176
- `Usage: testrelic merge <files...> -o <output>
762
+ `Usage: testrelic <command>
177
763
 
178
- Merge multiple shard report JSON files into one.
764
+ Commands:
765
+ merge <files...> -o <output> Merge multiple shard report JSON files
766
+ serve <dir> [--port <port>] Start a local server for a streaming report
767
+ open <dir> [--port <port>] Alias for serve
179
768
 
180
- Example:
181
- npx testrelic merge shard-1.json shard-2.json -o merged.json
769
+ Examples:
770
+ npx testrelic merge shard-1.json shard-2.json -o merged.json
771
+ npx testrelic serve ./test-results/.testrelic-report
772
+ npx testrelic serve ./test-results/.testrelic-report --port 9400
182
773
  `
183
774
  );
184
775
  }