@crvy/rprtr 0.1.4 → 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 +61 -0
- package/README.md +8 -7
- package/dist/{chunk-WRMHUY5A.js → chunk-HAFWYUNO.js} +181 -130
- package/dist/{chunk-KLGJSEKV.js → chunk-RFZGJSL3.js} +439 -127
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +6 -4
- package/dist/index.css +26 -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/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 +6 -0
- package/dist/server/app.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 +633 -273
- 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
|
|
|
@@ -357,8 +392,8 @@ function handleTestEnd(ctx, data) {
|
|
|
357
392
|
broadcastToBrowsers(ctx.wsClients, message);
|
|
358
393
|
}
|
|
359
394
|
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);
|
|
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 });
|
|
362
397
|
await ctx.saveReport();
|
|
363
398
|
console.log(`
|
|
364
399
|
Run complete \u2014 ${passed} passed, ${failed} failed, ${pending} skipped`);
|
|
@@ -411,16 +446,93 @@ function handleRegister(ctx, data) {
|
|
|
411
446
|
ctx.routesContext.approvalRouting.playwrightToHaveScreenshotPathTemplate = data.playwrightToHaveScreenshotPathTemplate;
|
|
412
447
|
}
|
|
413
448
|
}
|
|
449
|
+
if (data.configFile !== void 0 && data.cwd !== void 0) {
|
|
450
|
+
ctx.routesContext.runContext = { configFile: data.configFile, cwd: data.cwd };
|
|
451
|
+
}
|
|
414
452
|
console.log("[Server] Reporter registered with config:", {
|
|
415
453
|
playwrightSnapshotDir: data.playwrightSnapshotDir,
|
|
416
454
|
playwrightTestDir: data.playwrightTestDir
|
|
417
455
|
});
|
|
418
456
|
}
|
|
419
457
|
|
|
420
|
-
// src/server/
|
|
458
|
+
// src/server/playwright-config.ts
|
|
421
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
|
|
422
534
|
async function handleRoot(ctx) {
|
|
423
|
-
const html = await respondWithFile(
|
|
535
|
+
const html = await respondWithFile(join3(ctx.staticDir, "index.html"), "text/html");
|
|
424
536
|
return html ?? new Response("Not Found", { status: 404 });
|
|
425
537
|
}
|
|
426
538
|
async function handleAppCss() {
|
|
@@ -435,12 +547,15 @@ async function handleSrcFiles(req) {
|
|
|
435
547
|
return file ?? new Response("Not Found", { status: 404 });
|
|
436
548
|
}
|
|
437
549
|
function handleApiReport(ctx) {
|
|
438
|
-
return Response.json(
|
|
550
|
+
return Response.json({
|
|
551
|
+
...ctx.reportData,
|
|
552
|
+
runEnabled: ctx.runContext !== void 0
|
|
553
|
+
});
|
|
439
554
|
}
|
|
440
555
|
var APPROVAL_TARGET_ERROR = "Could not resolve approval target";
|
|
441
556
|
function actualPathFromUrl(ctx, actualUrl) {
|
|
442
557
|
if (actualUrl.startsWith("/screenshots/")) {
|
|
443
|
-
return
|
|
558
|
+
return join3(ctx.reportData.screenshotDir, actualUrl.slice("/screenshots/".length));
|
|
444
559
|
}
|
|
445
560
|
if (actualUrl.startsWith("/file/")) {
|
|
446
561
|
return decodeURIComponent(actualUrl.slice("/file/".length));
|
|
@@ -562,12 +677,12 @@ async function handleScreenshots(ctx, req) {
|
|
|
562
677
|
}
|
|
563
678
|
async function handleDist(ctx, req) {
|
|
564
679
|
const path = new URL(req.url).pathname.slice("/dist/".length);
|
|
565
|
-
const filePath =
|
|
680
|
+
const filePath = join3(ctx.staticDir, path);
|
|
566
681
|
const contentType = filePath.endsWith(".css") ? "text/css" : filePath.endsWith(".js") ? "application/javascript" : filePath.endsWith(".svelte") ? "text/plain" : "application/octet-stream";
|
|
567
682
|
const file = await respondWithFile(filePath, contentType);
|
|
568
683
|
return file ?? new Response("Not Found", { status: 404 });
|
|
569
684
|
}
|
|
570
|
-
function handleHttpRequest(ctx, req) {
|
|
685
|
+
function handleHttpRequest(ctx, req, runController) {
|
|
571
686
|
const pathname = new URL(req.url).pathname;
|
|
572
687
|
if (pathname === "/") {
|
|
573
688
|
return handleRoot(ctx);
|
|
@@ -587,6 +702,10 @@ function handleHttpRequest(ctx, req) {
|
|
|
587
702
|
if (pathname === "/api/approve-all" && req.method === "POST") {
|
|
588
703
|
return handleApiApproveAll(ctx);
|
|
589
704
|
}
|
|
705
|
+
const runResponse = handleRunRoutes(pathname, req.method, runController, req);
|
|
706
|
+
if (runResponse !== null) {
|
|
707
|
+
return runResponse;
|
|
708
|
+
}
|
|
590
709
|
if (pathname.startsWith("/api/images/")) {
|
|
591
710
|
return handleApiImages(req);
|
|
592
711
|
}
|
|
@@ -603,8 +722,220 @@ function handleHttpRequest(ctx, req) {
|
|
|
603
722
|
return Promise.resolve(new Response("Not Found", { status: 404 }));
|
|
604
723
|
}
|
|
605
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
|
+
|
|
606
938
|
// src/server/app.ts
|
|
607
|
-
var MAX_CONCURRENT_FILE_OPS = 5;
|
|
608
939
|
function createReportData(options) {
|
|
609
940
|
return {
|
|
610
941
|
isRunning: false,
|
|
@@ -630,37 +961,6 @@ async function loadReport(reportPath, reportData) {
|
|
|
630
961
|
console.log("No report.json found, using empty state");
|
|
631
962
|
}
|
|
632
963
|
}
|
|
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
964
|
async function handleParsedWebSocketMessage(ctx, msg) {
|
|
665
965
|
switch (msg.type) {
|
|
666
966
|
case "test-begin": {
|
|
@@ -727,16 +1027,16 @@ async function resolveStaticDir(staticDir) {
|
|
|
727
1027
|
const currentDir = dirname3(fileURLToPath(import.meta.url));
|
|
728
1028
|
const candidates = staticDir === void 0 ? [
|
|
729
1029
|
currentDir,
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
] : [staticDir,
|
|
1030
|
+
join5(currentDir, "dist"),
|
|
1031
|
+
join5(currentDir, "..", "dist"),
|
|
1032
|
+
join5(currentDir, "..", "..", "dist"),
|
|
1033
|
+
join5(currentDir, ".."),
|
|
1034
|
+
join5(currentDir, "..", "..")
|
|
1035
|
+
] : [staticDir, join5(staticDir, "dist")];
|
|
736
1036
|
const resolvedCandidates = await Promise.all(
|
|
737
1037
|
candidates.map(async (candidate) => ({
|
|
738
1038
|
candidate,
|
|
739
|
-
exists: await fileExists(
|
|
1039
|
+
exists: await fileExists(join5(candidate, "index.html"))
|
|
740
1040
|
}))
|
|
741
1041
|
);
|
|
742
1042
|
const resolved = resolvedCandidates.find(({ exists }) => exists);
|
|
@@ -747,27 +1047,33 @@ async function resolveStaticDir(staticDir) {
|
|
|
747
1047
|
}
|
|
748
1048
|
async function resolveReportPath(reportPath) {
|
|
749
1049
|
if (await isDirectory(reportPath)) {
|
|
750
|
-
return { reportFile:
|
|
1050
|
+
return { reportFile: join5(reportPath, "report.json"), offlineReportDir: reportPath };
|
|
751
1051
|
}
|
|
752
1052
|
return { reportFile: reportPath, offlineReportDir: dirname3(reportPath) };
|
|
753
1053
|
}
|
|
754
|
-
function
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
}
|
|
770
|
-
|
|
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
|
+
});
|
|
771
1077
|
}
|
|
772
1078
|
async function createServerApp(options = {}) {
|
|
773
1079
|
const port = options.port ?? 3e3;
|
|
@@ -777,26 +1083,32 @@ async function createServerApp(options = {}) {
|
|
|
777
1083
|
const staticDir = await resolveStaticDir(options.staticDir);
|
|
778
1084
|
const wsClients = /* @__PURE__ */ new Set();
|
|
779
1085
|
const currentRunIds = /* @__PURE__ */ new Set();
|
|
780
|
-
|
|
781
|
-
await writeJsonFile(reportFile, reportData);
|
|
782
|
-
}
|
|
1086
|
+
const saveReport = () => writeJsonFile(reportFile, reportData);
|
|
783
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
|
+
});
|
|
784
1093
|
const getHandlerContext = () => ({
|
|
785
1094
|
reportData,
|
|
786
1095
|
wsClients,
|
|
787
1096
|
currentRunIds,
|
|
1097
|
+
isFilteredRun,
|
|
788
1098
|
saveReport,
|
|
789
1099
|
approvalRouting: routesContext.approvalRouting,
|
|
790
|
-
routesContext
|
|
1100
|
+
routesContext,
|
|
1101
|
+
runController
|
|
791
1102
|
});
|
|
792
|
-
const handleRequest = (req) => handleHttpRequest(routesContext, req);
|
|
1103
|
+
const handleRequest = (req) => handleHttpRequest(routesContext, req, runController);
|
|
793
1104
|
const handleWebSocketMessage = createWebSocketMessageHandler(getHandlerContext);
|
|
794
1105
|
await loadReport(reportFile, reportData);
|
|
795
|
-
await loadOfflineReports(
|
|
1106
|
+
await loadOfflineReports(reportData, offlineReportDir);
|
|
796
1107
|
return {
|
|
797
1108
|
port,
|
|
798
1109
|
wsClients,
|
|
799
1110
|
close: () => {
|
|
1111
|
+
runController.dispose();
|
|
800
1112
|
},
|
|
801
1113
|
handleRequest,
|
|
802
1114
|
handleWebSocketMessage
|
package/dist/cli.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAKA,OAAO,EAAe,KAAK,aAAa,EAAE,MAAM,aAAa,CAAA;AAO7D,UAAU,kBAAmB,SAAQ,aAAa;IAChD,IAAI,EAAE,MAAM,CAAA;IACZ,aAAa,EAAE,MAAM,CAAA;IACrB,UAAU,EAAE,MAAM,CAAA;IAClB,SAAS,EAAE,MAAM,CAAA;CAClB;AAED,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,kBAAkB,
|
|
1
|
+
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAKA,OAAO,EAAe,KAAK,aAAa,EAAE,MAAM,aAAa,CAAA;AAO7D,UAAU,kBAAmB,SAAQ,aAAa;IAChD,IAAI,EAAE,MAAM,CAAA;IACZ,aAAa,EAAE,MAAM,CAAA;IACrB,UAAU,EAAE,MAAM,CAAA;IAClB,SAAS,EAAE,MAAM,CAAA;CAClB;AAED,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,kBAAkB,CA8BpE"}
|