@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
@@ -12,13 +12,14 @@ import {
12
12
  applyTestEndEvent,
13
13
  createMutableReportState,
14
14
  finalizeRunEvent,
15
+ isCI,
15
16
  isForeignAbsolutePath,
16
17
  resolveBaselineTargets,
17
18
  safeParse
18
- } from "./chunk-HAFWYUNO.js";
19
+ } from "./chunk-4YJL655E.js";
19
20
 
20
21
  // src/server/app.ts
21
- import { dirname as dirname3, join as join5 } from "path";
22
+ import { dirname as dirname4, join as join6 } from "path";
22
23
  import { fileURLToPath } from "url";
23
24
 
24
25
  // src/offline-reports.ts
@@ -208,8 +209,580 @@ function mergeOfflineReportsIntoTests(existingTests, offlineReports, options = {
208
209
  };
209
210
  }
210
211
 
212
+ // src/server/docker-support.ts
213
+ import { spawn } from "child_process";
214
+ import { readFileSync } from "node:fs";
215
+ import { createRequire } from "node:module";
216
+ import { isAbsolute, join as join2, relative, sep } from "node:path";
217
+ import { detect } from "package-manager-detector/detect";
218
+ function createDockerExec() {
219
+ return (args) => new Promise((resolve5, reject) => {
220
+ const child = spawn("docker", args, { stdio: ["ignore", "pipe", "pipe"] });
221
+ const stdout = [];
222
+ const stderr = [];
223
+ child.stdout.on("data", (chunk) => stdout.push(chunk));
224
+ child.stderr.on("data", (chunk) => stderr.push(chunk));
225
+ child.on("error", reject);
226
+ child.on("close", (code) => {
227
+ resolve5({
228
+ exitCode: code ?? 1,
229
+ stdout: Buffer.concat(stdout).toString(),
230
+ stderr: Buffer.concat(stderr).toString()
231
+ });
232
+ });
233
+ });
234
+ }
235
+ async function probeDockerDaemon(exec) {
236
+ try {
237
+ const result = await exec(["info"]);
238
+ return result.exitCode === 0;
239
+ } catch {
240
+ return false;
241
+ }
242
+ }
243
+ async function isDockerImagePresent(exec, image) {
244
+ try {
245
+ const result = await exec(["image", "inspect", image]);
246
+ return result.exitCode === 0;
247
+ } catch {
248
+ return false;
249
+ }
250
+ }
251
+ async function pullDockerImage(exec, image) {
252
+ try {
253
+ const result = await exec(["pull", image]);
254
+ return result.exitCode === 0;
255
+ } catch {
256
+ return false;
257
+ }
258
+ }
259
+ async function forceRemoveContainer(exec, name) {
260
+ try {
261
+ await exec(["rm", "-f", name]);
262
+ } catch {
263
+ }
264
+ }
265
+ function resolveDockerImage(options) {
266
+ if (options.image !== void 0 && options.image !== "") return options.image;
267
+ if (options.version === null) return null;
268
+ return `mcr.microsoft.com/playwright:v${options.version}-noble`;
269
+ }
270
+ var CONTAINER_INVOKERS = {
271
+ npm: ["npx"],
272
+ pnpm: ["pnpm", "exec"],
273
+ yarn: ["yarn"],
274
+ bun: ["bunx"]
275
+ };
276
+ var DEFAULT_CONTAINER_COMMAND = ["npx"];
277
+ function resolveContainerCommand(input) {
278
+ if (input.command !== void 0 && input.command.length > 0) return input.command;
279
+ if (!input.hasCustomImage) return [...DEFAULT_CONTAINER_COMMAND];
280
+ if (input.detectedAgentName === void 0 || input.detectedAgentName === null) {
281
+ input.warn?.("Could not detect a package manager for the custom docker image; falling back to npx.");
282
+ return [...DEFAULT_CONTAINER_COMMAND];
283
+ }
284
+ const invoker = CONTAINER_INVOKERS[input.detectedAgentName];
285
+ if (invoker === void 0) {
286
+ input.warn?.(`Package manager "${input.detectedAgentName}" is not supported in docker mode; falling back to npx.`);
287
+ return [...DEFAULT_CONTAINER_COMMAND];
288
+ }
289
+ return [...invoker];
290
+ }
291
+ var detectProjectAgent = async (cwd) => {
292
+ try {
293
+ const result = await detect({ cwd, strategies: ["lockfile", "packageManager-field"] });
294
+ return result === null ? null : { name: result.name, agent: result.agent };
295
+ } catch {
296
+ return null;
297
+ }
298
+ };
299
+ function rewriteContainerPath(path, mapping) {
300
+ const normalizedPath = normalizeForMatch(path);
301
+ const normalizedFrom = normalizeForMatch(mapping.from);
302
+ if (normalizedPath === normalizedFrom) return mapping.to;
303
+ if (normalizedPath.startsWith(`${normalizedFrom}/`)) return mapping.to + normalizedPath.slice(normalizedFrom.length);
304
+ return path;
305
+ }
306
+ function normalizeForMatch(p) {
307
+ return p.replace(/\\/g, "/").replace(/^[A-Z](?=:)/, (c) => c.toLowerCase());
308
+ }
309
+ var CONTAINER_TEST_LIST_PATH = "/tmp/crvy-rprtr-test-list.txt";
310
+ var REPORTER_BARE_SPECIFIER = "@crvy/rprtr";
311
+ var PATH_FLAGS = /* @__PURE__ */ new Set(["--config", "--reporter", "--test-list"]);
312
+ function rewritePlaywrightArgs(playwrightArgs, ctx, workDir, warn) {
313
+ const args = [];
314
+ const bindMounts = [];
315
+ for (let i = 0; i < playwrightArgs.length; i++) {
316
+ const flag = playwrightArgs[i];
317
+ if (!PATH_FLAGS.has(flag)) {
318
+ args.push(flag);
319
+ continue;
320
+ }
321
+ const value = playwrightArgs[i + 1];
322
+ if (value === void 0) break;
323
+ i += 1;
324
+ args.push(flag);
325
+ const rewritten = rewriteContainerPath(value, { from: ctx.cwd, to: workDir });
326
+ if (flag === "--reporter" && rewritten === value) {
327
+ args.push(REPORTER_BARE_SPECIFIER);
328
+ } else if (flag === "--test-list" && rewritten === value) {
329
+ args.push(CONTAINER_TEST_LIST_PATH);
330
+ bindMounts.push(`${value}:${CONTAINER_TEST_LIST_PATH}:ro`);
331
+ } else {
332
+ if (flag === "--config" && rewritten === value) {
333
+ warn(`--config "${value}" is outside the project directory and will not resolve inside the container.`);
334
+ }
335
+ args.push(rewritten);
336
+ }
337
+ }
338
+ return { args, bindMounts };
339
+ }
340
+ function resolvePlaywrightVersion(cwd) {
341
+ try {
342
+ const req = createRequire(join2(cwd, "package.json"));
343
+ const pkgPath = req.resolve("@playwright/test/package.json");
344
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
345
+ return typeof pkg === "object" && pkg !== null && "version" in pkg && typeof pkg.version === "string" ? pkg.version : null;
346
+ } catch {
347
+ return null;
348
+ }
349
+ }
350
+ function rewriteContainerTestDescriptors(tests, mapping) {
351
+ if (tests === void 0 || mapping === void 0) return tests;
352
+ return tests.map((d) => ({ ...d, file: rewriteContainerPath(d.file, mapping) }));
353
+ }
354
+ function testListEntry(d, file) {
355
+ const loc = d.column === void 0 ? `${file}:${d.line}` : `${file}:${d.line}:${d.column}`;
356
+ const title = d.titlePath.join(" \u203A ");
357
+ const prefix = d.projectName !== void 0 && d.projectName !== "" ? `[${d.projectName}] \u203A ` : "";
358
+ return `${prefix}${loc} \u203A ${title}`;
359
+ }
360
+ function buildTestListEntries(tests, rootDir, cwd, pathStyle = "host") {
361
+ const convert = (p) => pathStyle === "posix" ? p.replace(/\\/g, "/") : p;
362
+ if (rootDir !== void 0) {
363
+ return tests.map(
364
+ (d) => testListEntry(d, convert(isAbsolute(d.file) ? relative(rootDir, d.file) || d.file : d.file))
365
+ );
366
+ }
367
+ return tests.flatMap((d) => {
368
+ if (!isAbsolute(d.file)) return [testListEntry(d, convert(d.file))];
369
+ const rel = relative(cwd ?? process.cwd(), d.file);
370
+ const segments = pathStyle === "posix" ? rel.split(/[\\/]/) : rel.split(sep);
371
+ return segments.map((_, i) => testListEntry(d, convert(segments.slice(i).join(sep))));
372
+ });
373
+ }
374
+
375
+ // src/server/run-controller.ts
376
+ import { spawn as spawn2 } from "child_process";
377
+ import { unlinkSync, writeFileSync } from "node:fs";
378
+ import { createRequire as createRequire2 } from "node:module";
379
+ import { tmpdir } from "node:os";
380
+ import { join as join3 } from "node:path";
381
+
382
+ // src/server/run-launcher.ts
383
+ import { resolveCommand } from "package-manager-detector/commands";
384
+ import { getUserAgent } from "package-manager-detector/detect";
385
+ function resolvePlaywrightLaunch(cwd, playwrightArgs) {
386
+ const agent = getUserAgent();
387
+ const resolved = agent === null ? null : resolveCommand(agent, "execute-local", ["playwright", ...playwrightArgs]);
388
+ if (resolved !== null) return { cmd: resolved.command, args: resolved.args };
389
+ return { cmd: "npx", args: ["playwright", ...playwrightArgs] };
390
+ }
391
+ function buildSpawnEnv(port, baseEnv = process.env) {
392
+ const env = {};
393
+ for (const [key, value] of Object.entries(baseEnv)) {
394
+ if (key === "CI") continue;
395
+ env[key] = value;
396
+ }
397
+ env.CRVY_RPRTR_SERVER_URL = `ws://localhost:${port}`;
398
+ env.PLAYWRIGHT_HTML_OPEN = "never";
399
+ return env;
400
+ }
401
+ function createLocalLauncher(options) {
402
+ return {
403
+ mode: "local",
404
+ launch({ ctx, playwrightArgs }) {
405
+ const resolve5 = options.resolveLaunch ?? resolvePlaywrightLaunch;
406
+ const { cmd, args } = resolve5(ctx.cwd, playwrightArgs);
407
+ return { cmd, args, env: buildSpawnEnv(options.port, options.env) };
408
+ }
409
+ };
410
+ }
411
+
412
+ // src/server/run-controller.ts
413
+ var STOP_GRACE_MS = 5e3;
414
+ var KNOWN_SIGNALS = {
415
+ SIGTERM: "SIGTERM",
416
+ SIGKILL: "SIGKILL"
417
+ };
418
+ function sharedProject(tests) {
419
+ const names = new Set(tests.map((t) => t.projectName ?? ""));
420
+ if (names.size === 1) {
421
+ const name = [...names][0];
422
+ return name === "" ? void 0 : name;
423
+ }
424
+ return void 0;
425
+ }
426
+ function gteMinor(version, major, minor) {
427
+ const match = /^(\d+)\.(\d+)/.exec(version.trim());
428
+ if (match === null) return false;
429
+ const maj = parseInt(match[1], 10);
430
+ const min = parseInt(match[2], 10);
431
+ if (maj !== major) return maj > major;
432
+ return min >= minor;
433
+ }
434
+ var defaultWriteTempFile = (content) => {
435
+ const path = join3(tmpdir(), `crvy-rprtr-test-list-${process.pid}-${Date.now()}.txt`);
436
+ writeFileSync(path, content, "utf8");
437
+ return path;
438
+ };
439
+ function defaultDeleteTempFile(path) {
440
+ try {
441
+ unlinkSync(path);
442
+ } catch {
443
+ }
444
+ }
445
+ function resolveReporterDefault(cwd) {
446
+ try {
447
+ return createRequire2(join3(cwd, "package.json")).resolve("@crvy/rprtr");
448
+ } catch {
449
+ }
450
+ try {
451
+ return createRequire2(import.meta.url).resolve("@crvy/rprtr");
452
+ } catch {
453
+ return null;
454
+ }
455
+ }
456
+ var RunController = class {
457
+ constructor(deps) {
458
+ this.deps = deps;
459
+ }
460
+ child = null;
461
+ sigkillTimer = null;
462
+ testListPath = null;
463
+ get isRunning() {
464
+ return this.child !== null;
465
+ }
466
+ supportsTestList(cwd) {
467
+ const getVersion = this.deps.getPlaywrightVersion ?? resolvePlaywrightVersion;
468
+ const version = getVersion(cwd);
469
+ return version !== null && gteMinor(version, 1, 56);
470
+ }
471
+ cleanupTempFile() {
472
+ if (this.testListPath !== null) {
473
+ const del = this.deps.deleteTempFile ?? defaultDeleteTempFile;
474
+ del(this.testListPath);
475
+ this.testListPath = null;
476
+ }
477
+ }
478
+ buildPlaywrightArgs(ctx, filters, tests) {
479
+ const resolveReporter = this.deps.resolveReporter ?? resolveReporterDefault;
480
+ const reporterModule = resolveReporter(ctx.cwd);
481
+ const useTestList = tests !== void 0 && (tests.length > 1 || this.deps.containerPathMapping !== void 0) && this.supportsTestList(ctx.cwd);
482
+ const args = ["test", "--config", ctx.configFile];
483
+ if (reporterModule !== null) args.push("--reporter", reporterModule);
484
+ if (filters.update === true) args.push("--update-snapshots");
485
+ if (useTestList && tests !== void 0) {
486
+ const content = buildTestListEntries(
487
+ tests,
488
+ ctx.rootDir,
489
+ ctx.cwd,
490
+ this.deps.containerPathMapping === void 0 ? "host" : "posix"
491
+ ).join("\n");
492
+ const writeTemp = this.deps.writeTempFile ?? defaultWriteTempFile;
493
+ this.testListPath = writeTemp(content);
494
+ args.push("--test-list", this.testListPath);
495
+ } else if (tests !== void 0 && tests.length > 0) {
496
+ const project = sharedProject(tests);
497
+ if (project !== void 0) args.push(`--project=${project}`);
498
+ for (const d of tests) {
499
+ args.push(d.column === void 0 ? `${d.file}:${d.line}` : `${d.file}:${d.line}:${d.column}`);
500
+ }
501
+ }
502
+ return args;
503
+ }
504
+ start(filters) {
505
+ const ctx = this.deps.getRunContext();
506
+ if (ctx === null) return { ok: false, reason: "no-config" };
507
+ if (this.child !== null) return { ok: false, reason: "already-running" };
508
+ if (filters.tests !== void 0 && filters.tests.length === 0) return { ok: false, reason: "no-tests" };
509
+ if (this.deps.launcher.available === false) return { ok: false, reason: "docker-unavailable" };
510
+ const tests = rewriteContainerTestDescriptors(filters.tests, this.deps.containerPathMapping);
511
+ const args = this.buildPlaywrightArgs(ctx, filters, tests);
512
+ const spec = this.deps.launcher.launch({ ctx, playwrightArgs: args });
513
+ let child;
514
+ try {
515
+ child = this.deps.spawn(spec.cmd, spec.args, { cwd: ctx.cwd, env: spec.env, stdio: "inherit" });
516
+ } catch (err) {
517
+ this.cleanupTempFile();
518
+ throw err;
519
+ }
520
+ this.child = child;
521
+ child.on("exit", (code) => {
522
+ this.handleChildExit(code);
523
+ });
524
+ child.on("error", () => {
525
+ this.handleChildExit(null);
526
+ });
527
+ this.deps.setReportRunning(true);
528
+ this.deps.setRunFiltered?.(filters.tests !== void 0);
529
+ this.deps.broadcast({ type: "run-status", data: { running: true, mode: this.deps.launcher.mode } });
530
+ return { ok: true };
531
+ }
532
+ stop() {
533
+ if (this.child === null) return { ok: false, reason: "not-running" };
534
+ if (this.sigkillTimer !== null) this.deps.timers.clearTimeout(this.sigkillTimer);
535
+ this.child.kill("SIGTERM");
536
+ this.sigkillTimer = this.deps.timers.setTimeout(() => {
537
+ if (this.child !== null) {
538
+ this.child.kill("SIGKILL");
539
+ this.deps.launcher.onForceKill?.();
540
+ }
541
+ }, STOP_GRACE_MS);
542
+ return { ok: true };
543
+ }
544
+ async prepareRun() {
545
+ const launcher = this.deps.launcher;
546
+ if (launcher.prepare === void 0) return { ok: true };
547
+ const ctx = this.deps.getRunContext();
548
+ if (ctx === null) return { ok: true };
549
+ try {
550
+ await launcher.prepare({
551
+ ctx,
552
+ onProgress: (phase) => {
553
+ this.deps.broadcast({ type: "run-status", data: { running: true, mode: launcher.mode, phase } });
554
+ }
555
+ });
556
+ return { ok: true };
557
+ } catch (error) {
558
+ this.deps.broadcast({ type: "run-status", data: { running: false, mode: launcher.mode } });
559
+ const message = error instanceof Error ? error.message : String(error);
560
+ console.warn(`[RunController] run preparation failed: ${message}`);
561
+ return { ok: false, reason: "docker-unavailable" };
562
+ }
563
+ }
564
+ dispose() {
565
+ if (this.child === null) return;
566
+ if (this.sigkillTimer !== null) this.deps.timers.clearTimeout(this.sigkillTimer);
567
+ this.sigkillTimer = null;
568
+ this.child.kill("SIGKILL");
569
+ this.deps.launcher.onForceKill?.();
570
+ this.cleanupTempFile();
571
+ }
572
+ handleChildExit(code) {
573
+ if (this.child === null) return;
574
+ if (this.sigkillTimer !== null) this.deps.timers.clearTimeout(this.sigkillTimer);
575
+ this.sigkillTimer = null;
576
+ this.child = null;
577
+ this.cleanupTempFile();
578
+ if (code !== null && code !== 0) console.warn(`[RunController] playwright test exited with code ${code}`);
579
+ this.deps.setReportRunning(false);
580
+ this.deps.broadcast({ type: "run-status", data: { running: false, mode: this.deps.launcher.mode } });
581
+ void this.deps.saveReport?.();
582
+ }
583
+ };
584
+ function createRealSpawn() {
585
+ return (cmd, args, opts) => {
586
+ const cp = spawn2(cmd, args, opts);
587
+ return {
588
+ on: (event, cb) => cp.on(event, cb),
589
+ kill: (signal) => {
590
+ const sig = KNOWN_SIGNALS[signal];
591
+ if (sig !== void 0) cp.kill(sig);
592
+ }
593
+ };
594
+ };
595
+ }
596
+ function createRealTimers() {
597
+ const pending = [];
598
+ return {
599
+ setTimeout: (fn, ms) => {
600
+ const id = setTimeout(fn, ms);
601
+ pending.push(id);
602
+ return id;
603
+ },
604
+ clearTimeout: () => {
605
+ for (const h of pending.splice(0)) clearTimeout(h);
606
+ }
607
+ };
608
+ }
609
+
610
+ // src/server/docker-launcher.ts
611
+ var DOCKER_WORK_DIR = "/work";
612
+ var DOCKER_HOST_GATEWAY = "host.docker.internal";
613
+ var ENV_DENYLIST = /* @__PURE__ */ new Set([
614
+ "CI",
615
+ "PLAYWRIGHT_BROWSERS_PATH",
616
+ "CRVY_RPRTR_SERVER_URL",
617
+ "CRVY_RPRTR_PORTABLE_ARTIFACTS",
618
+ "TZ",
619
+ "LANG",
620
+ "LC_ALL",
621
+ "PLAYWRIGHT_HTML_OPEN",
622
+ "PATH"
623
+ ]);
624
+ var WINDOWS_ENV_NOISE = new Set(
625
+ "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(
626
+ " "
627
+ )
628
+ );
629
+ var DockerUnavailableError = class extends Error {
630
+ constructor() {
631
+ super("Docker daemon is not available");
632
+ this.name = "DockerUnavailableError";
633
+ }
634
+ };
635
+ function defaultWarn(message) {
636
+ console.warn(`[crvy-rprtr] ${message}`);
637
+ }
638
+ async function detectAgentName(detect2, cwd) {
639
+ const detected = await (detect2 ?? detectProjectAgent)(cwd);
640
+ return detected?.name ?? null;
641
+ }
642
+ async function prepareDocker(state, exec, ctx, deps, onProgress) {
643
+ if (deps.platform === "win32" && !state.warnedWin32) {
644
+ state.warnedWin32 = true;
645
+ deps.warn(
646
+ "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."
647
+ );
648
+ }
649
+ if (!await probeDockerDaemon(exec)) {
650
+ state.available = false;
651
+ throw new DockerUnavailableError();
652
+ }
653
+ state.available = true;
654
+ const image = resolveDockerImage({ image: deps.docker?.image, version: deps.getPlaywrightVersion(ctx.cwd) });
655
+ if (image === null) {
656
+ throw new Error("Could not resolve the installed @playwright/test version; set docker.image explicitly.");
657
+ }
658
+ state.image = image;
659
+ state.command = resolveContainerCommand({
660
+ command: deps.docker?.command,
661
+ hasCustomImage: deps.docker?.image !== void 0,
662
+ detectedAgentName: deps.docker?.image === void 0 ? "npm" : await detectAgentName(deps.detectAgent, ctx.cwd),
663
+ warn: deps.warn
664
+ });
665
+ if (!await isDockerImagePresent(exec, image)) {
666
+ onProgress("pulling");
667
+ if (!await pullDockerImage(exec, image)) {
668
+ throw new Error(`Failed to pull docker image: ${image}`);
669
+ }
670
+ }
671
+ }
672
+ function buildDockerRunArgs(ctx, playwrightArgs, deps) {
673
+ const { args: rewrittenArgs, bindMounts } = rewritePlaywrightArgs(playwrightArgs, ctx, deps.workDir, deps.warn);
674
+ const args = [
675
+ "run",
676
+ "--rm",
677
+ "--init",
678
+ "--name",
679
+ deps.containerName,
680
+ "--add-host",
681
+ `${DOCKER_HOST_GATEWAY}:host-gateway`,
682
+ "--ipc=host"
683
+ ];
684
+ if (deps.docker?.platform !== void 0) {
685
+ args.push("--platform", deps.docker.platform);
686
+ }
687
+ args.push("-v", `${ctx.cwd}:${deps.workDir}:rw`, "-w", deps.workDir);
688
+ for (const mount of bindMounts) {
689
+ args.push("-v", mount);
690
+ }
691
+ args.push("-e", `CRVY_RPRTR_SERVER_URL=ws://${DOCKER_HOST_GATEWAY}:${deps.port}`);
692
+ args.push("-e", "CRVY_RPRTR_PORTABLE_ARTIFACTS=1", "-e", "TZ=UTC", "-e", "LANG=C.UTF-8", "-e", "LC_ALL=C.UTF-8");
693
+ args.push("-e", "PLAYWRIGHT_HTML_OPEN=never");
694
+ for (const [key, value] of Object.entries(deps.env)) {
695
+ const upper = key.toUpperCase();
696
+ if (ENV_DENYLIST.has(upper) || WINDOWS_ENV_NOISE.has(upper) || value === void 0) continue;
697
+ args.push("-e", key);
698
+ }
699
+ if (deps.docker?.extraArgs !== void 0) args.push(...deps.docker.extraArgs);
700
+ args.push(deps.image, ...deps.command, "playwright", ...rewrittenArgs);
701
+ return args;
702
+ }
703
+ function stripCi(env) {
704
+ const out = {};
705
+ for (const [key, value] of Object.entries(env)) {
706
+ if (key === "CI") continue;
707
+ out[key] = value;
708
+ }
709
+ return out;
710
+ }
711
+ function createState(docker) {
712
+ return {
713
+ available: void 0,
714
+ prepared: null,
715
+ image: null,
716
+ command: docker?.command ?? DEFAULT_CONTAINER_COMMAND,
717
+ warnedWin32: false
718
+ };
719
+ }
720
+ function buildLauncher(state, deps) {
721
+ return {
722
+ mode: "docker",
723
+ get available() {
724
+ return state.available;
725
+ },
726
+ prepare({ ctx, onProgress }) {
727
+ state.prepared ??= prepareDocker(
728
+ state,
729
+ deps.exec,
730
+ ctx,
731
+ {
732
+ docker: deps.docker,
733
+ getPlaywrightVersion: deps.getVersion,
734
+ detectAgent: deps.detectAgent,
735
+ warn: deps.warn,
736
+ platform: deps.platform
737
+ },
738
+ onProgress
739
+ ).catch((error) => {
740
+ state.prepared = null;
741
+ state.warnedWin32 = false;
742
+ throw error;
743
+ });
744
+ return state.prepared;
745
+ },
746
+ launch({ ctx, playwrightArgs }) {
747
+ const image = state.image ?? resolveDockerImage({ image: deps.docker?.image, version: deps.getVersion(ctx.cwd) });
748
+ if (image === null) {
749
+ throw new Error("Could not resolve the docker image; run prepare() first or set docker.image.");
750
+ }
751
+ const args = buildDockerRunArgs(ctx, playwrightArgs, {
752
+ docker: deps.docker,
753
+ workDir: deps.workDir,
754
+ containerName: deps.containerName,
755
+ port: deps.port,
756
+ env: deps.baseEnv,
757
+ image,
758
+ command: state.command,
759
+ warn: deps.warn
760
+ });
761
+ return { cmd: "docker", args, env: stripCi(deps.baseEnv) };
762
+ },
763
+ onForceKill() {
764
+ void forceRemoveContainer(deps.exec, deps.containerName);
765
+ }
766
+ };
767
+ }
768
+ function createDockerLauncher(options) {
769
+ return buildLauncher(createState(options.docker), {
770
+ exec: options.exec ?? createDockerExec(),
771
+ workDir: options.workDir ?? DOCKER_WORK_DIR,
772
+ containerName: options.containerName ?? `crvy-rprtr-run-${process.pid}`,
773
+ baseEnv: options.env ?? process.env,
774
+ getVersion: options.getPlaywrightVersion ?? resolvePlaywrightVersion,
775
+ detectAgent: options.detectAgent,
776
+ warn: options.warn ?? defaultWarn,
777
+ platform: options.platform ?? process.platform,
778
+ docker: options.docker,
779
+ port: options.port
780
+ });
781
+ }
782
+
211
783
  // src/server/handlers.ts
