@cevek/screentest 0.3.1 → 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 +20 -13
- package/dist/global-setup.js +252 -8
- package/dist/index.js +287 -295
- package/dist/web/assets/{index-nQ8FCC_F.js → index-Ca30omZK.js} +1 -1
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
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
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
65
|
+
npx vitest run --config vitest.screentest.config.ts
|
|
63
66
|
```
|
|
64
67
|
|
|
65
|
-
|
|
66
|
-
On failure (or new snapshots)
|
|
67
|
-
skip the auto-UI and just propagate the exit
|
|
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
|
|
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
|
-
|
|
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
|
|
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 `
|
|
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
|
package/dist/global-setup.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
|
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
|
|
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 {
|