@cevek/screentest 0.3.0 → 0.3.2

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
@@ -25,9 +25,12 @@ npx screentest init
25
25
  npx screentest serve
26
26
 
27
27
  # 3. Run.
28
- APP_URL=http://localhost:5050 npx screentest test
28
+ APP_URL=http://localhost:5050 npx vitest run --config vitest.screentest.config.ts
29
29
  ```
30
30
 
31
+ > Add `"screentest": "vitest run --config vitest.screentest.config.ts"`
32
+ > to your package.json scripts so you can just `npm run screentest`.
33
+
31
34
  The daemon lives outside your project — one container serves every
32
35
  project on your machine. It listens on `ws://localhost:5180`, holds
33
36
  ~300MB RAM at idle, and stays up across reboots-of-your-app until you
@@ -46,9 +49,9 @@ container, over a WebSocket. Editing tests never needs an image rebuild.
46
49
 
47
50
  Existing files are not overwritten — re-run `init` safely.
48
51
 
49
- `screentest test` always runs vitest with
50
- `--config vitest.screentest.config.ts`, so unit tests via plain `vitest run`
51
- and screenshot tests via `screentest test` stay completely separate.
52
+ Pass `--config vitest.screentest.config.ts` to keep the screenshot run
53
+ separate from your normal `vitest run`. The screenshot config wires up
54
+ our globalSetup; plain `vitest run` skips it entirely — no Firefox launch.
52
55
 
53
56
  ## Commands
54
57
 
@@ -59,12 +62,13 @@ screentest status # is it running?
59
62
  ```
60
63
 
61
64
  ```bash
62
- screentest test
65
+ npx vitest run --config vitest.screentest.config.ts
63
66
  ```
64
67
 
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.
68
+ That's the test run — vitest on the host, our globalSetup connects to
69
+ the daemon. On failure (or new snapshots) the UI auto-launches in your
70
+ browser. Set `CI=1` to skip the auto-UI and just propagate the exit
71
+ code; `SCREENTEST_NO_UI=1` does the same locally.
68
72
 
69
73
  ```bash
70
74
  screentest review
@@ -79,12 +83,15 @@ screentest init
79
83
  Scaffold vitest config + example test (see above).
80
84
 
81
85
  ```bash
82
- screentest <doc-json-path> [--port 5174] [--no-open]
86
+ screentest <doc-json-path> [--port N] [--no-open]
83
87
  [--worker-url URL] [--token TOKEN]
84
88
  ```
85
89
 
86
- Open the review UI on a pre-built `doc.json`. Used internally by
87
- `screentest test` and `screentest review`.
90
+ Open the review UI on a pre-built `doc.json`. Used internally by the
91
+ globalSetup hook and by `screentest review`. With no `--port`, picks a
92
+ random free port in `[40000, 50000)` so parallel test runs don't clash.
93
+ The UI also beacons a shutdown back to the server on tab close, so you
94
+ don't need Ctrl+C in the terminal.
88
95
 
89
96
  ## Writing tests
90
97
 
@@ -153,7 +160,7 @@ Or pass `--worker-url` / `--token` to `screentest` directly.
153
160
 
154
161
  ```yaml
155
162
  - run: npx screentest serve # builds image + starts daemon
156
- - run: CI=1 npx screentest test
163
+ - run: CI=1 npx vitest run --config vitest.screentest.config.ts
157
164
  ```
158
165
 
159
166
  In `CI=1` the orchestrator exits with vitest's code and never tries to
@@ -162,7 +169,7 @@ open the UI.
162
169
  ## Input format reference
163
170
 
