@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 +115 -121
- package/dist/global-setup.d.ts +10 -0
- package/dist/global-setup.js +15 -0
- package/dist/index.js +332 -30
- package/dist/runner.d.ts +41 -0
- package/dist/runner.js +164 -0
- package/dist/templates/Dockerfile.tests +19 -0
- package/dist/templates/example.test.ts +27 -0
- package/dist/templates/vitest.config.ts +13 -0
- package/package.json +20 -4
package/README.md
CHANGED
|
@@ -1,146 +1,140 @@
|
|
|
1
|
-
# screentest
|
|
1
|
+
# @cevek/screentest
|
|
2
2
|
|
|
3
|
-
Local desktop tool for visual screenshot-test review
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
13
|
-
|
|
10
|
+
pnpm add -D @cevek/screentest playwright vitest
|
|
11
|
+
pnpm exec playwright install firefox
|
|
14
12
|
```
|
|
15
13
|
|
|
16
|
-
`
|
|
17
|
-
`screentest` bin into `node_modules/.bin/`.
|
|
14
|
+
`playwright` and `vitest` are peer-deps — keep them in your devDependencies.
|
|
18
15
|
|
|
19
|
-
##
|
|
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
|
|
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
|
-
|
|
27
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
115
|
-
inside the server, so concurrent accepts don't race.
|
|
118
|
+
Or pass `--worker-url` / `--token` to `screentest` directly.
|
|
116
119
|
|
|
117
|
-
##
|
|
120
|
+
## CI
|
|
118
121
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
+
```yaml
|
|
123
|
+
- run: docker build -f Dockerfile.tests -t screentest-tests .
|
|
124
|
+
- run: CI=1 pnpm exec screentest test
|
|
125
|
+
```
|
|
122
126
|
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
132
|
-
|
|
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
|
-
-
|
|
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
|
|
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,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/
|
|
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/
|
|
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/
|
|
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
|
|
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 <
|
|
15000
|
-
const a =
|
|
14998
|
+
for (let i = 0; i < rawArgs.length; i++) {
|
|
14999
|
+
const a = rawArgs[i];
|
|
15001
15000
|
if (a === "--port") {
|
|
15002
|
-
port = Number(
|
|
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 =
|
|
15006
|
+
workerUrl = rawArgs[++i] ?? "";
|
|
15008
15007
|
if (!workerUrl) throw new Error("--worker-url requires a value");
|
|
15009
15008
|
} else if (a === "--token") {
|
|
15010
|
-
token =
|
|
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
|
|
15036
|
-
process.
|
|
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:
|
|
15087
|
-
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,
|
|
15077
|
+
const srv = await startServer(state, opts.port);
|
|
15092
15078
|
process.stdout.write(`screentest running on ${srv.url}
|
|
15093
15079
|
`);
|
|
15094
|
-
if (
|
|
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}
|
package/dist/runner.d.ts
ADDED
|
@@ -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.
|
|
7
|
-
"description": "Local desktop tool for visual screenshot-test review —
|
|
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/
|
|
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
|
}
|