@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 +621 -30
- package/dist/index.cjs +666 -334
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +12 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +666 -334
- package/dist/index.js.map +1 -1
- package/dist/merge.cjs +1 -1
- package/dist/merge.cjs.map +1 -1
- package/dist/merge.js +1 -1
- package/dist/merge.js.map +1 -1
- package/package.json +13 -13
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
|
-
|
|
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
|
|
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
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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:
|
|
116
|
-
apiCallsByMethod:
|
|
117
|
-
apiCallsByStatusRange:
|
|
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:
|
|
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
|
-
|
|
138
|
-
|
|
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
|
-
|
|
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
|
|
762
|
+
`Usage: testrelic <command>
|
|
177
763
|
|
|
178
|
-
|
|
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
|
-
|
|
181
|
-
|
|
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
|
}
|