@cevek/screentest 0.1.0 → 0.2.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,146 +1,140 @@
1
- # screentest
1
+ # @cevek/screentest
2
2
 
3
- Local desktop tool for visual screenshot-test review. Takes a JSON tree of
4
- diffs as input, serves a React UI on `127.0.0.1`, lets a human accept new /
5
- changed snapshots one-by-one or in bulk. Accepted screenshots are uploaded to
6
- your Cloudflare Worker (R2) and the diff is written back into your project's
7
- `snapshot.json`.
3
+ Local desktop tool for visual screenshot-test review, **plus** the runner
4
+ helpers and Docker-based test harness needed to actually capture the
5
+ screenshots. Everything ships in one npm package no copy-paste.
8
6
 
9
7
  ## Install
10
8
 
11
9
  ```bash
12
- npm i -D @cevek/screentest
13
- # or pnpm add -D @cevek/screentest / yarn add -D @cevek/screentest
10
+ pnpm add -D @cevek/screentest playwright vitest
11
+ pnpm exec playwright install firefox
14
12
  ```
15
13
 
16
- `screentest` is a CLIno library API. The package installs a single
17
- `screentest` bin into `node_modules/.bin/`.
14
+ `playwright` and `vitest` are peer-deps keep them in your devDependencies.
18
15
 
19
- ## Run
16
+ ## Quick start
17
+
18
+ ```bash
19
+ # 1. Scaffold the test harness into your project.
20
+ pnpm exec screentest init
21
+
22
+ # 2. Build the Docker image once.
23
+ docker build -f Dockerfile.tests -t screentest-tests .
24
+
25
+ # 3. Run.
26
+ APP_URL=http://localhost:5050 pnpm exec screentest test
27
+ ```
28
+
29
+ `screentest init` creates:
30
+
31
+ - `Dockerfile.tests` — playwright-noble + your project deps + vitest CMD
32
+ - `vitest.config.ts` — pre-wired with the package's globalSetup
33
+ - `tests/example.test.ts` — minimal Playwright test using the runner helpers
34
+
35
+ Existing files are not overwritten — re-run `init` safely.
36
+
37
+ ## Commands
20
38
 
21
39
  ```bash
22
40
  screentest <doc-json-path> [--port 5174] [--no-open]
23
- [--worker-url <url>] [--token <token>]
41
+ [--worker-url URL] [--token TOKEN]
42
+ ```
43
+ Open the review UI on a pre-built `doc.json`. Used internally by `screentest
44
+ test` and `screentest review`.
45
+
46
+ ```bash
47
+ screentest test [--host]
48
+ ```
49
+ Default workflow: run vitest **inside Docker** (network=host), and on failure
50
+ auto-launch the review UI. `--host` runs vitest on the host instead (debug
51
+ only — bytes diverge from your CI baseline). Set `CI=1` to skip the auto-UI
52
+ launch.
53
+
54
+ ```bash
55
+ screentest review
24
56
  ```
57
+ Skip vitest, just regenerate `doc.json` from cached actuals and open the UI.
25
58
 
