webview-preview 1.0.0 → 1.1.1
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/AGENTS.md +40 -1
- package/README.md +103 -3
- package/bin/webview-preview.mjs +53 -4
- package/package.json +4 -1
- package/src/core/config.mjs +5 -4
- package/src/core/steps.mjs +75 -2
- package/src/core/watch.mjs +31 -0
- package/src/engines/playwright.mjs +30 -3
- package/src/engines/webkitgtk.mjs +15 -5
- package/src/engines/webkitgtk.py +103 -13
- package/src/index.d.ts +25 -1
- package/src/index.mjs +157 -32
- package/src/page/console.mjs +84 -0
- package/src/page/mock.mjs +3 -2
- package/src/page/network.mjs +79 -0
- package/src/snapshots/compare.mjs +13 -4
- package/src/snapshots/film.mjs +51 -0
- package/src/snapshots/report.mjs +71 -0
package/AGENTS.md
CHANGED
|
@@ -16,6 +16,9 @@ Read the [README](./README.md) for install and options. Everything here assumes
|
|
|
16
16
|
4. Open the PNG only after the numbers make sense. Crop it to the area you care about before looking.
|
|
17
17
|
5. Change, run again, compare the same numbers.
|
|
18
18
|
|
|
19
|
+
Do not use `--watch`: it never exits. It is for a person keeping a snapshot open while editing; you run the command
|
|
20
|
+
again after each change.
|
|
21
|
+
|
|
19
22
|
When you report back, say which engine you checked and what you could **not** check (see the limits below).
|
|
20
23
|
|
|
21
24
|
## Writing steps
|
|
@@ -63,6 +66,13 @@ element.getClientRects().length > 0
|
|
|
63
66
|
If the project's config has macros (`macros` in `webview-preview.config.mjs` — signing in, opening a dialog), use them
|
|
64
67
|
(`macro:signIn`): they are already correct. A flow you repeat more than twice belongs there too.
|
|
65
68
|
|
|
69
|
+
## Errors in the page
|
|
70
|
+
|
|
71
|
+
After the steps the tool prints the page's `console.error` calls, uncaught exceptions and unhandled rejections
|
|
72
|
+
(`console.error: …`, `uncaught: …`, `unhandled rejection: …`). Read them before you look at a snapshot: a blank or
|
|
73
|
+
half-rendered picture almost always has its cause there. Mention any you did not cause — they may be bugs the user
|
|
74
|
+
should know about. Add `--fail-on-error` when the run must fail on them.
|
|
75
|
+
|
|
66
76
|
## Measuring
|
|
67
77
|
|
|
68
78
|
Start with `measure:<selector>` — one line with position, size, box, visibility, font, colours and text:
|
|
@@ -83,7 +93,9 @@ screenshot scale.
|
|
|
83
93
|
"print:document.querySelector('form').getBoundingClientRect().width"
|
|
84
94
|
```
|
|
85
95
|
|
|
86
|
-
**Frame by frame.** To see
|
|
96
|
+
**Frame by frame.** To *see* an animation, use `film:` right after the step that starts it —
|
|
97
|
+
`"click:text=New project" "film:open:500@[role=dialog]"` gives one PNG with 10 frames over 500 ms, cropped to the
|
|
98
|
+
dialog. To *measure* whether something animates smoothly, jumps, or flickers, record every animation frame into
|
|
87
99
|
`window` and print the log afterwards. Keep only the frames where the value changed:
|
|
88
100
|
|
|
89
101
|
```
|
|
@@ -145,6 +157,10 @@ Put the result in the project's `webview-preview.config.mjs` (or `preview/mocks.
|
|
|
145
157
|
person's — gets there in one command. Mention in your report which requests you mocked and with what data; mocked
|
|
146
158
|
data can hide a real bug in how the app handles the real server.
|
|
147
159
|
|
|
160
|
+
Loading and offline states are reachable too: `--latency 2000` (or the step `latency:2000`) keeps every request
|
|
161
|
+
waiting, so you can snap the spinner or skeleton; `offline` / `online` steps drop the connection for `fetch` / XHR,
|
|
162
|
+
switch `navigator.onLine` and fire the events, so an app's offline banner or retry logic shows up for real.
|
|
163
|
+
|
|
148
164
|
## Keyboard, mouse and focus
|
|
149
165
|
|
|
150
166
|
Use the real input steps, not `js:` clicks, whenever the question is about what a person experiences:
|
|
@@ -152,6 +168,7 @@ Use the real input steps, not `js:` clicks, whenever the question is about what
|
|
|
152
168
|
| Question | Steps |
|
|
153
169
|
|---|---|
|
|
154
170
|
| Does Tab go in the right order, does a focus trap hold? | `click:` the first field, then `key:Tab` / `key:Shift+Tab`, `print:document.activeElement…` after each |
|
|
171
|
+
| Does Tab go through the right elements, in order, and stay inside a dialog? | `focusorder` (after opening the dialog); every stop should say `in dialog`, and it should loop back |
|
|
155
172
|
| Is the focus ring visible and not clipped? | `key:Tab` until the element is focused, then `snap:ring@<selector>` (8 px margin included) |
|
|
156
173
|
| Does the hover state look right? | `hover:<selector>`, `wait:300` for the transition, `snap:x@<selector>` |
|
|
157
174
|
| Does typing work (validation, masks, Cyrillic…)? | `click:<input>`, `type:<text>`, `print:` the value / error text |
|
|
@@ -178,6 +195,22 @@ npx webview-preview /screen <steps> snap:screen "snap:panel@[data-test=panel]" -
|
|
|
178
195
|
change is a regression to explain. Use a separate `--baseline` folder for such throw-away checks so you do not
|
|
179
196
|
overwrite the project's committed baselines. Run `webview-preview clean` when you are done with the snapshots.
|
|
180
197
|
|
|
198
|
+
When the user should see the result too, add `--report` and give them the path to `report.html`: baseline, this run
|
|
199
|
+
and the difference side by side, with the run's output on top.
|
|
200
|
+
|
|
201
|
+
## Sizes, theme and motion
|
|
202
|
+
|
|
203
|
+
A layout change can break only the narrow window or only the dark theme. Check them in the same run instead of
|
|
204
|
+
asking the user:
|
|
205
|
+
|
|
206
|
+
```
|
|
207
|
+
npx webview-preview /screen <steps> snap:screen --size 1200x650,800x600
|
|
208
|
+
npx webview-preview /screen <steps> snap:screen --color-scheme dark
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Each size runs the steps from the start and names the snapshots `<prefix><WxH>-<name>.png`. `--reduced-motion`
|
|
212
|
+
makes a page that respects it skip its transitions, so a snapshot right after a click shows the final state.
|
|
213
|
+
|
|
181
214
|
## Still out of reach — and a few traps
|
|
182
215
|
|
|
183
216
|
**Fractional scroll.** `scrollTop = 55.5` is rounded; real wheel and touchpad scrolling can stop between pixels. A
|
|
@@ -187,6 +220,9 @@ overwrite the project's committed baselines. Run `webview-preview clean` when yo
|
|
|
187
220
|
WebKitGTK). The app opens in its default state — the default tab, no saved session — so reach the state you need with
|
|
188
221
|
steps or `init`; do not rely on what an earlier run clicked.
|
|
189
222
|
|
|
223
|
+
**Tab on macOS.** WebKit on a Mac tabs to text fields only unless Full Keyboard Access is on; buttons and links are
|
|
224
|
+
skipped. That is what users of the app on a Mac get too — use `key:Alt+Tab` there to walk every element.
|
|
225
|
+
|
|
190
226
|
**Windows' WebView2 itself.** `msedge` is the same engine, not the same embedding; window chrome, scrollbars of the
|
|
191
227
|
host and DPI handling of a native window can differ.
|
|
192
228
|
|
|
@@ -203,6 +239,9 @@ host and DPI handling of a native window can differ.
|
|
|
203
239
|
- The numbers you defined in step 1 match, in the engine the user runs (`webkitgtk` for Tauri on Linux, `msedge` for
|
|
204
240
|
Windows).
|
|
205
241
|
- No `js failed` / `print failed` / selector errors you did not expect; exit code 0.
|
|
242
|
+
- What you measured by eye is pinned with `assert:` — `assert:document.activeElement.matches('[role=dialog] button')`,
|
|
243
|
+
`assert:getComputedStyle(document.querySelector('.card')).borderRadius === '12px'` — so a later run proves it in one
|
|
244
|
+
line. `assert:` shows only the final value; for `a === b` print `a` first if it fails.
|
|
206
245
|
- Keyboard and hover behaviour checked with `key:` / `hover:` where the change touches them.
|
|
207
246
|
- You looked at one snapshot of the final state.
|
|
208
247
|
- You listed what could not be checked: fractional scroll, the other platform's engine, real backend data.
|
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# webview-preview
|
|
2
2
|
|
|
3
|
+
[](https://github.com/n0t4u404/webview-preview/actions/workflows/ci.yml)
|
|
4
|
+
|
|
3
5
|
See your UI the way your desktop app really renders it. `webview-preview` opens a page in the **same engine your app
|
|
4
6
|
runs on** — WebKitGTK on Linux, Edge (the WebView2 engine) on Windows, WebKit on macOS — runs a list of steps
|
|
5
7
|
(click, type, wait, measure) and saves PNG snapshots.
|
|
@@ -27,6 +29,7 @@ plain init scripts and fit there too.
|
|
|
27
29
|
- [Config file](#config-file)
|
|
28
30
|
- [Steps](#steps)
|
|
29
31
|
- [Macros](#macros)
|
|
32
|
+
- [Errors in the page](#errors-in-the-page)
|
|
30
33
|
- [Command line](#command-line)
|
|
31
34
|
- [JavaScript API](#javascript-api)
|
|
32
35
|
- [Login, backend and other things outside the page](#login-backend-and-other-things-outside-the-page)
|
|
@@ -155,7 +158,12 @@ npx webview-preview /settings snap:settings
|
|
|
155
158
|
| `server.cwd` | Folder for `server.command`. | the config's folder |
|
|
156
159
|
| `server.readyTimeoutMs` | How long to wait for the port. | `30000` |
|
|
157
160
|
| `engine` | See [engines](#which-engine-matches-my-app). | `auto` |
|
|
158
|
-
| `size` | Window size in CSS pixels, `WxH`. | `1200x650` |
|
|
161
|
+
| `size` | Window size in CSS pixels, `WxH`. Several sizes — `"1200x650,800x600"` or a list — run the steps once per size; snapshots get the size in the name: `webkitgtk-800x600-page.png`. | `1200x650` |
|
|
162
|
+
| `colorScheme` | `prefers-color-scheme` for the page: `light` or `dark`. | system |
|
|
163
|
+
| `reducedMotion` | `prefers-reduced-motion: reduce` for the page. | `false` |
|
|
164
|
+
| `report` | Write `<out>/report.html` after every run. See [Comparing with a baseline](#comparing-with-a-baseline). | `false` |
|
|
165
|
+
| `watch` | What `--watch` follows: a folder, a file or a list, relative to the config's folder. The config file itself is always followed. | `src` if it exists, else the config's folder |
|
|
166
|
+
| `offline` / `latency` | Start offline / delay every `fetch` / XHR request by ms. See [Slow network and offline](#4-slow-network-and-offline). | `false` / `0` |
|
|
159
167
|
| `out` | Snapshot folder, relative to the config's folder. | `.webview-preview` |
|
|
160
168
|
| `prefix` | File name prefix. | `<engine>-` |
|
|
161
169
|
| `scale` | Device pixel ratio for Playwright engines (`2` = HiDPI). WebKitGTK: off-screen snapshots are 1×, `--headed` ones follow your screen. | `1` |
|
|
@@ -171,6 +179,7 @@ npx webview-preview /settings snap:settings
|
|
|
171
179
|
| `globals` | Objects to put on `window` before the page starts, by path: `{ "window.api": anyApi({ … }) }`. See [Desktop bridges](#desktop-bridges-tauri-electron-wails). | — |
|
|
172
180
|
| `locale` | `navigator.language` for the page, `en-US`, `ru-RU` … Playwright engines also switch `Intl` and `Accept-Language`; WebKitGTK only `navigator.language`. | system |
|
|
173
181
|
| `macros` | Named lists of steps, run with `macro:<name>`. See [Macros](#macros). | — |
|
|
182
|
+
| `failOnError` | Exit with 1 when the page logged `console.error` or threw. See [Errors in the page](#errors-in-the-page). | `false` |
|
|
174
183
|
|
|
175
184
|
Command-line options win over the config file.
|
|
176
185
|
|
|
@@ -222,6 +231,11 @@ Steps run one after another, after the page has loaded and settled for 1.5 s.
|
|
|
222
231
|
| `click:<selector>` | Clicks the middle of the element with the real mouse — focus moves, `:active` and `:focus` apply like for a person. |
|
|
223
232
|
| `print:<expression>` | Prints the value: strings as they are, DOM elements as their HTML (first 200 characters), everything else as JSON. Promises are not awaited. |
|
|
224
233
|
| `measure:<selector>` | Prints one line about the element: how many match, position and size, margin / padding / border, visible or hidden, display, opacity, font, colours and text. |
|
|
234
|
+
| `offline` / `online` | Drops the connection for `fetch` / XHR and brings it back, with `navigator.onLine` and the events, then waits 700 ms like `js:`. See [Slow network and offline](#4-slow-network-and-offline). |
|
|
235
|
+
| `latency:<ms>` | Delays every `fetch` / XHR request from now on; `latency:0` turns it off. |
|
|
236
|
+
| `focusorder` / `focusorder:<n>` | Presses `Tab` with the real keyboard n times (20 by default) and prints where focus went, one line per stop — tag, role, name, label, "in dialog" — and `→ back to 1 after N stops` when it loops. Shows a focus trap, a skipped field or focus escaping a dialog at a glance. Starts from the current focus. |
|
|
237
|
+
| `assert:<expression>` | Checks that the expression is truthy: prints `assert ok: …`, or `assert failed: … — got <value>` / `— threw <message>` and makes the run exit with 1. The other steps still run. |
|
|
238
|
+
| `film:<name>:<ms>[/<n>][@<selector>]` | Takes n frames (10 by default, up to 60) evenly over ms, starting now, and puts them into one `<prefix><name>.png`, 5 per row, left to right. With a selector every frame is cropped to the area the element covered during the whole film (plus `--snap-margin`). Prints the real time of every frame: `film open: 10 frames at 0 55 111 … 500 ms`. Put it right after the step that starts the animation. |
|
|
225
239
|
| `resize:<W>x<H>` | Resizes the window. |
|
|
226
240
|
| `macro:<name>` | Runs the steps of a macro from the config. See [Macros](#macros). |
|
|
227
241
|
|
|
@@ -288,6 +302,21 @@ npx webview-preview / macro:openSettings snap:settings
|
|
|
288
302
|
A macro may run other macros; one that runs itself stops with an error. Prefer real clicks and typing over setting
|
|
289
303
|
values from code: the run then goes through the same handlers, focus and validation as for a person.
|
|
290
304
|
|
|
305
|
+
## Errors in the page
|
|
306
|
+
|
|
307
|
+
Every run collects what went wrong inside the page — `console.error` calls, uncaught exceptions and unhandled promise
|
|
308
|
+
rejections — and prints them after the steps, the same message once with a count:
|
|
309
|
+
|
|
310
|
+
```
|
|
311
|
+
console.error: Failed to load {"code":500} (×2)
|
|
312
|
+
unhandled rejection: Error: no session — global code@http://localhost:5173/src/app.tsx:4:25
|
|
313
|
+
uncaught: TypeError: null is not an object (evaluating 'user.name') — onclick@http://localhost:5173/src/app.tsx:12:5
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
A blank snapshot usually has its reason here. With `--fail-on-error` (`failOnError: true`) the run exits with 1 when
|
|
317
|
+
there is anything to print — useful in CI. Pages opened from `file://` report uncaught errors only as
|
|
318
|
+
"Script error." in WebKit; serve the page over `http://` to see them.
|
|
319
|
+
|
|
291
320
|
## Command line
|
|
292
321
|
|
|
293
322
|
```
|
|
@@ -297,7 +326,11 @@ webview-preview [route-or-url] [steps...] [options]
|
|
|
297
326
|
| Option | |
|
|
298
327
|
|---|---|
|
|
299
328
|
| `--engine <name>` | `auto`, `webkitgtk`, `msedge`, `chrome`, `chromium`, `webkit` |
|
|
300
|
-
| `--size <WxH>` | Window size |
|
|
329
|
+
| `--size <WxH>` | Window size; several, comma-separated: `1200x650,800x600` |
|
|
330
|
+
| `--color-scheme <light\|dark>` | `prefers-color-scheme` for the page |
|
|
331
|
+
| `--reduced-motion` | `prefers-reduced-motion: reduce` for the page |
|
|
332
|
+
| `--watch` | Run again whenever a file changes; the server stays up between runs. Ctrl+C to stop |
|
|
333
|
+
| `--offline` / `--latency <ms>` | Start offline / delay every `fetch` / XHR request |
|
|
301
334
|
| `--out <dir>` | Snapshot folder |
|
|
302
335
|
| `--prefix <text>` | File name prefix |
|
|
303
336
|
| `--scale <n>` | Device pixel ratio (Playwright engines) |
|
|
@@ -305,6 +338,7 @@ webview-preview [route-or-url] [steps...] [options]
|
|
|
305
338
|
| `--snap-margin <px>` | Room around `snap:name@selector` |
|
|
306
339
|
| `--wait-timeout <ms>` | How long `waitfor:` waits |
|
|
307
340
|
| `--keep-storage` | WebKitGTK: keep storage between runs |
|
|
341
|
+
| `--report` | Write `<out>/report.html` with the output and all snapshots (baseline and difference after `--compare`) |
|
|
308
342
|
| `--compare` / `--update-baseline` | Compare snapshots with baselines / replace the baselines |
|
|
309
343
|
| `--baseline <dir>` / `--max-diff-pixels <n>` / `--compare-tolerance <n>` | Baseline folder, allowed difference |
|
|
310
344
|
| `--base-url <url>` | Base for routes |
|
|
@@ -314,6 +348,7 @@ webview-preview [route-or-url] [steps...] [options]
|
|
|
314
348
|
| `--log-requests` | Only list requests, answer nothing |
|
|
315
349
|
| `--tauri` | Stub the Tauri bridge (answers from `tauri.commands` in the config) |
|
|
316
350
|
| `--locale <tag>` | `navigator.language` for the page |
|
|
351
|
+
| `--fail-on-error` | Exit with 1 when the page logged `console.error` or threw |
|
|
317
352
|
| `--config <file>` | Use this config file |
|
|
318
353
|
| `--no-config` | Ignore config files |
|
|
319
354
|
| `-h`, `--help` / `-v`, `--version` | |
|
|
@@ -352,6 +387,9 @@ result.failed; // false
|
|
|
352
387
|
`preview()` reads the config file the same way the CLI does; pass `config: false` to skip it or `config: "path"` to
|
|
353
388
|
choose one. Types are included.
|
|
354
389
|
|
|
390
|
+
`watchPreview(options, onRun)` is `--watch`: it runs once, then again on every change, calls `onRun(result)` after
|
|
391
|
+
each run and resolves to a function that stops watching and the server.
|
|
392
|
+
|
|
355
393
|
## Login, backend and other things outside the page
|
|
356
394
|
|
|
357
395
|
A real app rarely shows the screen you want straight away: it checks a session and sends you to a login page, it
|
|
@@ -462,7 +500,23 @@ With `mock` set, the same list shows which requests were answered: `GET /api/me
|
|
|
462
500
|
desktop side does by itself (a Tauri command that calls a server from Rust). For desktop calls, stub the bridge in
|
|
463
501
|
`init`; for the rest, run the real service locally.
|
|
464
502
|
|
|
465
|
-
|
|
503
|
+
### 4. Slow network and offline
|
|
504
|
+
|
|
505
|
+
How does the screen look while data is on its way, and what happens when the connection drops?
|
|
506
|
+
|
|
507
|
+
```sh
|
|
508
|
+
npx webview-preview /dashboard --latency 2000 wait:300 snap:loading wait:2500 snap:loaded
|
|
509
|
+
npx webview-preview /dashboard waitfor:.instance-card offline snap:offline online wait:1000 snap:back-online
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
| | |
|
|
513
|
+
|---|---|
|
|
514
|
+
| `latency:<ms>` step / `--latency <ms>` / `latency` in the config | Every `fetch` / XHR request waits that long first, answers from `mock` too. `latency:0` turns it off. |
|
|
515
|
+
| `offline` / `online` steps, `--offline` / `offline: true` | `navigator.onLine` turns `false`, the page gets the `offline` event, `fetch` rejects with `TypeError: Failed to fetch`, XHR fires `error`. `online` brings it all back and fires `online`. `--offline` starts that way; the page itself still loads. |
|
|
516
|
+
|
|
517
|
+
While offline, the request list shows `GET /api/me -> offline`. Like `mock`, this covers `fetch` and XHR only:
|
|
518
|
+
pictures, WebSocket and the desktop side keep their connection.
|
|
519
|
+
|
|
466
520
|
|
|
467
521
|
Your frontend calls the desktop side: Tauri's `invoke`, an Electron preload API, Wails bindings. Outside the app
|
|
468
522
|
there is no desktop side, so those calls fail — and the screen you want may never render. `webview-preview` puts a
|
|
@@ -560,6 +614,9 @@ npx webview-preview /settings snap:settings "snap:menu@[role=menu]" --compare
|
|
|
560
614
|
The `.diff.png` shows the snapshot in grey with the changed pixels in red. More than `maxDiffPixels` changed
|
|
561
615
|
(default `0`) → exit code `1`.
|
|
562
616
|
- **The change is intended**: `--update-baseline` replaces the baselines with this run's snapshots.
|
|
617
|
+
- **See it all on one page**: add `--report`. `<out>/report.html` shows the run's output and every snapshot —
|
|
618
|
+
after `--compare` with the baseline, this run and the difference side by side, changed ones marked red. Open it in
|
|
619
|
+
any browser; it works without a server and follows the system's light or dark theme.
|
|
563
620
|
|
|
564
621
|
Snapshots differ between engines and screen scales, so the engine is part of the file name (`webkitgtk-…`,
|
|
565
622
|
`msedge-…`) and each engine has its own baselines. `compareTolerance` (default `2` of 255 per channel) ignores
|
|
@@ -591,8 +648,38 @@ for engine in webkitgtk chrome; do npx webview-preview /settings --engine $engin
|
|
|
591
648
|
|
|
592
649
|
```sh
|
|
593
650
|
npx webview-preview / snap:wide resize:900x500 wait:300 snap:narrow
|
|
651
|
+
npx webview-preview / snap:home --size 1200x650,800x600,480x800
|
|
652
|
+
```
|
|
653
|
+
|
|
654
|
+
**Keep a snapshot fresh while you edit** — leave it running next to the editor and keep the PNG open:
|
|
655
|
+
|
|
656
|
+
```sh
|
|
657
|
+
npx webview-preview /settings waitfor:.settings snap:settings "measure:.settings" --watch
|
|
658
|
+
```
|
|
659
|
+
|
|
660
|
+
Every save runs the steps again from a clean page (the dev server stays up), prints the numbers and rewrites the
|
|
661
|
+
snapshot. A change of the config file is picked up too.
|
|
662
|
+
|
|
663
|
+
**See an animation, not only where it ends** — a dialog that opens:
|
|
664
|
+
|
|
665
|
+
```sh
|
|
666
|
+
npx webview-preview / "click:text=New project" "film:open:500@[role=dialog]"
|
|
667
|
+
```
|
|
668
|
+
|
|
669
|
+
One picture with 10 frames: you see whether it fades and grows smoothly, starts from the wrong place, jumps at the end
|
|
670
|
+
or plays twice. Frames are real screenshots taken as fast as the engine allows, so the printed times are what to
|
|
671
|
+
trust, not the planned spacing.
|
|
672
|
+
|
|
673
|
+
**Light and dark, with and without motion:**
|
|
674
|
+
|
|
675
|
+
```sh
|
|
676
|
+
for scheme in light dark; do npx webview-preview / snap:home-$scheme --color-scheme $scheme; done
|
|
677
|
+
npx webview-preview / click:.menu-button snap:menu-open --reduced-motion
|
|
594
678
|
```
|
|
595
679
|
|
|
680
|
+
With `--reduced-motion` a page that respects it skips its transitions, so a snapshot right after a click shows the
|
|
681
|
+
final state instead of a frame in the middle of the animation — and you see whether the page respects it at all.
|
|
682
|
+
|
|
596
683
|
**Measure instead of looking** — sizes and positions are more reliable than eyes:
|
|
597
684
|
|
|
598
685
|
```sh
|
|
@@ -633,6 +720,8 @@ More of these — animations frame by frame, measuring, getting past login — i
|
|
|
633
720
|
| `WebKitGTK engine needs Linux with python3-gi and WebKit2 4.1` | Install the [Linux packages](#linux--webkitgtk). |
|
|
634
721
|
| `This engine needs playwright-core` | `npm i -D playwright-core`. |
|
|
635
722
|
| `Executable doesn't exist` (Playwright) | `npx playwright install webkit` (or `chromium`); for `msedge` / `chrome` install the browser itself. |
|
|
723
|
+
| macOS, `webkit`: `Tab` skips buttons and links | That is macOS: with Full Keyboard Access off (the default) WebKit tabs to text fields only — a Tauri app on a Mac behaves the same. Use `key:Alt+Tab` to reach every element, as Option+Tab does in Safari. |
|
|
724
|
+
| WebKitGTK in CI | Run under `xvfb-run -a` (it needs an X display even off-screen); `sudo apt-get install -y python3-gi gir1.2-webkit2-4.1 xvfb`. |
|
|
636
725
|
| `Server did not open port …` | The `server.command` failed or uses another port. Run it by hand to see why; raise `readyTimeoutMs` for slow starts. |
|
|
637
726
|
| `"/route" is not a full URL` | Set `baseUrl` in the config or pass `--base-url`. |
|
|
638
727
|
| Blank snapshot | The page needs more time or threw on start: add `wait:2000`, and `print:document.body.innerHTML.length`. |
|
|
@@ -653,6 +742,17 @@ agent to it (for example from `CLAUDE.md` or `AGENTS.md` in your project):
|
|
|
653
742
|
UI changes: check them in the real engine with `npx webview-preview` — see node_modules/webview-preview/AGENTS.md.
|
|
654
743
|
```
|
|
655
744
|
|
|
745
|
+
## Development
|
|
746
|
+
|
|
747
|
+
```sh
|
|
748
|
+
npm test # the default engine for this system
|
|
749
|
+
WEBVIEW_PREVIEW_ENGINE=chrome npm test # one engine
|
|
750
|
+
```
|
|
751
|
+
|
|
752
|
+
The tests open `test/fixture/index.html` and check snapshots, input, focus order, errors, mock, offline, sizes,
|
|
753
|
+
`film:`, compare and the report. GitHub Actions runs them on every push: WebKitGTK and Chromium on Linux, Edge and
|
|
754
|
+
Chrome on Windows, WebKit on macOS; snapshots and reports of every run are kept as artifacts.
|
|
755
|
+
|
|
656
756
|
## License
|
|
657
757
|
|
|
658
758
|
MIT © n0t4u404
|
package/bin/webview-preview.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
|
-
import { cleanSnapshots, ENGINES, preview } from "#src/index.mjs";
|
|
3
|
+
import { cleanSnapshots, ENGINES, preview, watchPreview } from "#src/index.mjs";
|
|
4
4
|
|
|
5
5
|
const FLAGS = {
|
|
6
6
|
"--engine": "engine",
|
|
@@ -16,7 +16,9 @@ const FLAGS = {
|
|
|
16
16
|
"--compare-tolerance": "compareTolerance",
|
|
17
17
|
"--older-than": "olderThan",
|
|
18
18
|
"--wait-timeout": "waitTimeout",
|
|
19
|
-
"--locale": "locale"
|
|
19
|
+
"--locale": "locale",
|
|
20
|
+
"--color-scheme": "colorScheme",
|
|
21
|
+
"--latency": "latency"
|
|
20
22
|
}
|
|
21
23
|
const LIST_FLAGS = { "--init": "init", "--init-file": "initFile", "--mock-file": "mockFile" }
|
|
22
24
|
|
|
@@ -34,17 +36,26 @@ Steps:
|
|
|
34
36
|
snap:<name> save <out>/<prefix><name>.png
|
|
35
37
|
print:<expr> print the value of an expression
|
|
36
38
|
measure:<css selector> print size, position, box, visibility, font and text of the element
|
|
39
|
+
assert:<expr> check that the expression is truthy; if not, print why and exit with 1
|
|
40
|
+
focusorder[:<n>] press Tab n times (default 20) and print where focus went; shows when it loops
|
|
41
|
+
film:<name>:<ms>[/<n>][@<css selector>] n frames (default 10) over ms, from now, into one
|
|
42
|
+
<out>/<prefix><name>.png, 5 per row; with a selector, cropped to where the element was
|
|
37
43
|
resize:<WxH> resize the window
|
|
38
44
|
snap:<name>@<css selector> save only that element (plus --snap-margin)
|
|
39
45
|
key:<key> press a key: Tab, Shift+Tab, Enter, Escape, ArrowDown, Control+a, x …
|
|
40
46
|
type:<text> type text with the keyboard
|
|
41
47
|
hover:<css selector> move the mouse to the element's centre
|
|
42
48
|
click:<css selector> click the element's centre with the mouse
|
|
49
|
+
offline / online go offline or back: navigator.onLine, online/offline events, fetch and XHR fail
|
|
50
|
+
latency:<ms> delay every fetch/XHR request by ms (0 turns it off)
|
|
43
51
|
macro:<name> run the steps of a macro from the config (macros: { name: [...] })
|
|
44
52
|
|
|
45
53
|
Options:
|
|
46
54
|
--engine <name> ${ENGINES.join(" | ")} (default: auto)
|
|
47
|
-
--size <WxH> window size in CSS pixels (default: 1200x650)
|
|
55
|
+
--size <WxH> window size in CSS pixels (default: 1200x650); several sizes, comma-separated
|
|
56
|
+
(1200x650,800x600), run the steps once per size into <prefix><WxH>-<name>.png
|
|
57
|
+
--color-scheme <light|dark> prefers-color-scheme for the page (default: the system's)
|
|
58
|
+
--reduced-motion prefers-reduced-motion: reduce
|
|
48
59
|
--out <dir> folder for snapshots (default: .webview-preview)
|
|
49
60
|
--prefix <text> file name prefix (default: "<engine>-")
|
|
50
61
|
--scale <n> device pixel ratio for Playwright engines (default: 1); WebKitGTK renders
|
|
@@ -53,6 +64,8 @@ Options:
|
|
|
53
64
|
--headed WebKitGTK: show a real window instead of rendering off-screen
|
|
54
65
|
--wait-timeout <ms> how long waitfor waits (default: 10000)
|
|
55
66
|
--keep-storage WebKitGTK: keep localStorage, cookies and cache between runs (default: start clean)
|
|
67
|
+
--report write <out>/report.html: the output and every snapshot, with baseline and
|
|
68
|
+
difference next to it after --compare
|
|
56
69
|
--compare compare every snapshot with the baseline of the same name (a missing one is created)
|
|
57
70
|
--update-baseline replace the baselines with this run's snapshots
|
|
58
71
|
--baseline <dir> baseline folder (default: webview-preview-baseline)
|
|
@@ -67,6 +80,11 @@ Options:
|
|
|
67
80
|
--tauri stub the Tauri bridge (invoke, events, window); answers come from
|
|
68
81
|
tauri.commands in the config, anything else gets null
|
|
69
82
|
--locale <tag> navigator.language for the page: en-US, ru-RU … (Playwright: also Intl)
|
|
83
|
+
--offline start offline (the page itself still loads)
|
|
84
|
+
--latency <ms> delay every fetch/XHR request from the start
|
|
85
|
+
--watch run again whenever a file changes (the config's watch, default src/ or the config's folder,
|
|
86
|
+
plus the config file); the server stays up between runs
|
|
87
|
+
--fail-on-error exit with 1 when the page logged console.error or threw (they are always printed)
|
|
70
88
|
--config <file> config file (default: webview-preview.config.{mjs,js,json})
|
|
71
89
|
--no-config ignore config files
|
|
72
90
|
|
|
@@ -102,6 +120,26 @@ const parseArgs = argv => {
|
|
|
102
120
|
options.headed = true;
|
|
103
121
|
continue;
|
|
104
122
|
}
|
|
123
|
+
if (arg === "--report") {
|
|
124
|
+
options.report = true;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (arg === "--watch") {
|
|
128
|
+
options.isWatching = true;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (arg === "--offline") {
|
|
132
|
+
options.offline = true;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (arg === "--reduced-motion") {
|
|
136
|
+
options.reducedMotion = true;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (arg === "--fail-on-error") {
|
|
140
|
+
options.failOnError = true;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
105
143
|
if (arg === "--tauri") {
|
|
106
144
|
options.tauri = true;
|
|
107
145
|
continue;
|
|
@@ -152,7 +190,18 @@ try {
|
|
|
152
190
|
console.log(`${cleanOptions.dryRun ? "would delete" : "deleted"} ${removed.length} snapshot(s) in ${out}`);
|
|
153
191
|
process.exit(0);
|
|
154
192
|
}
|
|
155
|
-
const
|
|
193
|
+
const { isWatching, ...previewOptions } = parsed.options;
|
|
194
|
+
if (isWatching) {
|
|
195
|
+
const stop = await watchPreview(previewOptions, result => {
|
|
196
|
+
for (const file of result.saved) console.log(file);
|
|
197
|
+
});
|
|
198
|
+
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) process.once(signal, () => {
|
|
199
|
+
stop();
|
|
200
|
+
process.exit(0);
|
|
201
|
+
});
|
|
202
|
+
await new Promise(() => {});
|
|
203
|
+
}
|
|
204
|
+
const result = await preview(previewOptions);
|
|
156
205
|
for (const file of result.saved) console.log(file);
|
|
157
206
|
process.exit(result.failed ? 1 : 0);
|
|
158
207
|
} catch (error) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webview-preview",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"description": "Open your UI in the engine your desktop app really runs on (WebKitGTK, Edge/WebView2, WebKit), drive it with steps and save PNG snapshots.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "n0t4u404",
|
|
@@ -30,6 +30,9 @@
|
|
|
30
30
|
"imports": {
|
|
31
31
|
"#src/*": "./src/*"
|
|
32
32
|
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"test": "node --test"
|
|
35
|
+
},
|
|
33
36
|
"bin": {
|
|
34
37
|
"webview-preview": "bin/webview-preview.mjs"
|
|
35
38
|
},
|
package/src/core/config.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { pathToFileURL } from "node:url";
|
|
4
4
|
|
|
@@ -10,7 +10,8 @@ export const loadConfig = async (configPath, cwd = process.cwd()) => {
|
|
|
10
10
|
: CONFIG_FILES.map(name => path.join(cwd, name)).find(candidate => existsSync(candidate));
|
|
11
11
|
if (!file) return {}
|
|
12
12
|
if (!existsSync(file)) throw new Error(`Config not found: ${file}`);
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
const where = { configDir: path.dirname(file), configFile: file }
|
|
14
|
+
if (file.endsWith(".json")) return { ...JSON.parse(readFileSync(file, "utf8")), ...where }
|
|
15
|
+
const module = await import(`${pathToFileURL(file).href}?mtime=${statSync(file).mtimeMs}`);
|
|
16
|
+
return { ...(module.default ?? module), ...where }
|
|
16
17
|
}
|
package/src/core/steps.mjs
CHANGED
|
@@ -1,10 +1,19 @@
|
|
|
1
|
+
import { NETWORK_STEPS, networkStep } from "#src/page/network.mjs";
|
|
2
|
+
|
|
1
3
|
export const LOAD_SETTLE_MS = 1500;
|
|
2
4
|
export const JS_SETTLE_MS = 700;
|
|
3
5
|
export const TIMEOUT_MS = 30000;
|
|
4
6
|
export const WAIT_FOR_TIMEOUT_MS = 10000;
|
|
5
7
|
export const WAIT_FOR_POLL_MS = 50;
|
|
6
8
|
|
|
7
|
-
const STEP_KINDS = new Set(["js", "jsnow", "wait", "waitfor", "snap", "print", "measure", "resize", "key", "type", "hover", "click"]);
|
|
9
|
+
const STEP_KINDS = new Set(["js", "jsnow", "wait", "waitfor", "snap", "print", "measure", "assert", "focusorder", "resize", "key", "type", "hover", "click", "offline", "online", "latency", "film"]);
|
|
10
|
+
export const ASSERT_MARKER = "__webviewPreviewAssert:";
|
|
11
|
+
export const FOCUS_MARKER = "__webviewPreviewFocus:";
|
|
12
|
+
const FOCUS_ORDER_DEFAULT = 20;
|
|
13
|
+
const FOCUS_ORDER_MAX = 200;
|
|
14
|
+
const FILM = /^([^:@]+):(\d+)(?:\/(\d+))?(?:@(.+))?$/s;
|
|
15
|
+
const FILM_FRAMES_DEFAULT = 10;
|
|
16
|
+
const FILM_FRAMES_MAX = 60;
|
|
8
17
|
const MODIFIERS = { Shift: "Shift", Control: "Control", Ctrl: "Control", Alt: "Alt", Meta: "Meta" }
|
|
9
18
|
export const KEY_NAMES = [
|
|
10
19
|
"Tab", "Enter", "Escape", "Backspace", "Delete", "Insert", "Space", "Home", "End", "PageUp", "PageDown",
|
|
@@ -82,6 +91,53 @@ const MEASURE_FUNCTION = `(selector, query, isVisible) => {
|
|
|
82
91
|
].join(" · ");
|
|
83
92
|
}`;
|
|
84
93
|
|
|
94
|
+
const assertExpression = expression => `(() => {
|
|
95
|
+
const show = value => {
|
|
96
|
+
if (typeof value === "string") return JSON.stringify(value);
|
|
97
|
+
if (typeof Node !== "undefined" && value instanceof Node) return value.nodeType === 1 ? value.outerHTML.slice(0, 120) : String(value.nodeName);
|
|
98
|
+
try {
|
|
99
|
+
return JSON.stringify(value) ?? String(value);
|
|
100
|
+
} catch {
|
|
101
|
+
return String(value);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const report = problem => ${JSON.stringify(ASSERT_MARKER)} + JSON.stringify([${JSON.stringify(expression)}, problem]);
|
|
105
|
+
try {
|
|
106
|
+
const value = (${expression});
|
|
107
|
+
return report(value ? null : "got " + show(value));
|
|
108
|
+
} catch (error) {
|
|
109
|
+
return report("threw " + (error && error.message || error));
|
|
110
|
+
}
|
|
111
|
+
})()`;
|
|
112
|
+
|
|
113
|
+
const FOCUS_DESCRIBE = `(() => {
|
|
114
|
+
const element = document.activeElement;
|
|
115
|
+
if (!element || element === document.body || element === document.documentElement) return ${JSON.stringify(FOCUS_MARKER)} + JSON.stringify(["item", null]);
|
|
116
|
+
const tag = element.tagName.toLowerCase();
|
|
117
|
+
const role = element.getAttribute("role");
|
|
118
|
+
const name = element.getAttribute("name");
|
|
119
|
+
const type = tag === "input" ? element.getAttribute("type") : null;
|
|
120
|
+
const label = (element.getAttribute("aria-label") || (element.labels && element.labels[0] ? element.labels[0].innerText : "") || element.getAttribute("placeholder") || element.innerText || element.value || element.getAttribute("title") || "").replace(/\\s+/g, " ").trim().slice(0, 40);
|
|
121
|
+
const parts = [tag + (role ? "[role=" + role + "]" : "") + (name ? "[name=" + name + "]" : "") + (type ? "[type=" + type + "]" : "")];
|
|
122
|
+
if (element.id && !/^(:|_r_|«)/.test(element.id)) parts[0] += "#" + element.id;
|
|
123
|
+
if (label) parts.push(JSON.stringify(label));
|
|
124
|
+
const dialog = element.closest("[role=dialog], dialog");
|
|
125
|
+
if (dialog) parts.push("in dialog");
|
|
126
|
+
const path = [];
|
|
127
|
+
for (let node = element; node && node.nodeType === 1; node = node.parentElement) path.unshift(node.tagName + ":" + [...(node.parentElement ? node.parentElement.children : [])].indexOf(node));
|
|
128
|
+
return ${JSON.stringify(FOCUS_MARKER)} + JSON.stringify(["item", parts.join(" "), path.join("/")]);
|
|
129
|
+
})()`;
|
|
130
|
+
|
|
131
|
+
const focusOrderSteps = (value, step) => {
|
|
132
|
+
const count = value === "" ? FOCUS_ORDER_DEFAULT : Number(value);
|
|
133
|
+
if (!Number.isInteger(count) || count < 1 || count > FOCUS_ORDER_MAX) throw new Error(`focusorder takes how many Tab presses, 1-${FOCUS_ORDER_MAX}, got "${step}"`);
|
|
134
|
+
return [
|
|
135
|
+
{ kind: "print", value: `${JSON.stringify(FOCUS_MARKER)} + JSON.stringify(["start", ${count}])` },
|
|
136
|
+
...Array.from({ length: count }, () => [{ kind: "key", value: "Tab", key: "Tab", modifiers: [] }, { kind: "print", value: FOCUS_DESCRIBE }]).flat(),
|
|
137
|
+
{ kind: "print", value: `${JSON.stringify(FOCUS_MARKER)} + JSON.stringify(["end"])` }
|
|
138
|
+
];
|
|
139
|
+
}
|
|
140
|
+
|
|
85
141
|
const measureExpression = selector => `(${MEASURE_FUNCTION})(${JSON.stringify(selector)}, ${QUERY_FUNCTION}, ${VISIBLE_FUNCTION})`;
|
|
86
142
|
|
|
87
143
|
const rectExpression = (selector, isScrolled) => `(() => {
|
|
@@ -101,6 +157,16 @@ const rectExpression = (selector, isScrolled) => `(() => {
|
|
|
101
157
|
return JSON.stringify({ x: rect.left, y: rect.top, width: rect.width, height: rect.height });
|
|
102
158
|
})()`;
|
|
103
159
|
|
|
160
|
+
const filmStep = (value, step) => {
|
|
161
|
+
const match = FILM.exec(value);
|
|
162
|
+
if (!match) throw new Error(`film needs name:ms, like film:open:400@[role=dialog], got "${step}"`);
|
|
163
|
+
const [, name, durationMs, count, selector] = match;
|
|
164
|
+
const frames = Number(count ?? FILM_FRAMES_DEFAULT);
|
|
165
|
+
if (frames < 2 || frames > FILM_FRAMES_MAX) throw new Error(`film takes 2 to ${FILM_FRAMES_MAX} frames, got "${step}"`);
|
|
166
|
+
const film = { kind: "film", value: name, durationMs: Number(durationMs), frames }
|
|
167
|
+
return selector ? { ...film, selector, rect: rectExpression(selector, false) } : film;
|
|
168
|
+
}
|
|
169
|
+
|
|
104
170
|
const parseKey = combo => {
|
|
105
171
|
const parts = combo.split("+");
|
|
106
172
|
const key = parts.pop();
|
|
@@ -131,7 +197,7 @@ export const parseSize = value => {
|
|
|
131
197
|
return { width: Number(match[1]), height: Number(match[2]) }
|
|
132
198
|
}
|
|
133
199
|
|
|
134
|
-
export const parseSteps = steps => steps.
|
|
200
|
+
export const parseSteps = steps => steps.flatMap(step => {
|
|
135
201
|
const separator = step.indexOf(":");
|
|
136
202
|
const kind = separator === -1 ? step : step.slice(0, separator);
|
|
137
203
|
const value = separator === -1 ? "" : step.slice(separator + 1);
|
|
@@ -158,6 +224,13 @@ export const parseSteps = steps => steps.map(step => {
|
|
|
158
224
|
if (!selector) throw new Error(`waitfor needs a selector, got "${step}"`);
|
|
159
225
|
return { kind, value, check: waitForExpression(selector, isGone) }
|
|
160
226
|
}
|
|
227
|
+
if (kind === "focusorder") return focusOrderSteps(value, step);
|
|
228
|
+
if (kind === "film") return filmStep(value, step);
|
|
229
|
+
if (NETWORK_STEPS.has(kind)) return networkStep(kind, value, step);
|
|
230
|
+
if (kind === "assert") {
|
|
231
|
+
if (!value) throw new Error(`assert needs an expression, got "${step}"`);
|
|
232
|
+
return { kind: "print", value: assertExpression(value), assert: value }
|
|
233
|
+
}
|
|
161
234
|
if (kind === "measure") {
|
|
162
235
|
if (!value) throw new Error(`measure needs a selector, got "${step}"`);
|
|
163
236
|
return { kind: "print", value: printExpression(measureExpression(value)) }
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { existsSync, statSync, watch } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
const IGNORED = /(^|[\\/])(node_modules|\.git)([\\/]|$)/;
|
|
5
|
+
const DEBOUNCE_MS = 300;
|
|
6
|
+
|
|
7
|
+
export const watchFiles = ({ targets, ignore, onChange }) => {
|
|
8
|
+
let timer;
|
|
9
|
+
let changed = null;
|
|
10
|
+
const watchers = targets.map(target => {
|
|
11
|
+
if (!existsSync(target)) throw new Error(`watch: ${target} does not exist`);
|
|
12
|
+
const isFile = statSync(target).isFile();
|
|
13
|
+
try {
|
|
14
|
+
return watch(target, { recursive: true }, (_, file) => {
|
|
15
|
+
const full = file && !isFile ? path.join(target, String(file)) : target;
|
|
16
|
+
if (IGNORED.test(full) || ignore.some(dir => full.startsWith(dir + path.sep))) return;
|
|
17
|
+
changed = full;
|
|
18
|
+
clearTimeout(timer);
|
|
19
|
+
timer = setTimeout(() => onChange(changed), DEBOUNCE_MS);
|
|
20
|
+
});
|
|
21
|
+
} catch (error) {
|
|
22
|
+
if (error.code === "ERR_FEATURE_UNAVAILABLE_ON_PLATFORM") throw new Error("--watch needs Node 20 or newer");
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
return () => {
|
|
28
|
+
clearTimeout(timer);
|
|
29
|
+
for (const watcher of watchers) watcher.close();
|
|
30
|
+
}
|
|
31
|
+
}
|