@cevek/screentest 0.2.4 → 0.3.0

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/README.md CHANGED
@@ -1,13 +1,12 @@
1
1
  # @cevek/screentest
2
2
 
3
- Local desktop tool for visual screenshot-test review, **plus** the runner
4
- helpers and Docker-based test harness needed to actually capture the
3
+ Local desktop tool for visual screenshot-test review **plus** the runner
4
+ helpers and Docker-based browser harness needed to capture the
5
5
  screenshots. Everything ships in one npm package — no copy-paste.
6
6
 
7
7
  ## Install
8
8
 
9
9
  ```bash
10
- # pick your package manager — all three are supported:
11
10
  npm i -D @cevek/screentest playwright vitest && npx playwright install firefox
12
11
  # pnpm add -D @cevek/screentest playwright vitest && pnpm exec playwright install firefox
13
12
  # yarn add -D @cevek/screentest playwright vitest && yarn playwright install firefox
@@ -15,26 +14,31 @@ npm i -D @cevek/screentest playwright vitest && npx playwright install firefox
15
14
 
16
15
  `playwright` and `vitest` are peer-deps — keep them in your devDependencies.
17
16
 
18
- `screentest init` detects your package manager (from your lockfile or
19
- `packageManager` field) and writes a matching `Dockerfile.tests`.
20
-
21
17
  ## Quick start
22
18
 
23
19
  ```bash
24
- # 1. Scaffold the test harness into your project.
20
+ # 1. Scaffold vitest.screentest.config.ts + tests/example.test.ts
25
21
  npx screentest init
26
22
 
27
- # 2. Build the Docker image once.
28
- docker build -f Dockerfile.tests -t screentest-tests .
23
+ # 2. Start the shared Firefox daemon (one-time per machine — image builds
24
+ # on first run, ~2 min; cached forever after).
25
+ npx screentest serve
29
26
 
30
27
  # 3. Run.
31
28
  APP_URL=http://localhost:5050 npx screentest test
32
29
  ```
33
30
 
31
+ The daemon lives outside your project — one container serves every
32
+ project on your machine. It listens on `ws://localhost:5180`, holds
33
+ ~300MB RAM at idle, and stays up across reboots-of-your-app until you
34
+ `screentest stop` it.
35
+
36
+ Your **tests run natively on the host** (your normal vitest + TS + path
37
+ aliases + watch mode); only the Firefox rendering happens in the
38
+ container, over a WebSocket. Editing tests never needs an image rebuild.
39
+
34
40
  `screentest init` creates:
35
41
 
36
- - `Dockerfile.tests` — playwright-noble + your project deps + vitest CMD
37
- (auto-picked for pnpm / npm / yarn based on your lockfile)
38
42
  - `vitest.screentest.config.ts` — separate vitest config wired with the
39
43
  package's globalSetup. Kept under its own name so your normal
40
44
  `vitest.config.ts` (unit tests) is untouched.
@@ -49,33 +53,38 @@ and screenshot tests via `screentest test` stay completely separate.
49
53
  ## Commands
50
54
 
51
55
  ```bash
52
- screentest <doc-json-path> [--port 5174] [--no-open]
53
- [--worker-url URL] [--token TOKEN]
56
+ screentest serve # start the shared Firefox browser-server daemon
57
+ screentest stop # stop it
58
+ screentest status # is it running?
54
59
  ```
55
60
 
56
- Open the review UI on a pre-built `doc.json`. Used internally by `screentest
57
- test` and `screentest review`.
58
-
59
61
  ```bash
60
- screentest test [--host]
62
+ screentest test
61
63
  ```
62
64
 
63
- Default workflow: run vitest **inside Docker** (network=host), and on failure
64
- auto-launch the review UI. `--host` runs vitest on the host instead (debug
65
- only bytes diverge from your CI baseline). Set `CI=1` to skip the auto-UI
66
- launch.
65
+ Run vitest on the host, connect to the daemon for browser rendering.
66
+ On failure (or new snapshots) auto-launch the review UI. Set `CI=1` to
67
+ skip the auto-UI and just propagate the exit code.
67
68
 
68
69
  ```bash
69
70
  screentest review
70
71
  ```
71
72
 
72
- Skip vitest, just regenerate `doc.json` from cached actuals and open the UI.
73
+ Skip vitest, regenerate `doc.json` from cached actuals, open the UI.
73
74
 
74
75
  ```bash
75
76
  screentest init
76
77
  ```
77
78
 
78
- Scaffold Dockerfile + vitest config + example test (see above).
79
+ Scaffold vitest config + example test (see above).
80
+
81
+ ```bash
82
+ screentest <doc-json-path> [--port 5174] [--no-open]
83
+ [--worker-url URL] [--token TOKEN]
84
+ ```
85
+
86
+ Open the review UI on a pre-built `doc.json`. Used internally by
87
+ `screentest test` and `screentest review`.
79
88
 
80
89
  ## Writing tests
81
90
 
@@ -102,26 +111,33 @@ describe('team', () => {
102
111
  await page.goto(`${APP_URL}/team`);
103
112
  await page.locator('input[name="username"]').waitFor({ state: 'visible' });
104
113
  await compareSnapshot(page, 'login'); // → team / login flow / login
114
+
115
+ // Snapshot just one element instead of the whole page:
116
+ await compareSnapshot(page.locator('header'), 'header');
105
117
  });