26
- `doc.json` is the input describing what to review. See the
27
- [input format](#input-format-docjson) below.
28
-
29
- Env vars:
30
-
31
- | Var | Default |
32
- | ----------------------- | ----------------------------------------- |
33
- | `CLOUDFLARE_WORKER_URL` | `https://screentests.x-cevek.workers.dev` |
34
- | `CLOUDFLARE_TOKEN` | `SECRET_123` |
35
-
36
- You almost certainly want to point these at your own R2-backed Worker. See
37
- the worker template at <https://github.com/x-cevek/screentest/tree/main/cloudflare-worker>.
38
-
39
- ## Hotkeys
40
-
41
- | Key | Action |
42
- | ------------------------------ | --------------------------------------- |
43
- | `↑` / `↓` | Previous / next pending test |
44
- | `Enter` | Accept current (if valid) |
45
- | `Cmd/Ctrl + wheel` over canvas | Move comparison slider |
46
- | Hold mouse on **Show diff** | Purple-pixel overlay (change-type only) |
47
-
48
- ## Input format: doc.json
49
-
50
- ```json
51
- {
52
- "groups": [
53
- {
54
- "name": "team",
55
- "items": [
56
- {
57
- "name": "login",
58
- "type": "new",
59
- "actualFilename": "actual/team/login.png",
60
- "patchSnapshotJsonFile": "/abs/path/to/snapshot.json"
61
- },
62
- {
63
- "name": "page",
64
- "type": "change",
65
- "expectedHash": "aaa…(64 hex chars)…",
66
- "actualFilename": "actual/team/page.png",
67
- "patchSnapshotJsonFile": "/abs/path/to/snapshot.json"
68
- },
69
- {
70
- "name": "deprecated-banner",
71
- "type": "deleted",
72
- "expectedHash": "bbb…(64 hex chars)…",
73
- "patchSnapshotJsonFile": "/abs/path/to/snapshot.json"
74
- }
75
- ]
76
- }
77
- ]
78
- }
59
+ ```bash
60
+ screentest init
79
61
  ```
62
+ Scaffold Dockerfile + vitest config + example test (see above).
63
+
64
+ ## Writing tests
65
+
66
+ ```ts
67
+ import { afterAll, beforeAll, describe, it } from 'vitest';
68
+ import type { Page } from 'playwright';
69
+ import { compareSnapshot, freezeDate, newPage } from '@cevek/screentest/runner';
70
+
71
+ const APP_URL = (process.env.APP_URL || 'http://localhost:5050').replace(/\/+$/, '');
72
+
73
+ let page: Page;
74
+ let cleanup: () => Promise<void>;
75
+
76
+ beforeAll(async () => {
77
+ const r = await newPage();
78
+ await freezeDate(r.ctx, '2024-01-15T12:00:00Z'); // optional
79
+ page = r.page;
80
+ cleanup = r.cleanup;
81
+ });
82
+ afterAll(() => cleanup?.());
83
+
84
+ describe('team', () => {
85
+ it('login flow', async () => {
86
+ await page.goto(`${APP_URL}/team`);
87
+ await page.locator('input[name="username"]').waitFor({ state: 'visible' });
88
+ await compareSnapshot(page, 'login'); // → team / login flow / login
89
+ });
90
+ });
91
+ ```
92
+
93
+ Snapshot paths are auto-derived from `describe(...)` + `it(...)`. The
94
+ hashes are stored in `snapshot.json` at your project root; actuals + the
95
+ generated `doc.json` go to `node_modules/.cache/screentest/` (gitignored
96
+ by convention).
97
+
98
+ ## Docker Desktop host networking
99
+
100
+ `screentest test` uses `--network=host` so the container can reach your
101
+ app at `localhost:*`. On macOS / Windows Docker Desktop enable it once:
102
+ `Settings → Resources → Network → Enable host networking`. On Linux Docker
103
+ (CI) it works out of the box.
104
+
105
+ ## Cloudflare Worker (blob store)
80
106
 
81
- Three test-item types:
82
-
83
- - **`new`** `actualFilename` required. On accept, hashes the file (SHA-256
84
- hex), uploads it to Cloudflare, inserts `{ name, hash }` into the snapshot
85
- file.
86
- - **`change`** — both `actualFilename` and `expectedHash` required. Tool shows
87
- expected vs actual with a slider + purple-pixel diff overlay. On accept, same
88
- as `new` (re-hash, re-upload, update the entry).
89
- - **`deleted`** `expectedHash` required, no `actualFilename`. Tool shows the
90
- expected image plus a "this will be removed" plate. On accept, removes the
91
- entry from the snapshot file and prunes any group that becomes empty.
92
-
93
- Paths in `actualFilename` are resolved relative to the doc.json directory.
94
- `patchSnapshotJsonFile` can be relative or absolute.
95
-
96
- ## Snapshot file format
97
-
98
- What the tool writes into `patchSnapshotJsonFile`:
99
-
100
- ```json
101
- {
102
- "groups": [
103
- {
104
- "name": "team",
105
- "items": [
106
- { "name": "login", "hash": "aaa…(64 hex chars)…" },
107
- { "name": "page", "hash": "bbb…(64 hex chars)…" }
108
- ]
109
- }
110
- ]
111
- }
107
+ Accepted screenshots are stored in your Cloudflare Worker (R2-backed). See
108
+ the [worker template](https://github.com/x-cevek/screentest/tree/main/cloudflare-worker)
109
+ for a 100-line implementation + `wrangler deploy` instructions.
110
+
111
+ Once deployed:
112
+
113
+ ```bash
114
+ export CLOUDFLARE_WORKER_URL="https://screentests.your-account.workers.dev"
115
+ export CLOUDFLARE_TOKEN="<UPLOAD_TOKEN secret you set with wrangler>"
112
116
  ```
113
117
 
114
- Writes are atomic (tmp file + rename) and serialized by a per-file mutex
115
- inside the server, so concurrent accepts don't race.
118
+ Or pass `--worker-url` / `--token` to `screentest` directly.
116
119
 
117
- ## How tests produce doc.json
120
+ ## CI
118
121
 
119
- The tool does NOT capture screenshots — it only reviews them. Producing the
120
- actual screenshots and the doc.json is your test runner's job. The simplest
121
- pattern:
122
+ ```yaml
123
+ - run: docker build -f Dockerfile.tests -t screentest-tests .
124
+ - run: CI=1 pnpm exec screentest test
125
+ ```
122
126
 
123
- 1. In each test, take a screenshot with Playwright; save to a known path; hash
124
- it; compare to the entry in `snapshot.json` for that test. Fail the test
125
- on mismatch.
126
- 2. On failure (locally), run a script that walks the captured screenshots,
127
- diffs them against `snapshot.json` (new / change / deleted), and writes a
128
- `doc.json`.
129
- 3. Spawn `screentest doc.json`. User reviews and accepts.
127
+ In `CI=1` the orchestrator exits with vitest's code and never tries to open
128
+ the UI.
130
129
 
131
- See the [example test-runner setup](https://github.com/x-cevek/screentest/tree/main/test-project)
132
- for a reference implementation (Vitest + Playwright + helpers).
130
+ ## Input format reference
131
+
132
+ If you need to construct your own `doc.json` for the UI (instead of going
133
+ through `screentest test` / `review`), see
134
+ [the format documentation in the source repo](https://github.com/x-cevek/screentest#input-format-docjson).
133
135
 
134
136
  ## Security
135
137
 
136
- - Server listens only on `127.0.0.1` never on `0.0.0.0`.
138
+ - The UI server listens only on `127.0.0.1`. Never on `0.0.0.0`.
137
139
  - `actual` files are served strictly from a whitelist computed from
138
- `doc.json` at startup. No path-traversal possible from the HTTP layer.
139
- - Stays in-process; no daemon, no auto-update.
140
-
141
- ## Cloudflare Worker
142
-
143
- The Worker is the blob store. It stores accepted screenshots keyed by their
144
- SHA-256 hash and serves them back on subsequent reviews. See the
145
- [worker template + wrangler config](https://github.com/x-cevek/screentest/tree/main/cloudflare-worker)
146
- for the minimal implementation backed by R2.
140
+ `doc.json` at startup no path traversal possible.
@@ -0,0 +1,10 @@
1
+ import type { TestProject } from 'vitest/node';
2
+
3
+ declare const setup: (project: TestProject) => Promise<() => Promise<void>>;
4
+ export default setup;
5
+
6
+ declare module 'vitest' {
7
+ interface ProvidedContext {
8
+ wsEndpoint: string;
9
+ }
10
+ }
@@ -0,0 +1,15 @@
1
+ // src/runner/global-setup.ts
2
+ import { firefox } from "playwright";
3
+ var server;
4
+ async function setup(project) {
5
+ server = await firefox.launchServer();
6
+ const wsEndpoint = server.wsEndpoint();
7
+ project.provide("wsEndpoint", wsEndpoint);
8
+ console.log(`[screentest] firefox server ready: ${wsEndpoint}`);
9
+ return async () => {
10
+ await server?.close();
11
+ };
12
+ }
13
+ export {
14
+ setup as default
15
+ };
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@ var __export = (target, all) => {
5
5
  __defProp(target, name, { get: all[name], enumerable: true });
6
6
  };
7
7
 
8
- // src/index.ts
8
+ // src/ui.ts
9
9
  import { promises as fs3 } from "fs";
10
10
  import { dirname as dirname3, isAbsolute, resolve as resolve4 } from "path";
11
11
 
@@ -14595,7 +14595,7 @@ function buildTestId(parents, name) {
14595
14595
  return [...parents, name].join("/");
14596
14596
  }
14597
14597
 
14598
- // src/index.ts
14598
+ // src/ui.ts
14599
14599
  import open from "open";
14600
14600
 
14601
14601
  // src/state.ts
@@ -14985,33 +14985,29 @@ function isFree(port) {
14985
14985
  });
14986
14986
  }
14987
14987
 
14988
- // src/index.ts
14988
+ // src/ui.ts
14989
14989
  var DEFAULT_WORKER_URL = "https://screentests.x-cevek.workers.dev";
14990
14990
  var DEFAULT_TOKEN = "SECRET_123";
14991
14991
  var DEFAULT_PORT = 5174;
14992
- function parseArgs(argv) {
14993
- const args = argv.slice(2);
14992
+ function parseUIArgs(rawArgs) {
14994
14993
  let docPath = null;
14995
14994
  let port = Number(process.env.PORT) || DEFAULT_PORT;
14996
14995
  let doOpen = true;
14997
14996
  let workerUrl = process.env.CLOUDFLARE_WORKER_URL || DEFAULT_WORKER_URL;
14998
14997
  let token = process.env.CLOUDFLARE_TOKEN || DEFAULT_TOKEN;
14999
- for (let i = 0; i < args.length; i++) {
15000
- const a = args[i];
14998
+ for (let i = 0; i < rawArgs.length; i++) {
14999
+ const a = rawArgs[i];
15001
15000
  if (a === "--port") {
15002
- port = Number(args[++i]);
15001
+ port = Number(rawArgs[++i]);
15003
15002
  if (!Number.isFinite(port) || port <= 0) throw new Error("--port must be a number");
15004
15003
  } else if (a === "--no-open") {
15005
15004
  doOpen = false;
15006
15005
  } else if (a === "--worker-url") {
15007
- workerUrl = args[++i] ?? "";
15006
+ workerUrl = rawArgs[++i] ?? "";
15008
15007
  if (!workerUrl) throw new Error("--worker-url requires a value");
15009
15008
  } else if (a === "--token") {
15010
- token = args[++i] ?? "";
15009
+ token = rawArgs[++i] ?? "";
15011
15010
  if (!token) throw new Error("--token requires a value");
15012
- } else if (a === "--help" || a === "-h") {
15013
- printHelp();
15014
- process.exit(0);
15015
15011
  } else if (a.startsWith("--")) {
15016
15012
  throw new Error(`Unknown flag: ${a}`);
15017
15013
  } else if (!docPath) {
@@ -15020,10 +15016,7 @@ function parseArgs(argv) {
15020
15016
  throw new Error(`Unexpected positional arg: ${a}`);
15021
15017
  }
15022
15018
  }
15023
- if (!docPath) {
15024
- printHelp();
15025
- process.exit(1);
15026
- }
15019
+ if (!docPath) throw new Error("doc-json path is required");
15027
15020
  return {
15028
15021
  docPath,
15029
15022
  port,
@@ -15032,15 +15025,8 @@ function parseArgs(argv) {
15032
15025
  token
15033
15026
  };
15034
15027
  }
15035
- function printHelp() {
15036
- process.stderr.write(
15037
- `Usage: screentest <doc-json-path> [--port 5174] [--no-open] [--worker-url URL] [--token TOKEN]
15038
- `
15039
- );
15040
- }
15041
- async function main() {
15042
- const args = parseArgs(process.argv);
15043
- const docAbs = isAbsolute(args.docPath) ? args.docPath : resolve4(process.cwd(), args.docPath);
15028
+ async function runUI(opts) {
15029
+ const docAbs = isAbsolute(opts.docPath) ? opts.docPath : resolve4(process.cwd(), opts.docPath);
15044
15030
  const docDir = dirname3(docAbs);
15045
15031
  let raw;
15046
15032
  try {
@@ -15083,15 +15069,15 @@ async function main() {
15083
15069
  const state = {
15084
15070
  doc,
15085
15071
  docDir,
15086
- workerUrl: args.workerUrl,
15087
- token: args.token,
15072
+ workerUrl: opts.workerUrl,
15073
+ token: opts.token,
15088
15074
  flat: flatResult.flat,
15089
15075
  actualWhitelist: flatResult.whitelist
15090
15076
  };
15091
- const srv = await startServer(state, args.port);
15077
+ const srv = await startServer(state, opts.port);
15092
15078
  process.stdout.write(`screentest running on ${srv.url}
15093
15079
  `);
15094
- if (args.open) {
15080
+ if (opts.open) {
15095
15081
  try {
15096
15082
  await open(srv.url);
15097
15083
  } catch {
@@ -15104,6 +15090,322 @@ async function main() {
15104
15090
  process.on("SIGINT", shutdown);
15105
15091
  process.on("SIGTERM", shutdown);
15106
15092
  }
15093
+
15094
+ // 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";
15099
+
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";
15116
+ function run(cmd, argv) {
15117
+ return new Promise((resolve7) => {
15118
+ const p = spawn(cmd, argv, { stdio: "inherit" });
15119
+ p.on("exit", (code) => resolve7(code ?? 0));
15120
+ });
15121
+ }
15122
+ function isGroup(node) {
15123
+ return Array.isArray(node.items);
15124
+ }
15125
+ async function readSnapshot(snapshotFile) {
15126
+ try {
15127
+ return JSON.parse(await fs4.readFile(snapshotFile, "utf8"));
15128
+ } catch {
15129
+ return { groups: [] };
15130
+ }
15131
+ }
15132
+ function indexSnapshot(snap) {
15133
+ const out = /* @__PURE__ */ new Map();
15134
+ const walk = (items, prefix) => {
15135
+ for (const it of items) {
15136
+ if (isGroup(it)) walk(it.items, [...prefix, it.name]);
15137
+ else out.set([...prefix, it.name].join("/"), it.hash);
15138
+ }
15139
+ };
15140
+ for (const g of snap.groups) walk(g.items ?? [], [g.name]);
15141
+ return out;
15142
+ }
15143
+ async function walkActual(dir, prefix = []) {
15144
+ let entries;
15145
+ try {
15146
+ entries = await fs4.readdir(dir, { withFileTypes: true });
15147
+ } catch {
15148
+ return [];
15149
+ }
15150
+ const out = [];
15151
+ for (const e of entries) {
15152
+ if (e.isDirectory()) {
15153
+ out.push(...await walkActual(join3(dir, e.name), [...prefix, e.name]));
15154
+ } else if (e.isFile() && e.name.endsWith(".png")) {
15155
+ const stem = e.name.slice(0, -4);
15156
+ out.push({ path: [...prefix, stem], absFile: join3(dir, e.name) });
15157
+ }
15158
+ }
15159
+ return out;
15160
+ }
15161
+ function insertLeaf(doc, path, leaf) {
15162
+ let groups = doc.groups;
15163
+ for (let i = 0; i < path.length - 1; i++) {
15164
+ const name = path[i];
15165
+ let g = groups.find((x) => "items" in x && x.name === name);
15166
+ if (!g) {
15167
+ g = { name, items: [] };
15168
+ groups.push(g);
15169
+ }
15170
+ groups = g.items;
15171
+ }
15172
+ groups.push({ name: path[path.length - 1], ...leaf });
15173
+ }
15174
+ function countLeaves(doc) {
15175
+ let n = 0;
15176
+ const walk = (items) => {
15177
+ for (const it of items) {
15178
+ if ("items" in it) walk(it.items);
15179
+ else n++;
15180
+ }
15181
+ };
15182
+ for (const g of doc.groups) walk(g.items);
15183
+ return n;
15184
+ }
15185
+ async function generateDoc(snapshotFile, actualDir, docFile, cacheDir) {
15186
+ const snap = await readSnapshot(snapshotFile);
15187
+ const expectedByPath = indexSnapshot(snap);
15188
+ const actuals = await walkActual(actualDir);
15189
+ const seen = /* @__PURE__ */ new Set();
15190
+ const doc = { groups: [] };
15191
+ const summary = { new: 0, change: 0, deleted: 0, unchanged: 0 };
15192
+ for (const a of actuals) {
15193
+ const buf = await fs4.readFile(a.absFile);
15194
+ const hash2 = createHash2("sha256").update(buf).digest("hex");
15195
+ const key = a.path.join("/");
15196
+ seen.add(key);
15197
+ const expHash = expectedByPath.get(key);
15198
+ const relPath = relative(cacheDir, a.absFile).split(sep).join("/");
15199
+ if (expHash === void 0 || expHash === null) {
15200
+ insertLeaf(doc, a.path, {
15201
+ type: "new",
15202
+ actualFilename: relPath,
15203
+ patchSnapshotJsonFile: snapshotFile
15204
+ });
15205
+ summary.new++;
15206
+ continue;
15207
+ }
15208
+ if (expHash === hash2) {
15209
+ summary.unchanged++;
15210
+ continue;
15211
+ }
15212
+ insertLeaf(doc, a.path, {
15213
+ type: "change",
15214
+ expectedHash: expHash,
15215
+ actualFilename: relPath,
15216
+ patchSnapshotJsonFile: snapshotFile
15217
+ });
15218
+ summary.change++;
15219
+ }
15220
+ for (const [key, hash2] of expectedByPath) {
15221
+ if (seen.has(key) || !hash2) continue;
15222
+ insertLeaf(doc, key.split("/"), {
15223
+ type: "deleted",
15224
+ expectedHash: hash2,
15225
+ patchSnapshotJsonFile: snapshotFile
15226
+ });
15227
+ summary.deleted++;
15228
+ }
15229
+ await fs4.mkdir(cacheDir, { recursive: true });
15230
+ await fs4.writeFile(docFile, JSON.stringify(doc, null, 2));
15231
+ const total = countLeaves(doc);
15232
+ process.stdout.write(
15233
+ `Wrote ${docFile}
15234
+ ${total} diff${total === 1 ? "" : "s"} to review (new: ${summary.new}, change: ${summary.change}, deleted: ${summary.deleted}, unchanged: ${summary.unchanged})
15235
+ `
15236
+ );
15237
+ return total;
15238
+ }
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;
15249
+ }
15250
+ return true;
15251
+ }
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);
15264
+ }
15265
+ async function runOrchestrator(opts = {}) {
15266
+ const paths = resolveRunnerPaths();
15267
+ if (opts.reviewOnly) {
15268
+ const total = await generateDoc(paths.snapshotFile, paths.actualDir, paths.docFile, paths.cacheDir);
15269
+ if (total === 0) {
15270
+ process.stdout.write("Nothing to review \u2014 all snapshots match.\n");
15271
+ return 0;
15272
+ }
15273
+ return await run("node", [process.argv[1], paths.docFile]);
15274
+ }
15275
+ await fs4.mkdir(paths.cacheDir, { recursive: true });
15276
+ try {
15277
+ await fs4.access(paths.snapshotFile);
15278
+ } catch {
15279
+ await fs4.writeFile(paths.snapshotFile, '{\n "groups": []\n}\n');
15280
+ }
15281
+ await fs4.rm(paths.actualDir, { recursive: true, force: true });
15282
+ let testCode;
15283
+ if (opts.hostMode) {
15284
+ testCode = await run("pnpm", ["exec", "vitest", "run"]);
15285
+ } else {
15286
+ if (opts.requireDockerImage !== false && !ensureDockerImage()) {
15287
+ return 1;
15288
+ }
15289
+ testCode = await runVitestInDocker(paths.snapshotFile, paths.cacheDir);
15290
+ }
15291
+ if (testCode !== 0 && !process.env.CI) {
15292
+ process.stderr.write(
15293
+ "\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"
15294
+ );
15295
+ const total = await generateDoc(paths.snapshotFile, paths.actualDir, paths.docFile, paths.cacheDir);
15296
+ if (total > 0) {
15297
+ const reviewCode = await run("node", [process.argv[1], paths.docFile]);
15298
+ if (reviewCode !== 0 && reviewCode !== 130) {
15299
+ process.stderr.write(`
15300
+ (screentest exited with code ${reviewCode})
15301
+ `);
15302
+ }
15303
+ } else {
15304
+ process.stderr.write(
15305
+ "\n(no diffs to review \u2014 vitest may have failed before any screenshot)\n"
15306
+ );
15307
+ }
15308
+ }
15309
+ return testCode;
15310
+ }
15311
+
15312
+ // src/init.ts
15313
+ import { copyFile, mkdir } from "fs/promises";
15314
+ import { existsSync as existsSync2 } from "fs";
15315
+ import { dirname as dirname4, join as join4, resolve as resolve6 } from "path";
15316
+ import { fileURLToPath as fileURLToPath2 } from "url";
15317
+ async function runInit() {
15318
+ const here = dirname4(fileURLToPath2(import.meta.url));
15319
+ const templatesDir = [resolve6(here, "../templates"), resolve6(here, "../../templates")].find((p) => existsSync2(p)) ?? resolve6(here, "../templates");
15320
+ const cwd = process.cwd();
15321
+ const targets = [
15322
+ { from: "Dockerfile.tests", to: "Dockerfile.tests" },
15323
+ { from: "vitest.config.ts", to: "vitest.config.ts" },
15324
+ { from: "example.test.ts", to: "tests/example.test.ts" }
15325
+ ];
15326
+ for (const { from, to } of targets) {
15327
+ const src = join4(templatesDir, from);
15328
+ const dst = join4(cwd, to);
15329
+ if (existsSync2(dst)) {
15330
+ process.stdout.write(` exists ${to}
15331
+ `);
15332
+ continue;
15333
+ }
15334
+ if (!existsSync2(src)) {
15335
+ process.stdout.write(` missing ${from} (package install incomplete?)
15336
+ `);
15337
+ continue;
15338
+ }
15339
+ await mkdir(dirname4(dst), { recursive: true });
15340
+ await copyFile(src, dst);
15341
+ process.stdout.write(` wrote ${to}
15342
+ `);
15343
+ }
15344
+ process.stdout.write(
15345
+ `
15346
+ Next steps:
15347
+ 1. docker build -f Dockerfile.tests -t screentest-tests .
15348
+ 2. APP_URL=http://localhost:<your-app-port> pnpm exec screentest test
15349
+ `
15350
+ );
15351
+ return 0;
15352
+ }
15353
+
15354
+ // src/index.ts
15355
+ function printHelp() {
15356
+ process.stderr.write(
15357
+ [
15358
+ "Usage:",
15359
+ " screentest <doc-json-path> [--port 5174] [--no-open] [--worker-url URL] [--token TOKEN]",
15360
+ " open the review UI on an existing doc.json",
15361
+ "",
15362
+ " screentest test [--host] run vitest, on failure regenerate doc.json + open UI",
15363
+ " default: vitest inside Docker (network=host)",
15364
+ " --host: vitest on host (debug only, bytes diverge from CI)",
15365
+ "",
15366
+ " screentest review regenerate doc.json from cached actuals + open UI",
15367
+ "",
15368
+ " screentest init scaffold Dockerfile.tests, vitest.config.ts,",
15369
+ " and tests/example.test.ts into the current project",
15370
+ "",
15371
+ "Env vars (UI mode):",
15372
+ " CLOUDFLARE_WORKER_URL default https://screentests.x-cevek.workers.dev",
15373
+ " CLOUDFLARE_TOKEN default SECRET_123",
15374
+ "",
15375
+ "Env vars (test mode):",
15376
+ " APP_URL default http://localhost:5050",
15377
+ " CI if set, do not auto-open the review UI on failure",
15378
+ ""
15379
+ ].join("\n")
15380
+ );
15381
+ }
15382
+ async function main() {
15383
+ const argv = process.argv.slice(2);
15384
+ if (argv.length === 0 || argv[0] === "--help" || argv[0] === "-h") {
15385
+ printHelp();
15386
+ process.exit(argv.length === 0 ? 1 : 0);
15387
+ }
15388
+ const cmd = argv[0];
15389
+ if (cmd === "test") {
15390
+ const rest = argv.slice(1);
15391
+ const hostMode = rest.includes("--host");
15392
+ const code = await runOrchestrator({ hostMode });
15393
+ process.exit(code);
15394
+ return;
15395
+ }
15396
+ if (cmd === "review") {
15397
+ const code = await runOrchestrator({ reviewOnly: true });
15398
+ process.exit(code);
15399
+ return;
15400
+ }
15401
+ if (cmd === "init") {
15402
+ const code = await runInit();
15403
+ process.exit(code);
15404
+ return;
15405
+ }
15406
+ const opts = parseUIArgs(argv);
15407
+ await runUI(opts);
15408
+ }
15107
15409
  main().catch((err) => {
15108
15410
  const e = err instanceof Error ? err : new Error(String(err));
15109
15411
  process.stderr.write(`Fatal: ${e.message}
@@ -0,0 +1,41 @@
1
+ import type { BrowserContext, BrowserContextOptions, Page } from 'playwright';
2
+
3
+ /**
4
+ * Capture a full-page PNG, save under
5
+ * `<project>/node_modules/.cache/screentest/actual/<...path>.png`, and
6
+ * compare its SHA256 against the hash in `<project>/snapshot.json`.
7
+ *
8
+ * The snapshot path is built automatically from the surrounding
9
+ * `describe(...)` + `it(...)` chain, plus `name` as the leaf. Prefix `name`
10
+ * with `/` to opt out of auto-grouping and use an absolute path.
11
+ */
12
+ export function compareSnapshot(page: Page, name: string): Promise<void>;
13
+
14
+ /**
15
+ * Freeze `Date` inside `ctx`. `when` may be an ISO string, unix-ms number,
16
+ * or `Date`. Must be called BEFORE the first navigation in the context.
17
+ */
18
+ export function freezeDate(
19
+ ctx: BrowserContext,
20
+ when: string | number | Date,
21
+ ): Promise<void>;
22
+
23
+ /**
24
+ * Wait for `document.fonts.ready` and every `<img>` to reach `complete`.
25
+ * Called automatically inside `compareSnapshot`; exported for cases where
26
+ * you take screenshots through other means.
27
+ */
28
+ export function waitForVisualStable(page: Page): Promise<void>;
29
+
30
+ export interface NewPageResult {
31
+ ctx: BrowserContext;
32
+ page: Page;
33
+ cleanup(): Promise<void>;
34
+ }
35
+
36
+ /**
37
+ * Connect to the shared Firefox browserServer (launched once per run by
38
+ * `@cevek/screentest/global-setup`), create a fresh context + page, return
39
+ * them along with a cleanup callback. Call in `beforeAll` in each test file.
40
+ */
41
+ export function newPage(opts?: BrowserContextOptions): Promise<NewPageResult>;
package/dist/runner.js ADDED
@@ -0,0 +1,164 @@
1
+ // src/runner/snapshot.ts
2
+ import { createHash } from "crypto";
3
+ import { mkdir, readFile, writeFile } from "fs/promises";
4
+ import { dirname, join as join2 } from "path";
5
+ import { expect } from "vitest";
6
+
7
+ // src/runner/paths.ts
8
+ import { join, resolve } from "path";
9
+ function resolveRunnerPaths(cwd = process.cwd()) {
10
+ const projectRoot = resolve(cwd);
11
+ const cacheDir = join(projectRoot, "node_modules", ".cache", "screentest");
12
+ return {
13
+ projectRoot,
14
+ snapshotFile: join(projectRoot, "snapshot.json"),
15
+ cacheDir,
16
+ actualDir: join(cacheDir, "actual"),
17
+ docFile: join(cacheDir, "doc.json")
18
+ };
19
+ }
20
+
21
+ // src/runner/stabilize.ts
22
+ async function freezeDate(ctx, when) {
23
+ const fixedMs = when instanceof Date ? when.getTime() : typeof when === "number" ? when : Date.parse(when);
24
+ if (!Number.isFinite(fixedMs)) {
25
+ throw new Error(`freezeDate: invalid "when" value: ${String(when)}`);
26
+ }
27
+ await ctx.addInitScript(initScript, { fixedMs });
28
+ }
29
+ async function waitForVisualStable(page) {
30
+ await page.evaluate(async () => {
31
+ if (document.fonts && typeof document.fonts.ready?.then === "function") {
32
+ await document.fonts.ready;
33
+ }
34
+ const imgs = Array.from(document.images);
35
+ await Promise.all(
36
+ imgs.map(
37
+ (img) => img.complete && img.naturalWidth > 0 ? Promise.resolve() : new Promise((resolve2) => {
38
+ const done = () => {
39
+ img.removeEventListener("load", done);
40
+ img.removeEventListener("error", done);
41
+ resolve2();
42
+ };
43
+ img.addEventListener("load", done);
44
+ img.addEventListener("error", done);
45
+ })
46
+ )
47
+ );
48
+ });
49
+ }
50
+ function initScript(opts) {
51
+ const fixed = opts.fixedMs;
52
+ const RealDate = Date;
53
+ function FakeDate(...args) {
54
+ if (!(this instanceof FakeDate)) {
55
+ return new RealDate(fixed).toString();
56
+ }
57
+ return args.length === 0 ? new RealDate(fixed) : new RealDate(...args);
58
+ }
59
+ FakeDate.prototype = RealDate.prototype;
60
+ FakeDate.now = () => fixed;
61
+ FakeDate.parse = RealDate.parse;
62
+ FakeDate.UTC = RealDate.UTC;
63
+ globalThis.Date = FakeDate;
64
+ }
65
+
66
+ // src/runner/snapshot.ts
67
+ function isGroup(x) {
68
+ return Array.isArray(x.items);
69
+ }
70
+ var cachedSnapshot = null;
71
+ async function loadSnapshot(snapshotFile) {
72
+ if (cachedSnapshot) return cachedSnapshot;
73
+ try {
74
+ const raw = await readFile(snapshotFile, "utf8");
75
+ cachedSnapshot = JSON.parse(raw);
76
+ } catch {
77
+ cachedSnapshot = { groups: [] };
78
+ }
79
+ return cachedSnapshot;
80
+ }
81
+ function findExpected(snap, path) {
82
+ let groups = snap.groups;
83
+ for (let i = 0; i < path.length - 1; i++) {
84
+ const segment = path[i];
85
+ const next = groups.find((it) => isGroup(it) && it.name === segment);
86
+ if (!next) return void 0;
87
+ groups = next.items;
88
+ }
89
+ const leaf = groups.find(
90
+ (it) => !isGroup(it) && it.name === path[path.length - 1]
91
+ );
92
+ return leaf ? leaf.hash : void 0;
93
+ }
94
+ function currentTestChain() {
95
+ const full = expect.getState().currentTestName ?? "";
96
+ if (!full) return [];
97
+ return full.split(" > ");
98
+ }
99
+ async function compareSnapshot(page, name) {
100
+ const explicit = name.split("/").filter(Boolean);
101
+ const path = name.startsWith("/") ? explicit : [...currentTestChain(), ...explicit];
102
+ if (path.length === 0) throw new Error("compareSnapshot: empty name");
103
+ const paths = resolveRunnerPaths();
104
+ const fileRelative = `${path.join("/")}.png`;
105
+ const absFile = join2(paths.actualDir, fileRelative);
106
+ await mkdir(dirname(absFile), { recursive: true });
107
+ await waitForVisualStable(page);
108
+ const actualBuf = await page.screenshot({
109
+ fullPage: true,
110
+ type: "png",
111
+ animations: "disabled",
112
+ caret: "hide"
113
+ });
114
+ await writeFile(absFile, actualBuf);
115
+ const actualHash = createHash("sha256").update(actualBuf).digest("hex");
116
+ const expectedHash = findExpected(await loadSnapshot(paths.snapshotFile), path);
117
+ if (expectedHash === void 0 || expectedHash === null) {
118
+ expect.soft(
119
+ null,
120
+ `Snapshot "${name}" is NEW (hash ${actualHash.slice(0, 12)}\u2026). Run \`screentest review\` to accept it.`
121
+ ).toBeTruthy();
122
+ return;
123
+ }
124
+ if (actualHash !== expectedHash) {
125
+ expect.soft(actualHash, `Snapshot "${name}" changed. Run \`screentest review\` to inspect.`).toBe(expectedHash);
126
+ }
127
+ }
128
+
129
+ // src/runner/browser.ts
130
+ import { inject } from "vitest";
131
+ import {
132
+ firefox
133
+ } from "playwright";
134
+ var browserPromise = null;
135
+ async function getBrowser() {
136
+ if (!browserPromise) {
137
+ const wsEndpoint = inject("wsEndpoint");
138
+ browserPromise = firefox.connect(wsEndpoint);
139
+ }
140
+ return browserPromise;
141
+ }
142
+ async function newPage(opts = {}) {
143
+ const browser = await getBrowser();
144
+ const ctx = await browser.newContext({
145
+ viewport: { width: 1280, height: 800 },
146
+ deviceScaleFactor: 1,
147
+ reducedMotion: "reduce",
148
+ ...opts
149
+ });
150
+ const page = await ctx.newPage();
151
+ return {
152
+ ctx,
153
+ page,
154
+ cleanup: async () => {
155
+ await ctx.close();
156
+ }
157
+ };
158
+ }
159
+ export {
160
+ compareSnapshot,
161
+ freezeDate,
162
+ newPage,
163
+ waitForVisualStable
164
+ };
@@ -0,0 +1,19 @@
1
+ FROM mcr.microsoft.com/playwright:v1.60.0-noble
2
+
3
+ WORKDIR /work
4
+
5
+ # Install pnpm — if your project uses npm/yarn, swap the next two lines.
6
+ RUN npm install -g pnpm@9
7
+
8
+ # Install dependencies. --ignore-scripts skips your project's postinstall
9
+ # hooks (which usually scan src/ — not needed for the test image).
10
+ COPY package.json pnpm-lock.yaml ./
11
+ COPY pnpm-workspace.yaml* ./
12
+ RUN pnpm install --frozen-lockfile --ignore-scripts
13
+
14
+ # Only the bits needed to run vitest.
15
+ COPY vitest.config.ts ./
16
+ COPY tsconfig*.json ./
17
+ COPY tests ./tests
18
+
19
+ CMD ["pnpm", "exec", "vitest", "run"]
@@ -0,0 +1,27 @@
1
+ import { afterAll, beforeAll, describe, it } from 'vitest';
2
+ import type { Page } from 'playwright';
3
+ import { compareSnapshot, freezeDate, newPage } from '@cevek/screentest/runner';
4
+
5
+ const APP_URL = (process.env.APP_URL || 'http://localhost:5050').replace(/\/+$/, '');
6
+
7
+ let page: Page;
8
+ let cleanup: () => Promise<void>;
9
+
10
+ beforeAll(async () => {
11
+ const r = await newPage();
12
+ // Optional: pin client clock so any rendered timestamp is byte-stable.
13
+ await freezeDate(r.ctx, '2024-01-15T12:00:00Z');
14
+ page = r.page;
15
+ cleanup = r.cleanup;
16
+ });
17
+
18
+ afterAll(async () => {
19
+ await cleanup?.();
20
+ });
21
+
22
+ describe('home', () => {
23
+ it('loads', async () => {
24
+ await page.goto(`${APP_URL}/`, { waitUntil: 'domcontentloaded' });
25
+ await compareSnapshot(page, 'home');
26
+ });
27
+ });
@@ -0,0 +1,13 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ include: ['tests/**/*.test.ts'],
6
+ globalSetup: ['@cevek/screentest/global-setup'],
7
+ testTimeout: 10_000,
8
+ hookTimeout: 10_000,
9
+ pool: 'forks',
10
+ poolOptions: { forks: { singleFork: true } },
11
+ fileParallelism: false,
12
+ },
13
+ });
package/package.json CHANGED
@@ -3,19 +3,29 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.1.0",
7
- "description": "Local desktop tool for visual screenshot-test review — Express + React UI shipped as a single CLI.",
6
+ "version": "0.2.0",
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",
10
10
  "bin": {
11
11
  "screentest": "dist/index.js"
12
12
  },
13
+ "exports": {
14
+ "./runner": {
15
+ "types": "./dist/runner.d.ts",
16
+ "import": "./dist/runner.js"
17
+ },
18
+ "./global-setup": {
19
+ "types": "./dist/global-setup.d.ts",
20
+ "import": "./dist/global-setup.js"
21
+ }
22
+ },
13
23
  "files": [
14
24
  "dist",
15
25
  "README.md"
16
26
  ],
17
27
  "scripts": {
18
- "build": "tsup && node scripts/copy-web.mjs",
28
+ "build": "tsup && node scripts/postbuild.mjs",
19
29
  "start": "node dist/index.js",
20
30
  "typecheck": "tsc --noEmit"
21
31
  },
@@ -23,12 +33,18 @@
23
33
  "express": "^5.2.1",
24
34
  "open": "^11.0.0"
25
35
  },
36
+ "peerDependencies": {
37
+ "playwright": "^1.50.0",
38
+ "vitest": "^3.0.0 || ^4.0.0"
39
+ },
26
40
  "devDependencies": {
27
41
  "@screentest/shared": "workspace:*",
28
42
  "@types/express": "^5.0.6",
29
43
  "@types/node": "^25.9.1",
44
+ "playwright": "^1.50.0",
30
45
  "tsup": "^8.5.1",
31
46
  "tsx": "^4.22.4",
32
- "typescript": "^6.0.3"
47
+ "typescript": "^6.0.3",
48
+ "vitest": "^4.0.0"
33
49
  }
34
50
  }