@crvy/rprtr 0.2.4 → 0.3.1

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 (49) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +41 -0
  3. package/dist/{chunk-473CWZ4V.js → chunk-4CVHIHAJ.js} +746 -281
  4. package/dist/{chunk-HAFWYUNO.js → chunk-4YJL655E.js} +16 -4
  5. package/dist/cli.d.ts +5 -1
  6. package/dist/cli.d.ts.map +1 -1
  7. package/dist/cli.js +24 -4
  8. package/dist/index.css +35 -0
  9. package/dist/index.js +152 -105
  10. package/dist/reporter-artifact-ops.d.ts +0 -2
  11. package/dist/reporter-artifact-ops.d.ts.map +1 -1
  12. package/dist/reporter.cjs +32 -38
  13. package/dist/reporter.d.ts +2 -1
  14. package/dist/reporter.d.ts.map +1 -1
  15. package/dist/reporter.js +27 -43
  16. package/dist/schemas/http.d.ts +2 -0
  17. package/dist/schemas/http.d.ts.map +1 -1
  18. package/dist/schemas.d.ts +14 -0
  19. package/dist/schemas.d.ts.map +1 -1
  20. package/dist/server/app.d.ts +6 -0
  21. package/dist/server/app.d.ts.map +1 -1
  22. package/dist/server/artifact-routes.d.ts.map +1 -1
  23. package/dist/server/docker-launcher.d.ts +27 -0
  24. package/dist/server/docker-launcher.d.ts.map +1 -0
  25. package/dist/server/docker-support.d.ts +88 -0
  26. package/dist/server/docker-support.d.ts.map +1 -0
  27. package/dist/server/handlers.d.ts +1 -1
  28. package/dist/server/handlers.d.ts.map +1 -1
  29. package/dist/server/launcher-resolver.d.ts +23 -0
  30. package/dist/server/launcher-resolver.d.ts.map +1 -0
  31. package/dist/server/playwright-config.d.ts +6 -0
  32. package/dist/server/playwright-config.d.ts.map +1 -1
  33. package/dist/server/routes-context.d.ts +6 -1
  34. package/dist/server/routes-context.d.ts.map +1 -1
  35. package/dist/server/routes.d.ts +7 -0
  36. package/dist/server/routes.d.ts.map +1 -1
  37. package/dist/server/run-controller.d.ts +19 -19
  38. package/dist/server/run-controller.d.ts.map +1 -1
  39. package/dist/server/run-launcher.d.ts +45 -0
  40. package/dist/server/run-launcher.d.ts.map +1 -0
  41. package/dist/server/run-mode.d.ts +15 -0
  42. package/dist/server/run-mode.d.ts.map +1 -0
  43. package/dist/server/server-factories.d.ts +21 -0
  44. package/dist/server/server-factories.d.ts.map +1 -0
  45. package/dist/server.cjs +921 -446
  46. package/dist/server.js +2 -2
  47. package/dist/types.d.ts +2 -0
  48. package/dist/types.d.ts.map +1 -1
  49. package/package.json +1 -1