164
171
  If you need to construct your own `doc.json` for the UI (instead of going
165
- through `screentest test` / `review`), see
172
+ through `vitest run` / `screentest review`), see
166
173
  [the format documentation in the source repo](https://github.com/x-cevek/screentest#input-format-docjson).
167
174
 
168
175
  ## Security
@@ -1,20 +1,264 @@
1
1
  // src/runner/global-setup.ts
2
+ import { spawn } from "child_process";
3
+ import { promises as fs2 } from "fs";
4
+ import { createConnection } from "net";
5
+ import { dirname, join as join3 } from "path";
6
+ import { fileURLToPath } from "url";
2
7
  import { firefox } from "playwright";
8
+
9
+ // src/doc-builder.ts
10
+ import { spawnSync } from "child_process";
11
+ import { createHash } from "crypto";
12
+ import { promises as fs } from "fs";
13
+ import { join, relative, sep } from "path";
14
+ function detectBranch(projectRoot) {
15
+ const r = spawnSync("git", ["-C", projectRoot, "rev-parse", "--abbrev-ref", "HEAD"], {
16
+ stdio: "pipe",
17
+ encoding: "utf8"
18
+ });
19
+ if (r.status !== 0) return null;
20
+ const branch = r.stdout.trim();
21
+ return branch || null;
22
+ }
23
+ function isGroup(node) {
24
+ return Array.isArray(node.items);
25
+ }
26
+ async function readSnapshot(snapshotFile) {
27
+ try {
28
+ return JSON.parse(await fs.readFile(snapshotFile, "utf8"));
29
+ } catch {
30
+ return { groups: [] };
31
+ }
32
+ }
33
+ function indexSnapshot(snap) {
34
+ const out = /* @__PURE__ */ new Map();
35
+ const walk = (items, prefix) => {
36
+ for (const it of items) {
37
+ if (isGroup(it)) walk(it.items, [...prefix, it.name]);
38
+ else out.set([...prefix, it.name].join("/"), it.hash);
39
+ }
40
+ };
41
+ for (const g of snap.groups) walk(g.items ?? [], [g.name]);
42
+ return out;
43
+ }
44
+ async function walkActual(dir, prefix = []) {
45
+ let entries;
46
+ try {
47
+ entries = await fs.readdir(dir, { withFileTypes: true });
48
+ } catch {
49
+ return [];
50
+ }
51
+ const out = [];
52
+ for (const e of entries) {
53
+ if (e.isDirectory()) {
54
+ out.push(...await walkActual(join(dir, e.name), [...prefix, e.name]));
55
+ } else if (e.isFile() && e.name.endsWith(".png")) {
56
+ const stem = e.name.slice(0, -4);
57
+ out.push({ path: [...prefix, stem], absFile: join(dir, e.name) });
58
+ }
59
+ }
60
+ return out;
61
+ }
62
+ function insertLeaf(doc, path, leaf) {
63
+ let groups = doc.groups;
64
+ for (let i = 0; i < path.length - 1; i++) {
65
+ const name = path[i];
66
+ let g = groups.find((x) => "items" in x && x.name === name);
67
+ if (!g) {
68
+ g = { name, items: [] };
69
+ groups.push(g);
70
+ }
71
+ groups = g.items;
72
+ }
73
+ groups.push({ name: path[path.length - 1], ...leaf });
74
+ }
75
+ function countLeaves(doc) {
76
+ let n = 0;
77
+ const walk = (items) => {
78
+ for (const it of items) {
79
+ if ("items" in it) walk(it.items);
80
+ else n++;
81
+ }
82
+ };
83
+ for (const g of doc.groups) walk(g.items);
84
+ return n;
85
+ }
86
+ async function generateDoc(projectRoot, snapshotFile, actualDir, docFile, cacheDir) {
87
+ const snap = await readSnapshot(snapshotFile);
88
+ const expectedByPath = indexSnapshot(snap);
89
+ const actuals = await walkActual(actualDir);
90
+ const seen = /* @__PURE__ */ new Set();
91
+ const doc = {
92
+ project: { path: projectRoot, branch: detectBranch(projectRoot) },
93
+ groups: []
94
+ };
95
+ const summary = { new: 0, change: 0, deleted: 0, unchanged: 0 };
96
+ for (const a of actuals) {
97
+ const buf = await fs.readFile(a.absFile);
98
+ const hash = createHash("sha256").update(buf).digest("hex");
99
+ const key = a.path.join("/");
100
+ seen.add(key);
101
+ const expHash = expectedByPath.get(key);
102
+ const relPath = relative(cacheDir, a.absFile).split(sep).join("/");
103
+ if (expHash === void 0 || expHash === null) {
104
+ insertLeaf(doc, a.path, {
105
+ type: "new",
106
+ actualFilename: relPath,
107
+ patchSnapshotJsonFile: snapshotFile
108
+ });
109
+ summary.new++;
110
+ continue;
111
+ }
112
+ if (expHash === hash) {
113
+ summary.unchanged++;
114
+ continue;
115
+ }
116
+ insertLeaf(doc, a.path, {
117
+ type: "change",
118
+ expectedHash: expHash,
119
+ actualFilename: relPath,
120
+ patchSnapshotJsonFile: snapshotFile
121
+ });
122
+ summary.change++;
123
+ }
124
+ for (const [key, hash] of expectedByPath) {
125
+ if (seen.has(key) || !hash) continue;
126
+ insertLeaf(doc, key.split("/"), {
127
+ type: "deleted",
128
+ expectedHash: hash,
129
+ patchSnapshotJsonFile: snapshotFile
130
+ });
131
+ summary.deleted++;
132
+ }
133
+ await fs.mkdir(cacheDir, { recursive: true });
134
+ await fs.writeFile(docFile, JSON.stringify(doc, null, 2));
135
+ const total = countLeaves(doc);
136
+ process.stdout.write(
137
+ `Wrote ${docFile}
138
+ ${total} diff${total === 1 ? "" : "s"} to review (new: ${summary.new}, change: ${summary.change}, deleted: ${summary.deleted}, unchanged: ${summary.unchanged})
139
+ `
140
+ );
141
+ return total;
142
+ }
143
+
144
+ // src/runner/paths.ts
145
+ import { existsSync } from "fs";
146
+ import { isAbsolute, join as join2, resolve } from "path";
147
+ function resolveRunnerPaths(cwd = process.cwd()) {
148
+ const projectRoot = resolve(cwd);
149
+ const cacheDir = join2(projectRoot, "node_modules", ".cache", "screentest");
150
+ return {
151
+ projectRoot,
152
+ snapshotFile: resolveSnapshotFile(projectRoot),
153
+ cacheDir,
154
+ actualDir: join2(cacheDir, "actual"),
155
+ docFile: join2(cacheDir, "doc.json")
156
+ };
157
+ }
158
+ function resolveSnapshotFile(projectRoot) {
159
+ const envPath = process.env.SCREENTEST_SNAPSHOT_FILE;
160
+ if (envPath) {
161
+ return isAbsolute(envPath) ? envPath : join2(projectRoot, envPath);
162
+ }
163
+ const legacyRoot = join2(projectRoot, "snapshot.json");
164
+ const insideTests = join2(projectRoot, "tests", "snapshot.json");
165
+ if (existsSync(legacyRoot) && !existsSync(insideTests)) return legacyRoot;
166
+ return insideTests;
167
+ }
168
+
169
+ // src/runner/global-setup.ts
170
+ var DAEMON_PORT = 5180;
171
+ var DAEMON_WS = `ws://localhost:${DAEMON_PORT}/screentest-firefox`;
3
172
  var server;
4
- async function setup(project) {
173
+ async function exists(p) {
174
+ try {
175
+ await fs2.access(p);
176
+ return true;
177
+ } catch {
178
+ return false;
179
+ }
180
+ }
181
+ function probeDaemon() {
182
+ return new Promise((resolve2) => {
183
+ const s = createConnection({ host: "127.0.0.1", port: DAEMON_PORT });
184
+ s.once("connect", () => {
185
+ s.end();
186
+ resolve2(true);
187
+ });
188
+ s.once("error", () => resolve2(false));
189
+ });
190
+ }
191
+ async function resolveWsEndpoint() {
5
192
  const externalWs = process.env.SCREENTEST_FIREFOX_WS;
6
193
  if (externalWs) {
7
- project.provide("wsEndpoint", externalWs);
8
- console.log(`[screentest] using external firefox daemon: ${externalWs}`);
9
- return async () => {
10
- };
194
+ console.log(`[screentest] using firefox WS from env: ${externalWs}`);
195
+ return externalWs;
196
+ }
197
+ if (await probeDaemon()) {
198
+ console.log(`[screentest] using firefox daemon at ${DAEMON_WS}`);
199
+ return DAEMON_WS;
11
200
  }
12
201
  server = await firefox.launchServer();
13
- const wsEndpoint = server.wsEndpoint();
202
+ const ws = server.wsEndpoint();
203
+ console.warn(
204
+ `[screentest] no daemon at :${DAEMON_PORT} \u2014 launched one-shot local firefox.
205
+ [screentest] NOTE: host Firefox PNG bytes may diverge from CI / daemon
206
+ [screentest] (different fontconfig, AA, glyph rasterization).
207
+ [screentest] Run \`screentest serve\` once for CI-parity baselines.`
208
+ );
209
+ return ws;
210
+ }
211
+ function spawnUI(docFile) {
212
+ const here = dirname(fileURLToPath(import.meta.url));
213
+ const bin = join3(here, "index.js");
214
+ return new Promise((resolve2) => {
215
+ const p = spawn(process.execPath, [bin, docFile], { stdio: "inherit" });
216
+ p.on("exit", (code) => resolve2(code ?? 0));
217
+ });
218
+ }
219
+ async function setup(project) {
220
+ const paths = resolveRunnerPaths();
221
+ await fs2.mkdir(paths.cacheDir, { recursive: true });
222
+ if (!await exists(paths.snapshotFile)) {
223
+ await fs2.mkdir(dirname(paths.snapshotFile), { recursive: true });
224
+ await fs2.writeFile(paths.snapshotFile, '{\n "groups": []\n}\n');
225
+ }
226
+ await fs2.rm(paths.actualDir, { recursive: true, force: true });
227
+ const wsEndpoint = await resolveWsEndpoint();
14
228
  project.provide("wsEndpoint", wsEndpoint);
15
- console.log(`[screentest] firefox server ready: ${wsEndpoint}`);
16
229
  return async () => {
17
- await server?.close();
230
+ if (server) await server.close().catch(() => {
231
+ });
232
+ let total = 0;
233
+ try {
234
+ total = await generateDoc(
235
+ paths.projectRoot,
236
+ paths.snapshotFile,
237
+ paths.actualDir,
238
+ paths.docFile,
239
+ paths.cacheDir
240
+ );
241
+ } catch (err) {
242
+ const msg = err instanceof Error ? err.message : String(err);
243
+ process.stderr.write(`[screentest] doc.json generation failed: ${msg}
244
+ `);
245
+ return;
246
+ }
247
+ if (total === 0) return;
248
+ if (process.env.CI) return;
249
+ if (process.env.SCREENTEST_NO_UI) return;
250
+ process.stderr.write(
251
+ `
252
+ \u2192 Visual diffs detected \u2014 launching screentest for review.
253
+ Close the tool (Ctrl+C) when done; the original test exit code will still propagate.
254
+
255
+ `
256
+ );
257
+ const code = await spawnUI(paths.docFile);
258
+ if (code !== 0 && code !== 130) {
259
+ process.stderr.write(`(screentest UI exited with code ${code})
260
+ `);
261
+ }
18
262
  };
19
263
  }
20
264
  export {