@cevek/screentest 0.2.5 → 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.
package/dist/index.js CHANGED
@@ -7,7 +7,7 @@ var __export = (target, all) => {
7
7
 
8
8
  // src/ui.ts
9
9
  import { promises as fs3 } from "fs";
10
- import { dirname as dirname3, isAbsolute, resolve as resolve4 } from "path";
10
+ import { dirname as dirname4, isAbsolute, resolve as resolve5 } from "path";
11
11
 
12
12
  // ../../node_modules/.pnpm/zod@4.4.3/node_modules/zod/v4/classic/external.js
13
13
  var external_exports = {};
@@ -14554,7 +14554,15 @@ var groupItemSchema = external_exports.lazy(
14554
14554
  items: external_exports.array(external_exports.union([groupItemSchema, diffItemSchema])).min(0)
14555
14555
  })
14556
14556
  );
14557
+ var projectInfoSchema = external_exports.object({
14558
+ /** Absolute path to the project root the doc.json was generated from. */
14559
+ path: external_exports.string(),
14560
+ /** Current git branch, or `null` if not a git repo (or `git` not on PATH). */
14561
+ branch: external_exports.string().nullable()
14562
+ });
14557
14563
  var docSchema = external_exports.object({
14564
+ /** Optional for backward compatibility with pre-0.3 doc.json files. */
14565
+ project: projectInfoSchema.optional(),
14558
14566
  groups: external_exports.array(groupItemSchema)
14559
14567
  });
