@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.
Files changed (41) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.md +8 -7
  3. package/dist/{chunk-WRMHUY5A.js → chunk-HAFWYUNO.js} +181 -130
  4. package/dist/{chunk-KLGJSEKV.js → chunk-RFZGJSL3.js} +439 -127
  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 +297 -76
  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 +6 -0
  24. package/dist/server/app.d.ts.map +1 -1
  25. package/dist/server/handlers.d.ts +3 -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/routes-context.d.ts +13 -0
  30. package/dist/server/routes-context.d.ts.map +1 -0
  31. package/dist/server/routes.d.ts +6 -1
  32. package/dist/server/routes.d.ts.map +1 -1
  33. package/dist/server/run-controller.d.ts +85 -0
  34. package/dist/server/run-controller.d.ts.map +1 -0
  35. package/dist/server/run-routes.d.ts +3 -0
  36. package/dist/server/run-routes.d.ts.map +1 -0
  37. package/dist/server.cjs +633 -273
  38. package/dist/server.js +2 -2
  39. package/dist/types.d.ts +6 -0
  40. package/dist/types.d.ts.map +1 -1
  41. 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
 
@@ -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/routes.ts
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(join2(ctx.staticDir, "index.html"), "text/html");
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(ctx.reportData);
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 join2(ctx.reportData.screenshotDir, actualUrl.slice("/screenshots/".length));
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 = join2(ctx.staticDir, path);
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
- join3(currentDir, "dist"),
731
- join3(currentDir, "..", "dist"),
732
- join3(currentDir, "..", "..", "dist"),
733
- join3(currentDir, ".."),
734
- join3(currentDir, "..", "..")
735
- ] : [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")];
736
1036
  const resolvedCandidates = await Promise.all(
737
1037
  candidates.map(async (candidate) => ({
738
1038
  candidate,
739
- exists: await fileExists(join3(candidate, "index.html"))
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: join3(reportPath, "report.json"), offlineReportDir: reportPath };
1050
+ return { reportFile: join5(reportPath, "report.json"), offlineReportDir: reportPath };
751
1051
  }
752
1052
  return { reportFile: reportPath, offlineReportDir: dirname3(reportPath) };
753
1053
  }
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,
761
- 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
- };
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
- async function saveReport() {
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(offlineReportDir, reportData);
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,CA4BpE"}
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"}