106
118
  });
107
119
  ```
108
120
 
109
- Snapshot paths are auto-derived from `describe(...)` + `it(...)`. The
110
- hashes are stored in `snapshot.json` at your project root; actuals + the
111
- generated `doc.json` go to `node_modules/.cache/screentest/` (gitignored
112
- by convention).
121
+ Snapshot paths are auto-derived from `describe(...)` + `it(...)` plus the
122
+ `compareSnapshot` leaf name. Hashes go into `tests/snapshot.json` by
123
+ default; override with `SCREENTEST_SNAPSHOT_FILE=path/to/snapshot.json`.
124
+ Actuals + the generated `doc.json` go to
125
+ `node_modules/.cache/screentest/` (gitignored by convention).
126
+
127
+ > **Upgrading from < 0.3:** an existing `<projectRoot>/snapshot.json` keeps
128
+ > working — the runner picks it up if `tests/snapshot.json` isn't there yet.
113
129
 
114
130
  ## Docker Desktop host networking
115
131
 
116
- `screentest test` uses `--network=host` so the container can reach your
117
- app at `localhost:*`. On macOS / Windows Docker Desktop enable it once:
118
- `Settings → Resources → Network → Enable host networking`. On Linux Docker
119
- (CI) it works out of the box.
132
+ The daemon container uses `--network=host` so its Firefox can reach
133
+ your app at `localhost:*`. On macOS / Windows Docker Desktop enable it
134
+ once: `Settings → Resources → Network → Enable host networking`. On
135
+ Linux Docker (CI) it works out of the box.
120
136
 
121
137
  ## Cloudflare Worker (blob store)
122
138
 
123
- Accepted screenshots are stored in your Cloudflare Worker (R2-backed). See
124
- the [worker template](https://github.com/x-cevek/screentest/tree/main/cloudflare-worker)
139
+ Accepted screenshots are stored in your Cloudflare Worker (R2-backed).
140
+ See the [worker template](https://github.com/x-cevek/screentest/tree/main/cloudflare-worker)
125
141
  for a 100-line implementation + `wrangler deploy` instructions.
126
142
 
127
143
  Once deployed:
@@ -136,12 +152,12 @@ Or pass `--worker-url` / `--token` to `screentest` directly.
136
152
  ## CI
137
153
 
138
154
  ```yaml
139
- - run: docker build -f Dockerfile.tests -t screentest-tests .
155
+ - run: npx screentest serve # builds image + starts daemon
140
156
  - run: CI=1 npx screentest test