14560
14568
  var snapshotTestSchema = external_exports.object({
@@ -14573,7 +14581,9 @@ var snapshotDocSchema = external_exports.object({
14573
14581
  var apiDocResponseSchema = external_exports.object({
14574
14582
  doc: docSchema,
14575
14583
  expectedUrlPattern: external_exports.string(),
14576
- workerUrl: external_exports.string()
14584
+ workerUrl: external_exports.string(),
14585
+ /** `@cevek/screentest` package version that served this doc.json. */
14586
+ serverVersion: external_exports.string()
14577
14587
  });
14578
14588
  var apiAcceptRequestSchema = external_exports.object({
14579
14589
  testId: external_exports.string().min(1)
@@ -14645,8 +14655,8 @@ function flatten(doc, docDir) {
14645
14655
  // src/server.ts
14646
14656
  import express from "express";
14647
14657
  import { existsSync } from "fs";
14648
- import { dirname as dirname2, join, resolve as resolve3 } from "path";
14649
- import { fileURLToPath } from "url";
14658
+ import { dirname as dirname3, join, resolve as resolve4 } from "path";
14659
+ import { fileURLToPath as fileURLToPath2 } from "url";
14650
14660
  import { createServer } from "http";
14651
14661
 
14652
14662
  // src/routes/doc.ts
@@ -14677,6 +14687,26 @@ function expectedUrlPattern(workerUrl) {
14677
14687
  return `${workerUrl}/{hash}.png`;
14678
14688
  }
14679
14689
 
14690
+ // src/version.ts
14691
+ import { readFileSync } from "fs";
14692
+ import { dirname, resolve as resolve2 } from "path";
14693
+ import { fileURLToPath } from "url";
14694
+ function readVersion() {
14695
+ const here = dirname(fileURLToPath(import.meta.url));
14696
+ const candidates = [resolve2(here, "../package.json"), resolve2(here, "../../package.json")];
14697
+ for (const p of candidates) {
14698
+ try {
14699
+ const pkg = JSON.parse(readFileSync(p, "utf8"));
14700
+ if (pkg.name === "@cevek/screentest" && typeof pkg.version === "string") {
14701
+ return pkg.version;
14702
+ }
14703
+ } catch {
14704
+ }
14705
+ }
14706
+ return "unknown";
14707
+ }
14708
+ var SCREENTEST_VERSION = readVersion();
14709
+
14680
14710
  // src/routes/doc.ts
14681
14711
  function docRoute(state) {
14682
14712
  const r = Router();
@@ -14684,7 +14714,8 @@ function docRoute(state) {
14684
14714
  res.json({
14685
14715
  doc: state.doc,
14686
14716
  expectedUrlPattern: expectedUrlPattern(state.workerUrl),
14687
- workerUrl: state.workerUrl
14717
+ workerUrl: state.workerUrl,
14718
+ serverVersion: SCREENTEST_VERSION
14688
14719
  });
14689
14720
  });
14690
14721
  return r;
@@ -14754,7 +14785,7 @@ function sha256Hex(buf) {
14754
14785
 
14755
14786
  // src/patcher.ts
14756
14787
  import { promises as fs } from "fs";
14757
- import { dirname, resolve as resolve2 } from "path";
14788
+ import { dirname as dirname2, resolve as resolve3 } from "path";
14758
14789
  var fileLocks = /* @__PURE__ */ new Map();
14759
14790
  async function withFileLock(absPath, fn) {
14760
14791
  const prev = fileLocks.get(absPath) ?? Promise.resolve();
@@ -14804,7 +14835,7 @@ function upsertHash(doc, path, hash2) {
14804
14835
  async function atomicWriteJson(absPath, data) {
14805
14836
  const json2 = JSON.stringify(data, null, 2);
14806
14837
  const tmp = `${absPath}.tmp-${process.pid}-${Date.now()}`;
14807
- await fs.mkdir(dirname(absPath), { recursive: true });
14838
+ await fs.mkdir(dirname2(absPath), { recursive: true });
14808
14839
  await fs.writeFile(tmp, json2, "utf8");
14809
14840
  await fs.rename(tmp, absPath);
14810
14841
  }
@@ -14839,7 +14870,7 @@ function removeAtPath(doc, path) {
14839
14870
  return true;
14840
14871
  }
14841
14872
  async function removeFromSnapshot(args) {
14842
- const abs = resolve2(args.docDir, args.patchSnapshotJsonFile);
14873
+ const abs = resolve3(args.docDir, args.patchSnapshotJsonFile);
14843
14874
  await withFileLock(abs, async () => {
14844
14875
  let doc;
14845
14876
  try {
@@ -14856,7 +14887,7 @@ async function removeFromSnapshot(args) {
14856
14887
  });
14857
14888
  }
14858
14889
  async function patchSnapshot(args) {
14859
- const abs = resolve2(args.docDir, args.patchSnapshotJsonFile);
14890
+ const abs = resolve3(args.docDir, args.patchSnapshotJsonFile);
14860
14891
  await withFileLock(abs, async () => {
14861
14892
  let doc;
14862
14893
  try {
@@ -14927,14 +14958,14 @@ function acceptRoute(state) {
14927
14958
 
14928
14959
  // src/server.ts
14929
14960
  function resolveWebDist() {
14930
- const here = dirname2(fileURLToPath(import.meta.url));
14961
+ const here = dirname3(fileURLToPath2(import.meta.url));
14931
14962
  const candidates = [
14932
14963
  // Published npm package: dist/index.js → ./web
14933
- resolve3(here, "./web"),
14964
+ resolve4(here, "./web"),
14934
14965
  // Local monorepo build: apps/server/dist/index.js → ../../web/dist
14935
- resolve3(here, "../../web/dist"),
14966
+ resolve4(here, "../../web/dist"),
14936
14967
  // Dev via tsx: apps/server/src/server.ts → ../../../apps/web/dist
14937
- resolve3(here, "../../../apps/web/dist")
14968
+ resolve4(here, "../../../apps/web/dist")
14938
14969
  ];
14939
14970
  for (const c of candidates) {
14940
14971
  if (existsSync(join(c, "index.html"))) return c;
@@ -15026,8 +15057,8 @@ function parseUIArgs(rawArgs) {
15026
15057
  };
15027
15058
  }
15028
15059
  async function runUI(opts) {
15029
- const docAbs = isAbsolute(opts.docPath) ? opts.docPath : resolve4(process.cwd(), opts.docPath);
15030
- const docDir = dirname3(docAbs);
15060
+ const docAbs = isAbsolute(opts.docPath) ? opts.docPath : resolve5(process.cwd(), opts.docPath);
15061
+ const docDir = dirname4(docAbs);
15031
15062
  let raw;
15032
15063
  try {
15033
15064
  raw = await fs3.readFile(docAbs, "utf8");
@@ -15092,14 +15123,227 @@ async function runUI(opts) {
15092
15123
  }
15093
15124
 
15094
15125
  // src/orchestrator.ts
15095
- import { spawn, spawnSync } from "child_process";
15126
+ import { spawn as spawn2 } from "child_process";
15096
15127
  import { promises as fs5 } from "fs";
15097
- import { join as join4 } from "path";
15128
+ import { dirname as dirname6 } from "path";
15129
+
15130
+ // src/daemon.ts
15131
+ import { spawn, spawnSync } from "child_process";
15132
+ import { existsSync as existsSync2 } from "fs";
15133
+ import { dirname as dirname5, resolve as resolve6 } from "path";
15134
+ import { fileURLToPath as fileURLToPath3 } from "url";
15135
+ import { createConnection } from "net";
15136
+ var IMAGE_NAME = "cevek/screentest-firefox";
15137
+ var IMAGE_TAG = "pw-1.60.0";
15138
+ var FULL_IMAGE = `${IMAGE_NAME}:${IMAGE_TAG}`;
15139
+ var CONTAINER_NAME = "screentest-firefox";
15140
+ var PORT = 5180;
15141
+ var WS_PATH = "screentest-firefox";
15142
+ var WS_ENDPOINT = `ws://localhost:${PORT}/${WS_PATH}`;
15143
+ function run(cmd, argv) {
15144
+ return new Promise((resolveExit) => {
15145
+ const p = spawn(cmd, argv, { stdio: "inherit" });
15146
+ p.on("exit", (code) => resolveExit(code ?? 0));
15147
+ });
15148
+ }
15149
+ function daemonState() {
15150
+ const ps = spawnSync(
15151
+ "docker",
15152
+ ["ps", "-a", "-f", `name=^${CONTAINER_NAME}$`, "--format", "{{.Status}} {{.Image}}"],
15153
+ { stdio: "pipe", encoding: "utf8" }
15154
+ );
15155
+ const line = ps.stdout.trim();
15156
+ if (line.startsWith("Up")) {
15157
+ const tab = line.indexOf(" ");
15158
+ const image = tab === -1 ? "" : line.slice(tab + 1).trim();
15159
+ return { state: "running", image };
15160
+ }
15161
+ const img = spawnSync("docker", ["image", "inspect", FULL_IMAGE], { stdio: "pipe" });
15162
+ if (img.status !== 0) return { state: "missing-image" };
15163
+ return { state: "stopped" };
15164
+ }
15165
+ function resolveTemplatesDir() {
15166
+ const here = dirname5(fileURLToPath3(import.meta.url));
15167
+ const candidates = [
15168
+ resolve6(here, "./templates"),
15169
+ resolve6(here, "../templates"),
15170
+ resolve6(here, "../../server/templates")
15171
+ ];
15172
+ for (const c of candidates) {
15173
+ if (existsSync2(c)) return c;
15174
+ }
15175
+ throw new Error("templates/ directory not found relative to daemon.ts");
15176
+ }
15177
+ async function buildImage() {
15178
+ const templates = resolveTemplatesDir();
15179
+ process.stderr.write(`Building Docker image ${FULL_IMAGE} (~2 min first time, cached after).
15180
+ `);
15181
+ const code = await run("docker", [
15182
+ "build",
15183
+ "-f",
15184
+ `${templates}/Dockerfile.firefox-server`,
15185
+ "-t",
15186
+ FULL_IMAGE,
15187
+ templates
15188
+ ]);
15189
+ if (code !== 0) {
15190
+ process.stderr.write(`docker build failed (exit ${code})
15191
+ `);
15192
+ return false;
15193
+ }
15194
+ return true;
15195
+ }
15196
+ function probePort() {
15197
+ return new Promise((resolveProbe) => {
15198
+ const s = createConnection({ host: "127.0.0.1", port: PORT });
15199
+ s.once("connect", () => {
15200
+ s.end();
15201
+ resolveProbe(true);
15202
+ });
15203
+ s.once("error", () => resolveProbe(false));
15204
+ });
15205
+ }
15206
+ async function waitForReady(timeoutMs = 1e4) {
15207
+ const start = Date.now();
15208
+ while (Date.now() - start < timeoutMs) {
15209
+ if (await probePort()) return true;
15210
+ await new Promise((r) => setTimeout(r, 150));
15211
+ }
15212
+ return false;
15213
+ }
15214
+ async function startDaemon() {
15215
+ const s = daemonState();
15216
+ if (s.state === "running") {
15217
+ if (s.image !== FULL_IMAGE) {
15218
+ process.stderr.write(
15219
+ `screentest-firefox daemon is running, but on a different image:
15220
+ running: ${s.image}
15221
+ this @cevek/screentest wants: ${FULL_IMAGE}
15222
+ \u2192 stop it first: screentest stop
15223
+ \u2192 then re-run: screentest serve
15224
+ `
15225
+ );
15226
+ return 1;
15227
+ }
15228
+ process.stdout.write(`screentest-firefox daemon already running at ${WS_ENDPOINT}
15229
+ `);
15230
+ return 0;
15231
+ }
15232
+ if (s.state === "missing-image") {
15233
+ if (!await buildImage()) return 1;
15234
+ }
15235
+ spawnSync("docker", ["rm", "-f", CONTAINER_NAME], { stdio: "pipe" });
15236
+ process.stderr.write(`Starting screentest-firefox daemon (port ${PORT})\u2026
15237
+ `);
15238
+ const code = await run("docker", [
15239
+ "run",
15240
+ "-d",
15241
+ "--rm",
15242
+ "--name",
15243
+ CONTAINER_NAME,
15244
+ "--network=host",
15245
+ FULL_IMAGE
15246
+ ]);
15247
+ if (code !== 0) {
15248
+ process.stderr.write(`docker run failed (exit ${code})
15249
+ `);
15250
+ return code;
15251
+ }
15252
+ if (!await waitForReady()) {
15253
+ process.stderr.write(`Daemon container started but port ${PORT} never opened. Logs:
15254
+ `);
15255
+ spawnSync("docker", ["logs", CONTAINER_NAME], { stdio: "inherit" });
15256
+ return 1;
15257
+ }
15258
+ process.stdout.write(`screentest-firefox daemon ready at ${WS_ENDPOINT}
15259
+ `);
15260
+ return 0;
15261
+ }
15262
+ async function stopDaemon() {
15263
+ const s = daemonState();
15264
+ if (s.state !== "running") {
15265
+ process.stdout.write("screentest-firefox daemon is not running\n");
15266
+ return 0;
15267
+ }
15268
+ const code = await run("docker", ["stop", CONTAINER_NAME]);
15269
+ if (code === 0) process.stdout.write("screentest-firefox daemon stopped\n");
15270
+ return code;
15271
+ }
15272
+ async function printStatus() {
15273
+ const s = daemonState();
15274
+ if (s.state === "running") {
15275
+ const match = s.image === FULL_IMAGE ? "(matches this @cevek/screentest)" : "(MISMATCH!)";
15276
+ process.stdout.write(
15277
+ `screentest-firefox daemon: running
15278
+ ws endpoint: ${WS_ENDPOINT}
15279
+ image: ${s.image} ${match}
15280
+ expected: ${FULL_IMAGE}
15281
+ `
15282
+ );
15283
+ if (s.image !== FULL_IMAGE) {
15284
+ process.stdout.write(
15285
+ ` \u2192 daemon is running an image pinned by a different version of this
15286
+ package. Stop it (screentest stop) and re-serve from the project
15287
+ whose Playwright version you want to use.
15288
+ `
15289
+ );
15290
+ }
15291
+ return 0;
15292
+ }
15293
+ if (s.state === "stopped") {
15294
+ process.stdout.write(
15295
+ `screentest-firefox daemon: stopped (image ${FULL_IMAGE} exists)
15296
+ start it with: screentest serve
15297
+ `
15298
+ );
15299
+ return 0;
15300
+ }
15301
+ process.stdout.write(
15302
+ `screentest-firefox daemon: not installed (image ${FULL_IMAGE} missing)
15303
+ build + start with: screentest serve
15304
+ `
15305
+ );
15306
+ return 0;
15307
+ }
15308
+ async function requireRunningDaemon() {
15309
+ const s = daemonState();
15310
+ if (s.state !== "running") {
15311
+ process.stderr.write(
15312
+ `screentest-firefox daemon is not running.
15313
+ Start it once with: screentest serve
15314
+ It then stays alive across projects until you stop it.
15315
+ `
15316
+ );
15317
+ return false;
15318
+ }
15319
+ if (s.image !== FULL_IMAGE) {
15320
+ process.stderr.write(
15321
+ `screentest-firefox daemon is running with a different Playwright version:
15322
+ running: ${s.image}
15323
+ this @cevek/screentest expects: ${FULL_IMAGE}
15324
+ \u2192 screentest stop && screentest serve
15325
+ (or align package versions across projects sharing the daemon)
15326
+ `
15327
+ );
15328
+ return false;
15329
+ }
15330
+ return true;
15331
+ }
15098
15332
 
15099
15333
  // src/doc-builder.ts
15334
+ import { spawnSync as spawnSync2 } from "child_process";
15100
15335
  import { createHash as createHash2 } from "crypto";
15101
15336
  import { promises as fs4 } from "fs";
15102
15337
  import { join as join2, relative, sep } from "path";
15338
+ function detectBranch(projectRoot) {
15339
+ const r = spawnSync2("git", ["-C", projectRoot, "rev-parse", "--abbrev-ref", "HEAD"], {
15340
+ stdio: "pipe",
15341
+ encoding: "utf8"
15342
+ });
15343
+ if (r.status !== 0) return null;
15344
+ const branch = r.stdout.trim();
15345
+ return branch || null;
15346
+ }
15103
15347
  function isGroup(node) {
15104
15348
  return Array.isArray(node.items);
15105
15349
  }
@@ -15163,12 +15407,15 @@ function countLeaves(doc) {
15163
15407
  for (const g of doc.groups) walk(g.items);
15164
15408
  return n;
15165
15409
  }
15166
- async function generateDoc(snapshotFile, actualDir, docFile, cacheDir) {
15410
+ async function generateDoc(projectRoot, snapshotFile, actualDir, docFile, cacheDir) {
15167
15411
  const snap = await readSnapshot(snapshotFile);
15168
15412
  const expectedByPath = indexSnapshot(snap);
15169
15413
  const actuals = await walkActual(actualDir);
15170
15414
  const seen = /* @__PURE__ */ new Set();
15171
- const doc = { groups: [] };
15415
+ const doc = {
15416
+ project: { path: projectRoot, branch: detectBranch(projectRoot) },
15417
+ groups: []
15418
+ };
15172
15419
  const summary = { new: 0, change: 0, deleted: 0, unchanged: 0 };
15173
15420
  for (const a of actuals) {
15174
15421
  const buf = await fs4.readFile(a.absFile);
@@ -15219,135 +15466,42 @@ async function generateDoc(snapshotFile, actualDir, docFile, cacheDir) {
15219
15466
  }
15220
15467
 
15221
15468
  // src/runner/paths.ts
15222
- import { join as join3, resolve as resolve5 } from "path";
15469
+ import { existsSync as existsSync3 } from "fs";
15470
+ import { isAbsolute as isAbsolute2, join as join3, resolve as resolve7 } from "path";
15223
15471
  function resolveRunnerPaths(cwd = process.cwd()) {
15224
- const projectRoot = resolve5(cwd);
15472
+ const projectRoot = resolve7(cwd);
15225
15473
  const cacheDir = join3(projectRoot, "node_modules", ".cache", "screentest");
15226
15474
  return {
15227
15475
  projectRoot,
15228
- snapshotFile: join3(projectRoot, "snapshot.json"),
15476
+ snapshotFile: resolveSnapshotFile(projectRoot),
15229
15477
  cacheDir,
15230
15478
  actualDir: join3(cacheDir, "actual"),
15231
15479
  docFile: join3(cacheDir, "doc.json")
15232
15480
  };
15233
15481
  }
15482
+ function resolveSnapshotFile(projectRoot) {
15483
+ const envPath = process.env.SCREENTEST_SNAPSHOT_FILE;
15484
+ if (envPath) {
15485
+ return isAbsolute2(envPath) ? envPath : join3(projectRoot, envPath);
15486
+ }
15487
+ const legacyRoot = join3(projectRoot, "snapshot.json");
15488
+ const insideTests = join3(projectRoot, "tests", "snapshot.json");
15489
+ if (existsSync3(legacyRoot) && !existsSync3(insideTests)) return legacyRoot;
15490
+ return insideTests;
15491
+ }
15234
15492
 
15235
15493
  // src/orchestrator.ts
15236
- var DOCKER_IMAGE = "screentest-tests";
15237
- function run(cmd, argv) {
15238
- return new Promise((resolve7) => {
15239
- const p = spawn(cmd, argv, { stdio: "inherit" });
15240
- p.on("exit", (code) => resolve7(code ?? 0));
15494
+ function run2(cmd, argv, env) {
15495
+ return new Promise((resolveExit) => {
15496
+ const p = spawn2(cmd, argv, { stdio: "inherit", env: env ?? process.env });
15497
+ p.on("exit", (code) => resolveExit(code ?? 0));
15241
15498
  });
15242
15499
  }
15243
- async function exists(absPath) {
15244
- try {
15245
- await fs5.access(absPath);
15246
- return true;
15247
- } catch {
15248
- return false;
15249
- }
15250
- }
15251
- async function ensureDockerImageFresh(projectRoot) {
15252
- const dockerfile = join4(projectRoot, "Dockerfile.tests");
15253
- if (!await exists(dockerfile)) {
15254
- process.stderr.write(
15255
- `Dockerfile.tests not found at ${dockerfile}
15256
- Did you run \`screentest init\` in this project?
15257
- `
15258
- );
15259
- return false;
15260
- }
15261
- const inspect = spawnSync(
15262
- "docker",
15263
- ["image", "inspect", "--format", "{{.Created}}", DOCKER_IMAGE],
15264
- { stdio: "pipe", encoding: "utf8" }
15265
- );
15266
- let reason = null;
15267
- if (inspect.status !== 0) {
15268
- reason = `Docker image "${DOCKER_IMAGE}" not found \u2014 building it (first build takes ~2 min).`;
15269
- } else {
15270
- const imageCreated = new Date(inspect.stdout.trim()).getTime();
15271
- const inputs = [
15272
- "package.json",
15273
- "pnpm-lock.yaml",
15274
- "package-lock.json",
15275
- "yarn.lock",
15276
- "Dockerfile.tests"
15277
- ];
15278
- for (const f of inputs) {
15279
- const abs = join4(projectRoot, f);
15280
- try {
15281
- const st = await fs5.stat(abs);
15282
- if (st.mtimeMs > imageCreated) {
15283
- reason = `${f} changed since the image was built \u2014 rebuilding.`;
15284
- break;
15285
- }
15286
- } catch {
15287
- }
15288
- }
15289
- }
15290
- if (!reason) return true;
15291
- process.stderr.write(`${reason}
15292
- `);
15293
- const code = await run("docker", [
15294
- "build",
15295
- "-f",
15296
- "Dockerfile.tests",
15297
- "-t",
15298
- DOCKER_IMAGE,
15299
- projectRoot
15300
- ]);
15301
- if (code !== 0) {
15302
- process.stderr.write(`docker build failed (exit ${code})
15303
- `);
15304
- return false;
15305
- }
15306
- return true;
15307
- }
15308
- async function runVitestInDocker(projectRoot, snapshotFile, cacheDir) {
15309
- const testsDir = join4(projectRoot, "tests");
15310
- const configFile = join4(projectRoot, "vitest.screentest.config.ts");
15311
- const tsconfigFile = join4(projectRoot, "tsconfig.json");
15312
- if (!await exists(testsDir)) {
15313
- process.stderr.write(
15314
- `tests/ not found at ${testsDir}
15315
- Did you run \`screentest init\` in this project?
15316
- `
15317
- );
15318
- return 1;
15319
- }
15320
- if (!await exists(configFile)) {
15321
- process.stderr.write(
15322
- `vitest.screentest.config.ts not found at ${configFile}
15323
- Did you run \`screentest init\` in this project?
15324
- `
15325
- );
15326
- return 1;
15327
- }
15328
- const dockerArgs = ["run", "--rm", "--init", "--network=host"];
15329
- dockerArgs.push("-e", `APP_URL=${process.env.APP_URL || "http://localhost:5050"}`);
15330
- if (process.env.CI) dockerArgs.push("-e", "CI=1");
15331
- dockerArgs.push(
15332
- "-v",
15333
- `${cacheDir}:/work/node_modules/.cache/screentest`,
15334
- "-v",
15335
- `${snapshotFile}:/work/snapshot.json`,
15336
- "-v",
15337
- `${testsDir}:/work/tests:ro`,
15338
- "-v",
15339
- `${configFile}:/work/vitest.screentest.config.ts:ro`
15340
- );
15341
- if (await exists(tsconfigFile)) {
15342
- dockerArgs.push("-v", `${tsconfigFile}:/work/tsconfig.json:ro`);
15343
- }
15344
- dockerArgs.push(DOCKER_IMAGE);
15345
- return run("docker", dockerArgs);
15346
- }
15347
15500
  async function runOrchestrator(opts = {}) {
15348
15501
  const paths = resolveRunnerPaths();
15349
15502
  if (opts.reviewOnly) {
15350
15503
  const total = await generateDoc(
15504
+ paths.projectRoot,
15351
15505
  paths.snapshotFile,
15352
15506
  paths.actualDir,
15353
15507
  paths.docFile,
@@ -15357,36 +15511,38 @@ async function runOrchestrator(opts = {}) {
15357
15511
  process.stdout.write("Nothing to review \u2014 all snapshots match.\n");
15358
15512
  return 0;
15359
15513
  }
15360
- return await run("node", [process.argv[1], paths.docFile]);
15514
+ return await run2("node", [process.argv[1], paths.docFile]);
15361
15515
  }
15362
15516
  await fs5.mkdir(paths.cacheDir, { recursive: true });
15363
15517
  try {
15364
15518
  await fs5.access(paths.snapshotFile);
15365
15519
  } catch {
15520
+ await fs5.mkdir(dirname6(paths.snapshotFile), { recursive: true });
15366
15521
  await fs5.writeFile(paths.snapshotFile, '{\n "groups": []\n}\n');
15367
15522
  }
15368
15523
  await fs5.rm(paths.actualDir, { recursive: true, force: true });
15369
- let testCode;
15370
- if (opts.hostMode) {
15371
- testCode = await run("npx", ["vitest", "--config", "vitest.screentest.config.ts", "run"]);
15372
- } else {
15373
- if (opts.requireDockerImage !== false && !await ensureDockerImageFresh(paths.projectRoot)) {
15374
- return 1;
15375
- }
15376
- testCode = await runVitestInDocker(paths.projectRoot, paths.snapshotFile, paths.cacheDir);
15524
+ if (!await requireRunningDaemon()) {
15525
+ return 1;
15377
15526
  }
15527
+ const env = { ...process.env, SCREENTEST_FIREFOX_WS: WS_ENDPOINT };
15528
+ const testCode = await run2(
15529
+ "npx",
15530
+ ["vitest", "--config", "vitest.screentest.config.ts", "run"],
15531
+ env
15532
+ );
15378
15533
  if (testCode !== 0 && !process.env.CI) {
15379
15534
  process.stderr.write(
15380
15535
  "\n\u2192 Tests failed \u2014 launching screentest for review.\n Close the tool (Ctrl+C) when done; the original failure will still propagate.\n\n"
15381
15536
  );
15382
15537
  const total = await generateDoc(
15538
+ paths.projectRoot,
15383
15539
  paths.snapshotFile,
15384
15540
  paths.actualDir,
15385
15541
  paths.docFile,
15386
15542
  paths.cacheDir
15387
15543
  );
15388
15544
  if (total > 0) {
15389
- const reviewCode = await run("node", [process.argv[1], paths.docFile]);
15545
+ const reviewCode = await run2("node", [process.argv[1], paths.docFile]);
15390
15546
  if (reviewCode !== 0 && reviewCode !== 130) {
15391
15547
  process.stderr.write(`
15392
15548
  (screentest exited with code ${reviewCode})
@@ -15402,52 +15558,32 @@ async function runOrchestrator(opts = {}) {
15402
15558
  }
15403
15559
 
15404
15560
  // src/init.ts
15405
- import { copyFile, mkdir, readFile } from "fs/promises";
15406
- import { existsSync as existsSync2 } from "fs";
15407
- import { dirname as dirname4, join as join5, resolve as resolve6 } from "path";
15408
- import { fileURLToPath as fileURLToPath2 } from "url";
15409
- async function detectPackageManager(cwd) {
15410
- if (existsSync2(join5(cwd, "pnpm-lock.yaml"))) return "pnpm";
15411
- if (existsSync2(join5(cwd, "yarn.lock"))) return "yarn";
15412
- if (existsSync2(join5(cwd, "package-lock.json"))) return "npm";
15413
- try {
15414
- const pkg = JSON.parse(
15415
- await readFile(join5(cwd, "package.json"), "utf8")
15416
- );
15417
- const pm = pkg.packageManager ?? "";
15418
- if (pm.startsWith("pnpm")) return "pnpm";
15419
- if (pm.startsWith("yarn")) return "yarn";
15420
- } catch {
15421
- }
15422
- return "npm";
15423
- }
15561
+ import { copyFile, mkdir } from "fs/promises";
15562
+ import { existsSync as existsSync4 } from "fs";
15563
+ import { dirname as dirname7, join as join4, resolve as resolve8 } from "path";
15564
+ import { fileURLToPath as fileURLToPath4 } from "url";
15424
15565
  async function runInit() {
15425
- const here = dirname4(fileURLToPath2(import.meta.url));
15426
- const templatesDir = [resolve6(here, "./templates"), resolve6(here, "../templates")].find((p) => existsSync2(p)) ?? resolve6(here, "./templates");
15566
+ const here = dirname7(fileURLToPath4(import.meta.url));
15567
+ const templatesDir = [resolve8(here, "./templates"), resolve8(here, "../templates")].find((p) => existsSync4(p)) ?? resolve8(here, "./templates");
15427
15568
  const cwd = process.cwd();
15428
- const pm = await detectPackageManager(cwd);
15429
- process.stdout.write(`Detected package manager: ${pm}
15430
-
15431
- `);
15432
15569
  const targets = [
15433
- { from: `Dockerfile.${pm}.tests`, to: "Dockerfile.tests" },
15434
15570
  { from: "vitest.screentest.config.ts", to: "vitest.screentest.config.ts" },
15435
15571
  { from: "example.test.ts", to: "tests/example.test.ts" }
15436
15572
  ];
15437
15573
  for (const { from, to } of targets) {
15438
- const src = join5(templatesDir, from);
15439
- const dst = join5(cwd, to);
15440
- if (existsSync2(dst)) {
15574
+ const src = join4(templatesDir, from);
15575
+ const dst = join4(cwd, to);
15576
+ if (existsSync4(dst)) {
15441
15577
  process.stdout.write(` exists ${to}
15442
15578
  `);
15443
15579
  continue;
15444
15580
  }
15445
- if (!existsSync2(src)) {
15581
+ if (!existsSync4(src)) {
15446
15582
  process.stdout.write(` missing ${from} (package install incomplete?)
15447
15583
  `);
15448
15584
  continue;
15449
15585
  }
15450
- await mkdir(dirname4(dst), { recursive: true });
15586
+ await mkdir(dirname7(dst), { recursive: true });
15451
15587
  await copyFile(src, dst);
15452
15588
  process.stdout.write(` wrote ${to}
15453
15589
  `);
@@ -15455,8 +15591,8 @@ async function runInit() {
15455
15591
  process.stdout.write(
15456
15592
  `
15457
15593
  Next steps:
15458
- 1. docker build -f Dockerfile.tests -t screentest-tests .
15459
- 2. APP_URL=http://localhost:<your-app-port> npx screentest test
15594
+ 1. screentest serve # one-time: starts the shared Firefox daemon
15595
+ 2. APP_URL=http://localhost:<port> screentest test
15460
15596
  `
15461
15597
  );
15462
15598
  return 0;
@@ -15470,14 +15606,20 @@ function printHelp() {
15470
15606
  " screentest <doc-json-path> [--port 5174] [--no-open] [--worker-url URL] [--token TOKEN]",
15471
15607
  " open the review UI on an existing doc.json",
15472
15608
  "",
15473
- " screentest test [--host] run vitest, on failure regenerate doc.json + open UI",
15474
- " default: vitest inside Docker (network=host)",
15475
- " --host: vitest on host (debug only, bytes diverge from CI)",
15609
+ " screentest test run vitest on the host, connect to the shared",
15610
+ " Firefox daemon, on failure regenerate doc.json",
15611
+ " and open the review UI",
15476
15612
  "",
15477
15613
  " screentest review regenerate doc.json from cached actuals + open UI",
15478
15614
  "",
15479
- " screentest init scaffold Dockerfile.tests, vitest.config.ts,",
15480
- " and tests/example.test.ts into the current project",
15615
+ " screentest serve start the shared Firefox browser-server daemon",
15616
+ " (builds the image on first run). One daemon per",
15617
+ " machine \u2014 used by every project",
15618
+ " screentest stop stop the daemon",
15619
+ " screentest status show whether the daemon is running",
15620
+ "",
15621
+ " screentest init scaffold vitest.screentest.config.ts and",
15622
+ " tests/example.test.ts into the current project",
15481
15623
  "",
15482
15624
  "Env vars (UI mode):",
15483
15625
  " CLOUDFLARE_WORKER_URL default https://screentests.x-cevek.workers.dev",
@@ -15498,9 +15640,7 @@ async function main() {
15498
15640
  }
15499
15641
  const cmd = argv[0];
15500
15642
  if (cmd === "test") {
15501
- const rest = argv.slice(1);
15502
- const hostMode = rest.includes("--host");
15503
- const code = await runOrchestrator({ hostMode });
15643
+ const code = await runOrchestrator();
15504
15644
  process.exit(code);
15505
15645
  return;
15506
15646
  }
@@ -15509,6 +15649,18 @@ async function main() {
15509
15649
  process.exit(code);
15510
15650
  return;
15511
15651
  }
15652
+ if (cmd === "serve") {
15653
+ process.exit(await startDaemon());
15654
+ return;
15655
+ }
15656
+ if (cmd === "stop") {
15657
+ process.exit(await stopDaemon());
15658
+ return;
15659
+ }
15660
+ if (cmd === "status") {
15661
+ process.exit(await printStatus());
15662
+ return;
15663
+ }
15512
15664
  if (cmd === "init") {
15513
15665
  const code = await runInit();
15514
15666
  process.exit(code);