@crvy/rprtr 0.1.3 → 0.2.2

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