webview-preview 1.0.0 → 1.1.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/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 whether something animates smoothly, jumps, or flickers, record every animation frame into
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
+ [![test](https://github.com/n0t4u404/webview-preview/actions/workflows/test.yml/badge.svg)](https://github.com/n0t4u404/webview-preview/actions/workflows/test.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
- ## Desktop bridges: Tauri, Electron, Wails
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
@@ -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 result = await preview(parsed.options);
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.0.0",
3
+ "version": "1.1.0",
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
  },
@@ -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
- if (file.endsWith(".json")) return { ...JSON.parse(readFileSync(file, "utf8")), configDir: path.dirname(file) }
14
- const module = await import(pathToFileURL(file).href);
15
- return { ...(module.default ?? module), configDir: path.dirname(file) }
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
  }
@@ -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.map(step => {
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
+ }