@crvy/rprtr 0.1.4 → 0.2.3
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 +66 -0
- package/README.md +8 -7
- package/dist/{chunk-KLGJSEKV.js → chunk-473CWZ4V.js} +539 -130
- package/dist/{chunk-WRMHUY5A.js → chunk-HAFWYUNO.js} +181 -130
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +6 -4
- package/dist/index.css +26 -0
- package/dist/index.js +300 -79
- package/dist/offline-reports.d.ts +4 -0
- package/dist/offline-reports.d.ts.map +1 -1
- package/dist/report-state.d.ts +3 -1
- package/dist/report-state.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 +186 -134
- 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 +8 -1
- package/dist/server/app.d.ts.map +1 -1
- package/dist/server/handlers.d.ts +6 -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/report-persistence.d.ts +27 -0
- package/dist/server/report-persistence.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 +87 -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/shutdown.d.ts +17 -0
- package/dist/server/shutdown.d.ts.map +1 -0
- package/dist/server.cjs +733 -276
- package/dist/server.d.ts.map +1 -1
- 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,6 +5,7 @@ import {
|
|
|
5
5
|
OfflineReportSchema,
|
|
6
6
|
RegisterDataSchema,
|
|
7
7
|
RunEndDataSchema,
|
|
8
|
+
RunRequestBodySchema,
|
|
8
9
|
TestBeginDataSchema,
|
|
9
10
|
TestEndDataSchema,
|
|
10
11
|
applyTestBeginEvent,
|
|
@@ -14,69 +15,16 @@ import {
|
|
|
14
15
|
isForeignAbsolutePath,
|
|
15
16
|
resolveBaselineTargets,
|
|
16
17
|
safeParse
|
|
17
|
-
} from "./chunk-
|
|
18
|
+
} from "./chunk-HAFWYUNO.js";
|
|
18
19
|
|
|
19
20
|
// src/server/app.ts
|
|
20
|
-
import { dirname as dirname3, join as
|
|
21
|
+
import { dirname as dirname3, join as join5 } from "path";
|
|
21
22
|
import { fileURLToPath } from "url";
|
|
22
|
-
import pLimit from "p-limit";
|
|
23
23
|
|
|
24
24
|
// src/offline-reports.ts
|
|
25
25
|
import { readdir } from "fs/promises";
|
|
26
26
|
import { join } from "path";
|
|
27
|
-
|
|
28
|
-
async function findOfflineReportPaths(searchDir) {
|
|
29
|
-
try {
|
|
30
|
-
const entries = await readdir(searchDir);
|
|
31
|
-
return entries.filter((entry) => OFFLINE_REPORT_FILE_PATTERN.test(entry)).sort((left, right) => left.localeCompare(right)).map((entry) => join(searchDir, entry));
|
|
32
|
-
} catch (error) {
|
|
33
|
-
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
34
|
-
return [];
|
|
35
|
-
}
|
|
36
|
-
throw error;
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
function parseOfflineReport(value) {
|
|
40
|
-
const parsed = safeParse(OfflineReportSchema, value);
|
|
41
|
-
if (parsed === null || parsed.version !== 1 || !Array.isArray(parsed.events)) {
|
|
42
|
-
return null;
|
|
43
|
-
}
|
|
44
|
-
return parsed;
|
|
45
|
-
}
|
|
46
|
-
function mergeOfflineReportsIntoTests(existingTests, offlineReports, options = {}) {
|
|
47
|
-
const state = createMutableReportState(options.screenshotDir);
|
|
48
|
-
let shouldFinalize = false;
|
|
49
|
-
for (const report of offlineReports) {
|
|
50
|
-
for (const event of report.events) {
|
|
51
|
-
switch (event.type) {
|
|
52
|
-
case "test-begin": {
|
|
53
|
-
const parsed = safeParse(TestBeginDataSchema, event.data);
|
|
54
|
-
if (parsed !== null) {
|
|
55
|
-
applyTestBeginEvent(state, parsed);
|
|
56
|
-
}
|
|
57
|
-
break;
|
|
58
|
-
}
|
|
59
|
-
case "test-end": {
|
|
60
|
-
const parsed = safeParse(TestEndDataSchema, event.data);
|
|
61
|
-
if (parsed !== null) {
|
|
62
|
-
applyTestEndEvent(state, parsed, { screenshotsBaseUrl: options.screenshotsBaseUrl });
|
|
63
|
-
}
|
|
64
|
-
break;
|
|
65
|
-
}
|
|
66
|
-
case "run-end":
|
|
67
|
-
shouldFinalize = true;
|
|
68
|
-
break;
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
if (shouldFinalize) {
|
|
73
|
-
finalizeRunEvent(state);
|
|
74
|
-
}
|
|
75
|
-
return {
|
|
76
|
-
...existingTests,
|
|
77
|
-
...state.reportData.tests
|
|
78
|
-
};
|
|
79
|
-
}
|
|
27
|
+
import pLimit from "p-limit";
|
|
80
28
|
|
|
81
29
|
// src/server/file-utils.ts
|
|
82
30
|
import { access, copyFile, mkdir, readFile, stat, writeFile } from "fs/promises";
|
|
@@ -173,6 +121,93 @@ async function respondWithFile(filePath, contentType) {
|
|
|
173
121
|
}
|
|
174
122
|
}
|
|
175
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
|
+
|
|
176
211
|
// src/server/handlers.ts
|
|
177
212
|
import { existsSync as existsSync2 } from "fs";
|
|
178
213
|
|
|
@@ -315,6 +350,7 @@ function handleArtifactRoute(ctx, req, pathname) {
|
|
|
315
350
|
function handleTestBegin(ctx, data) {
|
|
316
351
|
const test = applyTestBeginEvent(ctx, data);
|
|
317
352
|
ctx.reportData.isRunning = true;
|
|
353
|
+
ctx.scheduleReportSave();
|
|
318
354
|
console.log(` \u25B6 [${test.browser ?? "?"}] ${test.title}`);
|
|
319
355
|
const message = { type: "test-begin", data: test };
|
|
320
356
|
broadcastToBrowsers(ctx.wsClients, message);
|
|
@@ -353,12 +389,13 @@ function handleTestEnd(ctx, data) {
|
|
|
353
389
|
const errNote = data.error !== null && data.error !== void 0 ? `
|
|
354
390
|
Error: ${data.error}` : "";
|
|
355
391
|
console.log(` ${icon} [${test.browser}] ${test.title}${dur}${diffNote}${errNote}`);
|
|
392
|
+
ctx.scheduleReportSave();
|
|
356
393
|
const message = { type: "test-update", data: test };
|
|
357
394
|
broadcastToBrowsers(ctx.wsClients, message);
|
|
358
395
|
}
|
|
359
396
|
async function handleRunEnd(ctx, data) {
|
|
360
|
-
const removedTestIds = Object.keys(ctx.reportData.tests).filter((id) => !ctx.currentRunIds.has(id));
|
|
361
|
-
const { passed, failed, pending } = finalizeRunEvent(ctx);
|
|
397
|
+
const removedTestIds = ctx.isFilteredRun ? [] : Object.keys(ctx.reportData.tests).filter((id) => !ctx.currentRunIds.has(id));
|
|
398
|
+
const { passed, failed, pending } = finalizeRunEvent(ctx, { preserveNonCurrent: ctx.isFilteredRun });
|
|
362
399
|
await ctx.saveReport();
|
|
363
400
|
console.log(`
|
|
364
401
|
Run complete \u2014 ${passed} passed, ${failed} failed, ${pending} skipped`);
|
|
@@ -411,16 +448,148 @@ function handleRegister(ctx, data) {
|
|
|
411
448
|
ctx.routesContext.approvalRouting.playwrightToHaveScreenshotPathTemplate = data.playwrightToHaveScreenshotPathTemplate;
|
|
412
449
|
}
|
|
413
450
|
}
|
|
451
|
+
if (data.configFile !== void 0 && data.cwd !== void 0) {
|
|
452
|
+
ctx.routesContext.runContext = { configFile: data.configFile, cwd: data.cwd };
|
|
453
|
+
}
|
|
414
454
|
console.log("[Server] Reporter registered with config:", {
|
|
415
455
|
playwrightSnapshotDir: data.playwrightSnapshotDir,
|
|
416
456
|
playwrightTestDir: data.playwrightTestDir
|
|
417
457
|
});
|
|
418
458
|
}
|
|
419
459
|
|
|
420
|
-
// src/server/
|
|
460
|
+
// src/server/playwright-config.ts
|
|
421
461
|
import { join as join2 } from "path";
|
|
462
|
+
var CONFIG_FILES = [
|
|
463
|
+
"playwright.config.ts",
|
|
464
|
+
"playwright.config.mts",
|
|
465
|
+
"playwright.config.cts",
|
|
466
|
+
"playwright.config.js",
|
|
467
|
+
"playwright.config.mjs",
|
|
468
|
+
"playwright.config.cjs"
|
|
469
|
+
];
|
|
470
|
+
async function resolvePlaywrightConfig(cwd) {
|
|
471
|
+
const matches = await Promise.all(
|
|
472
|
+
CONFIG_FILES.map(async (file) => {
|
|
473
|
+
const candidate = join2(cwd, file);
|
|
474
|
+
return await fileExists(candidate) ? candidate : null;
|
|
475
|
+
})
|
|
476
|
+
);
|
|
477
|
+
return matches.find((path) => path !== null) ?? null;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// src/server/report-persistence.ts
|
|
481
|
+
function createDebouncedSaver(save, delayMs, setTimeoutFn = (fn, ms) => setTimeout(fn, ms), clearTimeoutFn = (handle) => {
|
|
482
|
+
if (handle !== null) clearTimeout(handle);
|
|
483
|
+
}) {
|
|
484
|
+
let timer = null;
|
|
485
|
+
let dirty = false;
|
|
486
|
+
let inFlight = null;
|
|
487
|
+
const runSave = () => {
|
|
488
|
+
dirty = false;
|
|
489
|
+
inFlight = save();
|
|
490
|
+
return inFlight.finally(() => {
|
|
491
|
+
inFlight = null;
|
|
492
|
+
});
|
|
493
|
+
};
|
|
494
|
+
return {
|
|
495
|
+
schedule() {
|
|
496
|
+
dirty = true;
|
|
497
|
+
if (timer !== null) clearTimeoutFn(timer);
|
|
498
|
+
timer = setTimeoutFn(() => {
|
|
499
|
+
timer = null;
|
|
500
|
+
void runSave();
|
|
501
|
+
}, delayMs);
|
|
502
|
+
},
|
|
503
|
+
async flush() {
|
|
504
|
+
if (timer !== null) {
|
|
505
|
+
clearTimeoutFn(timer);
|
|
506
|
+
timer = null;
|
|
507
|
+
}
|
|
508
|
+
if (inFlight !== null) {
|
|
509
|
+
await inFlight;
|
|
510
|
+
}
|
|
511
|
+
if (dirty) {
|
|
512
|
+
await runSave();
|
|
513
|
+
}
|
|
514
|
+
},
|
|
515
|
+
cancel() {
|
|
516
|
+
if (timer !== null) {
|
|
517
|
+
clearTimeoutFn(timer);
|
|
518
|
+
timer = null;
|
|
519
|
+
}
|
|
520
|
+
dirty = false;
|
|
521
|
+
}
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
function createReportPersistence(reportFile, reportData) {
|
|
525
|
+
const reportSaver = createDebouncedSaver(() => writeJsonFile(reportFile, reportData), 250);
|
|
526
|
+
return {
|
|
527
|
+
saveReport: () => reportSaver.flush(),
|
|
528
|
+
scheduleReportSave: () => {
|
|
529
|
+
reportSaver.schedule();
|
|
530
|
+
},
|
|
531
|
+
dispose: () => reportSaver.flush()
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// src/server/routes-context.ts
|
|
536
|
+
function createRoutesContext(reportData, staticDir, saveReport, options) {
|
|
537
|
+
const artifactRoots = [reportData.screenshotDir, options.outputDir, options.playwrightSnapshotDir].filter(
|
|
538
|
+
(root) => root !== void 0 && root !== ""
|
|
539
|
+
);
|
|
540
|
+
return {
|
|
541
|
+
reportData,
|
|
542
|
+
staticDir,
|
|
543
|
+
saveReport,
|
|
544
|
+
artifactRoots,
|
|
545
|
+
approvalRouting: {
|
|
546
|
+
configDir: options.configDir ?? process.cwd(),
|
|
547
|
+
playwrightTestDir: options.playwrightTestDir,
|
|
548
|
+
playwrightSnapshotDir: options.playwrightSnapshotDir,
|
|
549
|
+
playwrightSnapshotPathTemplate: options.playwrightSnapshotPathTemplate,
|
|
550
|
+
playwrightToHaveScreenshotPathTemplate: options.playwrightToHaveScreenshotPathTemplate
|
|
551
|
+
},
|
|
552
|
+
runContext: void 0
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// src/server/routes.ts
|
|
557
|
+
import { join as join3 } from "path";
|
|
558
|
+
|
|
559
|
+
// src/server/run-routes.ts
|
|
560
|
+
function handleRunRoutes(pathname, method, runController, req) {
|
|
561
|
+
if (pathname === "/api/run" && method === "POST") {
|
|
562
|
+
return handleApiRun(runController, req);
|
|
563
|
+
}
|
|
564
|
+
if (pathname === "/api/stop" && method === "POST") {
|
|
565
|
+
return Promise.resolve(handleApiStop(runController));
|
|
566
|
+
}
|
|
567
|
+
return null;
|
|
568
|
+
}
|
|
569
|
+
async function handleApiRun(runController, req) {
|
|
570
|
+
let body = {};
|
|
571
|
+
try {
|
|
572
|
+
body = await req.json();
|
|
573
|
+
} catch {
|
|
574
|
+
}
|
|
575
|
+
const parsed = safeParse(RunRequestBodySchema, body);
|
|
576
|
+
if (parsed === null) {
|
|
577
|
+
return Response.json({ ok: false, error: "Invalid request body" }, { status: 400 });
|
|
578
|
+
}
|
|
579
|
+
const result = runController.start(parsed);
|
|
580
|
+
if (result.ok) return Response.json(result);
|
|
581
|
+
const status = result.reason === "no-tests" ? 400 : 409;
|
|
582
|
+
return Response.json(result, { status });
|
|
583
|
+
}
|
|
584
|
+
function handleApiStop(runController) {
|
|
585
|
+
const result = runController.stop();
|
|
586
|
+
if (result.ok) return Response.json(result);
|
|
587
|
+
return Response.json(result, { status: 409 });
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// src/server/routes.ts
|
|
422
591
|
async function handleRoot(ctx) {
|
|
423
|
-
const html = await respondWithFile(
|
|
592
|
+
const html = await respondWithFile(join3(ctx.staticDir, "index.html"), "text/html");
|
|
424
593
|
return html ?? new Response("Not Found", { status: 404 });
|
|
425
594
|
}
|
|
426
595
|
async function handleAppCss() {
|
|
@@ -435,12 +604,15 @@ async function handleSrcFiles(req) {
|
|
|
435
604
|
return file ?? new Response("Not Found", { status: 404 });
|
|
436
605
|
}
|
|
437
606
|
function handleApiReport(ctx) {
|
|
438
|
-
return Response.json(
|
|
607
|
+
return Response.json({
|
|
608
|
+
...ctx.reportData,
|
|
609
|
+
runEnabled: ctx.runContext !== void 0
|
|
610
|
+
});
|
|
439
611
|
}
|
|
440
612
|
var APPROVAL_TARGET_ERROR = "Could not resolve approval target";
|
|
441
613
|
function actualPathFromUrl(ctx, actualUrl) {
|
|
442
614
|
if (actualUrl.startsWith("/screenshots/")) {
|
|
443
|
-
return
|
|
615
|
+
return join3(ctx.reportData.screenshotDir, actualUrl.slice("/screenshots/".length));
|
|
444
616
|
}
|
|
445
617
|
if (actualUrl.startsWith("/file/")) {
|
|
446
618
|
return decodeURIComponent(actualUrl.slice("/file/".length));
|
|
@@ -562,12 +734,12 @@ async function handleScreenshots(ctx, req) {
|
|
|
562
734
|
}
|
|
563
735
|
async function handleDist(ctx, req) {
|
|
564
736
|
const path = new URL(req.url).pathname.slice("/dist/".length);
|
|
565
|
-
const filePath =
|
|
737
|
+
const filePath = join3(ctx.staticDir, path);
|
|
566
738
|
const contentType = filePath.endsWith(".css") ? "text/css" : filePath.endsWith(".js") ? "application/javascript" : filePath.endsWith(".svelte") ? "text/plain" : "application/octet-stream";
|
|
567
739
|
const file = await respondWithFile(filePath, contentType);
|
|
568
740
|
return file ?? new Response("Not Found", { status: 404 });
|
|
569
741
|
}
|
|
570
|
-
function handleHttpRequest(ctx, req) {
|
|
742
|
+
function handleHttpRequest(ctx, req, runController) {
|
|
571
743
|
const pathname = new URL(req.url).pathname;
|
|
572
744
|
if (pathname === "/") {
|
|
573
745
|
return handleRoot(ctx);
|
|
@@ -587,6 +759,10 @@ function handleHttpRequest(ctx, req) {
|
|
|
587
759
|
if (pathname === "/api/approve-all" && req.method === "POST") {
|
|
588
760
|
return handleApiApproveAll(ctx);
|
|
589
761
|
}
|
|
762
|
+
const runResponse = handleRunRoutes(pathname, req.method, runController, req);
|
|
763
|
+
if (runResponse !== null) {
|
|
764
|
+
return runResponse;
|
|
765
|
+
}
|
|
590
766
|
if (pathname.startsWith("/api/images/")) {
|
|
591
767
|
return handleApiImages(req);
|
|
592
768
|
}
|
|
@@ -603,8 +779,219 @@ function handleHttpRequest(ctx, req) {
|
|
|
603
779
|
return Promise.resolve(new Response("Not Found", { status: 404 }));
|
|
604
780
|
}
|
|
605
781
|
|
|
782
|
+
// src/server/run-controller.ts
|
|
783
|
+
import { spawn } from "child_process";
|
|
784
|
+
import { readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
785
|
+
import { createRequire } from "node:module";
|
|
786
|
+
import { tmpdir } from "node:os";
|
|
787
|
+
import { join as join4 } from "node:path";
|
|
788
|
+
import { resolveCommand } from "package-manager-detector/commands";
|
|
789
|
+
import { getUserAgent } from "package-manager-detector/detect";
|
|
790
|
+
var STOP_GRACE_MS = 5e3;
|
|
791
|
+
var KNOWN_SIGNALS = {
|
|
792
|
+
SIGTERM: "SIGTERM",
|
|
793
|
+
SIGKILL: "SIGKILL"
|
|
794
|
+
};
|
|
795
|
+
function resolvePlaywrightLaunch(cwd, playwrightArgs) {
|
|
796
|
+
const agent = getUserAgent();
|
|
797
|
+
const resolved = agent === null ? null : resolveCommand(agent, "execute-local", ["playwright", ...playwrightArgs]);
|
|
798
|
+
if (resolved !== null) return { cmd: resolved.command, args: resolved.args };
|
|
799
|
+
return { cmd: "npx", args: ["playwright", ...playwrightArgs] };
|
|
800
|
+
}
|
|
801
|
+
function descriptorLocation(d) {
|
|
802
|
+
return d.column === void 0 ? `${d.file}:${d.line}` : `${d.file}:${d.line}:${d.column}`;
|
|
803
|
+
}
|
|
804
|
+
function sharedProject(tests) {
|
|
805
|
+
const names = new Set(tests.map((t) => t.projectName ?? ""));
|
|
806
|
+
if (names.size === 1) {
|
|
807
|
+
const name = [...names][0];
|
|
808
|
+
return name === "" ? void 0 : name;
|
|
809
|
+
}
|
|
810
|
+
return void 0;
|
|
811
|
+
}
|
|
812
|
+
function gteMinor(version, major, minor) {
|
|
813
|
+
const match = /^(\d+)\.(\d+)/.exec(version.trim());
|
|
814
|
+
if (match === null) return false;
|
|
815
|
+
const maj = parseInt(match[1], 10);
|
|
816
|
+
const min = parseInt(match[2], 10);
|
|
817
|
+
if (maj !== major) return maj > major;
|
|
818
|
+
return min >= minor;
|
|
819
|
+
}
|
|
820
|
+
function resolvePlaywrightVersion(cwd) {
|
|
821
|
+
try {
|
|
822
|
+
const req = createRequire(join4(cwd, "package.json"));
|
|
823
|
+
const pkgPath = req.resolve("@playwright/test/package.json");
|
|
824
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
825
|
+
return typeof pkg === "object" && pkg !== null && "version" in pkg && typeof pkg.version === "string" ? pkg.version : null;
|
|
826
|
+
} catch {
|
|
827
|
+
return null;
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
function buildTestListEntries(tests) {
|
|
831
|
+
return tests.map((d) => {
|
|
832
|
+
const loc = descriptorLocation(d);
|
|
833
|
+
const title = d.titlePath.join(" \u203A ");
|
|
834
|
+
const prefix = d.projectName !== void 0 && d.projectName !== "" ? `[${d.projectName}] \u203A ` : "";
|
|
835
|
+
return `${prefix}${loc} \u203A ${title}`;
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
function defaultWriteTempFile(content) {
|
|
839
|
+
const path = join4(tmpdir(), `crvy-rprtr-test-list-${process.pid}-${Date.now()}.txt`);
|
|
840
|
+
writeFileSync(path, content, "utf8");
|
|
841
|
+
return path;
|
|
842
|
+
}
|
|
843
|
+
function defaultDeleteTempFile(path) {
|
|
844
|
+
try {
|
|
845
|
+
unlinkSync(path);
|
|
846
|
+
} catch {
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
function resolveReporterDefault(cwd) {
|
|
850
|
+
try {
|
|
851
|
+
return createRequire(join4(cwd, "package.json")).resolve("@crvy/rprtr");
|
|
852
|
+
} catch {
|
|
853
|
+
}
|
|
854
|
+
try {
|
|
855
|
+
return createRequire(import.meta.url).resolve("@crvy/rprtr");
|
|
856
|
+
} catch {
|
|
857
|
+
return null;
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
function buildSpawnEnv(port) {
|
|
861
|
+
const env = {};
|
|
862
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
863
|
+
if (key === "CI") continue;
|
|
864
|
+
env[key] = value;
|
|
865
|
+
}
|
|
866
|
+
env.CRVY_RPRTR_SERVER_URL = `ws://localhost:${port}`;
|
|
867
|
+
env.PLAYWRIGHT_HTML_OPEN = "never";
|
|
868
|
+
return env;
|
|
869
|
+
}
|
|
870
|
+
var RunController = class {
|
|
871
|
+
constructor(deps) {
|
|
872
|
+
this.deps = deps;
|
|
873
|
+
}
|
|
874
|
+
child = null;
|
|
875
|
+
sigkillTimer = null;
|
|
876
|
+
testListPath = null;
|
|
877
|
+
get isRunning() {
|
|
878
|
+
return this.child !== null;
|
|
879
|
+
}
|
|
880
|
+
supportsTestList(cwd) {
|
|
881
|
+
const getVersion = this.deps.getPlaywrightVersion ?? resolvePlaywrightVersion;
|
|
882
|
+
const version = getVersion(cwd);
|
|
883
|
+
return version !== null && gteMinor(version, 1, 56);
|
|
884
|
+
}
|
|
885
|
+
cleanupTempFile() {
|
|
886
|
+
if (this.testListPath !== null) {
|
|
887
|
+
const del = this.deps.deleteTempFile ?? defaultDeleteTempFile;
|
|
888
|
+
del(this.testListPath);
|
|
889
|
+
this.testListPath = null;
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
start(filters) {
|
|
893
|
+
const ctx = this.deps.getRunContext();
|
|
894
|
+
if (ctx === null) return { ok: false, reason: "no-config" };
|
|
895
|
+
if (this.child !== null) return { ok: false, reason: "already-running" };
|
|
896
|
+
if (filters.tests !== void 0 && filters.tests.length === 0) {
|
|
897
|
+
return { ok: false, reason: "no-tests" };
|
|
898
|
+
}
|
|
899
|
+
const resolveReporter = this.deps.resolveReporter ?? resolveReporterDefault;
|
|
900
|
+
const reporterModule = resolveReporter(ctx.cwd);
|
|
901
|
+
const tests = filters.tests;
|
|
902
|
+
const useTestList = tests !== void 0 && tests.length > 1 && this.supportsTestList(ctx.cwd);
|
|
903
|
+
const args = ["test", "--config", ctx.configFile];
|
|
904
|
+
if (reporterModule !== null) args.push("--reporter", reporterModule);
|
|
905
|
+
if (useTestList && tests !== void 0) {
|
|
906
|
+
const content = buildTestListEntries(tests).join("\n");
|
|
907
|
+
const writeTemp = this.deps.writeTempFile ?? defaultWriteTempFile;
|
|
908
|
+
this.testListPath = writeTemp(content);
|
|
909
|
+
args.push("--test-list", this.testListPath);
|
|
910
|
+
} else if (tests !== void 0 && tests.length > 0) {
|
|
911
|
+
const project = sharedProject(tests);
|
|
912
|
+
if (project !== void 0) args.push("--project", project);
|
|
913
|
+
for (const d of tests) args.push(descriptorLocation(d));
|
|
914
|
+
}
|
|
915
|
+
const resolveLaunch = this.deps.resolveLaunch ?? resolvePlaywrightLaunch;
|
|
916
|
+
const { cmd, args: launchArgs } = resolveLaunch(ctx.cwd, args);
|
|
917
|
+
let child;
|
|
918
|
+
try {
|
|
919
|
+
child = this.deps.spawn(cmd, launchArgs, { cwd: ctx.cwd, env: buildSpawnEnv(this.deps.port), stdio: "inherit" });
|
|
920
|
+
} catch (err) {
|
|
921
|
+
this.cleanupTempFile();
|
|
922
|
+
throw err;
|
|
923
|
+
}
|
|
924
|
+
this.child = child;
|
|
925
|
+
child.on("exit", (code) => {
|
|
926
|
+
this.handleChildExit(code);
|
|
927
|
+
});
|
|
928
|
+
child.on("error", () => {
|
|
929
|
+
this.handleChildExit(null);
|
|
930
|
+
});
|
|
931
|
+
this.deps.setReportRunning(true);
|
|
932
|
+
this.deps.setRunFiltered?.(filters.tests !== void 0);
|
|
933
|
+
this.deps.broadcast({ type: "run-status", data: { running: true } });
|
|
934
|
+
return { ok: true };
|
|
935
|
+
}
|
|
936
|
+
stop() {
|
|
937
|
+
if (this.child === null) return { ok: false, reason: "not-running" };
|
|
938
|
+
if (this.sigkillTimer !== null) this.deps.timers.clearTimeout(this.sigkillTimer);
|
|
939
|
+
this.child.kill("SIGTERM");
|
|
940
|
+
this.sigkillTimer = this.deps.timers.setTimeout(() => {
|
|
941
|
+
if (this.child !== null) this.child.kill("SIGKILL");
|
|
942
|
+
}, STOP_GRACE_MS);
|
|
943
|
+
return { ok: true };
|
|
944
|
+
}
|
|
945
|
+
dispose() {
|
|
946
|
+
if (this.child === null) return;
|
|
947
|
+
if (this.sigkillTimer !== null) this.deps.timers.clearTimeout(this.sigkillTimer);
|
|
948
|
+
this.sigkillTimer = null;
|
|
949
|
+
this.child.kill("SIGKILL");
|
|
950
|
+
this.cleanupTempFile();
|
|
951
|
+
}
|
|
952
|
+
handleChildExit(code) {
|
|
953
|
+
if (this.child === null) return;
|
|
954
|
+
if (this.sigkillTimer !== null) {
|
|
955
|
+
this.deps.timers.clearTimeout(this.sigkillTimer);
|
|
956
|
+
this.sigkillTimer = null;
|
|
957
|
+
}
|
|
958
|
+
this.child = null;
|
|
959
|
+
this.cleanupTempFile();
|
|
960
|
+
if (code !== null && code !== 0) {
|
|
961
|
+
console.warn(`[RunController] playwright test exited with code ${code}`);
|
|
962
|
+
}
|
|
963
|
+
this.deps.setReportRunning(false);
|
|
964
|
+
this.deps.broadcast({ type: "run-status", data: { running: false } });
|
|
965
|
+
void this.deps.saveReport?.();
|
|
966
|
+
}
|
|
967
|
+
};
|
|
968
|
+
function createRealSpawn() {
|
|
969
|
+
return (cmd, args, opts) => {
|
|
970
|
+
const cp = spawn(cmd, args, opts);
|
|
971
|
+
return {
|
|
972
|
+
on: (event, cb) => cp.on(event, cb),
|
|
973
|
+
kill: (signal) => {
|
|
974
|
+
const sig = KNOWN_SIGNALS[signal];
|
|
975
|
+
if (sig !== void 0) cp.kill(sig);
|
|
976
|
+
}
|
|
977
|
+
};
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
function createRealTimers() {
|
|
981
|
+
const pending = [];
|
|
982
|
+
return {
|
|
983
|
+
setTimeout: (fn, ms) => {
|
|
984
|
+
const id = setTimeout(fn, ms);
|
|
985
|
+
pending.push(id);
|
|
986
|
+
return id;
|
|
987
|
+
},
|
|
988
|
+
clearTimeout: () => {
|
|
989
|
+
for (const h of pending.splice(0)) clearTimeout(h);
|
|
990
|
+
}
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
|
|
606
994
|
// src/server/app.ts
|
|
607
|
-
var MAX_CONCURRENT_FILE_OPS = 5;
|
|
608
995
|
function createReportData(options) {
|
|
609
996
|
return {
|
|
610
997
|
isRunning: false,
|
|
@@ -630,37 +1017,6 @@ async function loadReport(reportPath, reportData) {
|
|
|
630
1017
|
console.log("No report.json found, using empty state");
|
|
631
1018
|
}
|
|
632
1019
|
}
|
|
633
|
-
async function readOfflineReport(filePath) {
|
|
634
|
-
try {
|
|
635
|
-
const raw = await readJsonFile(filePath);
|
|
636
|
-
if (raw === null) {
|
|
637
|
-
return null;
|
|
638
|
-
}
|
|
639
|
-
const parsed = parseOfflineReport(raw);
|
|
640
|
-
if (parsed !== null) {
|
|
641
|
-
console.log(`[Server] Loading offline report: ${filePath}`);
|
|
642
|
-
return parsed;
|
|
643
|
-
}
|
|
644
|
-
} catch {
|
|
645
|
-
}
|
|
646
|
-
return null;
|
|
647
|
-
}
|
|
648
|
-
async function loadOfflineReports(offlineReportDir, reportData) {
|
|
649
|
-
const offlineReportPaths = await findOfflineReportPaths(offlineReportDir);
|
|
650
|
-
if (offlineReportPaths.length === 0) {
|
|
651
|
-
return;
|
|
652
|
-
}
|
|
653
|
-
const limit = pLimit(MAX_CONCURRENT_FILE_OPS);
|
|
654
|
-
const reports = await Promise.all(offlineReportPaths.map((filePath) => limit(() => readOfflineReport(filePath))));
|
|
655
|
-
const validReports = reports.filter((report) => report !== null);
|
|
656
|
-
if (validReports.length === 0) {
|
|
657
|
-
return;
|
|
658
|
-
}
|
|
659
|
-
reportData.tests = mergeOfflineReportsIntoTests(reportData.tests, validReports, {
|
|
660
|
-
screenshotDir: reportData.screenshotDir,
|
|
661
|
-
screenshotsBaseUrl: "/screenshots/"
|
|
662
|
-
});
|
|
663
|
-
}
|
|
664
1020
|
async function handleParsedWebSocketMessage(ctx, msg) {
|
|
665
1021
|
switch (msg.type) {
|
|
666
1022
|
case "test-begin": {
|
|
@@ -727,16 +1083,16 @@ async function resolveStaticDir(staticDir) {
|
|
|
727
1083
|
const currentDir = dirname3(fileURLToPath(import.meta.url));
|
|
728
1084
|
const candidates = staticDir === void 0 ? [
|
|
729
1085
|
currentDir,
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
] : [staticDir,
|
|
1086
|
+
join5(currentDir, "dist"),
|
|
1087
|
+
join5(currentDir, "..", "dist"),
|
|
1088
|
+
join5(currentDir, "..", "..", "dist"),
|
|
1089
|
+
join5(currentDir, ".."),
|
|
1090
|
+
join5(currentDir, "..", "..")
|
|
1091
|
+
] : [staticDir, join5(staticDir, "dist")];
|
|
736
1092
|
const resolvedCandidates = await Promise.all(
|
|
737
1093
|
candidates.map(async (candidate) => ({
|
|
738
1094
|
candidate,
|
|
739
|
-
exists: await fileExists(
|
|
1095
|
+
exists: await fileExists(join5(candidate, "index.html"))
|
|
740
1096
|
}))
|
|
741
1097
|
);
|
|
742
1098
|
const resolved = resolvedCandidates.find(({ exists }) => exists);
|
|
@@ -747,27 +1103,34 @@ async function resolveStaticDir(staticDir) {
|
|
|
747
1103
|
}
|
|
748
1104
|
async function resolveReportPath(reportPath) {
|
|
749
1105
|
if (await isDirectory(reportPath)) {
|
|
750
|
-
return { reportFile:
|
|
1106
|
+
return { reportFile: join5(reportPath, "report.json"), offlineReportDir: reportPath };
|
|
751
1107
|
}
|
|
752
1108
|
return { reportFile: reportPath, offlineReportDir: dirname3(reportPath) };
|
|
753
1109
|
}
|
|
754
|
-
function
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
1110
|
+
async function seedRunContext(routesContext, options) {
|
|
1111
|
+
if (routesContext.runContext !== void 0) {
|
|
1112
|
+
return;
|
|
1113
|
+
}
|
|
1114
|
+
const configFile = options.playwrightConfig ?? await resolvePlaywrightConfig(process.cwd());
|
|
1115
|
+
if (configFile !== null) {
|
|
1116
|
+
routesContext.runContext = { configFile, cwd: process.cwd() };
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
function createServerRunController(routesContext, wsClients, reportData, port, setRunFiltered, saveReport) {
|
|
1120
|
+
return new RunController({
|
|
1121
|
+
getRunContext: () => routesContext.runContext ?? null,
|
|
1122
|
+
port,
|
|
1123
|
+
broadcast: (message) => {
|
|
1124
|
+
broadcastToBrowsers(wsClients, message);
|
|
1125
|
+
},
|
|
1126
|
+
setReportRunning: (running) => {
|
|
1127
|
+
reportData.isRunning = running;
|
|
1128
|
+
},
|
|
1129
|
+
setRunFiltered,
|
|
761
1130
|
saveReport,
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
playwrightTestDir: options.playwrightTestDir,
|
|
766
|
-
playwrightSnapshotDir: options.playwrightSnapshotDir,
|
|
767
|
-
playwrightSnapshotPathTemplate: options.playwrightSnapshotPathTemplate,
|
|
768
|
-
playwrightToHaveScreenshotPathTemplate: options.playwrightToHaveScreenshotPathTemplate
|
|
769
|
-
}
|
|
770
|
-
};
|
|
1131
|
+
spawn: createRealSpawn(),
|
|
1132
|
+
timers: createRealTimers()
|
|
1133
|
+
});
|
|
771
1134
|
}
|
|
772
1135
|
async function createServerApp(options = {}) {
|
|
773
1136
|
const port = options.port ?? 3e3;
|
|
@@ -777,31 +1140,49 @@ async function createServerApp(options = {}) {
|
|
|
777
1140
|
const staticDir = await resolveStaticDir(options.staticDir);
|
|
778
1141
|
const wsClients = /* @__PURE__ */ new Set();
|
|
779
1142
|
const currentRunIds = /* @__PURE__ */ new Set();
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
1143
|
+
const persistence = createReportPersistence(reportFile, reportData);
|
|
1144
|
+
const routesContext = createRoutesContext(reportData, staticDir, persistence.saveReport, options);
|
|
1145
|
+
await seedRunContext(routesContext, options);
|
|
1146
|
+
let isFilteredRun = false;
|
|
1147
|
+
const runController = createServerRunController(
|
|
1148
|
+
routesContext,
|
|
1149
|
+
wsClients,
|
|
1150
|
+
reportData,
|
|
1151
|
+
port,
|
|
1152
|
+
(filtered) => {
|
|
1153
|
+
isFilteredRun = filtered;
|
|
1154
|
+
},
|
|
1155
|
+
persistence.saveReport
|
|
1156
|
+
);
|
|
784
1157
|
const getHandlerContext = () => ({
|
|
785
1158
|
reportData,
|
|
786
1159
|
wsClients,
|
|
787
1160
|
currentRunIds,
|
|
788
|
-
|
|
1161
|
+
isFilteredRun,
|
|
1162
|
+
saveReport: persistence.saveReport,
|
|
1163
|
+
scheduleReportSave: persistence.scheduleReportSave,
|
|
789
1164
|
approvalRouting: routesContext.approvalRouting,
|
|
790
|
-
routesContext
|
|
1165
|
+
routesContext,
|
|
1166
|
+
runController
|
|
791
1167
|
});
|
|
792
|
-
const handleRequest = (req) => handleHttpRequest(routesContext, req);
|
|
1168
|
+
const handleRequest = (req) => handleHttpRequest(routesContext, req, runController);
|
|
793
1169
|
const handleWebSocketMessage = createWebSocketMessageHandler(getHandlerContext);
|
|
794
1170
|
await loadReport(reportFile, reportData);
|
|
795
|
-
await loadOfflineReports(
|
|
1171
|
+
await loadOfflineReports(reportData, offlineReportDir);
|
|
796
1172
|
return {
|
|
797
1173
|
port,
|
|
798
1174
|
wsClients,
|
|
799
|
-
close: ()
|
|
800
|
-
},
|
|
1175
|
+
close: createCloseHandler(persistence, runController),
|
|
801
1176
|
handleRequest,
|
|
802
1177
|
handleWebSocketMessage
|
|
803
1178
|
};
|
|
804
1179
|
}
|
|
1180
|
+
function createCloseHandler(persistence, runController) {
|
|
1181
|
+
return async () => {
|
|
1182
|
+
await persistence.dispose();
|
|
1183
|
+
runController.dispose();
|
|
1184
|
+
};
|
|
1185
|
+
}
|
|
805
1186
|
|
|
806
1187
|
// src/server/bun-adapter.ts
|
|
807
1188
|
function logWebSocketError(prefix, error) {
|
|
@@ -999,9 +1380,37 @@ async function startNodeServer(app) {
|
|
|
999
1380
|
console.log(`Crvy Rprtr started at http://localhost:${app.port}`);
|
|
1000
1381
|
}
|
|
1001
1382
|
|
|
1383
|
+
// src/server/shutdown.ts
|
|
1384
|
+
var EXIT_CODES = {
|
|
1385
|
+
SIGINT: 130,
|
|
1386
|
+
SIGTERM: 143,
|
|
1387
|
+
SIGHUP: 129
|
|
1388
|
+
};
|
|
1389
|
+
function createSignalShutdown(deps) {
|
|
1390
|
+
let shuttingDown = false;
|
|
1391
|
+
return async function shutdown(signal) {
|
|
1392
|
+
if (shuttingDown) {
|
|
1393
|
+
deps.exit(EXIT_CODES[signal] ?? 1);
|
|
1394
|
+
return;
|
|
1395
|
+
}
|
|
1396
|
+
shuttingDown = true;
|
|
1397
|
+
try {
|
|
1398
|
+
await deps.close();
|
|
1399
|
+
} catch {
|
|
1400
|
+
}
|
|
1401
|
+
deps.exit(EXIT_CODES[signal] ?? 1);
|
|
1402
|
+
};
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1002
1405
|
// src/server.ts
|
|
1003
1406
|
async function startServer(options = {}) {
|
|
1004
1407
|
const app = await createServerApp(options);
|
|
1408
|
+
const shutdown = createSignalShutdown({ close: () => app.close(), exit: (code) => process.exit(code) });
|
|
1409
|
+
const onSignal = (signal) => {
|
|
1410
|
+
void shutdown(signal);
|
|
1411
|
+
};
|
|
1412
|
+
process.on("SIGINT", onSignal);
|
|
1413
|
+
process.on("SIGTERM", onSignal);
|
|
1005
1414
|
if (typeof Bun !== "undefined" && typeof Bun.serve === "function") {
|
|
1006
1415
|
startBunServer(app);
|
|
1007
1416
|
return;
|