141
157
  ```
142
158
 
143
- In `CI=1` the orchestrator exits with vitest's code and never tries to open
144
- the UI.
159
+ In `CI=1` the orchestrator exits with vitest's code and never tries to
160
+ open the UI.
145
161
 
146
162
  ## Input format reference
147
163
 
@@ -2,6 +2,13 @@
2
2
  import { firefox } from "playwright";
3
3
  var server;
4
4
  async function setup(project) {
5
+ const externalWs = process.env.SCREENTEST_FIREFOX_WS;
6
+ if (externalWs) {
7
+ project.provide("wsEndpoint", externalWs);
8
+ console.log(`[screentest] using external firefox daemon: ${externalWs}`);
9
+ return async () => {
10
+ };
11
+ }
5
12
  server = await firefox.launchServer();
6
13
  const wsEndpoint = server.wsEndpoint();
7
14
  project.provide("wsEndpoint", wsEndpoint);
package/dist/index.js CHANGED
@@ -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({
@@ -15092,32 +15100,226 @@ async function runUI(opts) {
15092
15100
  }
15093
15101
 
15094
15102
  // src/orchestrator.ts
15095
- import { spawn, spawnSync } from "child_process";
15096
- import { createHash as createHash2 } from "crypto";
15097
- import { promises as fs4 } from "fs";
15098
- import { join as join3, relative, sep } from "path";
15103
+ import { spawn as spawn2 } from "child_process";
15104
+ import { promises as fs5 } from "fs";
15105
+ import { dirname as dirname5 } from "path";
15099
15106
 
15100
- // src/runner/paths.ts
15101
- import { join as join2, resolve as resolve5 } from "path";
15102
- function resolveRunnerPaths(cwd = process.cwd()) {
15103
- const projectRoot = resolve5(cwd);
15104
- const cacheDir = join2(projectRoot, "node_modules", ".cache", "screentest");
15105
- return {
15106
- projectRoot,
15107
- snapshotFile: join2(projectRoot, "snapshot.json"),
15108
- cacheDir,
15109
- actualDir: join2(cacheDir, "actual"),
15110
- docFile: join2(cacheDir, "doc.json")
15111
- };
15112
- }
15113
-
15114
- // src/orchestrator.ts
15115
- var DOCKER_IMAGE = "screentest-tests";
15107
+ // src/daemon.ts
15108
+ import { spawn, spawnSync } from "child_process";
15109
+ import { existsSync as existsSync2 } from "fs";
15110
+ import { dirname as dirname4, resolve as resolve5 } from "path";
15111
+ import { fileURLToPath as fileURLToPath2 } from "url";
15112
+ import { createConnection } from "net";
15113
+ var IMAGE_NAME = "cevek/screentest-firefox";
15114
+ var IMAGE_TAG = "pw-1.60.0";
15115
+ var FULL_IMAGE = `${IMAGE_NAME}:${IMAGE_TAG}`;
15116
+ var CONTAINER_NAME = "screentest-firefox";
15117
+ var PORT = 5180;
15118
+ var WS_PATH = "screentest-firefox";
15119
+ var WS_ENDPOINT = `ws://localhost:${PORT}/${WS_PATH}`;
15116
15120
  function run(cmd, argv) {
15117
- return new Promise((resolve7) => {
15121
+ return new Promise((resolveExit) => {
15118
15122
  const p = spawn(cmd, argv, { stdio: "inherit" });
15119
- p.on("exit", (code) => resolve7(code ?? 0));
15123
+ p.on("exit", (code) => resolveExit(code ?? 0));
15124
+ });
15125
+ }
15126
+ function daemonState() {
15127
+ const ps = spawnSync(
15128
+ "docker",
15129
+ ["ps", "-a", "-f", `name=^${CONTAINER_NAME}$`, "--format", "{{.Status}} {{.Image}}"],
15130
+ { stdio: "pipe", encoding: "utf8" }
15131
+ );
15132
+ const line = ps.stdout.trim();
15133
+ if (line.startsWith("Up")) {
15134
+ const tab = line.indexOf(" ");
15135
+ const image = tab === -1 ? "" : line.slice(tab + 1).trim();
15136
+ return { state: "running", image };
15137
+ }
15138
+ const img = spawnSync("docker", ["image", "inspect", FULL_IMAGE], { stdio: "pipe" });
15139
+ if (img.status !== 0) return { state: "missing-image" };
15140
+ return { state: "stopped" };
15141
+ }
15142
+ function resolveTemplatesDir() {
15143
+ const here = dirname4(fileURLToPath2(import.meta.url));
15144
+ const candidates = [
15145
+ resolve5(here, "./templates"),
15146
+ resolve5(here, "../templates"),
15147
+ resolve5(here, "../../server/templates")
15148
+ ];
15149
+ for (const c of candidates) {
15150
+ if (existsSync2(c)) return c;
15151
+ }
15152
+ throw new Error("templates/ directory not found relative to daemon.ts");
15153
+ }
15154
+ async function buildImage() {
15155
+ const templates = resolveTemplatesDir();
15156
+ process.stderr.write(`Building Docker image ${FULL_IMAGE} (~2 min first time, cached after).
15157
+ `);
15158
+ const code = await run("docker", [
15159
+ "build",
15160
+ "-f",
15161
+ `${templates}/Dockerfile.firefox-server`,
15162
+ "-t",
15163
+ FULL_IMAGE,
15164
+ templates
15165
+ ]);
15166
+ if (code !== 0) {
15167
+ process.stderr.write(`docker build failed (exit ${code})
15168
+ `);
15169
+ return false;
15170
+ }
15171
+ return true;
15172
+ }
15173
+ function probePort() {
15174
+ return new Promise((resolveProbe) => {
15175
+ const s = createConnection({ host: "127.0.0.1", port: PORT });
15176
+ s.once("connect", () => {
15177
+ s.end();
15178
+ resolveProbe(true);
15179
+ });
15180
+ s.once("error", () => resolveProbe(false));
15181
+ });
15182
+ }
15183
+ async function waitForReady(timeoutMs = 1e4) {
15184
+ const start = Date.now();
15185
+ while (Date.now() - start < timeoutMs) {
15186
+ if (await probePort()) return true;
15187
+ await new Promise((r) => setTimeout(r, 150));
15188
+ }
15189
+ return false;
15190
+ }
15191
+ async function startDaemon() {
15192
+ const s = daemonState();
15193
+ if (s.state === "running") {
15194
+ if (s.image !== FULL_IMAGE) {
15195
+ process.stderr.write(
15196
+ `screentest-firefox daemon is running, but on a different image:
15197
+ running: ${s.image}
15198
+ this @cevek/screentest wants: ${FULL_IMAGE}
15199
+ \u2192 stop it first: screentest stop
15200
+ \u2192 then re-run: screentest serve
15201
+ `
15202
+ );
15203
+ return 1;
15204
+ }
15205
+ process.stdout.write(`screentest-firefox daemon already running at ${WS_ENDPOINT}
15206
+ `);
15207
+ return 0;
15208
+ }
15209
+ if (s.state === "missing-image") {
15210
+ if (!await buildImage()) return 1;
15211
+ }
15212
+ spawnSync("docker", ["rm", "-f", CONTAINER_NAME], { stdio: "pipe" });
15213
+ process.stderr.write(`Starting screentest-firefox daemon (port ${PORT})\u2026
15214
+ `);
15215
+ const code = await run("docker", [
15216
+ "run",
15217
+ "-d",
15218
+ "--rm",
15219
+ "--name",
15220
+ CONTAINER_NAME,
15221
+ "--network=host",
15222
+ FULL_IMAGE
15223
+ ]);
15224
+ if (code !== 0) {
15225
+ process.stderr.write(`docker run failed (exit ${code})
15226
+ `);
15227
+ return code;
15228
+ }
15229
+ if (!await waitForReady()) {
15230
+ process.stderr.write(`Daemon container started but port ${PORT} never opened. Logs:
15231
+ `);
15232
+ spawnSync("docker", ["logs", CONTAINER_NAME], { stdio: "inherit" });
15233
+ return 1;
15234
+ }
15235
+ process.stdout.write(`screentest-firefox daemon ready at ${WS_ENDPOINT}
15236
+ `);
15237
+ return 0;
15238
+ }
15239
+ async function stopDaemon() {
15240
+ const s = daemonState();
15241
+ if (s.state !== "running") {
15242
+ process.stdout.write("screentest-firefox daemon is not running\n");
15243
+ return 0;
15244
+ }
15245
+ const code = await run("docker", ["stop", CONTAINER_NAME]);
15246
+ if (code === 0) process.stdout.write("screentest-firefox daemon stopped\n");
15247
+ return code;
15248
+ }
15249
+ async function printStatus() {
15250
+ const s = daemonState();
15251
+ if (s.state === "running") {
15252
+ const match = s.image === FULL_IMAGE ? "(matches this @cevek/screentest)" : "(MISMATCH!)";
15253
+ process.stdout.write(
15254
+ `screentest-firefox daemon: running
15255
+ ws endpoint: ${WS_ENDPOINT}
15256
+ image: ${s.image} ${match}
15257
+ expected: ${FULL_IMAGE}
15258
+ `
15259
+ );
15260
+ if (s.image !== FULL_IMAGE) {
15261
+ process.stdout.write(
15262
+ ` \u2192 daemon is running an image pinned by a different version of this
15263
+ package. Stop it (screentest stop) and re-serve from the project
15264
+ whose Playwright version you want to use.
15265
+ `
15266
+ );
15267
+ }
15268
+ return 0;
15269
+ }
15270
+ if (s.state === "stopped") {
15271
+ process.stdout.write(
15272
+ `screentest-firefox daemon: stopped (image ${FULL_IMAGE} exists)
15273
+ start it with: screentest serve
15274
+ `
15275
+ );
15276
+ return 0;
15277
+ }
15278
+ process.stdout.write(
15279
+ `screentest-firefox daemon: not installed (image ${FULL_IMAGE} missing)
15280
+ build + start with: screentest serve
15281
+ `
15282
+ );
15283
+ return 0;
15284
+ }
15285
+ async function requireRunningDaemon() {
15286
+ const s = daemonState();
15287
+ if (s.state !== "running") {
15288
+ process.stderr.write(
15289
+ `screentest-firefox daemon is not running.
15290
+ Start it once with: screentest serve
15291
+ It then stays alive across projects until you stop it.
15292
+ `
15293
+ );
15294
+ return false;
15295
+ }
15296
+ if (s.image !== FULL_IMAGE) {
15297
+ process.stderr.write(
15298
+ `screentest-firefox daemon is running with a different Playwright version:
15299
+ running: ${s.image}
15300
+ this @cevek/screentest expects: ${FULL_IMAGE}
15301
+ \u2192 screentest stop && screentest serve
15302
+ (or align package versions across projects sharing the daemon)
15303
+ `
15304
+ );
15305
+ return false;
15306
+ }
15307
+ return true;
15308
+ }
15309
+
15310
+ // src/doc-builder.ts
15311
+ import { spawnSync as spawnSync2 } from "child_process";
15312
+ import { createHash as createHash2 } from "crypto";
15313
+ import { promises as fs4 } from "fs";
15314
+ import { join as join2, relative, sep } from "path";
15315
+ function detectBranch(projectRoot) {
15316
+ const r = spawnSync2("git", ["-C", projectRoot, "rev-parse", "--abbrev-ref", "HEAD"], {
15317
+ stdio: "pipe",
15318
+ encoding: "utf8"
15120
15319
  });
15320
+ if (r.status !== 0) return null;
15321
+ const branch = r.stdout.trim();
15322
+ return branch || null;
15121
15323
  }
15122
15324
  function isGroup(node) {
15123
15325
  return Array.isArray(node.items);
@@ -15150,10 +15352,10 @@ async function walkActual(dir, prefix = []) {
15150
15352
  const out = [];
15151
15353
  for (const e of entries) {
15152
15354
  if (e.isDirectory()) {
15153
- out.push(...await walkActual(join3(dir, e.name), [...prefix, e.name]));
15355
+ out.push(...await walkActual(join2(dir, e.name), [...prefix, e.name]));
15154
15356
  } else if (e.isFile() && e.name.endsWith(".png")) {
15155
15357
  const stem = e.name.slice(0, -4);
15156
- out.push({ path: [...prefix, stem], absFile: join3(dir, e.name) });
15358
+ out.push({ path: [...prefix, stem], absFile: join2(dir, e.name) });
15157
15359
  }
15158
15360
  }
15159
15361
  return out;
@@ -15182,12 +15384,15 @@ function countLeaves(doc) {
15182
15384
  for (const g of doc.groups) walk(g.items);
15183
15385
  return n;
15184
15386
  }
15185
- async function generateDoc(snapshotFile, actualDir, docFile, cacheDir) {
15387
+ async function generateDoc(projectRoot, snapshotFile, actualDir, docFile, cacheDir) {
15186
15388
  const snap = await readSnapshot(snapshotFile);
15187
15389
  const expectedByPath = indexSnapshot(snap);
15188
15390
  const actuals = await walkActual(actualDir);
15189
15391
  const seen = /* @__PURE__ */ new Set();
15190
- const doc = { groups: [] };
15392
+ const doc = {
15393
+ project: { path: projectRoot, branch: detectBranch(projectRoot) },
15394
+ groups: []
15395
+ };
15191
15396
  const summary = { new: 0, change: 0, deleted: 0, unchanged: 0 };
15192
15397
  for (const a of actuals) {
15193
15398
  const buf = await fs4.readFile(a.absFile);
@@ -15236,36 +15441,44 @@ async function generateDoc(snapshotFile, actualDir, docFile, cacheDir) {
15236
15441
  );
15237
15442
  return total;
15238
15443
  }
15239
- function ensureDockerImage() {
15240
- const r = spawnSync("docker", ["image", "inspect", DOCKER_IMAGE], { stdio: "pipe" });
15241
- if (r.status !== 0) {
15242
- process.stderr.write(
15243
- `Docker image "${DOCKER_IMAGE}" not found.
15244
- Build it first: pnpm exec screentest init && docker build -f Dockerfile.tests -t screentest-tests .
15245
- Or run host-side: pnpm exec screentest test --host
15246
- `
15247
- );
15248
- return false;
15444
+
15445
+ // src/runner/paths.ts
15446
+ import { existsSync as existsSync3 } from "fs";
15447
+ import { isAbsolute as isAbsolute2, join as join3, resolve as resolve6 } from "path";
15448
+ function resolveRunnerPaths(cwd = process.cwd()) {
15449
+ const projectRoot = resolve6(cwd);
15450
+ const cacheDir = join3(projectRoot, "node_modules", ".cache", "screentest");
15451
+ return {
15452
+ projectRoot,
15453
+ snapshotFile: resolveSnapshotFile(projectRoot),
15454
+ cacheDir,
15455
+ actualDir: join3(cacheDir, "actual"),
15456
+ docFile: join3(cacheDir, "doc.json")
15457
+ };
15458
+ }
15459
+ function resolveSnapshotFile(projectRoot) {
15460
+ const envPath = process.env.SCREENTEST_SNAPSHOT_FILE;
15461
+ if (envPath) {
15462
+ return isAbsolute2(envPath) ? envPath : join3(projectRoot, envPath);
15249
15463
  }
15250
- return true;
15464
+ const legacyRoot = join3(projectRoot, "snapshot.json");
15465
+ const insideTests = join3(projectRoot, "tests", "snapshot.json");
15466
+ if (existsSync3(legacyRoot) && !existsSync3(insideTests)) return legacyRoot;
15467
+ return insideTests;
15251
15468
  }
15252
- async function runVitestInDocker(snapshotFile, cacheDir) {
15253
- const dockerArgs = ["run", "--rm", "--init", "--network=host"];
15254
- dockerArgs.push("-e", `APP_URL=${process.env.APP_URL || "http://localhost:5050"}`);
15255
- if (process.env.CI) dockerArgs.push("-e", "CI=1");
15256
- dockerArgs.push(
15257
- "-v",
15258
- `${cacheDir}:/work/node_modules/.cache/screentest`,
15259
- "-v",
15260
- `${snapshotFile}:/work/snapshot.json`,
15261
- DOCKER_IMAGE
15262
- );
15263
- return run("docker", dockerArgs);
15469
+
15470
+ // src/orchestrator.ts
15471
+ function run2(cmd, argv, env) {
15472
+ return new Promise((resolveExit) => {
15473
+ const p = spawn2(cmd, argv, { stdio: "inherit", env: env ?? process.env });
15474
+ p.on("exit", (code) => resolveExit(code ?? 0));
15475
+ });
15264
15476
  }
15265
15477
  async function runOrchestrator(opts = {}) {
15266
15478
  const paths = resolveRunnerPaths();
15267
15479
  if (opts.reviewOnly) {
15268
15480
  const total = await generateDoc(
15481
+ paths.projectRoot,
15269
15482
  paths.snapshotFile,
15270
15483
  paths.actualDir,
15271
15484
  paths.docFile,
@@ -15275,36 +15488,38 @@ async function runOrchestrator(opts = {}) {
15275
15488
  process.stdout.write("Nothing to review \u2014 all snapshots match.\n");
15276
15489
  return 0;
15277
15490
  }
15278
- return await run("node", [process.argv[1], paths.docFile]);
15491
+ return await run2("node", [process.argv[1], paths.docFile]);
15279
15492
  }
15280
- await fs4.mkdir(paths.cacheDir, { recursive: true });
15493
+ await fs5.mkdir(paths.cacheDir, { recursive: true });
15281
15494
  try {
15282
- await fs4.access(paths.snapshotFile);
15495
+ await fs5.access(paths.snapshotFile);
15283
15496
  } catch {
15284
- await fs4.writeFile(paths.snapshotFile, '{\n "groups": []\n}\n');
15285
- }
15286
- await fs4.rm(paths.actualDir, { recursive: true, force: true });
15287
- let testCode;
15288
- if (opts.hostMode) {
15289
- testCode = await run("npx", ["vitest", "--config", "vitest.screentest.config.ts", "run"]);
15290
- } else {
15291
- if (opts.requireDockerImage !== false && !ensureDockerImage()) {
15292
- return 1;
15293
- }
15294
- testCode = await runVitestInDocker(paths.snapshotFile, paths.cacheDir);
15295
- }
15497
+ await fs5.mkdir(dirname5(paths.snapshotFile), { recursive: true });
15498
+ await fs5.writeFile(paths.snapshotFile, '{\n "groups": []\n}\n');
15499
+ }
15500
+ await fs5.rm(paths.actualDir, { recursive: true, force: true });
15501
+ if (!await requireRunningDaemon()) {
15502
+ return 1;
15503
+ }
15504
+ const env = { ...process.env, SCREENTEST_FIREFOX_WS: WS_ENDPOINT };
15505
+ const testCode = await run2(
15506
+ "npx",
15507
+ ["vitest", "--config", "vitest.screentest.config.ts", "run"],
15508
+ env
15509
+ );
15296
15510
  if (testCode !== 0 && !process.env.CI) {
15297
15511
  process.stderr.write(
15298
15512
  "\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"
15299
15513
  );
15300
15514
  const total = await generateDoc(
15515
+ paths.projectRoot,
15301
15516
  paths.snapshotFile,
15302
15517
  paths.actualDir,
15303
15518
  paths.docFile,
15304
15519
  paths.cacheDir
15305
15520
  );
15306
15521
  if (total > 0) {
15307
- const reviewCode = await run("node", [process.argv[1], paths.docFile]);
15522
+ const reviewCode = await run2("node", [process.argv[1], paths.docFile]);
15308
15523
  if (reviewCode !== 0 && reviewCode !== 130) {
15309
15524
  process.stderr.write(`
15310
15525
  (screentest exited with code ${reviewCode})
@@ -15320,52 +15535,32 @@ async function runOrchestrator(opts = {}) {
15320
15535
  }
15321
15536
 
15322
15537
  // src/init.ts
15323
- import { copyFile, mkdir, readFile } from "fs/promises";
15324
- import { existsSync as existsSync2 } from "fs";
15325
- import { dirname as dirname4, join as join4, resolve as resolve6 } from "path";
15326
- import { fileURLToPath as fileURLToPath2 } from "url";
15327
- async function detectPackageManager(cwd) {
15328
- if (existsSync2(join4(cwd, "pnpm-lock.yaml"))) return "pnpm";
15329
- if (existsSync2(join4(cwd, "yarn.lock"))) return "yarn";
15330
- if (existsSync2(join4(cwd, "package-lock.json"))) return "npm";
15331
- try {
15332
- const pkg = JSON.parse(
15333
- await readFile(join4(cwd, "package.json"), "utf8")
15334
- );
15335
- const pm = pkg.packageManager ?? "";
15336
- if (pm.startsWith("pnpm")) return "pnpm";
15337
- if (pm.startsWith("yarn")) return "yarn";
15338
- } catch {
15339
- }
15340
- return "npm";
15341
- }
15538
+ import { copyFile, mkdir } from "fs/promises";
15539
+ import { existsSync as existsSync4 } from "fs";
15540
+ import { dirname as dirname6, join as join4, resolve as resolve7 } from "path";
15541
+ import { fileURLToPath as fileURLToPath3 } from "url";
15342
15542
  async function runInit() {
15343
- const here = dirname4(fileURLToPath2(import.meta.url));
15344
- const templatesDir = [resolve6(here, "./templates"), resolve6(here, "../templates")].find((p) => existsSync2(p)) ?? resolve6(here, "./templates");
15543
+ const here = dirname6(fileURLToPath3(import.meta.url));
15544
+ const templatesDir = [resolve7(here, "./templates"), resolve7(here, "../templates")].find((p) => existsSync4(p)) ?? resolve7(here, "./templates");
15345
15545
  const cwd = process.cwd();
15346
- const pm = await detectPackageManager(cwd);
15347
- process.stdout.write(`Detected package manager: ${pm}
15348
-
15349
- `);
15350
15546
  const targets = [
15351
- { from: `Dockerfile.${pm}.tests`, to: "Dockerfile.tests" },
15352
15547
  { from: "vitest.screentest.config.ts", to: "vitest.screentest.config.ts" },
15353
15548
  { from: "example.test.ts", to: "tests/example.test.ts" }
15354
15549
  ];
15355
15550
  for (const { from, to } of targets) {
15356
15551
  const src = join4(templatesDir, from);
15357
15552
  const dst = join4(cwd, to);
15358
- if (existsSync2(dst)) {
15553
+ if (existsSync4(dst)) {
15359
15554
  process.stdout.write(` exists ${to}
15360
15555
  `);
15361
15556
  continue;
15362
15557
  }
15363
- if (!existsSync2(src)) {
15558
+ if (!existsSync4(src)) {
15364
15559
  process.stdout.write(` missing ${from} (package install incomplete?)
15365
15560
  `);
15366
15561
  continue;
15367
15562
  }
15368
- await mkdir(dirname4(dst), { recursive: true });
15563
+ await mkdir(dirname6(dst), { recursive: true });
15369
15564
  await copyFile(src, dst);
15370
15565
  process.stdout.write(` wrote ${to}
15371
15566
  `);
@@ -15373,8 +15568,8 @@ async function runInit() {
15373
15568
  process.stdout.write(
15374
15569
  `
15375
15570
  Next steps:
15376
- 1. docker build -f Dockerfile.tests -t screentest-tests .
15377
- 2. APP_URL=http://localhost:<your-app-port> npx screentest test
15571
+ 1. screentest serve # one-time: starts the shared Firefox daemon
15572
+ 2. APP_URL=http://localhost:<port> screentest test
15378
15573
  `
15379
15574
  );
15380
15575
  return 0;
@@ -15388,14 +15583,20 @@ function printHelp() {
15388
15583
  " screentest <doc-json-path> [--port 5174] [--no-open] [--worker-url URL] [--token TOKEN]",
15389
15584
  " open the review UI on an existing doc.json",
15390
15585
  "",
15391
- " screentest test [--host] run vitest, on failure regenerate doc.json + open UI",
15392
- " default: vitest inside Docker (network=host)",
15393
- " --host: vitest on host (debug only, bytes diverge from CI)",
15586
+ " screentest test run vitest on the host, connect to the shared",
15587
+ " Firefox daemon, on failure regenerate doc.json",
15588
+ " and open the review UI",
15394
15589
  "",
15395
15590
  " screentest review regenerate doc.json from cached actuals + open UI",
15396
15591
  "",
15397
- " screentest init scaffold Dockerfile.tests, vitest.config.ts,",
15398
- " and tests/example.test.ts into the current project",
15592
+ " screentest serve start the shared Firefox browser-server daemon",
15593
+ " (builds the image on first run). One daemon per",
15594
+ " machine \u2014 used by every project",
15595
+ " screentest stop stop the daemon",
15596
+ " screentest status show whether the daemon is running",
15597
+ "",
15598
+ " screentest init scaffold vitest.screentest.config.ts and",
15599
+ " tests/example.test.ts into the current project",
15399
15600
  "",
15400
15601
  "Env vars (UI mode):",
15401
15602
  " CLOUDFLARE_WORKER_URL default https://screentests.x-cevek.workers.dev",
@@ -15416,9 +15617,7 @@ async function main() {
15416
15617
  }
15417
15618
  const cmd = argv[0];
15418
15619
  if (cmd === "test") {
15419
- const rest = argv.slice(1);
15420
- const hostMode = rest.includes("--host");
15421
- const code = await runOrchestrator({ hostMode });
15620
+ const code = await runOrchestrator();
15422
15621
  process.exit(code);
15423
15622
  return;
15424
15623
  }
@@ -15427,6 +15626,18 @@ async function main() {
15427
15626
  process.exit(code);
15428
15627
  return;
15429
15628
  }
15629
+ if (cmd === "serve") {
15630
+ process.exit(await startDaemon());
15631
+ return;
15632
+ }
15633
+ if (cmd === "stop") {
15634
+ process.exit(await stopDaemon());
15635
+ return;
15636
+ }
15637
+ if (cmd === "status") {
15638
+ process.exit(await printStatus());
15639
+ return;
15640
+ }
15430
15641
  if (cmd === "init") {
15431
15642
  const code = await runInit();
15432
15643
  process.exit(code);
package/dist/runner.js CHANGED
@@ -5,18 +5,29 @@ import { dirname, join as join2 } from "path";
5
5
  import { expect } from "vitest";
6
6
 
7
7
  // src/runner/paths.ts
8
- import { join, resolve } from "path";
8
+ import { existsSync } from "fs";
9
+ import { isAbsolute, join, resolve } from "path";
9
10
  function resolveRunnerPaths(cwd = process.cwd()) {
10
11
  const projectRoot = resolve(cwd);
11
12
  const cacheDir = join(projectRoot, "node_modules", ".cache", "screentest");
12
13
  return {
13
14
  projectRoot,
14
- snapshotFile: join(projectRoot, "snapshot.json"),
15
+ snapshotFile: resolveSnapshotFile(projectRoot),
15
16
  cacheDir,
16
17
  actualDir: join(cacheDir, "actual"),
17
18
  docFile: join(cacheDir, "doc.json")
18
19
  };
19
20
  }
21
+ function resolveSnapshotFile(projectRoot) {
22
+ const envPath = process.env.SCREENTEST_SNAPSHOT_FILE;
23
+ if (envPath) {
24
+ return isAbsolute(envPath) ? envPath : join(projectRoot, envPath);
25
+ }
26
+ const legacyRoot = join(projectRoot, "snapshot.json");
27
+ const insideTests = join(projectRoot, "tests", "snapshot.json");
28
+ if (existsSync(legacyRoot) && !existsSync(insideTests)) return legacyRoot;
29
+ return insideTests;
30
+ }
20
31
 
21
32
  // src/runner/stabilize.ts
22
33
  async function freezeDate(ctx, when) {
@@ -0,0 +1,25 @@
1
+ # Universal screentest Firefox browser-server image.
2
+ #
3
+ # Used by `screentest serve` to run a long-lived headless Firefox in a Linux
4
+ # container. Your tests run on the host (native vitest) and connect via
5
+ # WebSocket — so PNG bytes are baked by Linux Firefox (CI-parity) while the
6
+ # rest of your toolchain (TypeScript, deps, watch mode) stays on the host.
7
+ #
8
+ # The image is project-agnostic: it ONLY contains Playwright + Firefox + a
9
+ # 10-line launch script. Rebuild only when the Playwright version pinned
10
+ # below moves.
11
+
12
+ FROM mcr.microsoft.com/playwright:v1.60.0-noble
13
+
14
+ WORKDIR /work
15
+
16
+ # Browsers are pre-installed at /ms-playwright in the base image; we only
17
+ # need the JS package to use `firefox.launchServer(...)`.
18
+ RUN npm init -y >/dev/null \
19
+ && npm install --no-audit --no-fund --no-progress playwright@1.60.0
20
+
21
+ COPY launch-server.mjs ./
22
+
23
+ EXPOSE 5180
24
+
25
+ CMD ["node", "launch-server.mjs"]
@@ -0,0 +1,22 @@
1
+ // Long-lived headless Firefox browser-server. Tests on the host connect via
2
+ // WS, see playwright.dev/docs/api/class-browsertype#browser-type-launch-server
3
+ import { firefox } from 'playwright';
4
+
5
+ const port = Number.parseInt(process.env.SCREENTEST_PORT ?? '5180', 10);
6
+ const wsPath = process.env.SCREENTEST_WS_PATH ?? 'screentest-firefox';
7
+
8
+ const server = await firefox.launchServer({
9
+ port,
10
+ host: '0.0.0.0',
11
+ wsPath,
12
+ });
13
+
14
+ console.log(`[screentest] firefox browser-server ready: ${server.wsEndpoint()}`);
15
+
16
+ const shutdown = async (signal) => {
17
+ console.log(`[screentest] received ${signal}, closing browser-server`);
18
+ await server.close();
19
+ process.exit(0);
20
+ };
21
+ process.on('SIGTERM', () => void shutdown('SIGTERM'));
22
+ process.on('SIGINT', () => void shutdown('SIGINT'));
@@ -6,8 +6,11 @@ export default defineConfig({
6
6
  globalSetup: ['@cevek/screentest/global-setup'],
7
7
  testTimeout: 10_000,
8
8
  hookTimeout: 10_000,
9
+ // One file at a time: screenshot tests share a Firefox browserServer
10
+ // (launched in global-setup) and would collide if files ran in parallel.
11
+ // In vitest 4 `poolOptions` was removed; `fileParallelism: false` forces
12
+ // maxWorkers=1, which is the equivalent of the old `singleFork: true`.
9
13
  pool: 'forks',
10
- poolOptions: { forks: { singleFork: true } },
11
14
  fileParallelism: false,
12
15
  },
13
16
  });
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.2.4",
6
+ "version": "0.3.0",
7
7
  "description": "Local desktop tool for visual screenshot-test review — CLI, runner helpers, and review UI shipped as a single package.",
8
8
  "license": "MIT",
9
9
  "type": "module",
@@ -1,17 +0,0 @@
1
- FROM mcr.microsoft.com/playwright:v1.60.0-noble
2
-
3
- WORKDIR /work
4
-
5
- # Install dependencies. --ignore-scripts skips your project's postinstall
6
- # hooks (which usually scan src/ — not needed for the test image).
7
- COPY package.json package-lock.json ./
8
- RUN npm ci --ignore-scripts
9
-
10
- # Only the bits needed to run vitest.
11
- COPY vitest.screentest.config.ts ./
12
- COPY tsconfig*.json ./
13
- COPY tests ./tests
14
-
15
- # Run vitest directly (skipping `npm exec`) — keeps the test image simple
16
- # and matches how the host orchestrator invokes it.
17
- CMD ["./node_modules/.bin/vitest", "--config", "vitest.screentest.config.ts", "run"]
@@ -1,28 +0,0 @@
1
- FROM mcr.microsoft.com/playwright:v1.60.0-noble
2
-
3
- WORKDIR /work
4
-
5
- # corepack lets us use whatever pnpm version your project's `packageManager`
6
- # field pins (pnpm 11 by default in modern projects). If your project uses
7
- # npm or yarn instead, swap the install commands accordingly.
8
- RUN corepack enable
9
-
10
- # Install dependencies. Flags:
11
- # --ignore-scripts skips your project's postinstall hooks (which
12
- # usually scan src/ — not needed for the test image).
13
- # --config.minimumReleaseAge=0 bypass pnpm 11's supply-chain policy for
14
- # freshly-published packages (the test image is
15
- # ephemeral, runs only what's already in lockfile).
16
- COPY package.json pnpm-lock.yaml ./
17
- COPY pnpm-workspace.yaml* ./
18
- RUN pnpm install --frozen-lockfile --ignore-scripts --config.minimumReleaseAge=0
19
-
20
- # Only the bits needed to run vitest.
21
- COPY vitest.screentest.config.ts ./
22
- COPY tsconfig*.json ./
23
- COPY tests ./tests
24
-
25
- # Run vitest directly (not via pnpm exec) — pnpm 11 re-runs its supply-chain
26
- # policy on every exec, which would re-fail freshly-published packages
27
- # even after install succeeded.
28
- CMD ["./node_modules/.bin/vitest", "--config", "vitest.screentest.config.ts", "run"]
@@ -1,21 +0,0 @@
1
- FROM mcr.microsoft.com/playwright:v1.60.0-noble
2
-
3
- WORKDIR /work
4
-
5
- # corepack lets yarn 2+/berry self-install from your project's
6
- # `packageManager` field. For yarn 1 it's a no-op (yarn is bundled in newer
7
- # Node images anyway).
8
- RUN corepack enable
9
-
10
- # Install dependencies. --ignore-scripts skips your project's postinstall
11
- # hooks (which usually scan src/ — not needed for the test image).
12
- COPY package.json yarn.lock ./
13
- COPY .yarnrc.yml* ./
14
- RUN yarn install --frozen-lockfile --ignore-scripts
15
-
16
- # Only the bits needed to run vitest.
17
- COPY vitest.screentest.config.ts ./
18
- COPY tsconfig*.json ./
19
- COPY tests ./tests
20
-
21
- CMD ["./node_modules/.bin/vitest", "--config", "vitest.screentest.config.ts", "run"]