212
784
  import { existsSync as existsSync2 } from "fs";
785
+ import { dirname as dirname3, resolve as resolve3 } from "path";
213
786
 
214
787
  // src/server/artifact-routes.ts
215
788
  import { existsSync } from "fs";
@@ -217,7 +790,7 @@ import { realpath } from "fs/promises";
217
790
  import { dirname as dirname2, resolve as resolve2 } from "path";
218
791
 
219
792
  // src/server/utils.ts
220
- import { isAbsolute, relative, resolve, sep } from "path";
793
+ import { isAbsolute as isAbsolute2, relative as relative2, resolve, sep as sep2 } from "path";
221
794
  var LIVE_UPDATES_WEBSOCKET_PATH = "/";
222
795
  function broadcastToBrowsers(wsClients, msg) {
223
796
  const payload = JSON.stringify(msg);
@@ -231,8 +804,8 @@ function isWebSocketUpgradeRequest(req) {
231
804
  function isPathWithinRoots(target, roots) {
232
805
  const resolvedTarget = resolve(target);
233
806
  return roots.some((root) => {
234
- const rel = relative(resolve(root), resolvedTarget);
235
- return rel === "" || !rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel);
807
+ const rel = relative2(resolve(root), resolvedTarget);
808
+ return rel === "" || !rel.startsWith(`..${sep2}`) && rel !== ".." && !isAbsolute2(rel);
236
809
  });
237
810
  }
238
811
 
@@ -282,11 +855,14 @@ function reporterTitlePath(test) {
282
855
  return ["", projectName, testFile ?? "", ...test.titlePath, test.title];
283
856
  }
284
857
  function resolveBaselineSnapshotPath(routing, test, retry, imageName) {
285
- const testFile = test.location?.file;
858
+ const mapping = routing?.containerPathMapping;
859
+ const rawTestFile = test.location?.file;
860
+ const testFile = rawTestFile === void 0 || mapping === void 0 ? rawTestFile : rewriteContainerPath(rawTestFile, mapping);
286
861
  const declaration = test.results?.[retry]?.visualDeclarations?.find((candidate) => candidate.visualName === imageName);
287
862
  if (routing === void 0 || testFile === void 0 || declaration === void 0) {
288
863
  return null;
289
864
  }
865
+ const isContainerPath = mapping !== void 0 && testFile !== rawTestFile;
290
866
  const targets = resolveBaselineTargets({
291
867
  testFile,
292
868
  reporterTitlePath: reporterTitlePath(test),
@@ -296,7 +872,7 @@ function resolveBaselineSnapshotPath(routing, test, retry, imageName) {
296
872
  testDir: routing.playwrightTestDir ?? dirname2(testFile),
297
873
  snapshotDir: routing.playwrightSnapshotDir ?? dirname2(testFile),
298
874
  projectName: test.projectName ?? test.browser,
299
- snapshotSuffix: process.platform,
875
+ snapshotSuffix: isContainerPath ? "linux" : process.platform,
300
876
  snapshotPathTemplate: routing.playwrightSnapshotPathTemplate,
301
877
  toHaveScreenshotPathTemplate: routing.playwrightToHaveScreenshotPathTemplate
302
878
  },
@@ -419,7 +995,19 @@ function handleSync(ctx) {
419
995
  };
420
996
  broadcastToBrowsers(ctx.wsClients, message);
421
997
  }
422
- function handleRegister(ctx, data) {
998
+ function applyContainerPathMapping(rawData, mapping) {
999
+ return {
1000
+ ...rawData,
1001
+ playwrightSnapshotDir: rawData.playwrightSnapshotDir === void 0 ? void 0 : rewriteContainerPath(rawData.playwrightSnapshotDir, mapping),
1002
+ playwrightTestDir: rawData.playwrightTestDir === void 0 ? void 0 : rewriteContainerPath(rawData.playwrightTestDir, mapping),
1003
+ playwrightRootDir: rawData.playwrightRootDir === void 0 ? void 0 : rewriteContainerPath(rawData.playwrightRootDir, mapping),
1004
+ configFile: rawData.configFile === void 0 ? void 0 : rewriteContainerPath(rawData.configFile, mapping),
1005
+ cwd: rawData.cwd === void 0 ? void 0 : rewriteContainerPath(rawData.cwd, mapping)
1006
+ };
1007
+ }
1008
+ function handleRegister(ctx, rawData) {
1009
+ const mapping = ctx.routesContext.containerPathMapping;
1010
+ const data = mapping === void 0 ? rawData : applyContainerPathMapping(rawData, mapping);
423
1011
  const roots = [];
424
1012
  if (data.playwrightSnapshotDir !== void 0 && data.playwrightSnapshotDir !== "") {
425
1013
  roots.push(data.playwrightSnapshotDir);
@@ -447,18 +1035,61 @@ function handleRegister(ctx, data) {
447
1035
  if (data.playwrightToHaveScreenshotPathTemplate !== void 0) {
448
1036
  ctx.routesContext.approvalRouting.playwrightToHaveScreenshotPathTemplate = data.playwrightToHaveScreenshotPathTemplate;
449
1037
  }
450
- }
451
- if (data.configFile !== void 0 && data.cwd !== void 0) {
452
- ctx.routesContext.runContext = { configFile: data.configFile, cwd: data.cwd };
453
- }
454
- console.log("[Server] Reporter registered with config:", {
455
- playwrightSnapshotDir: data.playwrightSnapshotDir,
456
- playwrightTestDir: data.playwrightTestDir
1038
+ }
1039
+ if (data.configFile !== void 0 && data.cwd !== void 0) {
1040
+ ctx.routesContext.runContext = buildRunContext(data.configFile, data);
1041
+ }
1042
+ console.log("[Server] Reporter registered with config:", {
1043
+ playwrightSnapshotDir: data.playwrightSnapshotDir,
1044
+ playwrightTestDir: data.playwrightTestDir,
1045
+ configFile: data.configFile,
1046
+ cwd: data.cwd
1047
+ });
1048
+ }
1049
+ function buildRunContext(configFile, data) {
1050
+ const configDir = dirname3(configFile);
1051
+ return {
1052
+ configFile,
1053
+ cwd: configDir,
1054
+ rootDir: data.playwrightRootDir ?? (data.playwrightTestDir === void 0 ? configDir : resolve3(configDir, data.playwrightTestDir))
1055
+ };
1056
+ }
1057
+
1058
+ // src/server/run-mode.ts
1059
+ async function resolveRunMode(options) {
1060
+ if (options.runMode === "local") return "local";
1061
+ if (options.runMode === "docker") return "docker";
1062
+ if (options.isCI) return "local";
1063
+ if (await options.probeDocker()) return "docker";
1064
+ options.warn?.(
1065
+ "Docker daemon unavailable \u2014 running tests locally; screenshots may differ from CI. Use --run-mode local to silence this warning."
1066
+ );
1067
+ return "local";
1068
+ }
1069
+
1070
+ // src/server/launcher-resolver.ts
1071
+ async function resolveRunBackend(options) {
1072
+ const dockerExec = createDockerExec();
1073
+ const resolvedRunMode = await resolveRunMode({
1074
+ runMode: options.runMode ?? "auto",
1075
+ isCI: isCI(),
1076
+ probeDocker: () => probeDockerDaemon(dockerExec),
1077
+ warn: (message) => {
1078
+ console.warn(`[crvy-rprtr] ${message}`);
1079
+ }
457
1080
  });
1081
+ const launcher = resolvedRunMode === "docker" ? createDockerLauncher({ port: options.port, docker: options.docker, exec: dockerExec }) : createLocalLauncher({ port: options.port });
1082
+ return {
1083
+ launcher,
1084
+ routesContextOptions: {
1085
+ runInfo: { mode: resolvedRunMode },
1086
+ containerPathMapping: resolvedRunMode === "docker" ? { from: DOCKER_WORK_DIR, to: process.cwd() } : void 0
1087
+ }
1088
+ };
458
1089
  }
459
1090
 
460
1091
  // src/server/playwright-config.ts
461
- import { join as join2 } from "path";
1092
+ import { join as join4, resolve as resolve4 } from "path";
462
1093
  var CONFIG_FILES = [
463
1094
  "playwright.config.ts",
464
1095
  "playwright.config.mts",
@@ -470,12 +1101,15 @@ var CONFIG_FILES = [
470
1101
  async function resolvePlaywrightConfig(cwd) {
471
1102
  const matches = await Promise.all(
472
1103
  CONFIG_FILES.map(async (file) => {
473
- const candidate = join2(cwd, file);
1104
+ const candidate = join4(cwd, file);
474
1105
  return await fileExists(candidate) ? candidate : null;
475
1106
  })
476
1107
  );
477
1108
  return matches.find((path) => path !== null) ?? null;
478
1109
  }
1110
+ function resolveSeedConfigFile(option, cwd) {
1111
+ return option === void 0 ? resolvePlaywrightConfig(cwd) : Promise.resolve(resolve4(cwd, option));
1112
+ }
479
1113
 
480
1114
  // src/server/report-persistence.ts
481
1115
  function createDebouncedSaver(save, delayMs, setTimeoutFn = (fn, ms) => setTimeout(fn, ms), clearTimeoutFn = (handle) => {
@@ -547,14 +1181,17 @@ function createRoutesContext(reportData, staticDir, saveReport, options) {
547
1181
  playwrightTestDir: options.playwrightTestDir,
548
1182
  playwrightSnapshotDir: options.playwrightSnapshotDir,
549
1183
  playwrightSnapshotPathTemplate: options.playwrightSnapshotPathTemplate,
550
- playwrightToHaveScreenshotPathTemplate: options.playwrightToHaveScreenshotPathTemplate
1184
+ playwrightToHaveScreenshotPathTemplate: options.playwrightToHaveScreenshotPathTemplate,
1185
+ containerPathMapping: options.containerPathMapping
551
1186
  },
552
- runContext: void 0
1187
+ runContext: void 0,
1188
+ runInfo: options.runInfo,
1189
+ containerPathMapping: options.containerPathMapping
553
1190
  };
554
1191
  }
555
1192
 
556
1193
  // src/server/routes.ts
557
- import { join as join3 } from "path";
1194
+ import { join as join5 } from "path";
558
1195
 
559
1196
  // src/server/run-routes.ts
560
1197
  function handleRunRoutes(pathname, method, runController, req) {
@@ -576,6 +1213,10 @@ async function handleApiRun(runController, req) {
576
1213
  if (parsed === null) {
577
1214
  return Response.json({ ok: false, error: "Invalid request body" }, { status: 400 });
578
1215
  }
1216
+ const preparation = await runController.prepareRun();
1217
+ if (!preparation.ok) {
1218
+ return Response.json({ ok: false, reason: preparation.reason }, { status: 409 });
1219
+ }
579
1220
  const result = runController.start(parsed);
580
1221
  if (result.ok) return Response.json(result);
581
1222
  const status = result.reason === "no-tests" ? 400 : 409;
@@ -589,7 +1230,7 @@ function handleApiStop(runController) {
589
1230
 
590
1231
  // src/server/routes.ts
591
1232
  async function handleRoot(ctx) {
592
- const html = await respondWithFile(join3(ctx.staticDir, "index.html"), "text/html");
1233
+ const html = await respondWithFile(join5(ctx.staticDir, "index.html"), "text/html");
593
1234
  return html ?? new Response("Not Found", { status: 404 });
594
1235
  }
595
1236
  async function handleAppCss() {
@@ -606,13 +1247,14 @@ async function handleSrcFiles(req) {
606
1247
  function handleApiReport(ctx) {
607
1248
  return Response.json({
608
1249
  ...ctx.reportData,
609
- runEnabled: ctx.runContext !== void 0
1250
+ runEnabled: ctx.runContext !== void 0,
1251
+ runMode: ctx.runInfo?.mode
610
1252
  });
611
1253
  }
612
1254
  var APPROVAL_TARGET_ERROR = "Could not resolve approval target";
613
1255
  function actualPathFromUrl(ctx, actualUrl) {
614
1256
  if (actualUrl.startsWith("/screenshots/")) {
615
- return join3(ctx.reportData.screenshotDir, actualUrl.slice("/screenshots/".length));
1257
+ return join5(ctx.reportData.screenshotDir, actualUrl.slice("/screenshots/".length));
616
1258
  }
617
1259
  if (actualUrl.startsWith("/file/")) {
618
1260
  return decodeURIComponent(actualUrl.slice("/file/".length));
@@ -734,7 +1376,7 @@ async function handleScreenshots(ctx, req) {
734
1376
  }
735
1377
  async function handleDist(ctx, req) {
736
1378
  const path = new URL(req.url).pathname.slice("/dist/".length);
737
- const filePath = join3(ctx.staticDir, path);
1379
+ const filePath = join5(ctx.staticDir, path);
738
1380
  const contentType = filePath.endsWith(".css") ? "text/css" : filePath.endsWith(".js") ? "application/javascript" : filePath.endsWith(".svelte") ? "text/plain" : "application/octet-stream";
739
1381
  const file = await respondWithFile(filePath, contentType);
740
1382
  return file ?? new Response("Not Found", { status: 404 });
@@ -779,216 +1421,56 @@ function handleHttpRequest(ctx, req, runController) {
779
1421
  return Promise.resolve(new Response("Not Found", { status: 404 }));
780
1422
  }
781
1423
 
782
- // src/server/run-controller.ts
783
- import { spawn } from "child_process";
784
- import { readFileSync, unlinkSync, writeFileSync } from "node:fs";
785
- import { createRequire } from "node:module";
786
- import { tmpdir } from "node:os";
787
- import { join as join4 } from "node:path";
788
- import { resolveCommand } from "package-manager-detector/commands";
789
- import { getUserAgent } from "package-manager-detector/detect";
790
- var STOP_GRACE_MS = 5e3;
791
- var KNOWN_SIGNALS = {
792
- SIGTERM: "SIGTERM",
793
- SIGKILL: "SIGKILL"
794
- };
795
- function resolvePlaywrightLaunch(cwd, playwrightArgs) {
796
- const agent = getUserAgent();
797
- const resolved = agent === null ? null : resolveCommand(agent, "execute-local", ["playwright", ...playwrightArgs]);
798
- if (resolved !== null) return { cmd: resolved.command, args: resolved.args };
799
- return { cmd: "npx", args: ["playwright", ...playwrightArgs] };
800
- }
801
- function descriptorLocation(d) {
802
- return d.column === void 0 ? `${d.file}:${d.line}` : `${d.file}:${d.line}:${d.column}`;
803
- }
804
- function sharedProject(tests) {
805
- const names = new Set(tests.map((t) => t.projectName ?? ""));
806
- if (names.size === 1) {
807
- const name = [...names][0];
808
- return name === "" ? void 0 : name;
809
- }
810
- return void 0;
811
- }
812
- function gteMinor(version, major, minor) {
813
- const match = /^(\d+)\.(\d+)/.exec(version.trim());
814
- if (match === null) return false;
815
- const maj = parseInt(match[1], 10);
816
- const min = parseInt(match[2], 10);
817
- if (maj !== major) return maj > major;
818
- return min >= minor;
819
- }
820
- function resolvePlaywrightVersion(cwd) {
821
- try {
822
- const req = createRequire(join4(cwd, "package.json"));
823
- const pkgPath = req.resolve("@playwright/test/package.json");
824
- const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
825
- return typeof pkg === "object" && pkg !== null && "version" in pkg && typeof pkg.version === "string" ? pkg.version : null;
826
- } catch {
827
- return null;
828
- }
829
- }
830
- function buildTestListEntries(tests) {
831
- return tests.map((d) => {
832
- const loc = descriptorLocation(d);
833
- const title = d.titlePath.join(" \u203A ");
834
- const prefix = d.projectName !== void 0 && d.projectName !== "" ? `[${d.projectName}] \u203A ` : "";
835
- return `${prefix}${loc} \u203A ${title}`;
1424
+ // src/server/server-factories.ts
1425
+ function createServerRunController(routesContext, wsClients, reportData, port, setRunFiltered, saveReport, launcher) {
1426
+ return new RunController({
1427
+ getRunContext: () => routesContext.runContext ?? null,
1428
+ port,
1429
+ broadcast: (message) => {
1430
+ broadcastToBrowsers(wsClients, message);
1431
+ },
1432
+ setReportRunning: (running) => {
1433
+ reportData.isRunning = running;
1434
+ },
1435
+ setRunFiltered,
1436
+ containerPathMapping: routesContext.containerPathMapping,
1437
+ saveReport,
1438
+ spawn: createRealSpawn(),
1439
+ timers: createRealTimers(),
1440
+ launcher
836
1441
  });
837
1442
  }
838
- function defaultWriteTempFile(content) {
839
- const path = join4(tmpdir(), `crvy-rprtr-test-list-${process.pid}-${Date.now()}.txt`);
840
- writeFileSync(path, content, "utf8");
841
- return path;
842
- }
843
- function defaultDeleteTempFile(path) {
844
- try {
845
- unlinkSync(path);
846
- } catch {
847
- }
848
- }
849
- function resolveReporterDefault(cwd) {
850
- try {
851
- return createRequire(join4(cwd, "package.json")).resolve("@crvy/rprtr");
852
- } catch {
853
- }
854
- try {
855
- return createRequire(import.meta.url).resolve("@crvy/rprtr");
856
- } catch {
857
- return null;
858
- }
859
- }
860
- function buildSpawnEnv(port) {
861
- const env = {};
862
- for (const [key, value] of Object.entries(process.env)) {
863
- if (key === "CI") continue;
864
- env[key] = value;
865
- }
866
- env.CRVY_RPRTR_SERVER_URL = `ws://localhost:${port}`;
867
- env.PLAYWRIGHT_HTML_OPEN = "never";
868
- return env;
869
- }
870
- var RunController = class {
871
- constructor(deps) {
872
- this.deps = deps;
873
- }
874
- child = null;
875
- sigkillTimer = null;
876
- testListPath = null;
877
- get isRunning() {
878
- return this.child !== null;
879
- }
880
- supportsTestList(cwd) {
881
- const getVersion = this.deps.getPlaywrightVersion ?? resolvePlaywrightVersion;
882
- const version = getVersion(cwd);
883
- return version !== null && gteMinor(version, 1, 56);
884
- }
885
- cleanupTempFile() {
886
- if (this.testListPath !== null) {
887
- const del = this.deps.deleteTempFile ?? defaultDeleteTempFile;
888
- del(this.testListPath);
889
- this.testListPath = null;
890
- }
891
- }
892
- start(filters) {
893
- const ctx = this.deps.getRunContext();
894
- if (ctx === null) return { ok: false, reason: "no-config" };
895
- if (this.child !== null) return { ok: false, reason: "already-running" };
896
- if (filters.tests !== void 0 && filters.tests.length === 0) {
897
- return { ok: false, reason: "no-tests" };
898
- }
899
- const resolveReporter = this.deps.resolveReporter ?? resolveReporterDefault;
900
- const reporterModule = resolveReporter(ctx.cwd);
901
- const tests = filters.tests;
902
- const useTestList = tests !== void 0 && tests.length > 1 && this.supportsTestList(ctx.cwd);
903
- const args = ["test", "--config", ctx.configFile];
904
- if (reporterModule !== null) args.push("--reporter", reporterModule);
905
- if (useTestList && tests !== void 0) {
906
- const content = buildTestListEntries(tests).join("\n");
907
- const writeTemp = this.deps.writeTempFile ?? defaultWriteTempFile;
908
- this.testListPath = writeTemp(content);
909
- args.push("--test-list", this.testListPath);
910
- } else if (tests !== void 0 && tests.length > 0) {
911
- const project = sharedProject(tests);
912
- if (project !== void 0) args.push("--project", project);
913
- for (const d of tests) args.push(descriptorLocation(d));
914
- }
915
- const resolveLaunch = this.deps.resolveLaunch ?? resolvePlaywrightLaunch;
916
- const { cmd, args: launchArgs } = resolveLaunch(ctx.cwd, args);
917
- let child;
918
- try {
919
- child = this.deps.spawn(cmd, launchArgs, { cwd: ctx.cwd, env: buildSpawnEnv(this.deps.port), stdio: "inherit" });
920
- } catch (err) {
921
- this.cleanupTempFile();
922
- throw err;
923
- }
924
- this.child = child;
925
- child.on("exit", (code) => {
926
- this.handleChildExit(code);
927
- });
928
- child.on("error", () => {
929
- this.handleChildExit(null);
930
- });
931
- this.deps.setReportRunning(true);
932
- this.deps.setRunFiltered?.(filters.tests !== void 0);
933
- this.deps.broadcast({ type: "run-status", data: { running: true } });
934
- return { ok: true };
935
- }
936
- stop() {
937
- if (this.child === null) return { ok: false, reason: "not-running" };
938
- if (this.sigkillTimer !== null) this.deps.timers.clearTimeout(this.sigkillTimer);
939
- this.child.kill("SIGTERM");
940
- this.sigkillTimer = this.deps.timers.setTimeout(() => {
941
- if (this.child !== null) this.child.kill("SIGKILL");
942
- }, STOP_GRACE_MS);
943
- return { ok: true };
944
- }
945
- dispose() {
946
- if (this.child === null) return;
947
- if (this.sigkillTimer !== null) this.deps.timers.clearTimeout(this.sigkillTimer);
948
- this.sigkillTimer = null;
949
- this.child.kill("SIGKILL");
950
- this.cleanupTempFile();
951
- }
952
- handleChildExit(code) {
953
- if (this.child === null) return;
954
- if (this.sigkillTimer !== null) {
955
- this.deps.timers.clearTimeout(this.sigkillTimer);
956
- this.sigkillTimer = null;
957
- }
958
- this.child = null;
959
- this.cleanupTempFile();
960
- if (code !== null && code !== 0) {
961
- console.warn(`[RunController] playwright test exited with code ${code}`);
962
- }
963
- this.deps.setReportRunning(false);
964
- this.deps.broadcast({ type: "run-status", data: { running: false } });
965
- void this.deps.saveReport?.();
966
- }
967
- };
968
- function createRealSpawn() {
969
- return (cmd, args, opts) => {
970
- const cp = spawn(cmd, args, opts);
971
- return {
972
- on: (event, cb) => cp.on(event, cb),
973
- kill: (signal) => {
974
- const sig = KNOWN_SIGNALS[signal];
975
- if (sig !== void 0) cp.kill(sig);
976
- }
977
- };
1443
+ function createCloseHandler(persistence, runController) {
1444
+ return async () => {
1445
+ await persistence.dispose();
1446
+ runController.dispose();
978
1447
  };
979
1448
  }
980
- function createRealTimers() {
981
- const pending = [];
982
- return {
983
- setTimeout: (fn, ms) => {
984
- const id = setTimeout(fn, ms);
985
- pending.push(id);
986
- return id;
1449
+ function createRunControllerAndHandlers(routesContext, wsClients, reportData, currentRunIds, port, persistence, launcher) {
1450
+ let isFilteredRun = false;
1451
+ const runController = createServerRunController(
1452
+ routesContext,
1453
+ wsClients,
1454
+ reportData,
1455
+ port,
1456
+ (filtered) => {
1457
+ isFilteredRun = filtered;
987
1458
  },
988
- clearTimeout: () => {
989
- for (const h of pending.splice(0)) clearTimeout(h);
990
- }
991
- };
1459
+ persistence.saveReport,
1460
+ launcher
1461
+ );
1462
+ const getHandlerContext = () => ({
1463
+ reportData,
1464
+ wsClients,
1465
+ currentRunIds,
1466
+ isFilteredRun,
1467
+ saveReport: persistence.saveReport,
1468
+ scheduleReportSave: persistence.scheduleReportSave,
1469
+ approvalRouting: routesContext.approvalRouting,
1470
+ routesContext,
1471
+ runController
1472
+ });
1473
+ return { runController, getHandlerContext };
992
1474
  }
993
1475
 
994
1476
  // src/server/app.ts
@@ -1080,19 +1562,19 @@ function createWebSocketMessageHandler(getHandlerContext) {
1080
1562
  };
1081
1563
  }
1082
1564
  async function resolveStaticDir(staticDir) {
1083
- const currentDir = dirname3(fileURLToPath(import.meta.url));
1565
+ const currentDir = dirname4(fileURLToPath(import.meta.url));
1084
1566
  const candidates = staticDir === void 0 ? [
1085
1567
  currentDir,
1086
- join5(currentDir, "dist"),
1087
- join5(currentDir, "..", "dist"),
1088
- join5(currentDir, "..", "..", "dist"),
1089
- join5(currentDir, ".."),
1090
- join5(currentDir, "..", "..")
1091
- ] : [staticDir, join5(staticDir, "dist")];
1568
+ join6(currentDir, "dist"),
1569
+ join6(currentDir, "..", "dist"),
1570
+ join6(currentDir, "..", "..", "dist"),
1571
+ join6(currentDir, ".."),
1572
+ join6(currentDir, "..", "..")
1573
+ ] : [staticDir, join6(staticDir, "dist")];
1092
1574
  const resolvedCandidates = await Promise.all(
1093
1575
  candidates.map(async (candidate) => ({
1094
1576
  candidate,
1095
- exists: await fileExists(join5(candidate, "index.html"))
1577
+ exists: await fileExists(join6(candidate, "index.html"))
1096
1578
  }))
1097
1579
  );
1098
1580
  const resolved = resolvedCandidates.find(({ exists }) => exists);
@@ -1103,34 +1585,31 @@ async function resolveStaticDir(staticDir) {
1103
1585
  }
1104
1586
  async function resolveReportPath(reportPath) {
1105
1587
  if (await isDirectory(reportPath)) {
1106
- return { reportFile: join5(reportPath, "report.json"), offlineReportDir: reportPath };
1588
+ return { reportFile: join6(reportPath, "report.json"), offlineReportDir: reportPath };
1107
1589
  }
1108
- return { reportFile: reportPath, offlineReportDir: dirname3(reportPath) };
1590
+ return { reportFile: reportPath, offlineReportDir: dirname4(reportPath) };
1109
1591
  }
1110
1592
  async function seedRunContext(routesContext, options) {
1111
1593
  if (routesContext.runContext !== void 0) {
1112
1594
  return;
1113
1595
  }
1114
- const configFile = options.playwrightConfig ?? await resolvePlaywrightConfig(process.cwd());
1596
+ const configFile = await resolveSeedConfigFile(options.playwrightConfig, process.cwd());
1115
1597
  if (configFile !== null) {
1116
1598
  routesContext.runContext = { configFile, cwd: process.cwd() };
1117
1599
  }
1118
1600
  }
1119
- function createServerRunController(routesContext, wsClients, reportData, port, setRunFiltered, saveReport) {
1120
- return new RunController({
1121
- getRunContext: () => routesContext.runContext ?? null,
1122
- port,
1123
- broadcast: (message) => {
1124
- broadcastToBrowsers(wsClients, message);
1125
- },
1126
- setReportRunning: (running) => {
1127
- reportData.isRunning = running;
1128
- },
1129
- setRunFiltered,
1130
- saveReport,
1131
- spawn: createRealSpawn(),
1132
- timers: createRealTimers()
1601
+ async function setupRoutesContext(options, reportData, staticDir, saveReport, port) {
1602
+ const { launcher, routesContextOptions } = await resolveRunBackend({
1603
+ runMode: options.runMode,
1604
+ docker: options.docker,
1605
+ port
1606
+ });
1607
+ const routesContext = createRoutesContext(reportData, staticDir, saveReport, {
1608
+ ...options,
1609
+ ...routesContextOptions
1133
1610
  });
1611
+ await seedRunContext(routesContext, options);
1612
+ return { routesContext, launcher };
1134
1613
  }
1135
1614
  async function createServerApp(options = {}) {
1136
1615
  const port = options.port ?? 3e3;
@@ -1141,30 +1620,22 @@ async function createServerApp(options = {}) {
1141
1620
  const wsClients = /* @__PURE__ */ new Set();
1142
1621
  const currentRunIds = /* @__PURE__ */ new Set();
1143
1622
  const persistence = createReportPersistence(reportFile, reportData);
1144
- const routesContext = createRoutesContext(reportData, staticDir, persistence.saveReport, options);
1145
- await seedRunContext(routesContext, options);
1146
- let isFilteredRun = false;
1147
- const runController = createServerRunController(
1623
+ const { routesContext, launcher } = await setupRoutesContext(
1624
+ options,
1625
+ reportData,
1626
+ staticDir,
1627
+ persistence.saveReport,
1628
+ port
1629
+ );
1630
+ const { runController, getHandlerContext } = createRunControllerAndHandlers(
1148
1631
  routesContext,
1149
1632
  wsClients,
1150
1633
  reportData,
1634
+ currentRunIds,
1151
1635
  port,
1152
- (filtered) => {
1153
- isFilteredRun = filtered;
1154
- },
1155
- persistence.saveReport
1636
+ persistence,
1637
+ launcher
1156
1638
  );
1157
- const getHandlerContext = () => ({
1158
- reportData,
1159
- wsClients,
1160
- currentRunIds,
1161
- isFilteredRun,
1162
- saveReport: persistence.saveReport,
1163
- scheduleReportSave: persistence.scheduleReportSave,
1164
- approvalRouting: routesContext.approvalRouting,
1165
- routesContext,
1166
- runController
1167
- });
1168
1639
  const handleRequest = (req) => handleHttpRequest(routesContext, req, runController);
1169
1640
  const handleWebSocketMessage = createWebSocketMessageHandler(getHandlerContext);
1170
1641
  await loadReport(reportFile, reportData);
@@ -1177,12 +1648,6 @@ async function createServerApp(options = {}) {
1177
1648
  handleWebSocketMessage
1178
1649
  };
1179
1650
  }
1180
- function createCloseHandler(persistence, runController) {
1181
- return async () => {
1182
- await persistence.dispose();
1183
- runController.dispose();
1184
- };
1185
- }
1186
1651
 
1187
1652
  // src/server/bun-adapter.ts
1188
1653
  function logWebSocketError(prefix, error) {
@@ -1359,7 +1824,7 @@ function attachWebSocketServer(server, app) {
1359
1824
  });
1360
1825
  }
1361
1826
  async function listen(server, port) {
1362
- await new Promise((resolve3, reject) => {
1827
+ await new Promise((resolve5, reject) => {
1363
1828
  const onError = (error) => {
1364
1829
  server.off("error", onError);
1365
1830
  reject(error);
@@ -1367,7 +1832,7 @@ async function listen(server, port) {
1367
1832
  server.on("error", onError);
1368
1833
  server.listen(port, () => {
1369
1834
  server.off("error", onError);
1370
- resolve3();
1835
+ resolve5();
1371
1836
  });
1372
1837
  });
1373
1838
  }