package/dist/server.cjs CHANGED
@@ -35,7 +35,7 @@ __export(server_exports, {
35
35
  module.exports = __toCommonJS(server_exports);
36
36
 
37
37
  // src/server/app.ts
38
- var import_path8 = require("path");
38
+ var import_path9 = require("path");
39
39
  var import_url = require("url");
40
40
 
41
41
  // src/offline-reports.ts
@@ -276,13 +276,14 @@ var RunTestDescriptorSchema = import_zod.z.object({
276
276
  titlePath: import_zod.z.array(import_zod.z.string())
277
277
  });
278
278
  var RunRequestBodySchema = import_zod.z.object({
279
- tests: import_zod.z.array(RunTestDescriptorSchema).optional()
279
+ tests: import_zod.z.array(RunTestDescriptorSchema).optional(),
280
+ update: import_zod.z.boolean().optional()
280
281
  });
281
282
  var RunResponseSchema = import_zod.z.discriminatedUnion("ok", [
282
283
  import_zod.z.object({ ok: import_zod.z.literal(true) }),
283
284
  import_zod.z.object({
284
285
  ok: import_zod.z.literal(false),
285
- reason: import_zod.z.enum(["no-config", "already-running", "no-tests"])
286
+ reason: import_zod.z.enum(["no-config", "already-running", "no-tests", "docker-unavailable"])
286
287
  })
287
288
  ]);
288
289
  var StopResponseSchema = import_zod.z.discriminatedUnion("ok", [
@@ -389,7 +390,9 @@ var WebSocketMessageSchema = import_zod2.z.discriminatedUnion("type", [
389
390
  import_zod2.z.object({
390
391
  type: import_zod2.z.literal("run-status"),
391
392
  data: import_zod2.z.object({
392
- running: import_zod2.z.boolean()
393
+ running: import_zod2.z.boolean(),
394
+ mode: import_zod2.z.enum(["local", "docker"]).optional(),
395
+ phase: import_zod2.z.string().optional()
393
396
  })
394
397
  })
395
398
  ]);
@@ -419,6 +422,7 @@ var RunEndDataSchema = import_zod2.z.object({
419
422
  var RegisterDataSchema = import_zod2.z.object({
420
423
  playwrightSnapshotDir: import_zod2.z.string().optional(),
421
424
  playwrightTestDir: import_zod2.z.string().optional(),
425
+ playwrightRootDir: import_zod2.z.string().optional(),
422
426
  playwrightSnapshotPathTemplate: import_zod2.z.string().optional(),
423
427
  playwrightToHaveScreenshotPathTemplate: import_zod2.z.string().optional(),
424
428
  configFile: import_zod2.z.string().optional(),
@@ -451,7 +455,8 @@ var ReportApiResponseSchema = import_zod2.z.object({
451
455
  tests: import_zod2.z.record(import_zod2.z.string(), TestDataSchema),
452
456
  isUpdateMode: import_zod2.z.boolean().optional(),
453
457
  isRunning: import_zod2.z.boolean().optional(),
454
- runEnabled: import_zod2.z.boolean().optional()
458
+ runEnabled: import_zod2.z.boolean().optional(),
459
+ runMode: import_zod2.z.enum(["local", "docker"]).optional()
455
460
  });
456
461
  var ClientBootstrapDataSchema = import_zod2.z.object({
457
462
  report: ReportApiResponseSchema.extend({
@@ -654,198 +659,771 @@ function mergeOfflineReportsIntoTests(existingTests, offlineReports, options = {
654
659
  };
655
660
  }
656
661
 
657
- // src/server/handlers.ts
658
- var import_fs2 = require("fs");
659
-
660
- // src/server/artifact-routes.ts
661
- var import_fs = require("fs");
662
- var import_promises3 = require("fs/promises");
663
- var import_path5 = require("path");
664
-
665
- // src/snapshot-path-resolver.ts
666
- var import_crypto = require("crypto");
667
- var import_path3 = require("path");
668
- var DEFAULT_SCREENSHOT_TEMPLATE = "{snapshotDir}/{testFileDir}/{testFileName}-snapshots/{arg}{-projectName}{-snapshotSuffix}{ext}";
669
- var WINDOWS_FILESYSTEM_FRIENDLY_LENGTH = 60;
670
- function isUnsafeFilePathCharacter(character) {
671
- const codePoint = character.codePointAt(0);
672
- return codePoint !== void 0 && (codePoint <= 44 || codePoint >= 46 && codePoint <= 47 || codePoint >= 58 && codePoint <= 64 || codePoint >= 91 && codePoint <= 96 || codePoint >= 123 && codePoint <= 127);
673
- }
674
- function sanitizeForFilePath(value) {
675
- return Array.from(value).reduce(
676
- (state, character) => {
677
- const unsafeCharacter = isUnsafeFilePathCharacter(character);
678
- return unsafeCharacter ? state.previousCharacterWasUnsafe ? state : {
679
- value: `${state.value}-`,
680
- previousCharacterWasUnsafe: true
681
- } : {
682
- value: `${state.value}${character}`,
683
- previousCharacterWasUnsafe: false
684
- };
685
- },
686
- {
687
- value: "",
688
- previousCharacterWasUnsafe: false
689
- }
690
- ).value;
662
+ // src/server/docker-support.ts
663
+ var import_child_process = require("child_process");
664
+ var import_node_fs = require("node:fs");
665
+ var import_node_module = require("node:module");
666
+ var import_node_path = require("node:path");
667
+ var import_detect = require("package-manager-detector/detect");
668
+ function createDockerExec() {
669
+ return (args) => new Promise((resolve6, reject) => {
670
+ const child = (0, import_child_process.spawn)("docker", args, { stdio: ["ignore", "pipe", "pipe"] });
671
+ const stdout = [];
672
+ const stderr = [];
673
+ child.stdout.on("data", (chunk) => stdout.push(chunk));
674
+ child.stderr.on("data", (chunk) => stderr.push(chunk));
675
+ child.on("error", reject);
676
+ child.on("close", (code) => {
677
+ resolve6({
678
+ exitCode: code ?? 1,
679
+ stdout: Buffer.concat(stdout).toString(),
680
+ stderr: Buffer.concat(stderr).toString()
681
+ });
682
+ });
683
+ });
691
684
  }
692
- function trimLongString(value, length = WINDOWS_FILESYSTEM_FRIENDLY_LENGTH) {
693
- if (value.length <= length) {
694
- return value;
685
+ async function probeDockerDaemon(exec) {
686
+ try {
687
+ const result = await exec(["info"]);
688
+ return result.exitCode === 0;
689
+ } catch {
690
+ return false;
695
691
  }
696
- const hash = (0, import_crypto.createHash)("sha1").update(value).digest("hex");
697
- const middle = `-${hash.slice(0, 5)}-`;
698
- const start = Math.floor((length - middle.length) / 2);
699
- const end = length - middle.length - start;
700
- return value.slice(0, start) + middle + value.slice(-end);
701
- }
702
- function sanitizeFilePathBeforeExtension(filePath, extension = (0, import_path3.extname)(filePath)) {
703
- const base = filePath.slice(0, filePath.length - extension.length);
704
- return sanitizeForFilePath(base) + extension;
705
- }
706
- function addSuffixToFilePath(filePath, suffix) {
707
- const extension = (0, import_path3.extname)(filePath);
708
- return filePath.slice(0, filePath.length - extension.length) + suffix + extension;
709
- }
710
- function normalizedSnapshotDir(config) {
711
- return (0, import_path3.resolve)(config.configDir, config.snapshotDir);
712
- }
713
- function templateValue(template, token, value) {
714
- return template.replace(
715
- new RegExp(`\\{(.)?${token}\\}`, "g"),
716
- (_, prefix) => value === "" ? "" : `${prefix ?? ""}${value}`
717
- );
718
- }
719
- function applyTemplate(input, nameArgument, extension) {
720
- const template = input.config.toHaveScreenshotPathTemplate ?? input.config.snapshotPathTemplate ?? DEFAULT_SCREENSHOT_TEMPLATE;
721
- const relativeTestFilePath = (0, import_path3.relative)(input.config.testDir, input.testFile);
722
- const parsed = (0, import_path3.parse)(relativeTestFilePath);
723
- const tokens = [
724
- ["testDir", input.config.testDir],
725
- ["snapshotDir", normalizedSnapshotDir(input.config)],
726
- ["snapshotSuffix", input.config.snapshotSuffix],
727
- ["testFileDir", parsed.dir],
728
- ["platform", process.platform],
729
- ["projectName", sanitizeForFilePath(input.config.projectName)],
730
- ["testName", ""],
731
- ["testFileName", parsed.base],
732
- ["testFilePath", relativeTestFilePath],
733
- ["arg", nameArgument],
734
- ["ext", extension]
735
- ];
736
- const snapshotPath = tokens.reduce(
737
- (currentTemplate, [token, value]) => templateValue(currentTemplate, token, value),
738
- template
739
- );
740
- return (0, import_path3.resolve)(input.config.configDir, snapshotPath);
741
- }
742
- function removeExtension(filePath, extension = (0, import_path3.extname)(filePath)) {
743
- return filePath.slice(0, filePath.length - extension.length);
744
- }
745
- function snapshotNameParts(declaredName) {
746
- const extension = (0, import_path3.extname)(declaredName) || ".png";
747
- return {
748
- extension,
749
- filePath: (0, import_path3.extname)(declaredName) === "" ? `${declaredName}${extension}` : declaredName
750
- };
751
692
  }
752
- function filePathForOccurrence(filePath, occurrenceIndex) {
753
- return occurrenceIndex === 1 ? filePath : addSuffixToFilePath(filePath, `-${occurrenceIndex - 1}`);
693
+ async function isDockerImagePresent(exec, image) {
694
+ try {
695
+ const result = await exec(["image", "inspect", image]);
696
+ return result.exitCode === 0;
697
+ } catch {
698
+ return false;
699
+ }
754
700
  }
755
- function createResolvedBaselineTarget(input, declaration, nameArgument, extension) {
756
- return {
757
- visualName: declaration.visualName,
758
- attachmentBaseName: declaration.visualName,
759
- artifactBaseName: sanitizeForFilePath(declaration.visualName),
760
- snapshotPath: applyTemplate(input, nameArgument, extension)
761
- };
701
+ async function pullDockerImage(exec, image) {
702
+ try {
703
+ const result = await exec(["pull", image]);
704
+ return result.exitCode === 0;
705
+ } catch {
706
+ return false;
707
+ }
762
708
  }
763
- function resolveStringCallTarget(input, declaration) {
764
- const { extension, filePath } = snapshotNameParts(declaration.declaredName);
765
- const occurrenceFilePath = filePathForOccurrence(filePath, declaration.occurrenceIndex);
766
- const sanitizedNameWithExtension = sanitizeFilePathBeforeExtension(occurrenceFilePath, extension);
767
- const nameArgument = removeExtension(sanitizedNameWithExtension, extension);
768
- return createResolvedBaselineTarget(input, declaration, nameArgument, extension);
709
+ async function forceRemoveContainer(exec, name) {
710
+ try {
711
+ await exec(["rm", "-f", name]);
712
+ } catch {
713
+ }
769
714
  }
770
- function resolveArrayCallTarget(input, declaration) {
771
- const { extension, filePath } = snapshotNameParts(declaration.declaredName);
772
- const occurrenceFilePath = filePathForOccurrence(filePath, declaration.occurrenceIndex);
773
- const nameArgument = (0, import_path3.join)((0, import_path3.dirname)(occurrenceFilePath), (0, import_path3.basename)(occurrenceFilePath, extension));
774
- return createResolvedBaselineTarget(input, declaration, nameArgument, extension);
715
+ function resolveDockerImage(options) {
716
+ if (options.image !== void 0 && options.image !== "") return options.image;
717
+ if (options.version === null) return null;
718
+ return `mcr.microsoft.com/playwright:v${options.version}-noble`;
775
719
  }
776
- function resolveNamedTarget(input, declaration) {
777
- if (!declaration.declaredName.includes("/")) {
778
- return resolveStringCallTarget(input, declaration);
779
- }
780
- const stringCallTarget = resolveStringCallTarget(input, declaration);
781
- const arrayCallTarget = resolveArrayCallTarget(input, declaration);
782
- if (stringCallTarget.snapshotPath === arrayCallTarget.snapshotPath) {
783
- return stringCallTarget;
784
- }
785
- if (input.snapshotPathExists === void 0) {
786
- return void 0;
720
+ var CONTAINER_INVOKERS = {
721
+ npm: ["npx"],
722
+ pnpm: ["pnpm", "exec"],
723
+ yarn: ["yarn"],
724
+ bun: ["bunx"]
725
+ };
726
+ var DEFAULT_CONTAINER_COMMAND = ["npx"];
727
+ function resolveContainerCommand(input) {
728
+ if (input.command !== void 0 && input.command.length > 0) return input.command;
729
+ if (!input.hasCustomImage) return [...DEFAULT_CONTAINER_COMMAND];
730
+ if (input.detectedAgentName === void 0 || input.detectedAgentName === null) {
731
+ input.warn?.("Could not detect a package manager for the custom docker image; falling back to npx.");
732
+ return [...DEFAULT_CONTAINER_COMMAND];
733
+ }
734
+ const invoker = CONTAINER_INVOKERS[input.detectedAgentName];
735
+ if (invoker === void 0) {
736
+ input.warn?.(`Package manager "${input.detectedAgentName}" is not supported in docker mode; falling back to npx.`);
737
+ return [...DEFAULT_CONTAINER_COMMAND];
738
+ }
739
+ return [...invoker];
740
+ }
741
+ var detectProjectAgent = async (cwd) => {
742
+ try {
743
+ const result = await (0, import_detect.detect)({ cwd, strategies: ["lockfile", "packageManager-field"] });
744
+ return result === null ? null : { name: result.name, agent: result.agent };
745
+ } catch {
746
+ return null;
787
747
  }
788
- const stringCallTargetExists = input.snapshotPathExists(stringCallTarget.snapshotPath);
789
- const arrayCallTargetExists = input.snapshotPathExists(arrayCallTarget.snapshotPath);
790
- if (stringCallTargetExists === arrayCallTargetExists) {
791
- return void 0;
748
+ };
749
+ function rewriteContainerPath(path, mapping) {
750
+ const normalizedPath = normalizeForMatch(path);
751
+ const normalizedFrom = normalizeForMatch(mapping.from);
752
+ if (normalizedPath === normalizedFrom) return mapping.to;
753
+ if (normalizedPath.startsWith(`${normalizedFrom}/`)) return mapping.to + normalizedPath.slice(normalizedFrom.length);
754
+ return path;
755
+ }
756
+ function normalizeForMatch(p) {
757
+ return p.replace(/\\/g, "/").replace(/^[A-Z](?=:)/, (c) => c.toLowerCase());
758
+ }
759
+ var CONTAINER_TEST_LIST_PATH = "/tmp/crvy-rprtr-test-list.txt";
760
+ var REPORTER_BARE_SPECIFIER = "@crvy/rprtr";
761
+ var PATH_FLAGS = /* @__PURE__ */ new Set(["--config", "--reporter", "--test-list"]);
762
+ function rewritePlaywrightArgs(playwrightArgs, ctx, workDir, warn) {
763
+ const args = [];
764
+ const bindMounts = [];
765
+ for (let i = 0; i < playwrightArgs.length; i++) {
766
+ const flag = playwrightArgs[i];
767
+ if (!PATH_FLAGS.has(flag)) {
768
+ args.push(flag);
769
+ continue;
770
+ }
771
+ const value = playwrightArgs[i + 1];
772
+ if (value === void 0) break;
773
+ i += 1;
774
+ args.push(flag);
775
+ const rewritten = rewriteContainerPath(value, { from: ctx.cwd, to: workDir });
776
+ if (flag === "--reporter" && rewritten === value) {
777
+ args.push(REPORTER_BARE_SPECIFIER);
778
+ } else if (flag === "--test-list" && rewritten === value) {
779
+ args.push(CONTAINER_TEST_LIST_PATH);
780
+ bindMounts.push(`${value}:${CONTAINER_TEST_LIST_PATH}:ro`);
781
+ } else {
782
+ if (flag === "--config" && rewritten === value) {
783
+ warn(`--config "${value}" is outside the project directory and will not resolve inside the container.`);
784
+ }
785
+ args.push(rewritten);
786
+ }
792
787
  }
793
- return stringCallTargetExists ? stringCallTarget : arrayCallTarget;
788
+ return { args, bindMounts };
794
789
  }
795
- function reporterTitlesWithoutProjectAndFile(reporterTitlePath2) {
796
- return reporterTitlePath2.slice(3).filter((part) => part !== "");
790
+ function resolvePlaywrightVersion(cwd) {
791
+ try {
792
+ const req = (0, import_node_module.createRequire)((0, import_node_path.join)(cwd, "package.json"));
793
+ const pkgPath = req.resolve("@playwright/test/package.json");
794
+ const pkg = JSON.parse((0, import_node_fs.readFileSync)(pkgPath, "utf8"));
795
+ return typeof pkg === "object" && pkg !== null && "version" in pkg && typeof pkg.version === "string" ? pkg.version : null;
796
+ } catch {
797
+ return null;
798
+ }
797
799
  }
798
- function anonymousNameFromTitles(titles, occurrenceIndex) {
799
- return sanitizeFilePathBeforeExtension(trimLongString(`${titles.join(" ")} ${occurrenceIndex}.png`), ".png");
800
+ function rewriteContainerTestDescriptors(tests, mapping) {
801
+ if (tests === void 0 || mapping === void 0) return tests;
802
+ return tests.map((d) => ({ ...d, file: rewriteContainerPath(d.file, mapping) }));
800
803
  }
801
- function anonymousName(reporterTitlePath2, occurrenceIndex) {
802
- return anonymousNameFromTitles(reporterTitlesWithoutProjectAndFile(reporterTitlePath2), occurrenceIndex);
804
+ function testListEntry(d, file) {
805
+ const loc = d.column === void 0 ? `${file}:${d.line}` : `${file}:${d.line}:${d.column}`;
806
+ const title = d.titlePath.join(" \u203A ");
807
+ const prefix = d.projectName !== void 0 && d.projectName !== "" ? `[${d.projectName}] \u203A ` : "";
808
+ return `${prefix}${loc} \u203A ${title}`;
803
809
  }
804
- function resolveTarget(input, declaration) {
805
- switch (declaration.kind) {
806
- case "named":
807
- return typeof declaration.declaredName === "string" && declaration.declaredName !== "" ? resolveNamedTarget(input, declaration) : void 0;
808
- case "unnamed": {
809
- const anonymousFileName = anonymousName(input.reporterTitlePath, declaration.occurrenceIndex);
810
- const extension = ".png";
811
- const nameArgument = (0, import_path3.join)((0, import_path3.dirname)(anonymousFileName), (0, import_path3.basename)(anonymousFileName, extension));
812
- return createResolvedBaselineTarget(input, declaration, nameArgument, extension);
813
- }
810
+ function buildTestListEntries(tests, rootDir, cwd, pathStyle = "host") {
811
+ const convert = (p) => pathStyle === "posix" ? p.replace(/\\/g, "/") : p;
812
+ if (rootDir !== void 0) {
813
+ return tests.map(
814
+ (d) => testListEntry(d, convert((0, import_node_path.isAbsolute)(d.file) ? (0, import_node_path.relative)(rootDir, d.file) || d.file : d.file))
815
+ );
814
816
  }
815
- }
816
- function resolveBaselineTargets(input) {
817
- return input.declarations.flatMap((declaration) => {
818
- const resolvedTarget = resolveTarget(input, declaration);
819
- return resolvedTarget === void 0 ? [] : [resolvedTarget];
817
+ return tests.flatMap((d) => {
818
+ if (!(0, import_node_path.isAbsolute)(d.file)) return [testListEntry(d, convert(d.file))];
819
+ const rel = (0, import_node_path.relative)(cwd ?? process.cwd(), d.file);
820
+ const segments = pathStyle === "posix" ? rel.split(/[\\/]/) : rel.split(import_node_path.sep);
821
+ return segments.map((_, i) => testListEntry(d, convert(segments.slice(i).join(import_node_path.sep))));
820
822
  });
821
823
  }
822
824
 
823
- // src/server/utils.ts
824
- var import_path4 = require("path");
825
- var LIVE_UPDATES_WEBSOCKET_PATH = "/";
826
- function broadcastToBrowsers(wsClients, msg) {
827
- const payload = JSON.stringify(msg);
828
- wsClients.forEach((ws) => {
829
- ws.send(payload);
830
- });
825
+ // src/server/run-controller.ts
826
+ var import_child_process2 = require("child_process");
827
+ var import_node_fs2 = require("node:fs");
828
+ var import_node_module2 = require("node:module");
829
+ var import_node_os = require("node:os");
830
+ var import_node_path2 = require("node:path");
831
+
832
+ // src/server/run-launcher.ts
833
+ var import_commands = require("package-manager-detector/commands");
834
+ var import_detect2 = require("package-manager-detector/detect");
835
+ function resolvePlaywrightLaunch(cwd, playwrightArgs) {
836
+ const agent = (0, import_detect2.getUserAgent)();
837
+ const resolved = agent === null ? null : (0, import_commands.resolveCommand)(agent, "execute-local", ["playwright", ...playwrightArgs]);
838
+ if (resolved !== null) return { cmd: resolved.command, args: resolved.args };
839
+ return { cmd: "npx", args: ["playwright", ...playwrightArgs] };
831
840
  }
832
- function isWebSocketUpgradeRequest(req) {
833
- return new URL(req.url).pathname === LIVE_UPDATES_WEBSOCKET_PATH && req.headers.get("upgrade")?.toLowerCase() === "websocket";
841
+ function buildSpawnEnv(port, baseEnv = process.env) {
842
+ const env = {};
843
+ for (const [key, value] of Object.entries(baseEnv)) {
844
+ if (key === "CI") continue;
845
+ env[key] = value;
846
+ }
847
+ env.CRVY_RPRTR_SERVER_URL = `ws://localhost:${port}`;
848
+ env.PLAYWRIGHT_HTML_OPEN = "never";
849
+ return env;
834
850
  }
835
- function isPathWithinRoots(target, roots) {
836
- const resolvedTarget = (0, import_path4.resolve)(target);
837
- return roots.some((root) => {
838
- const rel = (0, import_path4.relative)((0, import_path4.resolve)(root), resolvedTarget);
839
- return rel === "" || !rel.startsWith(`..${import_path4.sep}`) && rel !== ".." && !(0, import_path4.isAbsolute)(rel);
840
- });
851
+ function createLocalLauncher(options) {
852
+ return {
853
+ mode: "local",
854
+ launch({ ctx, playwrightArgs }) {
855
+ const resolve6 = options.resolveLaunch ?? resolvePlaywrightLaunch;
856
+ const { cmd, args } = resolve6(ctx.cwd, playwrightArgs);
857
+ return { cmd, args, env: buildSpawnEnv(options.port, options.env) };
858
+ }
859
+ };
841
860
  }
842
861
 
843
- // src/server/artifact-routes.ts
844
- async function realpathOrNull(path) {
862
+ // src/server/run-controller.ts
863
+ var import_meta = { url: require("url").pathToFileURL(__filename).href };
864
+ var STOP_GRACE_MS = 5e3;
865
+ var KNOWN_SIGNALS = {
866
+ SIGTERM: "SIGTERM",
867
+ SIGKILL: "SIGKILL"
868
+ };
869
+ function sharedProject(tests) {
870
+ const names = new Set(tests.map((t) => t.projectName ?? ""));
871
+ if (names.size === 1) {
872
+ const name = [...names][0];
873
+ return name === "" ? void 0 : name;
874
+ }
875
+ return void 0;
876
+ }
877
+ function gteMinor(version, major, minor) {
878
+ const match = /^(\d+)\.(\d+)/.exec(version.trim());
879
+ if (match === null) return false;
880
+ const maj = parseInt(match[1], 10);
881
+ const min = parseInt(match[2], 10);
882
+ if (maj !== major) return maj > major;
883
+ return min >= minor;
884
+ }
885
+ var defaultWriteTempFile = (content) => {
886
+ const path = (0, import_node_path2.join)((0, import_node_os.tmpdir)(), `crvy-rprtr-test-list-${process.pid}-${Date.now()}.txt`);
887
+ (0, import_node_fs2.writeFileSync)(path, content, "utf8");
888
+ return path;
889
+ };
890
+ function defaultDeleteTempFile(path) {
845
891
  try {
846
- return await (0, import_promises3.realpath)(path);
892
+ (0, import_node_fs2.unlinkSync)(path);
847
893
  } catch {
848
- return null;
894
+ }
895
+ }
896
+ function resolveReporterDefault(cwd) {
897
+ try {
898
+ return (0, import_node_module2.createRequire)((0, import_node_path2.join)(cwd, "package.json")).resolve("@crvy/rprtr");
899
+ } catch {
900
+ }
901
+ try {
902
+ return (0, import_node_module2.createRequire)(import_meta.url).resolve("@crvy/rprtr");
903
+ } catch {
904
+ return null;
905
+ }
906
+ }
907
+ var RunController = class {
908
+ constructor(deps) {
909
+ this.deps = deps;
910
+ }
911
+ child = null;
912
+ sigkillTimer = null;
913
+ testListPath = null;
914
+ get isRunning() {
915
+ return this.child !== null;
916
+ }
917
+ supportsTestList(cwd) {
918
+ const getVersion = this.deps.getPlaywrightVersion ?? resolvePlaywrightVersion;
919
+ const version = getVersion(cwd);
920
+ return version !== null && gteMinor(version, 1, 56);
921
+ }
922
+ cleanupTempFile() {
923
+ if (this.testListPath !== null) {
924
+ const del = this.deps.deleteTempFile ?? defaultDeleteTempFile;
925
+ del(this.testListPath);
926
+ this.testListPath = null;
927
+ }
928
+ }
929
+ buildPlaywrightArgs(ctx, filters, tests) {
930
+ const resolveReporter = this.deps.resolveReporter ?? resolveReporterDefault;
931
+ const reporterModule = resolveReporter(ctx.cwd);
932
+ const useTestList = tests !== void 0 && (tests.length > 1 || this.deps.containerPathMapping !== void 0) && this.supportsTestList(ctx.cwd);
933
+ const args = ["test", "--config", ctx.configFile];
934
+ if (reporterModule !== null) args.push("--reporter", reporterModule);
935
+ if (filters.update === true) args.push("--update-snapshots");
936
+ if (useTestList && tests !== void 0) {
937
+ const content = buildTestListEntries(
938
+ tests,
939
+ ctx.rootDir,
940
+ ctx.cwd,
941
+ this.deps.containerPathMapping === void 0 ? "host" : "posix"
942
+ ).join("\n");
943
+ const writeTemp = this.deps.writeTempFile ?? defaultWriteTempFile;
944
+ this.testListPath = writeTemp(content);
945
+ args.push("--test-list", this.testListPath);
946
+ } else if (tests !== void 0 && tests.length > 0) {
947
+ const project = sharedProject(tests);
948
+ if (project !== void 0) args.push(`--project=${project}`);
949
+ for (const d of tests) {
950
+ args.push(d.column === void 0 ? `${d.file}:${d.line}` : `${d.file}:${d.line}:${d.column}`);
951
+ }
952
+ }
953
+ return args;
954
+ }
955
+ start(filters) {
956
+ const ctx = this.deps.getRunContext();
957
+ if (ctx === null) return { ok: false, reason: "no-config" };
958
+ if (this.child !== null) return { ok: false, reason: "already-running" };
959
+ if (filters.tests !== void 0 && filters.tests.length === 0) return { ok: false, reason: "no-tests" };
960
+ if (this.deps.launcher.available === false) return { ok: false, reason: "docker-unavailable" };
961
+ const tests = rewriteContainerTestDescriptors(filters.tests, this.deps.containerPathMapping);
962
+ const args = this.buildPlaywrightArgs(ctx, filters, tests);
963
+ const spec = this.deps.launcher.launch({ ctx, playwrightArgs: args });
964
+ let child;
965
+ try {
966
+ child = this.deps.spawn(spec.cmd, spec.args, { cwd: ctx.cwd, env: spec.env, stdio: "inherit" });
967
+ } catch (err) {
968
+ this.cleanupTempFile();
969
+ throw err;
970
+ }
971
+ this.child = child;
972
+ child.on("exit", (code) => {
973
+ this.handleChildExit(code);
974
+ });
975
+ child.on("error", () => {
976
+ this.handleChildExit(null);
977
+ });
978
+ this.deps.setReportRunning(true);
979
+ this.deps.setRunFiltered?.(filters.tests !== void 0);
980
+ this.deps.broadcast({ type: "run-status", data: { running: true, mode: this.deps.launcher.mode } });
981
+ return { ok: true };
982
+ }
983
+ stop() {
984
+ if (this.child === null) return { ok: false, reason: "not-running" };
985
+ if (this.sigkillTimer !== null) this.deps.timers.clearTimeout(this.sigkillTimer);
986
+ this.child.kill("SIGTERM");
987
+ this.sigkillTimer = this.deps.timers.setTimeout(() => {
988
+ if (this.child !== null) {
989
+ this.child.kill("SIGKILL");
990
+ this.deps.launcher.onForceKill?.();
991
+ }
992
+ }, STOP_GRACE_MS);
993
+ return { ok: true };
994
+ }
995
+ async prepareRun() {
996
+ const launcher = this.deps.launcher;
997
+ if (launcher.prepare === void 0) return { ok: true };
998
+ const ctx = this.deps.getRunContext();
999
+ if (ctx === null) return { ok: true };
1000
+ try {
1001
+ await launcher.prepare({
1002
+ ctx,
1003
+ onProgress: (phase) => {
1004
+ this.deps.broadcast({ type: "run-status", data: { running: true, mode: launcher.mode, phase } });
1005
+ }
1006
+ });
1007
+ return { ok: true };
1008
+ } catch (error) {
1009
+ this.deps.broadcast({ type: "run-status", data: { running: false, mode: launcher.mode } });
1010
+ const message = error instanceof Error ? error.message : String(error);
1011
+ console.warn(`[RunController] run preparation failed: ${message}`);
1012
+ return { ok: false, reason: "docker-unavailable" };
1013
+ }
1014
+ }
1015
+ dispose() {
1016
+ if (this.child === null) return;
1017
+ if (this.sigkillTimer !== null) this.deps.timers.clearTimeout(this.sigkillTimer);
1018
+ this.sigkillTimer = null;
1019
+ this.child.kill("SIGKILL");
1020
+ this.deps.launcher.onForceKill?.();
1021
+ this.cleanupTempFile();
1022
+ }
1023
+ handleChildExit(code) {
1024
+ if (this.child === null) return;
1025
+ if (this.sigkillTimer !== null) this.deps.timers.clearTimeout(this.sigkillTimer);
1026
+ this.sigkillTimer = null;
1027
+ this.child = null;
1028
+ this.cleanupTempFile();
1029
+ if (code !== null && code !== 0) console.warn(`[RunController] playwright test exited with code ${code}`);
1030
+ this.deps.setReportRunning(false);
1031
+ this.deps.broadcast({ type: "run-status", data: { running: false, mode: this.deps.launcher.mode } });
1032
+ void this.deps.saveReport?.();
1033
+ }
1034
+ };
1035
+ function createRealSpawn() {
1036
+ return (cmd, args, opts) => {
1037
+ const cp = (0, import_child_process2.spawn)(cmd, args, opts);
1038
+ return {
1039
+ on: (event, cb) => cp.on(event, cb),
1040
+ kill: (signal) => {
1041
+ const sig = KNOWN_SIGNALS[signal];
1042
+ if (sig !== void 0) cp.kill(sig);
1043
+ }
1044
+ };
1045
+ };
1046
+ }
1047
+ function createRealTimers() {
1048
+ const pending = [];
1049
+ return {
1050
+ setTimeout: (fn, ms) => {
1051
+ const id = setTimeout(fn, ms);
1052
+ pending.push(id);
1053
+ return id;
1054
+ },
1055
+ clearTimeout: () => {
1056
+ for (const h of pending.splice(0)) clearTimeout(h);
1057
+ }
1058
+ };
1059
+ }
1060
+
1061
+ // src/server/docker-launcher.ts
1062
+ var DOCKER_WORK_DIR = "/work";
1063
+ var DOCKER_HOST_GATEWAY = "host.docker.internal";
1064
+ var ENV_DENYLIST = /* @__PURE__ */ new Set([
1065
+ "CI",
1066
+ "PLAYWRIGHT_BROWSERS_PATH",
1067
+ "CRVY_RPRTR_SERVER_URL",
1068
+ "CRVY_RPRTR_PORTABLE_ARTIFACTS",
1069
+ "TZ",
1070
+ "LANG",
1071
+ "LC_ALL",
1072
+ "PLAYWRIGHT_HTML_OPEN",
1073
+ "PATH"
1074
+ ]);
1075
+ var WINDOWS_ENV_NOISE = new Set(
1076
+ "SYSTEMROOT COMSPEC WINDIR PATHEXT OS PROGRAMFILES PROGRAMFILES(X86) PROGRAMW6432 PROGRAMDATA ALLUSERSPROFILE PUBLIC APPDATA LOCALAPPDATA TEMP TMP USERPROFILE HOMEDRIVE HOMEPATH USERNAME PSMODULEPATH DRIVERDATA NUMBER_OF_PROCESSORS PROCESSOR_ARCHITECTURE PROCESSOR_IDENTIFIER PROCESSOR_LEVEL PROCESSOR_REVISION".split(
1077
+ " "
1078
+ )
1079
+ );
1080
+ var DockerUnavailableError = class extends Error {
1081
+ constructor() {
1082
+ super("Docker daemon is not available");
1083
+ this.name = "DockerUnavailableError";
1084
+ }
1085
+ };
1086
+ function defaultWarn(message) {
1087
+ console.warn(`[crvy-rprtr] ${message}`);
1088
+ }
1089
+ async function detectAgentName(detect2, cwd) {
1090
+ const detected = await (detect2 ?? detectProjectAgent)(cwd);
1091
+ return detected?.name ?? null;
1092
+ }
1093
+ async function prepareDocker(state, exec, ctx, deps, onProgress) {
1094
+ if (deps.platform === "win32" && !state.warnedWin32) {
1095
+ state.warnedWin32 = true;
1096
+ deps.warn(
1097
+ "Native Windows host detected: docker run mode is experimental on this platform. For CI-identical baselines, run crvy-rprtr from WSL2 with the project stored in the WSL filesystem."
1098
+ );
1099
+ }
1100
+ if (!await probeDockerDaemon(exec)) {
1101
+ state.available = false;
1102
+ throw new DockerUnavailableError();
1103
+ }
1104
+ state.available = true;
1105
+ const image = resolveDockerImage({ image: deps.docker?.image, version: deps.getPlaywrightVersion(ctx.cwd) });
1106
+ if (image === null) {
1107
+ throw new Error("Could not resolve the installed @playwright/test version; set docker.image explicitly.");
1108
+ }
1109
+ state.image = image;
1110
+ state.command = resolveContainerCommand({
1111
+ command: deps.docker?.command,
1112
+ hasCustomImage: deps.docker?.image !== void 0,
1113
+ detectedAgentName: deps.docker?.image === void 0 ? "npm" : await detectAgentName(deps.detectAgent, ctx.cwd),
1114
+ warn: deps.warn
1115
+ });
1116
+ if (!await isDockerImagePresent(exec, image)) {
1117
+ onProgress("pulling");
1118
+ if (!await pullDockerImage(exec, image)) {
1119
+ throw new Error(`Failed to pull docker image: ${image}`);
1120
+ }
1121
+ }
1122
+ }
1123
+ function buildDockerRunArgs(ctx, playwrightArgs, deps) {
1124
+ const { args: rewrittenArgs, bindMounts } = rewritePlaywrightArgs(playwrightArgs, ctx, deps.workDir, deps.warn);
1125
+ const args = [
1126
+ "run",
1127
+ "--rm",
1128
+ "--init",
1129
+ "--name",
1130
+ deps.containerName,
1131
+ "--add-host",
1132
+ `${DOCKER_HOST_GATEWAY}:host-gateway`,
1133
+ "--ipc=host"
1134
+ ];
1135
+ if (deps.docker?.platform !== void 0) {
1136
+ args.push("--platform", deps.docker.platform);
1137
+ }
1138
+ args.push("-v", `${ctx.cwd}:${deps.workDir}:rw`, "-w", deps.workDir);
1139
+ for (const mount of bindMounts) {
1140
+ args.push("-v", mount);
1141
+ }
1142
+ args.push("-e", `CRVY_RPRTR_SERVER_URL=ws://${DOCKER_HOST_GATEWAY}:${deps.port}`);
1143
+ args.push("-e", "CRVY_RPRTR_PORTABLE_ARTIFACTS=1", "-e", "TZ=UTC", "-e", "LANG=C.UTF-8", "-e", "LC_ALL=C.UTF-8");
1144
+ args.push("-e", "PLAYWRIGHT_HTML_OPEN=never");
1145
+ for (const [key, value] of Object.entries(deps.env)) {
1146
+ const upper = key.toUpperCase();
1147
+ if (ENV_DENYLIST.has(upper) || WINDOWS_ENV_NOISE.has(upper) || value === void 0) continue;
1148
+ args.push("-e", key);
1149
+ }
1150
+ if (deps.docker?.extraArgs !== void 0) args.push(...deps.docker.extraArgs);
1151
+ args.push(deps.image, ...deps.command, "playwright", ...rewrittenArgs);
1152
+ return args;
1153
+ }
1154
+ function stripCi(env) {
1155
+ const out = {};
1156
+ for (const [key, value] of Object.entries(env)) {
1157
+ if (key === "CI") continue;
1158
+ out[key] = value;
1159
+ }
1160
+ return out;
1161
+ }
1162
+ function createState(docker) {
1163
+ return {
1164
+ available: void 0,
1165
+ prepared: null,
1166
+ image: null,
1167
+ command: docker?.command ?? DEFAULT_CONTAINER_COMMAND,
1168
+ warnedWin32: false
1169
+ };
1170
+ }
1171
+ function buildLauncher(state, deps) {
1172
+ return {
1173
+ mode: "docker",
1174
+ get available() {
1175
+ return state.available;
1176
+ },
1177
+ prepare({ ctx, onProgress }) {
1178
+ state.prepared ??= prepareDocker(
1179
+ state,
1180
+ deps.exec,
1181
+ ctx,
1182
+ {
1183
+ docker: deps.docker,
1184
+ getPlaywrightVersion: deps.getVersion,
1185
+ detectAgent: deps.detectAgent,
1186
+ warn: deps.warn,
1187
+ platform: deps.platform
1188
+ },
1189
+ onProgress
1190
+ ).catch((error) => {
1191
+ state.prepared = null;
1192
+ state.warnedWin32 = false;
1193
+ throw error;
1194
+ });
1195
+ return state.prepared;
1196
+ },
1197
+ launch({ ctx, playwrightArgs }) {
1198
+ const image = state.image ?? resolveDockerImage({ image: deps.docker?.image, version: deps.getVersion(ctx.cwd) });
1199
+ if (image === null) {
1200
+ throw new Error("Could not resolve the docker image; run prepare() first or set docker.image.");
1201
+ }
1202
+ const args = buildDockerRunArgs(ctx, playwrightArgs, {
1203
+ docker: deps.docker,
1204
+ workDir: deps.workDir,
1205
+ containerName: deps.containerName,
1206
+ port: deps.port,
1207
+ env: deps.baseEnv,
1208
+ image,
1209
+ command: state.command,
1210
+ warn: deps.warn
1211
+ });
1212
+ return { cmd: "docker", args, env: stripCi(deps.baseEnv) };
1213
+ },
1214
+ onForceKill() {
1215
+ void forceRemoveContainer(deps.exec, deps.containerName);
1216
+ }
1217
+ };
1218
+ }
1219
+ function createDockerLauncher(options) {
1220
+ return buildLauncher(createState(options.docker), {
1221
+ exec: options.exec ?? createDockerExec(),
1222
+ workDir: options.workDir ?? DOCKER_WORK_DIR,
1223
+ containerName: options.containerName ?? `crvy-rprtr-run-${process.pid}`,
1224
+ baseEnv: options.env ?? process.env,
1225
+ getVersion: options.getPlaywrightVersion ?? resolvePlaywrightVersion,
1226
+ detectAgent: options.detectAgent,
1227
+ warn: options.warn ?? defaultWarn,
1228
+ platform: options.platform ?? process.platform,
1229
+ docker: options.docker,
1230
+ port: options.port
1231
+ });
1232
+ }
1233
+
1234
+ // src/server/handlers.ts
1235
+ var import_fs2 = require("fs");
1236
+ var import_path6 = require("path");
1237
+
1238
+ // src/server/artifact-routes.ts
1239
+ var import_fs = require("fs");
1240
+ var import_promises3 = require("fs/promises");
1241
+ var import_path5 = require("path");
1242
+
1243
+ // src/snapshot-path-resolver.ts
1244
+ var import_crypto = require("crypto");
1245
+ var import_path3 = require("path");
1246
+ var DEFAULT_SCREENSHOT_TEMPLATE = "{snapshotDir}/{testFileDir}/{testFileName}-snapshots/{arg}{-projectName}{-snapshotSuffix}{ext}";
1247
+ var WINDOWS_FILESYSTEM_FRIENDLY_LENGTH = 60;
1248
+ function isUnsafeFilePathCharacter(character) {
1249
+ const codePoint = character.codePointAt(0);
1250
+ return codePoint !== void 0 && (codePoint <= 44 || codePoint >= 46 && codePoint <= 47 || codePoint >= 58 && codePoint <= 64 || codePoint >= 91 && codePoint <= 96 || codePoint >= 123 && codePoint <= 127);
1251
+ }
1252
+ function sanitizeForFilePath(value) {
1253
+ return Array.from(value).reduce(
1254
+ (state, character) => {
1255
+ const unsafeCharacter = isUnsafeFilePathCharacter(character);
1256
+ return unsafeCharacter ? state.previousCharacterWasUnsafe ? state : {
1257
+ value: `${state.value}-`,
1258
+ previousCharacterWasUnsafe: true
1259
+ } : {
1260
+ value: `${state.value}${character}`,
1261
+ previousCharacterWasUnsafe: false
1262
+ };
1263
+ },
1264
+ {
1265
+ value: "",
1266
+ previousCharacterWasUnsafe: false
1267
+ }
1268
+ ).value;
1269
+ }
1270
+ function trimLongString(value, length = WINDOWS_FILESYSTEM_FRIENDLY_LENGTH) {
1271
+ if (value.length <= length) {
1272
+ return value;
1273
+ }
1274
+ const hash = (0, import_crypto.createHash)("sha1").update(value).digest("hex");
1275
+ const middle = `-${hash.slice(0, 5)}-`;
1276
+ const start = Math.floor((length - middle.length) / 2);
1277
+ const end = length - middle.length - start;
1278
+ return value.slice(0, start) + middle + value.slice(-end);
1279
+ }
1280
+ function sanitizeFilePathBeforeExtension(filePath, extension = (0, import_path3.extname)(filePath)) {
1281
+ const base = filePath.slice(0, filePath.length - extension.length);
1282
+ return sanitizeForFilePath(base) + extension;
1283
+ }
1284
+ function addSuffixToFilePath(filePath, suffix) {
1285
+ const extension = (0, import_path3.extname)(filePath);
1286
+ return filePath.slice(0, filePath.length - extension.length) + suffix + extension;
1287
+ }
1288
+ function normalizedSnapshotDir(config) {
1289
+ return (0, import_path3.resolve)(config.configDir, config.snapshotDir);
1290
+ }
1291
+ function templateValue(template, token, value) {
1292
+ return template.replace(
1293
+ new RegExp(`\\{(.)?${token}\\}`, "g"),
1294
+ (_, prefix) => value === "" ? "" : `${prefix ?? ""}${value}`
1295
+ );
1296
+ }
1297
+ function applyTemplate(input, nameArgument, extension) {
1298
+ const template = input.config.toHaveScreenshotPathTemplate ?? input.config.snapshotPathTemplate ?? DEFAULT_SCREENSHOT_TEMPLATE;
1299
+ const relativeTestFilePath = (0, import_path3.relative)(input.config.testDir, input.testFile);
1300
+ const parsed = (0, import_path3.parse)(relativeTestFilePath);
1301
+ const tokens = [
1302
+ ["testDir", input.config.testDir],
1303
+ ["snapshotDir", normalizedSnapshotDir(input.config)],
1304
+ ["snapshotSuffix", input.config.snapshotSuffix],
1305
+ ["testFileDir", parsed.dir],
1306
+ ["platform", process.platform],
1307
+ ["projectName", sanitizeForFilePath(input.config.projectName)],
1308
+ ["testName", ""],
1309
+ ["testFileName", parsed.base],
1310
+ ["testFilePath", relativeTestFilePath],
1311
+ ["arg", nameArgument],
1312
+ ["ext", extension]
1313
+ ];
1314
+ const snapshotPath = tokens.reduce(
1315
+ (currentTemplate, [token, value]) => templateValue(currentTemplate, token, value),
1316
+ template
1317
+ );
1318
+ return (0, import_path3.resolve)(input.config.configDir, snapshotPath);
1319
+ }
1320
+ function removeExtension(filePath, extension = (0, import_path3.extname)(filePath)) {
1321
+ return filePath.slice(0, filePath.length - extension.length);
1322
+ }
1323
+ function snapshotNameParts(declaredName) {
1324
+ const extension = (0, import_path3.extname)(declaredName) || ".png";
1325
+ return {
1326
+ extension,
1327
+ filePath: (0, import_path3.extname)(declaredName) === "" ? `${declaredName}${extension}` : declaredName
1328
+ };
1329
+ }
1330
+ function filePathForOccurrence(filePath, occurrenceIndex) {
1331
+ return occurrenceIndex === 1 ? filePath : addSuffixToFilePath(filePath, `-${occurrenceIndex - 1}`);
1332
+ }
1333
+ function createResolvedBaselineTarget(input, declaration, nameArgument, extension) {
1334
+ return {
1335
+ visualName: declaration.visualName,
1336
+ attachmentBaseName: declaration.visualName,
1337
+ artifactBaseName: sanitizeForFilePath(declaration.visualName),
1338
+ snapshotPath: applyTemplate(input, nameArgument, extension)
1339
+ };
1340
+ }
1341
+ function resolveStringCallTarget(input, declaration) {
1342
+ const { extension, filePath } = snapshotNameParts(declaration.declaredName);
1343
+ const occurrenceFilePath = filePathForOccurrence(filePath, declaration.occurrenceIndex);
1344
+ const sanitizedNameWithExtension = sanitizeFilePathBeforeExtension(occurrenceFilePath, extension);
1345
+ const nameArgument = removeExtension(sanitizedNameWithExtension, extension);
1346
+ return createResolvedBaselineTarget(input, declaration, nameArgument, extension);
1347
+ }
1348
+ function resolveArrayCallTarget(input, declaration) {
1349
+ const { extension, filePath } = snapshotNameParts(declaration.declaredName);
1350
+ const occurrenceFilePath = filePathForOccurrence(filePath, declaration.occurrenceIndex);
1351
+ const nameArgument = (0, import_path3.join)((0, import_path3.dirname)(occurrenceFilePath), (0, import_path3.basename)(occurrenceFilePath, extension));
1352
+ return createResolvedBaselineTarget(input, declaration, nameArgument, extension);
1353
+ }
1354
+ function resolveNamedTarget(input, declaration) {
1355
+ if (!declaration.declaredName.includes("/")) {
1356
+ return resolveStringCallTarget(input, declaration);
1357
+ }
1358
+ const stringCallTarget = resolveStringCallTarget(input, declaration);
1359
+ const arrayCallTarget = resolveArrayCallTarget(input, declaration);
1360
+ if (stringCallTarget.snapshotPath === arrayCallTarget.snapshotPath) {
1361
+ return stringCallTarget;
1362
+ }
1363
+ if (input.snapshotPathExists === void 0) {
1364
+ return void 0;
1365
+ }
1366
+ const stringCallTargetExists = input.snapshotPathExists(stringCallTarget.snapshotPath);
1367
+ const arrayCallTargetExists = input.snapshotPathExists(arrayCallTarget.snapshotPath);
1368
+ if (stringCallTargetExists === arrayCallTargetExists) {
1369
+ return void 0;
1370
+ }
1371
+ return stringCallTargetExists ? stringCallTarget : arrayCallTarget;
1372
+ }
1373
+ function reporterTitlesWithoutProjectAndFile(reporterTitlePath2) {
1374
+ return reporterTitlePath2.slice(3).filter((part) => part !== "");
1375
+ }
1376
+ function anonymousNameFromTitles(titles, occurrenceIndex) {
1377
+ return sanitizeFilePathBeforeExtension(trimLongString(`${titles.join(" ")} ${occurrenceIndex}.png`), ".png");
1378
+ }
1379
+ function anonymousName(reporterTitlePath2, occurrenceIndex) {
1380
+ return anonymousNameFromTitles(reporterTitlesWithoutProjectAndFile(reporterTitlePath2), occurrenceIndex);
1381
+ }
1382
+ function resolveTarget(input, declaration) {
1383
+ switch (declaration.kind) {
1384
+ case "named":
1385
+ return typeof declaration.declaredName === "string" && declaration.declaredName !== "" ? resolveNamedTarget(input, declaration) : void 0;
1386
+ case "unnamed": {
1387
+ const anonymousFileName = anonymousName(input.reporterTitlePath, declaration.occurrenceIndex);
1388
+ const extension = ".png";
1389
+ const nameArgument = (0, import_path3.join)((0, import_path3.dirname)(anonymousFileName), (0, import_path3.basename)(anonymousFileName, extension));
1390
+ return createResolvedBaselineTarget(input, declaration, nameArgument, extension);
1391
+ }
1392
+ }
1393
+ }
1394
+ function resolveBaselineTargets(input) {
1395
+ return input.declarations.flatMap((declaration) => {
1396
+ const resolvedTarget = resolveTarget(input, declaration);
1397
+ return resolvedTarget === void 0 ? [] : [resolvedTarget];
1398
+ });
1399
+ }
1400
+
1401
+ // src/server/utils.ts
1402
+ var import_path4 = require("path");
1403
+ var LIVE_UPDATES_WEBSOCKET_PATH = "/";
1404
+ function broadcastToBrowsers(wsClients, msg) {
1405
+ const payload = JSON.stringify(msg);
1406
+ wsClients.forEach((ws) => {
1407
+ ws.send(payload);
1408
+ });
1409
+ }
1410
+ function isWebSocketUpgradeRequest(req) {
1411
+ return new URL(req.url).pathname === LIVE_UPDATES_WEBSOCKET_PATH && req.headers.get("upgrade")?.toLowerCase() === "websocket";
1412
+ }
1413
+ function isPathWithinRoots(target, roots) {
1414
+ const resolvedTarget = (0, import_path4.resolve)(target);
1415
+ return roots.some((root) => {
1416
+ const rel = (0, import_path4.relative)((0, import_path4.resolve)(root), resolvedTarget);
1417
+ return rel === "" || !rel.startsWith(`..${import_path4.sep}`) && rel !== ".." && !(0, import_path4.isAbsolute)(rel);
1418
+ });
1419
+ }
1420
+
1421
+ // src/server/artifact-routes.ts
1422
+ async function realpathOrNull(path) {
1423
+ try {
1424
+ return await (0, import_promises3.realpath)(path);
1425
+ } catch {
1426
+ return null;
849
1427
  }
850
1428
  }
851
1429
  async function handleFile(ctx, req) {
@@ -886,11 +1464,14 @@ function reporterTitlePath(test) {
886
1464
  return ["", projectName, testFile ?? "", ...test.titlePath, test.title];
887
1465
  }
888
1466
  function resolveBaselineSnapshotPath(routing, test, retry, imageName) {
889
- const testFile = test.location?.file;
1467
+ const mapping = routing?.containerPathMapping;
1468
+ const rawTestFile = test.location?.file;
1469
+ const testFile = rawTestFile === void 0 || mapping === void 0 ? rawTestFile : rewriteContainerPath(rawTestFile, mapping);
890
1470
  const declaration = test.results?.[retry]?.visualDeclarations?.find((candidate) => candidate.visualName === imageName);
891
1471
  if (routing === void 0 || testFile === void 0 || declaration === void 0) {
892
1472
  return null;
893
1473
  }
1474
+ const isContainerPath = mapping !== void 0 && testFile !== rawTestFile;
894
1475
  const targets = resolveBaselineTargets({
895
1476
  testFile,
896
1477
  reporterTitlePath: reporterTitlePath(test),
@@ -900,7 +1481,7 @@ function resolveBaselineSnapshotPath(routing, test, retry, imageName) {
900
1481
  testDir: routing.playwrightTestDir ?? (0, import_path5.dirname)(testFile),
901
1482
  snapshotDir: routing.playwrightSnapshotDir ?? (0, import_path5.dirname)(testFile),
902
1483
  projectName: test.projectName ?? test.browser,
903
- snapshotSuffix: process.platform,
1484
+ snapshotSuffix: isContainerPath ? "linux" : process.platform,
904
1485
  snapshotPathTemplate: routing.playwrightSnapshotPathTemplate,
905
1486
  toHaveScreenshotPathTemplate: routing.playwrightToHaveScreenshotPathTemplate
906
1487
  },
@@ -1023,7 +1604,19 @@ function handleSync(ctx) {
1023
1604
  };
1024
1605
  broadcastToBrowsers(ctx.wsClients, message);
1025
1606
  }
1026
- function handleRegister(ctx, data) {
1607
+ function applyContainerPathMapping(rawData, mapping) {
1608
+ return {
1609
+ ...rawData,
1610
+ playwrightSnapshotDir: rawData.playwrightSnapshotDir === void 0 ? void 0 : rewriteContainerPath(rawData.playwrightSnapshotDir, mapping),
1611
+ playwrightTestDir: rawData.playwrightTestDir === void 0 ? void 0 : rewriteContainerPath(rawData.playwrightTestDir, mapping),
1612
+ playwrightRootDir: rawData.playwrightRootDir === void 0 ? void 0 : rewriteContainerPath(rawData.playwrightRootDir, mapping),
1613
+ configFile: rawData.configFile === void 0 ? void 0 : rewriteContainerPath(rawData.configFile, mapping),
1614
+ cwd: rawData.cwd === void 0 ? void 0 : rewriteContainerPath(rawData.cwd, mapping)
1615
+ };
1616
+ }
1617
+ function handleRegister(ctx, rawData) {
1618
+ const mapping = ctx.routesContext.containerPathMapping;
1619
+ const data = mapping === void 0 ? rawData : applyContainerPathMapping(rawData, mapping);
1027
1620
  const roots = [];
1028
1621
  if (data.playwrightSnapshotDir !== void 0 && data.playwrightSnapshotDir !== "") {
1029
1622
  roots.push(data.playwrightSnapshotDir);
@@ -1053,16 +1646,65 @@ function handleRegister(ctx, data) {
1053
1646
  }
1054
1647
  }
1055
1648
  if (data.configFile !== void 0 && data.cwd !== void 0) {
1056
- ctx.routesContext.runContext = { configFile: data.configFile, cwd: data.cwd };
1649
+ ctx.routesContext.runContext = buildRunContext(data.configFile, data);
1057
1650
  }
1058
1651
  console.log("[Server] Reporter registered with config:", {
1059
1652
  playwrightSnapshotDir: data.playwrightSnapshotDir,
1060
- playwrightTestDir: data.playwrightTestDir
1653
+ playwrightTestDir: data.playwrightTestDir,
1654
+ configFile: data.configFile,
1655
+ cwd: data.cwd
1656
+ });
1657
+ }
1658
+ function buildRunContext(configFile, data) {
1659
+ const configDir = (0, import_path6.dirname)(configFile);
1660
+ return {
1661
+ configFile,
1662
+ cwd: configDir,
1663
+ rootDir: data.playwrightRootDir ?? (data.playwrightTestDir === void 0 ? configDir : (0, import_path6.resolve)(configDir, data.playwrightTestDir))
1664
+ };
1665
+ }
1666
+
1667
+ // src/ci.ts
1668
+ function isCI(env = process.env) {
1669
+ const ci = env.CI;
1670
+ return ci !== void 0 && ci !== "" && ci !== "false" && ci !== "0";
1671
+ }
1672
+
1673
+ // src/server/run-mode.ts
1674
+ async function resolveRunMode(options) {
1675
+ if (options.runMode === "local") return "local";
1676
+ if (options.runMode === "docker") return "docker";
1677
+ if (options.isCI) return "local";
1678
+ if (await options.probeDocker()) return "docker";
1679
+ options.warn?.(
1680
+ "Docker daemon unavailable \u2014 running tests locally; screenshots may differ from CI. Use --run-mode local to silence this warning."
1681
+ );
1682
+ return "local";
1683
+ }
1684
+
1685
+ // src/server/launcher-resolver.ts
1686
+ async function resolveRunBackend(options) {
1687
+ const dockerExec = createDockerExec();
1688
+ const resolvedRunMode = await resolveRunMode({
1689
+ runMode: options.runMode ?? "auto",
1690
+ isCI: isCI(),
1691
+ probeDocker: () => probeDockerDaemon(dockerExec),
1692
+ warn: (message) => {
1693
+ console.warn(`[crvy-rprtr] ${message}`);
1694
+ }
1061
1695
  });
1696
+ const launcher = resolvedRunMode === "docker" ? createDockerLauncher({ port: options.port, docker: options.docker, exec: dockerExec }) : createLocalLauncher({ port: options.port });
1697
+ return {
1698
+ launcher,
1699
+ routesContextOptions: {
1700
+ runInfo: { mode: resolvedRunMode },
1701
+ containerPathMapping: resolvedRunMode === "docker" ? { from: DOCKER_WORK_DIR, to: process.cwd() } : void 0
1702
+ }
1703
+ };
1062
1704
  }
1063
1705
 
1064
1706
  // src/server/playwright-config.ts
1065
- var import_path6 = require("path");
1707
+ var import_path7 = require("path");
1066
1708
  var CONFIG_FILES = [
1067
1709
  "playwright.config.ts",
1068
1710
  "playwright.config.mts",
@@ -1074,12 +1716,15 @@ var CONFIG_FILES = [
1074
1716
  async function resolvePlaywrightConfig(cwd) {
1075
1717
  const matches = await Promise.all(
1076
1718
  CONFIG_FILES.map(async (file) => {
1077
- const candidate = (0, import_path6.join)(cwd, file);
1719
+ const candidate = (0, import_path7.join)(cwd, file);
1078
1720
  return await fileExists(candidate) ? candidate : null;
1079
1721
  })
1080
1722
  );
1081
1723
  return matches.find((path) => path !== null) ?? null;
1082
1724
  }
1725
+ function resolveSeedConfigFile(option, cwd) {
1726
+ return option === void 0 ? resolvePlaywrightConfig(cwd) : Promise.resolve((0, import_path7.resolve)(cwd, option));
1727
+ }
1083
1728
 
1084
1729
  // src/server/report-persistence.ts
1085
1730
  function createDebouncedSaver(save, delayMs, setTimeoutFn = (fn, ms) => setTimeout(fn, ms), clearTimeoutFn = (handle) => {
@@ -1151,14 +1796,17 @@ function createRoutesContext(reportData, staticDir, saveReport, options) {
1151
1796
  playwrightTestDir: options.playwrightTestDir,
1152
1797
  playwrightSnapshotDir: options.playwrightSnapshotDir,
1153
1798
  playwrightSnapshotPathTemplate: options.playwrightSnapshotPathTemplate,
1154
- playwrightToHaveScreenshotPathTemplate: options.playwrightToHaveScreenshotPathTemplate
1799
+ playwrightToHaveScreenshotPathTemplate: options.playwrightToHaveScreenshotPathTemplate,
1800
+ containerPathMapping: options.containerPathMapping
1155
1801
  },
1156
- runContext: void 0
1802
+ runContext: void 0,
1803
+ runInfo: options.runInfo,
1804
+ containerPathMapping: options.containerPathMapping
1157
1805
  };
1158
1806
  }
1159
1807
 
1160
1808
  // src/server/routes.ts
1161
- var import_path7 = require("path");
1809
+ var import_path8 = require("path");
1162
1810
 
1163
1811
  // src/server/run-routes.ts
1164
1812
  function handleRunRoutes(pathname, method, runController, req) {
@@ -1180,6 +1828,10 @@ async function handleApiRun(runController, req) {
1180
1828
  if (parsed === null) {
1181
1829
  return Response.json({ ok: false, error: "Invalid request body" }, { status: 400 });
1182
1830
  }
1831
+ const preparation = await runController.prepareRun();
1832
+ if (!preparation.ok) {
1833
+ return Response.json({ ok: false, reason: preparation.reason }, { status: 409 });
1834
+ }
1183
1835
  const result = runController.start(parsed);
1184
1836
  if (result.ok) return Response.json(result);
1185
1837
  const status = result.reason === "no-tests" ? 400 : 409;
@@ -1193,7 +1845,7 @@ function handleApiStop(runController) {
1193
1845
 
1194
1846
  // src/server/routes.ts
1195
1847
  async function handleRoot(ctx) {
1196
- const html = await respondWithFile((0, import_path7.join)(ctx.staticDir, "index.html"), "text/html");
1848
+ const html = await respondWithFile((0, import_path8.join)(ctx.staticDir, "index.html"), "text/html");
1197
1849
  return html ?? new Response("Not Found", { status: 404 });
1198
1850
  }
1199
1851
  async function handleAppCss() {
@@ -1210,13 +1862,14 @@ async function handleSrcFiles(req) {
1210
1862
  function handleApiReport(ctx) {
1211
1863
  return Response.json({
1212
1864
  ...ctx.reportData,
1213
- runEnabled: ctx.runContext !== void 0
1865
+ runEnabled: ctx.runContext !== void 0,
1866
+ runMode: ctx.runInfo?.mode
1214
1867
  });
1215
1868
  }
1216
1869
  var APPROVAL_TARGET_ERROR = "Could not resolve approval target";
1217
1870
  function actualPathFromUrl(ctx, actualUrl) {
1218
1871
  if (actualUrl.startsWith("/screenshots/")) {
1219
- return (0, import_path7.join)(ctx.reportData.screenshotDir, actualUrl.slice("/screenshots/".length));
1872
+ return (0, import_path8.join)(ctx.reportData.screenshotDir, actualUrl.slice("/screenshots/".length));
1220
1873
  }
1221
1874
  if (actualUrl.startsWith("/file/")) {
1222
1875
  return decodeURIComponent(actualUrl.slice("/file/".length));
@@ -1338,7 +1991,7 @@ async function handleScreenshots(ctx, req) {
1338
1991
  }
1339
1992
  async function handleDist(ctx, req) {
1340
1993
  const path = new URL(req.url).pathname.slice("/dist/".length);
1341
- const filePath = (0, import_path7.join)(ctx.staticDir, path);
1994
+ const filePath = (0, import_path8.join)(ctx.staticDir, path);
1342
1995
  const contentType = filePath.endsWith(".css") ? "text/css" : filePath.endsWith(".js") ? "application/javascript" : filePath.endsWith(".svelte") ? "text/plain" : "application/octet-stream";
1343
1996
  const file = await respondWithFile(filePath, contentType);
1344
1997
  return file ?? new Response("Not Found", { status: 404 });
@@ -1383,217 +2036,56 @@ function handleHttpRequest(ctx, req, runController) {
1383
2036
  return Promise.resolve(new Response("Not Found", { status: 404 }));
1384
2037
  }
1385
2038
 
1386
- // src/server/run-controller.ts
1387
- var import_child_process = require("child_process");
1388
- var import_node_fs = require("node:fs");
1389
- var import_node_module = require("node:module");
1390
- var import_node_os = require("node:os");
1391
- var import_node_path = require("node:path");
1392
- var import_commands = require("package-manager-detector/commands");
1393
- var import_detect = require("package-manager-detector/detect");
1394
- var import_meta = { url: require("url").pathToFileURL(__filename).href };
1395
- var STOP_GRACE_MS = 5e3;
1396
- var KNOWN_SIGNALS = {
1397
- SIGTERM: "SIGTERM",
1398
- SIGKILL: "SIGKILL"
1399
- };
1400
- function resolvePlaywrightLaunch(cwd, playwrightArgs) {
1401
- const agent = (0, import_detect.getUserAgent)();
1402
- const resolved = agent === null ? null : (0, import_commands.resolveCommand)(agent, "execute-local", ["playwright", ...playwrightArgs]);
1403
- if (resolved !== null) return { cmd: resolved.command, args: resolved.args };
1404
- return { cmd: "npx", args: ["playwright", ...playwrightArgs] };
1405
- }
1406
- function descriptorLocation(d) {
1407
- return d.column === void 0 ? `${d.file}:${d.line}` : `${d.file}:${d.line}:${d.column}`;
1408
- }
1409
- function sharedProject(tests) {
1410
- const names = new Set(tests.map((t) => t.projectName ?? ""));
1411
- if (names.size === 1) {
1412
- const name = [...names][0];
1413
- return name === "" ? void 0 : name;
1414
- }
1415
- return void 0;
1416
- }
1417
- function gteMinor(version, major, minor) {
1418
- const match = /^(\d+)\.(\d+)/.exec(version.trim());
1419
- if (match === null) return false;
1420
- const maj = parseInt(match[1], 10);
1421
- const min = parseInt(match[2], 10);
1422
- if (maj !== major) return maj > major;
1423
- return min >= minor;
1424
- }
1425
- function resolvePlaywrightVersion(cwd) {
1426
- try {
1427
- const req = (0, import_node_module.createRequire)((0, import_node_path.join)(cwd, "package.json"));
1428
- const pkgPath = req.resolve("@playwright/test/package.json");
1429
- const pkg = JSON.parse((0, import_node_fs.readFileSync)(pkgPath, "utf8"));
1430
- return typeof pkg === "object" && pkg !== null && "version" in pkg && typeof pkg.version === "string" ? pkg.version : null;
1431
- } catch {
1432
- return null;
1433
- }
1434
- }
1435
- function buildTestListEntries(tests) {
1436
- return tests.map((d) => {
1437
- const loc = descriptorLocation(d);
1438
- const title = d.titlePath.join(" \u203A ");
1439
- const prefix = d.projectName !== void 0 && d.projectName !== "" ? `[${d.projectName}] \u203A ` : "";
1440
- return `${prefix}${loc} \u203A ${title}`;
2039
+ // src/server/server-factories.ts
2040
+ function createServerRunController(routesContext, wsClients, reportData, port, setRunFiltered, saveReport, launcher) {
2041
+ return new RunController({
2042
+ getRunContext: () => routesContext.runContext ?? null,
2043
+ port,
2044
+ broadcast: (message) => {
2045
+ broadcastToBrowsers(wsClients, message);
2046
+ },
2047
+ setReportRunning: (running) => {
2048
+ reportData.isRunning = running;
2049
+ },
2050
+ setRunFiltered,
2051
+ containerPathMapping: routesContext.containerPathMapping,
2052
+ saveReport,
2053
+ spawn: createRealSpawn(),
2054
+ timers: createRealTimers(),
2055
+ launcher
1441
2056
  });
1442
2057
  }
1443
- function defaultWriteTempFile(content) {
1444
- const path = (0, import_node_path.join)((0, import_node_os.tmpdir)(), `crvy-rprtr-test-list-${process.pid}-${Date.now()}.txt`);
1445
- (0, import_node_fs.writeFileSync)(path, content, "utf8");
1446
- return path;
1447
- }
1448
- function defaultDeleteTempFile(path) {
1449
- try {
1450
- (0, import_node_fs.unlinkSync)(path);
1451
- } catch {
1452
- }
1453
- }
1454
- function resolveReporterDefault(cwd) {
1455
- try {
1456
- return (0, import_node_module.createRequire)((0, import_node_path.join)(cwd, "package.json")).resolve("@crvy/rprtr");
1457
- } catch {
1458
- }
1459
- try {
1460
- return (0, import_node_module.createRequire)(import_meta.url).resolve("@crvy/rprtr");
1461
- } catch {
1462
- return null;
1463
- }
1464
- }
1465
- function buildSpawnEnv(port) {
1466
- const env = {};
1467
- for (const [key, value] of Object.entries(process.env)) {
1468
- if (key === "CI") continue;
1469
- env[key] = value;
1470
- }
1471
- env.CRVY_RPRTR_SERVER_URL = `ws://localhost:${port}`;
1472
- env.PLAYWRIGHT_HTML_OPEN = "never";
1473
- return env;
1474
- }
1475
- var RunController = class {
1476
- constructor(deps) {
1477
- this.deps = deps;
1478
- }
1479
- child = null;
1480
- sigkillTimer = null;
1481
- testListPath = null;
1482
- get isRunning() {
1483
- return this.child !== null;
1484
- }
1485
- supportsTestList(cwd) {
1486
- const getVersion = this.deps.getPlaywrightVersion ?? resolvePlaywrightVersion;
1487
- const version = getVersion(cwd);
1488
- return version !== null && gteMinor(version, 1, 56);
1489
- }
1490
- cleanupTempFile() {
1491
- if (this.testListPath !== null) {
1492
- const del = this.deps.deleteTempFile ?? defaultDeleteTempFile;
1493
- del(this.testListPath);
1494
- this.testListPath = null;
1495
- }
1496
- }
1497
- start(filters) {
1498
- const ctx = this.deps.getRunContext();
1499
- if (ctx === null) return { ok: false, reason: "no-config" };
1500
- if (this.child !== null) return { ok: false, reason: "already-running" };
1501
- if (filters.tests !== void 0 && filters.tests.length === 0) {
1502
- return { ok: false, reason: "no-tests" };
1503
- }
1504
- const resolveReporter = this.deps.resolveReporter ?? resolveReporterDefault;
1505
- const reporterModule = resolveReporter(ctx.cwd);
1506
- const tests = filters.tests;
1507
- const useTestList = tests !== void 0 && tests.length > 1 && this.supportsTestList(ctx.cwd);
1508
- const args = ["test", "--config", ctx.configFile];
1509
- if (reporterModule !== null) args.push("--reporter", reporterModule);
1510
- if (useTestList && tests !== void 0) {
1511
- const content = buildTestListEntries(tests).join("\n");
1512
- const writeTemp = this.deps.writeTempFile ?? defaultWriteTempFile;
1513
- this.testListPath = writeTemp(content);
1514
- args.push("--test-list", this.testListPath);
1515
- } else if (tests !== void 0 && tests.length > 0) {
1516
- const project = sharedProject(tests);
1517
- if (project !== void 0) args.push("--project", project);
1518
- for (const d of tests) args.push(descriptorLocation(d));
1519
- }
1520
- const resolveLaunch = this.deps.resolveLaunch ?? resolvePlaywrightLaunch;
1521
- const { cmd, args: launchArgs } = resolveLaunch(ctx.cwd, args);
1522
- let child;
1523
- try {
1524
- child = this.deps.spawn(cmd, launchArgs, { cwd: ctx.cwd, env: buildSpawnEnv(this.deps.port), stdio: "inherit" });
1525
- } catch (err) {
1526
- this.cleanupTempFile();
1527
- throw err;
1528
- }
1529
- this.child = child;
1530
- child.on("exit", (code) => {
1531
- this.handleChildExit(code);
1532
- });
1533
- child.on("error", () => {
1534
- this.handleChildExit(null);
1535
- });
1536
- this.deps.setReportRunning(true);
1537
- this.deps.setRunFiltered?.(filters.tests !== void 0);
1538
- this.deps.broadcast({ type: "run-status", data: { running: true } });
1539
- return { ok: true };
1540
- }
1541
- stop() {
1542
- if (this.child === null) return { ok: false, reason: "not-running" };
1543
- if (this.sigkillTimer !== null) this.deps.timers.clearTimeout(this.sigkillTimer);
1544
- this.child.kill("SIGTERM");
1545
- this.sigkillTimer = this.deps.timers.setTimeout(() => {
1546
- if (this.child !== null) this.child.kill("SIGKILL");
1547
- }, STOP_GRACE_MS);
1548
- return { ok: true };
1549
- }
1550
- dispose() {
1551
- if (this.child === null) return;
1552
- if (this.sigkillTimer !== null) this.deps.timers.clearTimeout(this.sigkillTimer);
1553
- this.sigkillTimer = null;
1554
- this.child.kill("SIGKILL");
1555
- this.cleanupTempFile();
1556
- }
1557
- handleChildExit(code) {
1558
- if (this.child === null) return;
1559
- if (this.sigkillTimer !== null) {
1560
- this.deps.timers.clearTimeout(this.sigkillTimer);
1561
- this.sigkillTimer = null;
1562
- }
1563
- this.child = null;
1564
- this.cleanupTempFile();
1565
- if (code !== null && code !== 0) {
1566
- console.warn(`[RunController] playwright test exited with code ${code}`);
1567
- }
1568
- this.deps.setReportRunning(false);
1569
- this.deps.broadcast({ type: "run-status", data: { running: false } });
1570
- void this.deps.saveReport?.();
1571
- }
1572
- };
1573
- function createRealSpawn() {
1574
- return (cmd, args, opts) => {
1575
- const cp = (0, import_child_process.spawn)(cmd, args, opts);
1576
- return {
1577
- on: (event, cb) => cp.on(event, cb),
1578
- kill: (signal) => {
1579
- const sig = KNOWN_SIGNALS[signal];
1580
- if (sig !== void 0) cp.kill(sig);
1581
- }
1582
- };
2058
+ function createCloseHandler(persistence, runController) {
2059
+ return async () => {
2060
+ await persistence.dispose();
2061
+ runController.dispose();
1583
2062
  };
1584
2063
  }
1585
- function createRealTimers() {
1586
- const pending = [];
1587
- return {
1588
- setTimeout: (fn, ms) => {
1589
- const id = setTimeout(fn, ms);
1590
- pending.push(id);
1591
- return id;
2064
+ function createRunControllerAndHandlers(routesContext, wsClients, reportData, currentRunIds, port, persistence, launcher) {
2065
+ let isFilteredRun = false;
2066
+ const runController = createServerRunController(
2067
+ routesContext,
2068
+ wsClients,
2069
+ reportData,
2070
+ port,
2071
+ (filtered) => {
2072
+ isFilteredRun = filtered;
1592
2073
  },
1593
- clearTimeout: () => {
1594
- for (const h of pending.splice(0)) clearTimeout(h);
1595
- }
1596
- };
2074
+ persistence.saveReport,
2075
+ launcher
2076
+ );
2077
+ const getHandlerContext = () => ({
2078
+ reportData,
2079
+ wsClients,
2080
+ currentRunIds,
2081
+ isFilteredRun,
2082
+ saveReport: persistence.saveReport,
2083
+ scheduleReportSave: persistence.scheduleReportSave,
2084
+ approvalRouting: routesContext.approvalRouting,
2085
+ routesContext,
2086
+ runController
2087
+ });
2088
+ return { runController, getHandlerContext };
1597
2089
  }
1598
2090
 
1599
2091
  // src/server/app.ts
@@ -1686,19 +2178,19 @@ function createWebSocketMessageHandler(getHandlerContext) {
1686
2178
  };
1687
2179
  }
1688
2180
  async function resolveStaticDir(staticDir) {
1689
- const currentDir = (0, import_path8.dirname)((0, import_url.fileURLToPath)(import_meta2.url));
2181
+ const currentDir = (0, import_path9.dirname)((0, import_url.fileURLToPath)(import_meta2.url));
1690
2182
  const candidates = staticDir === void 0 ? [
1691
2183
  currentDir,
1692
- (0, import_path8.join)(currentDir, "dist"),
1693
- (0, import_path8.join)(currentDir, "..", "dist"),
1694
- (0, import_path8.join)(currentDir, "..", "..", "dist"),
1695
- (0, import_path8.join)(currentDir, ".."),
1696
- (0, import_path8.join)(currentDir, "..", "..")
1697
- ] : [staticDir, (0, import_path8.join)(staticDir, "dist")];
2184
+ (0, import_path9.join)(currentDir, "dist"),
2185
+ (0, import_path9.join)(currentDir, "..", "dist"),
2186
+ (0, import_path9.join)(currentDir, "..", "..", "dist"),
2187
+ (0, import_path9.join)(currentDir, ".."),
2188
+ (0, import_path9.join)(currentDir, "..", "..")
2189
+ ] : [staticDir, (0, import_path9.join)(staticDir, "dist")];
1698
2190
  const resolvedCandidates = await Promise.all(
1699
2191
  candidates.map(async (candidate) => ({
1700
2192
  candidate,
1701
- exists: await fileExists((0, import_path8.join)(candidate, "index.html"))
2193
+ exists: await fileExists((0, import_path9.join)(candidate, "index.html"))
1702
2194
  }))
1703
2195
  );
1704
2196
  const resolved = resolvedCandidates.find(({ exists }) => exists);
@@ -1709,34 +2201,31 @@ async function resolveStaticDir(staticDir) {
1709
2201
  }
1710
2202
  async function resolveReportPath(reportPath) {
1711
2203
  if (await isDirectory(reportPath)) {
1712
- return { reportFile: (0, import_path8.join)(reportPath, "report.json"), offlineReportDir: reportPath };
2204
+ return { reportFile: (0, import_path9.join)(reportPath, "report.json"), offlineReportDir: reportPath };
1713
2205
  }
1714
- return { reportFile: reportPath, offlineReportDir: (0, import_path8.dirname)(reportPath) };
2206
+ return { reportFile: reportPath, offlineReportDir: (0, import_path9.dirname)(reportPath) };
1715
2207
  }
1716
2208
  async function seedRunContext(routesContext, options) {
1717
2209
  if (routesContext.runContext !== void 0) {
1718
2210
  return;
1719
2211
  }
1720
- const configFile = options.playwrightConfig ?? await resolvePlaywrightConfig(process.cwd());
2212
+ const configFile = await resolveSeedConfigFile(options.playwrightConfig, process.cwd());
1721
2213
  if (configFile !== null) {
1722
2214
  routesContext.runContext = { configFile, cwd: process.cwd() };
1723
2215
  }
1724
2216
  }
1725
- function createServerRunController(routesContext, wsClients, reportData, port, setRunFiltered, saveReport) {
1726
- return new RunController({
1727
- getRunContext: () => routesContext.runContext ?? null,
1728
- port,
1729
- broadcast: (message) => {
1730
- broadcastToBrowsers(wsClients, message);
1731
- },
1732
- setReportRunning: (running) => {
1733
- reportData.isRunning = running;
1734
- },
1735
- setRunFiltered,
1736
- saveReport,
1737
- spawn: createRealSpawn(),
1738
- timers: createRealTimers()
2217
+ async function setupRoutesContext(options, reportData, staticDir, saveReport, port) {
2218
+ const { launcher, routesContextOptions } = await resolveRunBackend({
2219
+ runMode: options.runMode,
2220
+ docker: options.docker,
2221
+ port
2222
+ });
2223
+ const routesContext = createRoutesContext(reportData, staticDir, saveReport, {
2224
+ ...options,
2225
+ ...routesContextOptions
1739
2226
  });
2227
+ await seedRunContext(routesContext, options);
2228
+ return { routesContext, launcher };
1740
2229
  }
1741
2230
  async function createServerApp(options = {}) {
1742
2231
  const port = options.port ?? 3e3;
@@ -1747,30 +2236,22 @@ async function createServerApp(options = {}) {
1747
2236
  const wsClients = /* @__PURE__ */ new Set();
1748
2237
  const currentRunIds = /* @__PURE__ */ new Set();
1749
2238
  const persistence = createReportPersistence(reportFile, reportData);
1750
- const routesContext = createRoutesContext(reportData, staticDir, persistence.saveReport, options);
1751
- await seedRunContext(routesContext, options);
1752
- let isFilteredRun = false;
1753
- const runController = createServerRunController(
2239
+ const { routesContext, launcher } = await setupRoutesContext(
2240
+ options,
2241
+ reportData,
2242
+ staticDir,
2243
+ persistence.saveReport,
2244
+ port
2245
+ );
2246
+ const { runController, getHandlerContext } = createRunControllerAndHandlers(
1754
2247
  routesContext,
1755
2248
  wsClients,
1756
2249
  reportData,
2250
+ currentRunIds,
1757
2251
  port,
1758
- (filtered) => {
1759
- isFilteredRun = filtered;
1760
- },
1761
- persistence.saveReport
2252
+ persistence,
2253
+ launcher
1762
2254
  );
1763
- const getHandlerContext = () => ({
1764
- reportData,
1765
- wsClients,
1766
- currentRunIds,
1767
- isFilteredRun,
1768
- saveReport: persistence.saveReport,
1769
- scheduleReportSave: persistence.scheduleReportSave,
1770
- approvalRouting: routesContext.approvalRouting,
1771
- routesContext,
1772
- runController
1773
- });
1774
2255
  const handleRequest = (req) => handleHttpRequest(routesContext, req, runController);
1775
2256
  const handleWebSocketMessage = createWebSocketMessageHandler(getHandlerContext);
1776
2257
  await loadReport(reportFile, reportData);
@@ -1783,12 +2264,6 @@ async function createServerApp(options = {}) {
1783
2264
  handleWebSocketMessage
1784
2265
  };
1785
2266
  }
1786
- function createCloseHandler(persistence, runController) {
1787
- return async () => {
1788
- await persistence.dispose();
1789
- runController.dispose();
1790
- };
1791
- }
1792
2267
 
1793
2268
  // src/server/bun-adapter.ts
1794
2269
  function logWebSocketError(prefix, error) {
@@ -1965,7 +2440,7 @@ function attachWebSocketServer(server, app) {
1965
2440
  });
1966
2441
  }
1967
2442
  async function listen(server, port) {
1968
- await new Promise((resolve4, reject) => {
2443
+ await new Promise((resolve6, reject) => {
1969
2444
  const onError = (error) => {
1970
2445
  server.off("error", onError);
1971
2446
  reject(error);
@@ -1973,7 +2448,7 @@ async function listen(server, port) {
1973
2448
  server.on("error", onError);
1974
2449
  server.listen(port, () => {
1975
2450
  server.off("error", onError);
1976
- resolve4();
2451
+ resolve6();
1977
2452
  });
1978
2453
  });
1979
2454
  }