@crvy/rprtr 0.1.3 → 0.2.2
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/CHANGELOG.md +84 -0
- package/README.md +15 -6
- package/dist/{chunk-X2TCDD54.js → chunk-HAFWYUNO.js} +204 -137
- package/dist/{chunk-F7NSIHCV.js → chunk-RFZGJSL3.js} +448 -128
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +6 -4
- package/dist/index.css +29 -0
- package/dist/index.js +297 -76
- package/dist/offline-reports.d.ts +4 -0
- package/dist/offline-reports.d.ts.map +1 -1
- package/dist/path-utils.d.ts +3 -0
- package/dist/path-utils.d.ts.map +1 -0
- package/dist/report-state.d.ts +3 -1
- package/dist/report-state.d.ts.map +1 -1
- package/dist/report-utils.d.ts.map +1 -1
- package/dist/reporter-utils.d.ts +0 -1
- package/dist/reporter-utils.d.ts.map +1 -1
- package/dist/reporter.cjs +232 -170
- package/dist/reporter.d.ts +1 -1
- package/dist/reporter.d.ts.map +1 -1
- package/dist/reporter.js +8 -6
- package/dist/schemas/http.d.ts +44 -0
- package/dist/schemas/http.d.ts.map +1 -0
- package/dist/schemas.d.ts +25 -6
- package/dist/schemas.d.ts.map +1 -1
- package/dist/server/app.d.ts +6 -0
- package/dist/server/app.d.ts.map +1 -1
- package/dist/server/artifact-routes.d.ts.map +1 -1
- package/dist/server/handlers.d.ts +3 -0
- package/dist/server/handlers.d.ts.map +1 -1
- package/dist/server/playwright-config.d.ts +2 -0
- package/dist/server/playwright-config.d.ts.map +1 -0
- package/dist/server/routes-context.d.ts +13 -0
- package/dist/server/routes-context.d.ts.map +1 -0
- package/dist/server/routes.d.ts +6 -1
- package/dist/server/routes.d.ts.map +1 -1
- package/dist/server/run-controller.d.ts +85 -0
- package/dist/server/run-controller.d.ts.map +1 -0
- package/dist/server/run-routes.d.ts +3 -0
- package/dist/server/run-routes.d.ts.map +1 -0
- package/dist/server.cjs +668 -286
- package/dist/server.js +2 -2
- package/dist/types.d.ts +6 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +6 -5
|
@@ -5,77 +5,26 @@ import {
|
|
|
5
5
|
OfflineReportSchema,
|
|
6
6
|
RegisterDataSchema,
|
|
7
7
|
RunEndDataSchema,
|
|
8
|
+
RunRequestBodySchema,
|
|
8
9
|
TestBeginDataSchema,
|
|
9
10
|
TestEndDataSchema,
|
|
10
11
|
applyTestBeginEvent,
|
|
11
12
|
applyTestEndEvent,
|
|
12
13
|
createMutableReportState,
|
|
13
14
|
finalizeRunEvent,
|
|
15
|
+
isForeignAbsolutePath,
|
|
14
16
|
resolveBaselineTargets,
|
|
15
17
|
safeParse
|
|
16
|
-
} from "./chunk-
|
|
18
|
+
} from "./chunk-HAFWYUNO.js";
|
|
17
19
|
|
|
18
20
|
// src/server/app.ts
|
|
19
|
-
import { dirname as dirname3, join as
|
|
21
|
+
import { dirname as dirname3, join as join5 } from "path";
|
|
20
22
|
import { fileURLToPath } from "url";
|
|
21
|
-
import pLimit from "p-limit";
|
|
22
23
|
|
|
23
24
|
// src/offline-reports.ts
|
|
24
25
|
import { readdir } from "fs/promises";
|
|
25
26
|
import { join } from "path";
|
|
26
|
-
|
|
27
|
-
async function findOfflineReportPaths(searchDir) {
|
|
28
|
-
try {
|
|
29
|
-
const entries = await readdir(searchDir);
|
|
30
|
-
return entries.filter((entry) => OFFLINE_REPORT_FILE_PATTERN.test(entry)).sort((left, right) => left.localeCompare(right)).map((entry) => join(searchDir, entry));
|
|
31
|
-
} catch (error) {
|
|
32
|
-
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
33
|
-
return [];
|
|
34
|
-
}
|
|
35
|
-
throw error;
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
function parseOfflineReport(value) {
|
|
39
|
-
const parsed = safeParse(OfflineReportSchema, value);
|
|
40
|
-
if (parsed === null || parsed.version !== 1 || !Array.isArray(parsed.events)) {
|
|
41
|
-
return null;
|
|
42
|
-
}
|
|
43
|
-
return parsed;
|
|
44
|
-
}
|
|
45
|
-
function mergeOfflineReportsIntoTests(existingTests, offlineReports, options = {}) {
|
|
46
|
-
const state = createMutableReportState(options.screenshotDir);
|
|
47
|
-
let shouldFinalize = false;
|
|
48
|
-
for (const report of offlineReports) {
|
|
49
|
-
for (const event of report.events) {
|
|
50
|
-
switch (event.type) {
|
|
51
|
-
case "test-begin": {
|
|
52
|
-
const parsed = safeParse(TestBeginDataSchema, event.data);
|
|
53
|
-
if (parsed !== null) {
|
|
54
|
-
applyTestBeginEvent(state, parsed);
|
|
55
|
-
}
|
|
56
|
-
break;
|
|
57
|
-
}
|
|
58
|
-
case "test-end": {
|
|
59
|
-
const parsed = safeParse(TestEndDataSchema, event.data);
|
|
60
|
-
if (parsed !== null) {
|
|
61
|
-
applyTestEndEvent(state, parsed, { screenshotsBaseUrl: options.screenshotsBaseUrl });
|
|
62
|
-
}
|
|
63
|
-
break;
|
|
64
|
-
}
|
|
65
|
-
case "run-end":
|
|
66
|
-
shouldFinalize = true;
|
|
67
|
-
break;
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
if (shouldFinalize) {
|
|
72
|
-
finalizeRunEvent(state);
|
|
73
|
-
}
|
|
74
|
-
return {
|
|
75
|
-
...existingTests,
|
|
76
|
-
...state.reportData.tests
|
|
77
|
-
};
|
|
78
|
-
}
|
|
27
|
+
import pLimit from "p-limit";
|
|
79
28
|
|
|
80
29
|
// src/server/file-utils.ts
|
|
81
30
|
import { access, copyFile, mkdir, readFile, stat, writeFile } from "fs/promises";
|
|
@@ -172,6 +121,93 @@ async function respondWithFile(filePath, contentType) {
|
|
|
172
121
|
}
|
|
173
122
|
}
|
|
174
123
|
|
|
124
|
+
// src/offline-reports.ts
|
|
125
|
+
var MAX_CONCURRENT_FILE_OPS = 5;
|
|
126
|
+
var OFFLINE_REPORT_FILE_PATTERN = /^crvy-rprtr(?:-\d+)?\.json$/;
|
|
127
|
+
async function findOfflineReportPaths(searchDir) {
|
|
128
|
+
try {
|
|
129
|
+
const entries = await readdir(searchDir);
|
|
130
|
+
return entries.filter((entry) => OFFLINE_REPORT_FILE_PATTERN.test(entry)).sort((left, right) => left.localeCompare(right)).map((entry) => join(searchDir, entry));
|
|
131
|
+
} catch (error) {
|
|
132
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
133
|
+
return [];
|
|
134
|
+
}
|
|
135
|
+
throw error;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
function parseOfflineReport(value) {
|
|
139
|
+
const parsed = safeParse(OfflineReportSchema, value);
|
|
140
|
+
if (parsed === null || parsed.version !== 1 || !Array.isArray(parsed.events)) {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
return parsed;
|
|
144
|
+
}
|
|
145
|
+
async function readOfflineReport(filePath) {
|
|
146
|
+
try {
|
|
147
|
+
const raw = await readJsonFile(filePath);
|
|
148
|
+
if (raw === null) {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
const parsed = parseOfflineReport(raw);
|
|
152
|
+
if (parsed !== null) {
|
|
153
|
+
console.log(`[Server] Loading offline report: ${filePath}`);
|
|
154
|
+
return parsed;
|
|
155
|
+
}
|
|
156
|
+
} catch {
|
|
157
|
+
}
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
async function loadOfflineReports(reportData, offlineReportDir) {
|
|
161
|
+
const offlineReportPaths = await findOfflineReportPaths(offlineReportDir);
|
|
162
|
+
if (offlineReportPaths.length === 0) {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const limit = pLimit(MAX_CONCURRENT_FILE_OPS);
|
|
166
|
+
const reports = await Promise.all(offlineReportPaths.map((filePath) => limit(() => readOfflineReport(filePath))));
|
|
167
|
+
const validReports = reports.filter((report) => report !== null);
|
|
168
|
+
if (validReports.length === 0) {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
reportData.tests = mergeOfflineReportsIntoTests(reportData.tests, validReports, {
|
|
172
|
+
screenshotDir: reportData.screenshotDir,
|
|
173
|
+
screenshotsBaseUrl: "/screenshots/"
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
function mergeOfflineReportsIntoTests(existingTests, offlineReports, options = {}) {
|
|
177
|
+
const state = createMutableReportState(options.screenshotDir);
|
|
178
|
+
let shouldFinalize = false;
|
|
179
|
+
for (const report of offlineReports) {
|
|
180
|
+
for (const event of report.events) {
|
|
181
|
+
switch (event.type) {
|
|
182
|
+
case "test-begin": {
|
|
183
|
+
const parsed = safeParse(TestBeginDataSchema, event.data);
|
|
184
|
+
if (parsed !== null) {
|
|
185
|
+
applyTestBeginEvent(state, parsed);
|
|
186
|
+
}
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
case "test-end": {
|
|
190
|
+
const parsed = safeParse(TestEndDataSchema, event.data);
|
|
191
|
+
if (parsed !== null) {
|
|
192
|
+
applyTestEndEvent(state, parsed, { screenshotsBaseUrl: options.screenshotsBaseUrl });
|
|
193
|
+
}
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
196
|
+
case "run-end":
|
|
197
|
+
shouldFinalize = true;
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (shouldFinalize) {
|
|
203
|
+
finalizeRunEvent(state);
|
|
204
|
+
}
|
|
205
|
+
return {
|
|
206
|
+
...existingTests,
|
|
207
|
+
...state.reportData.tests
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
175
211
|
// src/server/handlers.ts
|
|
176
212
|
import { existsSync as existsSync2 } from "fs";
|
|
177
213
|
|
|
@@ -210,14 +246,21 @@ async function realpathOrNull(path) {
|
|
|
210
246
|
}
|
|
211
247
|
async function handleFile(ctx, req) {
|
|
212
248
|
const notFound = () => new Response("Not Found", { status: 404 });
|
|
249
|
+
let rawDecoded;
|
|
213
250
|
let decodedPath;
|
|
214
251
|
try {
|
|
215
|
-
|
|
252
|
+
rawDecoded = decodeURIComponent(new URL(req.url).pathname.slice("/file/".length));
|
|
253
|
+
decodedPath = resolve2(rawDecoded);
|
|
216
254
|
} catch {
|
|
217
255
|
return notFound();
|
|
218
256
|
}
|
|
219
257
|
const realTarget = await realpathOrNull(decodedPath);
|
|
220
258
|
if (realTarget === null) {
|
|
259
|
+
if (isForeignAbsolutePath(rawDecoded, process.platform)) {
|
|
260
|
+
console.warn(
|
|
261
|
+
`[Crvy Rprtr] /file request resolved to a foreign-OS absolute path that cannot be served on ${process.platform}: ${rawDecoded}`
|
|
262
|
+
);
|
|
263
|
+
}
|
|
221
264
|
return notFound();
|
|
222
265
|
}
|
|
223
266
|
const realRoots = (await Promise.all((ctx.artifactRoots ?? []).map((root) => realpathOrNull(resolve2(root))))).filter(
|
|
@@ -349,8 +392,8 @@ function handleTestEnd(ctx, data) {
|
|
|
349
392
|
broadcastToBrowsers(ctx.wsClients, message);
|
|
350
393
|
}
|
|
351
394
|
async function handleRunEnd(ctx, data) {
|
|
352
|
-
const removedTestIds = Object.keys(ctx.reportData.tests).filter((id) => !ctx.currentRunIds.has(id));
|
|
353
|
-
const { passed, failed, pending } = finalizeRunEvent(ctx);
|
|
395
|
+
const removedTestIds = ctx.isFilteredRun ? [] : Object.keys(ctx.reportData.tests).filter((id) => !ctx.currentRunIds.has(id));
|
|
396
|
+
const { passed, failed, pending } = finalizeRunEvent(ctx, { preserveNonCurrent: ctx.isFilteredRun });
|
|
354
397
|
await ctx.saveReport();
|
|
355
398
|
console.log(`
|
|
356
399
|
Run complete \u2014 ${passed} passed, ${failed} failed, ${pending} skipped`);
|
|
@@ -403,16 +446,93 @@ function handleRegister(ctx, data) {
|
|
|
403
446
|
ctx.routesContext.approvalRouting.playwrightToHaveScreenshotPathTemplate = data.playwrightToHaveScreenshotPathTemplate;
|
|
404
447
|
}
|
|
405
448
|
}
|
|
449
|
+
if (data.configFile !== void 0 && data.cwd !== void 0) {
|
|
450
|
+
ctx.routesContext.runContext = { configFile: data.configFile, cwd: data.cwd };
|
|
451
|
+
}
|
|
406
452
|
console.log("[Server] Reporter registered with config:", {
|
|
407
453
|
playwrightSnapshotDir: data.playwrightSnapshotDir,
|
|
408
454
|
playwrightTestDir: data.playwrightTestDir
|
|
409
455
|
});
|
|
410
456
|
}
|
|
411
457
|
|
|
412
|
-
// src/server/
|
|
458
|
+
// src/server/playwright-config.ts
|
|
413
459
|
import { join as join2 } from "path";
|
|
460
|
+
var CONFIG_FILES = [
|
|
461
|
+
"playwright.config.ts",
|
|
462
|
+
"playwright.config.mts",
|
|
463
|
+
"playwright.config.cts",
|
|
464
|
+
"playwright.config.js",
|
|
465
|
+
"playwright.config.mjs",
|
|
466
|
+
"playwright.config.cjs"
|
|
467
|
+
];
|
|
468
|
+
async function resolvePlaywrightConfig(cwd) {
|
|
469
|
+
const matches = await Promise.all(
|
|
470
|
+
CONFIG_FILES.map(async (file) => {
|
|
471
|
+
const candidate = join2(cwd, file);
|
|
472
|
+
return await fileExists(candidate) ? candidate : null;
|
|
473
|
+
})
|
|
474
|
+
);
|
|
475
|
+
return matches.find((path) => path !== null) ?? null;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// src/server/routes-context.ts
|
|
479
|
+
function createRoutesContext(reportData, staticDir, saveReport, options) {
|
|
480
|
+
const artifactRoots = [reportData.screenshotDir, options.outputDir, options.playwrightSnapshotDir].filter(
|
|
481
|
+
(root) => root !== void 0 && root !== ""
|
|
482
|
+
);
|
|
483
|
+
return {
|
|
484
|
+
reportData,
|
|
485
|
+
staticDir,
|
|
486
|
+
saveReport,
|
|
487
|
+
artifactRoots,
|
|
488
|
+
approvalRouting: {
|
|
489
|
+
configDir: options.configDir ?? process.cwd(),
|
|
490
|
+
playwrightTestDir: options.playwrightTestDir,
|
|
491
|
+
playwrightSnapshotDir: options.playwrightSnapshotDir,
|
|
492
|
+
playwrightSnapshotPathTemplate: options.playwrightSnapshotPathTemplate,
|
|
493
|
+
playwrightToHaveScreenshotPathTemplate: options.playwrightToHaveScreenshotPathTemplate
|
|
494
|
+
},
|
|
495
|
+
runContext: void 0
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// src/server/routes.ts
|
|
500
|
+
import { join as join3 } from "path";
|
|
501
|
+
|
|
502
|
+
// src/server/run-routes.ts
|
|
503
|
+
function handleRunRoutes(pathname, method, runController, req) {
|
|
504
|
+
if (pathname === "/api/run" && method === "POST") {
|
|
505
|
+
return handleApiRun(runController, req);
|
|
506
|
+
}
|
|
507
|
+
if (pathname === "/api/stop" && method === "POST") {
|
|
508
|
+
return Promise.resolve(handleApiStop(runController));
|
|
509
|
+
}
|
|
510
|
+
return null;
|
|
511
|
+
}
|
|
512
|
+
async function handleApiRun(runController, req) {
|
|
513
|
+
let body = {};
|
|
514
|
+
try {
|
|
515
|
+
body = await req.json();
|
|
516
|
+
} catch {
|
|
517
|
+
}
|
|
518
|
+
const parsed = safeParse(RunRequestBodySchema, body);
|
|
519
|
+
if (parsed === null) {
|
|
520
|
+
return Response.json({ ok: false, error: "Invalid request body" }, { status: 400 });
|
|
521
|
+
}
|
|
522
|
+
const result = runController.start(parsed);
|
|
523
|
+
if (result.ok) return Response.json(result);
|
|
524
|
+
const status = result.reason === "no-tests" ? 400 : 409;
|
|
525
|
+
return Response.json(result, { status });
|
|
526
|
+
}
|
|
527
|
+
function handleApiStop(runController) {
|
|
528
|
+
const result = runController.stop();
|
|
529
|
+
if (result.ok) return Response.json(result);
|
|
530
|
+
return Response.json(result, { status: 409 });
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// src/server/routes.ts
|
|
414
534
|
async function handleRoot(ctx) {
|
|
415
|
-
const html = await respondWithFile(
|
|
535
|
+
const html = await respondWithFile(join3(ctx.staticDir, "index.html"), "text/html");
|
|
416
536
|
return html ?? new Response("Not Found", { status: 404 });
|
|
417
537
|
}
|
|
418
538
|
async function handleAppCss() {
|
|
@@ -427,12 +547,15 @@ async function handleSrcFiles(req) {
|
|
|
427
547
|
return file ?? new Response("Not Found", { status: 404 });
|
|
428
548
|
}
|
|
429
549
|
function handleApiReport(ctx) {
|
|
430
|
-
return Response.json(
|
|
550
|
+
return Response.json({
|
|
551
|
+
...ctx.reportData,
|
|
552
|
+
runEnabled: ctx.runContext !== void 0
|
|
553
|
+
});
|
|
431
554
|
}
|
|
432
555
|
var APPROVAL_TARGET_ERROR = "Could not resolve approval target";
|
|
433
556
|
function actualPathFromUrl(ctx, actualUrl) {
|
|
434
557
|
if (actualUrl.startsWith("/screenshots/")) {
|
|
435
|
-
return
|
|
558
|
+
return join3(ctx.reportData.screenshotDir, actualUrl.slice("/screenshots/".length));
|
|
436
559
|
}
|
|
437
560
|
if (actualUrl.startsWith("/file/")) {
|
|
438
561
|
return decodeURIComponent(actualUrl.slice("/file/".length));
|
|
@@ -554,12 +677,12 @@ async function handleScreenshots(ctx, req) {
|
|
|
554
677
|
}
|
|
555
678
|
async function handleDist(ctx, req) {
|
|
556
679
|
const path = new URL(req.url).pathname.slice("/dist/".length);
|
|
557
|
-
const filePath =
|
|
680
|
+
const filePath = join3(ctx.staticDir, path);
|
|
558
681
|
const contentType = filePath.endsWith(".css") ? "text/css" : filePath.endsWith(".js") ? "application/javascript" : filePath.endsWith(".svelte") ? "text/plain" : "application/octet-stream";
|
|
559
682
|
const file = await respondWithFile(filePath, contentType);
|
|
560
683
|
return file ?? new Response("Not Found", { status: 404 });
|
|
561
684
|
}
|
|
562
|
-
function handleHttpRequest(ctx, req) {
|
|
685
|
+
function handleHttpRequest(ctx, req, runController) {
|
|
563
686
|
const pathname = new URL(req.url).pathname;
|
|
564
687
|
if (pathname === "/") {
|
|
565
688
|
return handleRoot(ctx);
|
|
@@ -579,6 +702,10 @@ function handleHttpRequest(ctx, req) {
|
|
|
579
702
|
if (pathname === "/api/approve-all" && req.method === "POST") {
|
|
580
703
|
return handleApiApproveAll(ctx);
|
|
581
704
|
}
|
|
705
|
+
const runResponse = handleRunRoutes(pathname, req.method, runController, req);
|
|
706
|
+
if (runResponse !== null) {
|
|
707
|
+
return runResponse;
|
|
708
|
+
}
|
|
582
709
|
if (pathname.startsWith("/api/images/")) {
|
|
583
710
|
return handleApiImages(req);
|
|
584
711
|
}
|
|
@@ -595,8 +722,220 @@ function handleHttpRequest(ctx, req) {
|
|
|
595
722
|
return Promise.resolve(new Response("Not Found", { status: 404 }));
|
|
596
723
|
}
|
|
597
724
|
|
|
725
|
+
// src/server/run-controller.ts
|
|
726
|
+
import { spawn } from "child_process";
|
|
727
|
+
import { readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
728
|
+
import { createRequire } from "node:module";
|
|
729
|
+
import { tmpdir } from "node:os";
|
|
730
|
+
import { join as join4 } from "node:path";
|
|
731
|
+
import { resolveCommand } from "package-manager-detector/commands";
|
|
732
|
+
import { getUserAgent } from "package-manager-detector/detect";
|
|
733
|
+
var STOP_GRACE_MS = 5e3;
|
|
734
|
+
var KNOWN_SIGNALS = {
|
|
735
|
+
SIGTERM: "SIGTERM",
|
|
736
|
+
SIGKILL: "SIGKILL"
|
|
737
|
+
};
|
|
738
|
+
function resolvePlaywrightLaunch(cwd, playwrightArgs) {
|
|
739
|
+
const agent = getUserAgent();
|
|
740
|
+
const resolved = agent === null ? null : resolveCommand(agent, "execute-local", ["playwright", ...playwrightArgs]);
|
|
741
|
+
if (resolved !== null) return { cmd: resolved.command, args: resolved.args };
|
|
742
|
+
return { cmd: "npx", args: ["playwright", ...playwrightArgs] };
|
|
743
|
+
}
|
|
744
|
+
function descriptorLocation(d) {
|
|
745
|
+
return d.column === void 0 ? `${d.file}:${d.line}` : `${d.file}:${d.line}:${d.column}`;
|
|
746
|
+
}
|
|
747
|
+
function sharedProject(tests) {
|
|
748
|
+
const names = new Set(tests.map((t) => t.projectName ?? ""));
|
|
749
|
+
if (names.size === 1) {
|
|
750
|
+
const name = [...names][0];
|
|
751
|
+
return name === "" ? void 0 : name;
|
|
752
|
+
}
|
|
753
|
+
return void 0;
|
|
754
|
+
}
|
|
755
|
+
function gteMinor(version, major, minor) {
|
|
756
|
+
const match = /^(\d+)\.(\d+)/.exec(version.trim());
|
|
757
|
+
if (match === null) return false;
|
|
758
|
+
const maj = parseInt(match[1], 10);
|
|
759
|
+
const min = parseInt(match[2], 10);
|
|
760
|
+
if (maj !== major) return maj > major;
|
|
761
|
+
return min >= minor;
|
|
762
|
+
}
|
|
763
|
+
function resolvePlaywrightVersion(cwd) {
|
|
764
|
+
try {
|
|
765
|
+
const req = createRequire(join4(cwd, "package.json"));
|
|
766
|
+
const pkgPath = req.resolve("@playwright/test/package.json");
|
|
767
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
768
|
+
return typeof pkg === "object" && pkg !== null && "version" in pkg && typeof pkg.version === "string" ? pkg.version : null;
|
|
769
|
+
} catch {
|
|
770
|
+
return null;
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
function buildTestListEntries(tests) {
|
|
774
|
+
return tests.map((d) => {
|
|
775
|
+
const loc = descriptorLocation(d);
|
|
776
|
+
const title = d.titlePath.join(" \u203A ");
|
|
777
|
+
const prefix = d.projectName !== void 0 && d.projectName !== "" ? `[${d.projectName}] \u203A ` : "";
|
|
778
|
+
return `${prefix}${loc} \u203A ${title}`;
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
function defaultWriteTempFile(content) {
|
|
782
|
+
const path = join4(tmpdir(), `crvy-rprtr-test-list-${process.pid}-${Date.now()}.txt`);
|
|
783
|
+
writeFileSync(path, content, "utf8");
|
|
784
|
+
return path;
|
|
785
|
+
}
|
|
786
|
+
function defaultDeleteTempFile(path) {
|
|
787
|
+
try {
|
|
788
|
+
unlinkSync(path);
|
|
789
|
+
} catch {
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
function resolveReporterDefault(cwd) {
|
|
793
|
+
try {
|
|
794
|
+
return createRequire(join4(cwd, "package.json")).resolve("@crvy/rprtr");
|
|
795
|
+
} catch {
|
|
796
|
+
}
|
|
797
|
+
try {
|
|
798
|
+
return createRequire(import.meta.url).resolve("@crvy/rprtr");
|
|
799
|
+
} catch {
|
|
800
|
+
return null;
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
function buildSpawnEnv(port) {
|
|
804
|
+
const env = {};
|
|
805
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
806
|
+
if (key === "CI") continue;
|
|
807
|
+
env[key] = value;
|
|
808
|
+
}
|
|
809
|
+
env.CRVY_RPRTR_SERVER_URL = `ws://localhost:${port}`;
|
|
810
|
+
env.PLAYWRIGHT_HTML_OPEN = "never";
|
|
811
|
+
return env;
|
|
812
|
+
}
|
|
813
|
+
var RunController = class {
|
|
814
|
+
constructor(deps) {
|
|
815
|
+
this.deps = deps;
|
|
816
|
+
}
|
|
817
|
+
child = null;
|
|
818
|
+
sigkillTimer = null;
|
|
819
|
+
testListPath = null;
|
|
820
|
+
get isRunning() {
|
|
821
|
+
return this.child !== null;
|
|
822
|
+
}
|
|
823
|
+
supportsTestList(cwd) {
|
|
824
|
+
const getVersion = this.deps.getPlaywrightVersion ?? resolvePlaywrightVersion;
|
|
825
|
+
const version = getVersion(cwd);
|
|
826
|
+
return version !== null && gteMinor(version, 1, 56);
|
|
827
|
+
}
|
|
828
|
+
cleanupTempFile() {
|
|
829
|
+
if (this.testListPath !== null) {
|
|
830
|
+
const del = this.deps.deleteTempFile ?? defaultDeleteTempFile;
|
|
831
|
+
del(this.testListPath);
|
|
832
|
+
this.testListPath = null;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
start(filters) {
|
|
836
|
+
const ctx = this.deps.getRunContext();
|
|
837
|
+
if (ctx === null) return { ok: false, reason: "no-config" };
|
|
838
|
+
if (this.child !== null) return { ok: false, reason: "already-running" };
|
|
839
|
+
if (filters.tests !== void 0 && filters.tests.length === 0) {
|
|
840
|
+
return { ok: false, reason: "no-tests" };
|
|
841
|
+
}
|
|
842
|
+
const resolveReporter = this.deps.resolveReporter ?? resolveReporterDefault;
|
|
843
|
+
const reporterModule = resolveReporter(ctx.cwd);
|
|
844
|
+
const tests = filters.tests;
|
|
845
|
+
const useTestList = tests !== void 0 && tests.length > 1 && this.supportsTestList(ctx.cwd);
|
|
846
|
+
const args = ["test", "--config", ctx.configFile];
|
|
847
|
+
if (reporterModule !== null) args.push("--reporter", reporterModule);
|
|
848
|
+
if (useTestList && tests !== void 0) {
|
|
849
|
+
const content = buildTestListEntries(tests).join("\n");
|
|
850
|
+
const writeTemp = this.deps.writeTempFile ?? defaultWriteTempFile;
|
|
851
|
+
this.testListPath = writeTemp(content);
|
|
852
|
+
args.push("--test-list", this.testListPath);
|
|
853
|
+
} else if (tests !== void 0 && tests.length > 0) {
|
|
854
|
+
const project = sharedProject(tests);
|
|
855
|
+
if (project !== void 0) args.push("--project", project);
|
|
856
|
+
for (const d of tests) args.push(descriptorLocation(d));
|
|
857
|
+
}
|
|
858
|
+
const resolveLaunch = this.deps.resolveLaunch ?? resolvePlaywrightLaunch;
|
|
859
|
+
const { cmd, args: launchArgs } = resolveLaunch(ctx.cwd, args);
|
|
860
|
+
let child;
|
|
861
|
+
try {
|
|
862
|
+
child = this.deps.spawn(cmd, launchArgs, { cwd: ctx.cwd, env: buildSpawnEnv(this.deps.port), stdio: "inherit" });
|
|
863
|
+
} catch (err) {
|
|
864
|
+
this.cleanupTempFile();
|
|
865
|
+
throw err;
|
|
866
|
+
}
|
|
867
|
+
this.child = child;
|
|
868
|
+
child.on("exit", (code) => {
|
|
869
|
+
this.handleChildExit(code);
|
|
870
|
+
});
|
|
871
|
+
child.on("error", () => {
|
|
872
|
+
this.handleChildExit(null);
|
|
873
|
+
});
|
|
874
|
+
this.deps.setReportRunning(true);
|
|
875
|
+
this.deps.setRunFiltered?.(filters.tests !== void 0);
|
|
876
|
+
this.deps.broadcast({ type: "run-status", data: { running: true } });
|
|
877
|
+
return { ok: true };
|
|
878
|
+
}
|
|
879
|
+
stop() {
|
|
880
|
+
if (this.child === null) return { ok: false, reason: "not-running" };
|
|
881
|
+
if (this.sigkillTimer !== null) this.deps.timers.clearTimeout(this.sigkillTimer);
|
|
882
|
+
this.child.kill("SIGTERM");
|
|
883
|
+
this.sigkillTimer = this.deps.timers.setTimeout(() => {
|
|
884
|
+
if (this.child !== null) this.child.kill("SIGKILL");
|
|
885
|
+
}, STOP_GRACE_MS);
|
|
886
|
+
return { ok: true };
|
|
887
|
+
}
|
|
888
|
+
dispose() {
|
|
889
|
+
if (this.child === null) return;
|
|
890
|
+
if (this.sigkillTimer !== null) this.deps.timers.clearTimeout(this.sigkillTimer);
|
|
891
|
+
this.sigkillTimer = null;
|
|
892
|
+
this.child.kill("SIGKILL");
|
|
893
|
+
this.cleanupTempFile();
|
|
894
|
+
}
|
|
895
|
+
handleChildExit(code) {
|
|
896
|
+
if (this.child === null) return;
|
|
897
|
+
if (this.sigkillTimer !== null) {
|
|
898
|
+
this.deps.timers.clearTimeout(this.sigkillTimer);
|
|
899
|
+
this.sigkillTimer = null;
|
|
900
|
+
}
|
|
901
|
+
this.child = null;
|
|
902
|
+
this.cleanupTempFile();
|
|
903
|
+
if (code !== null && code !== 0) {
|
|
904
|
+
console.warn(`[RunController] playwright test exited with code ${code}`);
|
|
905
|
+
}
|
|
906
|
+
this.deps.setReportRunning(false);
|
|
907
|
+
this.deps.broadcast({ type: "run-status", data: { running: false } });
|
|
908
|
+
}
|
|
909
|
+
};
|
|
910
|
+
function createRealSpawn() {
|
|
911
|
+
return (cmd, args, opts) => {
|
|
912
|
+
const cp = spawn(cmd, args, opts);
|
|
913
|
+
return {
|
|
914
|
+
on: (event, cb) => {
|
|
915
|
+
cp.on(event, cb);
|
|
916
|
+
},
|
|
917
|
+
kill: (signal) => {
|
|
918
|
+
const sig = KNOWN_SIGNALS[signal];
|
|
919
|
+
if (sig !== void 0) cp.kill(sig);
|
|
920
|
+
}
|
|
921
|
+
};
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
function createRealTimers() {
|
|
925
|
+
const pending = [];
|
|
926
|
+
return {
|
|
927
|
+
setTimeout: (fn, ms) => {
|
|
928
|
+
const id = setTimeout(fn, ms);
|
|
929
|
+
pending.push(id);
|
|
930
|
+
return id;
|
|
931
|
+
},
|
|
932
|
+
clearTimeout: () => {
|
|
933
|
+
for (const h of pending.splice(0)) clearTimeout(h);
|
|
934
|
+
}
|
|
935
|
+
};
|
|
936
|
+
}
|
|
937
|
+
|
|
598
938
|
// src/server/app.ts
|
|
599
|
-
var MAX_CONCURRENT_FILE_OPS = 5;
|
|
600
939
|
function createReportData(options) {
|
|
601
940
|
return {
|
|
602
941
|
isRunning: false,
|
|
@@ -622,37 +961,6 @@ async function loadReport(reportPath, reportData) {
|
|
|
622
961
|
console.log("No report.json found, using empty state");
|
|
623
962
|
}
|
|
624
963
|
}
|
|
625
|
-
async function readOfflineReport(filePath) {
|
|
626
|
-
try {
|
|
627
|
-
const raw = await readJsonFile(filePath);
|
|
628
|
-
if (raw === null) {
|
|
629
|
-
return null;
|
|
630
|
-
}
|
|
631
|
-
const parsed = parseOfflineReport(raw);
|
|
632
|
-
if (parsed !== null) {
|
|
633
|
-
console.log(`[Server] Loading offline report: ${filePath}`);
|
|
634
|
-
return parsed;
|
|
635
|
-
}
|
|
636
|
-
} catch {
|
|
637
|
-
}
|
|
638
|
-
return null;
|
|
639
|
-
}
|
|
640
|
-
async function loadOfflineReports(offlineReportDir, reportData) {
|
|
641
|
-
const offlineReportPaths = await findOfflineReportPaths(offlineReportDir);
|
|
642
|
-
if (offlineReportPaths.length === 0) {
|
|
643
|
-
return;
|
|
644
|
-
}
|
|
645
|
-
const limit = pLimit(MAX_CONCURRENT_FILE_OPS);
|
|
646
|
-
const reports = await Promise.all(offlineReportPaths.map((filePath) => limit(() => readOfflineReport(filePath))));
|
|
647
|
-
const validReports = reports.filter((report) => report !== null);
|
|
648
|
-
if (validReports.length === 0) {
|
|
649
|
-
return;
|
|
650
|
-
}
|
|
651
|
-
reportData.tests = mergeOfflineReportsIntoTests(reportData.tests, validReports, {
|
|
652
|
-
screenshotDir: reportData.screenshotDir,
|
|
653
|
-
screenshotsBaseUrl: "/screenshots/"
|
|
654
|
-
});
|
|
655
|
-
}
|
|
656
964
|
async function handleParsedWebSocketMessage(ctx, msg) {
|
|
657
965
|
switch (msg.type) {
|
|
658
966
|
case "test-begin": {
|
|
@@ -719,16 +1027,16 @@ async function resolveStaticDir(staticDir) {
|
|
|
719
1027
|
const currentDir = dirname3(fileURLToPath(import.meta.url));
|
|
720
1028
|
const candidates = staticDir === void 0 ? [
|
|
721
1029
|
currentDir,
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
] : [staticDir,
|
|
1030
|
+
join5(currentDir, "dist"),
|
|
1031
|
+
join5(currentDir, "..", "dist"),
|
|
1032
|
+
join5(currentDir, "..", "..", "dist"),
|
|
1033
|
+
join5(currentDir, ".."),
|
|
1034
|
+
join5(currentDir, "..", "..")
|
|
1035
|
+
] : [staticDir, join5(staticDir, "dist")];
|
|
728
1036
|
const resolvedCandidates = await Promise.all(
|
|
729
1037
|
candidates.map(async (candidate) => ({
|
|
730
1038
|
candidate,
|
|
731
|
-
exists: await fileExists(
|
|
1039
|
+
exists: await fileExists(join5(candidate, "index.html"))
|
|
732
1040
|
}))
|
|
733
1041
|
);
|
|
734
1042
|
const resolved = resolvedCandidates.find(({ exists }) => exists);
|
|
@@ -739,27 +1047,33 @@ async function resolveStaticDir(staticDir) {
|
|
|
739
1047
|
}
|
|
740
1048
|
async function resolveReportPath(reportPath) {
|
|
741
1049
|
if (await isDirectory(reportPath)) {
|
|
742
|
-
return { reportFile:
|
|
1050
|
+
return { reportFile: join5(reportPath, "report.json"), offlineReportDir: reportPath };
|
|
743
1051
|
}
|
|
744
1052
|
return { reportFile: reportPath, offlineReportDir: dirname3(reportPath) };
|
|
745
1053
|
}
|
|
746
|
-
function
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
}
|
|
762
|
-
|
|
1054
|
+
async function seedRunContext(routesContext, options) {
|
|
1055
|
+
if (routesContext.runContext !== void 0) {
|
|
1056
|
+
return;
|
|
1057
|
+
}
|
|
1058
|
+
const configFile = options.playwrightConfig ?? await resolvePlaywrightConfig(process.cwd());
|
|
1059
|
+
if (configFile !== null) {
|
|
1060
|
+
routesContext.runContext = { configFile, cwd: process.cwd() };
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
function createServerRunController(routesContext, wsClients, reportData, port, setRunFiltered) {
|
|
1064
|
+
return new RunController({
|
|
1065
|
+
getRunContext: () => routesContext.runContext ?? null,
|
|
1066
|
+
port,
|
|
1067
|
+
broadcast: (message) => {
|
|
1068
|
+
broadcastToBrowsers(wsClients, message);
|
|
1069
|
+
},
|
|
1070
|
+
setReportRunning: (running) => {
|
|
1071
|
+
reportData.isRunning = running;
|
|
1072
|
+
},
|
|
1073
|
+
setRunFiltered,
|
|
1074
|
+
spawn: createRealSpawn(),
|
|
1075
|
+
timers: createRealTimers()
|
|
1076
|
+
});
|
|
763
1077
|
}
|
|
764
1078
|
async function createServerApp(options = {}) {
|
|
765
1079
|
const port = options.port ?? 3e3;
|
|
@@ -769,26 +1083,32 @@ async function createServerApp(options = {}) {
|
|
|
769
1083
|
const staticDir = await resolveStaticDir(options.staticDir);
|
|
770
1084
|
const wsClients = /* @__PURE__ */ new Set();
|
|
771
1085
|
const currentRunIds = /* @__PURE__ */ new Set();
|
|
772
|
-
|
|
773
|
-
await writeJsonFile(reportFile, reportData);
|
|
774
|
-
}
|
|
1086
|
+
const saveReport = () => writeJsonFile(reportFile, reportData);
|
|
775
1087
|
const routesContext = createRoutesContext(reportData, staticDir, saveReport, options);
|
|
1088
|
+
await seedRunContext(routesContext, options);
|
|
1089
|
+
let isFilteredRun = false;
|
|
1090
|
+
const runController = createServerRunController(routesContext, wsClients, reportData, port, (filtered) => {
|
|
1091
|
+
isFilteredRun = filtered;
|
|
1092
|
+
});
|
|
776
1093
|
const getHandlerContext = () => ({
|
|
777
1094
|
reportData,
|
|
778
1095
|
wsClients,
|
|
779
1096
|
currentRunIds,
|
|
1097
|
+
isFilteredRun,
|
|
780
1098
|
saveReport,
|
|
781
1099
|
approvalRouting: routesContext.approvalRouting,
|
|
782
|
-
routesContext
|
|
1100
|
+
routesContext,
|
|
1101
|
+
runController
|
|
783
1102
|
});
|
|
784
|
-
const handleRequest = (req) => handleHttpRequest(routesContext, req);
|
|
1103
|
+
const handleRequest = (req) => handleHttpRequest(routesContext, req, runController);
|
|
785
1104
|
const handleWebSocketMessage = createWebSocketMessageHandler(getHandlerContext);
|
|
786
1105
|
await loadReport(reportFile, reportData);
|
|
787
|
-
await loadOfflineReports(
|
|
1106
|
+
await loadOfflineReports(reportData, offlineReportDir);
|
|
788
1107
|
return {
|
|
789
1108
|
port,
|
|
790
1109
|
wsClients,
|
|
791
1110
|
close: () => {
|
|
1111
|
+
runController.dispose();
|
|
792
1112
|
},
|
|
793
1113
|
handleRequest,
|
|
794
1114
|
handleWebSocketMessage
|