@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.
Files changed (46) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +8 -7
  3. package/dist/{chunk-KLGJSEKV.js → chunk-473CWZ4V.js} +539 -130
  4. package/dist/{chunk-WRMHUY5A.js → chunk-HAFWYUNO.js} +181 -130
  5. package/dist/cli.d.ts.map +1 -1
  6. package/dist/cli.js +6 -4
  7. package/dist/index.css +26 -0
  8. package/dist/index.js +300 -79
  9. package/dist/offline-reports.d.ts +4 -0
  10. package/dist/offline-reports.d.ts.map +1 -1
  11. package/dist/report-state.d.ts +3 -1
  12. package/dist/report-state.d.ts.map +1 -1
  13. package/dist/reporter-utils.d.ts +0 -1
  14. package/dist/reporter-utils.d.ts.map +1 -1
  15. package/dist/reporter.cjs +186 -134
  16. package/dist/reporter.d.ts +1 -1
  17. package/dist/reporter.d.ts.map +1 -1
  18. package/dist/reporter.js +8 -6
  19. package/dist/schemas/http.d.ts +44 -0
  20. package/dist/schemas/http.d.ts.map +1 -0
  21. package/dist/schemas.d.ts +25 -6
  22. package/dist/schemas.d.ts.map +1 -1
  23. package/dist/server/app.d.ts +8 -1
  24. package/dist/server/app.d.ts.map +1 -1
  25. package/dist/server/handlers.d.ts +6 -0
  26. package/dist/server/handlers.d.ts.map +1 -1
  27. package/dist/server/playwright-config.d.ts +2 -0
  28. package/dist/server/playwright-config.d.ts.map +1 -0
  29. package/dist/server/report-persistence.d.ts +27 -0
  30. package/dist/server/report-persistence.d.ts.map +1 -0
  31. package/dist/server/routes-context.d.ts +13 -0
  32. package/dist/server/routes-context.d.ts.map +1 -0
  33. package/dist/server/routes.d.ts +6 -1
  34. package/dist/server/routes.d.ts.map +1 -1
  35. package/dist/server/run-controller.d.ts +87 -0
  36. package/dist/server/run-controller.d.ts.map +1 -0
  37. package/dist/server/run-routes.d.ts +3 -0
  38. package/dist/server/run-routes.d.ts.map +1 -0
  39. package/dist/server/shutdown.d.ts +17 -0
  40. package/dist/server/shutdown.d.ts.map +1 -0
  41. package/dist/server.cjs +733 -276
  42. package/dist/server.d.ts.map +1 -1
  43. package/dist/server.js +2 -2
  44. package/dist/types.d.ts +6 -0
  45. package/dist/types.d.ts.map +1 -1
  46. 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-WRMHUY5A.js";
18
+ } from "./chunk-HAFWYUNO.js";
18
19
 
19
20
  // src/server/app.ts
20
- import { dirname as dirname3, join as join3 } from "path";
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
- var OFFLINE_REPORT_FILE_PATTERN = /^crvy-rprtr(?:-\d+)?\.json$/;
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/routes.ts
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(join2(ctx.staticDir, "index.html"), "text/html");
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(ctx.reportData);
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 join2(ctx.reportData.screenshotDir, actualUrl.slice("/screenshots/".length));
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 = join2(ctx.staticDir, path);
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
- join3(currentDir, "dist"),
731
- join3(currentDir, "..", "dist"),
732
- join3(currentDir, "..", "..", "dist"),
733
- join3(currentDir, ".."),
734
- join3(currentDir, "..", "..")
735
- ] : [staticDir, join3(staticDir, "dist")];
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(join3(candidate, "index.html"))
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: join3(reportPath, "report.json"), offlineReportDir: reportPath };
1106
+ return { reportFile: join5(reportPath, "report.json"), offlineReportDir: reportPath };
751
1107
  }
752
1108
  return { reportFile: reportPath, offlineReportDir: dirname3(reportPath) };
753
1109
  }
754
- function createRoutesContext(reportData, staticDir, saveReport, options) {
755
- const artifactRoots = [reportData.screenshotDir, options.outputDir, options.playwrightSnapshotDir].filter(
756
- (root) => root !== void 0 && root !== ""
757
- );
758
- return {
759
- reportData,
760
- staticDir,
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
- artifactRoots,
763
- approvalRouting: {
764
- configDir: options.configDir ?? process.cwd(),
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
- async function saveReport() {
781
- await writeJsonFile(reportFile, reportData);
782
- }
783
- const routesContext = createRoutesContext(reportData, staticDir, saveReport, options);
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
- saveReport,
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(offlineReportDir, reportData);
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;