webview-preview 0.0.1 → 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
@@ -60,8 +63,15 @@ element.getClientRects().length > 0
60
63
  input.dispatchEvent(new Event('input', { bubbles: true })) }
61
64
  ```
62
65
 
63
- If the project's preview page exposes helpers (`window.preview.signIn()`, `window.preview.type(name, value)`), use
64
- them — they are faster and already correct.
66
+ If the project's config has macros (`macros` in `webview-preview.config.mjs` — signing in, opening a dialog), use them
67
+ (`macro:signIn`): they are already correct. A flow you repeat more than twice belongs there too.
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.
65
75
 
66
76
  ## Measuring
67
77
 
@@ -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
  ```
@@ -112,7 +124,7 @@ for y in range(320 * scale, 340 * scale):
112
124
 
113
125
  The screen you need is often behind a login, or empty because the backend is not running. **Never change the app's
114
126
  code to skip a check** (no `if (PREVIEW) skipAuth`, no commented-out guards): such switches end up in releases. All of
115
- it goes into the preview config: `init`, `mock`, helpers. The app's code stays as it is.
127
+ it goes into the preview config: `init`, `mock`, `tauri`, `globals`, `macros`. The app's code stays as it is.
116
128
 
117
129
  1. **Find out what stands in the way.** Run with `--log-requests` and print the list:
118
130
 
@@ -133,9 +145,10 @@ it goes into the preview config: `init`, `mock`, helpers. The app's code stays a
133
145
  - session in storage → `init`: `localStorage.setItem('<key>', '<value in the exact format the code parses>')`;
134
146
  - session from a request → `mock` that request (`"GET /api/me": { json: { … } }`) and anything else the screen
135
147
  loads; take field names from the types / interfaces in the code;
136
- - desktop bridge (Tauri `invoke`) → a stub of `window.__TAURI_INTERNALS__` in `init`;
137
- - session only in memory (no storage, no request) → steps through the real login form, or a helper the project
138
- already exposes (`window.preview.signIn()`).
148
+ - desktop bridge → `tauri: { commands: { … } }` for Tauri, `globals` with `anyApi({ … })` for Electron's
149
+ preload API or Wails bindings; the request list shows `invoke <command> -> null` / `call <path> -> default`
150
+ for every call that still needs an answer;
151
+ - session only in memory (no storage, no request) → steps through the real login form, saved as a macro.
139
152
 
140
153
  4. **Check that it worked:** print `location.href` (you stayed on the route) and the request list (every request
141
154
  the screen needs is `-> mock …`, none fails), then snap.
@@ -144,6 +157,10 @@ Put the result in the project's `webview-preview.config.mjs` (or `preview/mocks.
144
157
  person's — gets there in one command. Mention in your report which requests you mocked and with what data; mocked
145
158
  data can hide a real bug in how the app handles the real server.
146
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
+
147
164
  ## Keyboard, mouse and focus
148
165
 
149
166
  Use the real input steps, not `js:` clicks, whenever the question is about what a person experiences:
@@ -151,6 +168,7 @@ Use the real input steps, not `js:` clicks, whenever the question is about what
151
168
  | Question | Steps |
152
169
  |---|---|
153
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 |
154
172
  | Is the focus ring visible and not clipped? | `key:Tab` until the element is focused, then `snap:ring@<selector>` (8 px margin included) |
155
173
  | Does the hover state look right? | `hover:<selector>`, `wait:300` for the transition, `snap:x@<selector>` |
156
174
  | Does typing work (validation, masks, Cyrillic…)? | `click:<input>`, `type:<text>`, `print:` the value / error text |
@@ -177,6 +195,22 @@ npx webview-preview /screen <steps> snap:screen "snap:panel@[data-test=panel]" -
177
195
  change is a regression to explain. Use a separate `--baseline` folder for such throw-away checks so you do not
178
196
  overwrite the project's committed baselines. Run `webview-preview clean` when you are done with the snapshots.
179
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
+
180
214
  ## Still out of reach — and a few traps
181
215
 
182
216
  **Fractional scroll.** `scrollTop = 55.5` is rounded; real wheel and touchpad scrolling can stop between pixels. A
@@ -186,6 +220,9 @@ overwrite the project's committed baselines. Run `webview-preview clean` when yo
186
220
  WebKitGTK). The app opens in its default state — the default tab, no saved session — so reach the state you need with
187
221
  steps or `init`; do not rely on what an earlier run clicked.
188
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
+
189
226
  **Windows' WebView2 itself.** `msedge` is the same engine, not the same embedding; window chrome, scrollbars of the
190
227
  host and DPI handling of a native window can differ.
191
228
 
@@ -202,6 +239,9 @@ host and DPI handling of a native window can differ.
202
239
  - The numbers you defined in step 1 match, in the engine the user runs (`webkitgtk` for Tauri on Linux, `msedge` for
203
240
  Windows).
204
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.
205
245
  - Keyboard and hover behaviour checked with `key:` / `hover:` where the change touches them.
206
246
  - You looked at one snapshot of the final state.
207
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.
@@ -15,15 +17,23 @@ npx webview-preview https://example.com snap:home
15
17
  # → .webview-preview/webkitgtk-home.png (on Linux)
16
18
  ```
17
19
 
20
+ **What it is — and what it is not.** A tool to *look at* a screen in the real engine: open it in the state you need,
21
+ press keys, measure, snap, compare with yesterday. Steps stay one-liners you can type in a terminal; mocks and
22
+ desktop stubs only say *what to answer* so the screen renders. It is not a test runner: no test suites, no stateful
23
+ mocks, no `expect` library, no retries. For end-to-end tests use Playwright Test or WebdriverIO — the stubs here are
24
+ plain init scripts and fit there too.
25
+
18
26
  - [Which engine matches my app](#which-engine-matches-my-app)
19
27
  - [Install](#install)
20
28
  - [Quick start](#quick-start)
21
29
  - [Config file](#config-file)
22
30
  - [Steps](#steps)
31
+ - [Macros](#macros)
32
+ - [Errors in the page](#errors-in-the-page)
23
33
  - [Command line](#command-line)
24
34
  - [JavaScript API](#javascript-api)
25
35
  - [Login, backend and other things outside the page](#login-backend-and-other-things-outside-the-page)
26
- - [Running a Tauri UI outside Tauri](#running-a-tauri-ui-outside-tauri)
36
+ - [Desktop bridges: Tauri, Electron, Wails](#desktop-bridges-tauri-electron-wails)
27
37
  - [Comparing with a baseline](#comparing-with-a-baseline)
28
38
  - [Cleaning up snapshots](#cleaning-up-snapshots)
29
39
  - [Recipes](#recipes)
@@ -148,7 +158,12 @@ npx webview-preview /settings snap:settings
148
158
  | `server.cwd` | Folder for `server.command`. | the config's folder |
149
159
  | `server.readyTimeoutMs` | How long to wait for the port. | `30000` |
150
160
  | `engine` | See [engines](#which-engine-matches-my-app). | `auto` |
151
- | `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` |
152
167
  | `out` | Snapshot folder, relative to the config's folder. | `.webview-preview` |
153
168
  | `prefix` | File name prefix. | `<engine>-` |
154
169
  | `scale` | Device pixel ratio for Playwright engines (`2` = HiDPI). WebKitGTK: off-screen snapshots are 1×, `--headed` ones follow your screen. | `1` |
@@ -160,6 +175,11 @@ npx webview-preview /settings snap:settings
160
175
  | `init` | Code that runs **before the page's own scripts**, on every load: a string, `{ file: "path" }`, or a list of them. See [below](#1-init--set-things-up-before-the-app-starts). | — |
161
176
  | `mock` | Answers for `fetch` / `XMLHttpRequest` requests: `{ "GET /api/me": { json: { … } } }`. See [below](#2-mock--answer-backend-requests). | — |
162
177
  | `logRequests` | List every `fetch` / `XMLHttpRequest` request in `window.__webviewPreviewRequests` (also on whenever `mock` is set). | `false` |
178
+ | `tauri` | Stub the Tauri bridge: `true`, or `{ commands: { … }, label: "main" }`. See [Desktop bridges](#desktop-bridges-tauri-electron-wails). | — |
179
+ | `globals` | Objects to put on `window` before the page starts, by path: `{ "window.api": anyApi({ … }) }`. See [Desktop bridges](#desktop-bridges-tauri-electron-wails). | — |
180
+ | `locale` | `navigator.language` for the page, `en-US`, `ru-RU` … Playwright engines also switch `Intl` and `Accept-Language`; WebKitGTK only `navigator.language`. | system |
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` |
163
183
 
164
184
  Command-line options win over the config file.
165
185
 
@@ -211,7 +231,13 @@ Steps run one after another, after the page has loaded and settled for 1.5 s.
211
231
  | `click:<selector>` | Clicks the middle of the element with the real mouse — focus moves, `:active` and `:focus` apply like for a person. |
212
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. |
213
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. |
214
239
  | `resize:<W>x<H>` | Resizes the window. |
240
+ | `macro:<name>` | Runs the steps of a macro from the config. See [Macros](#macros). |
215
241
 
216
242
  ### Selectors
217
243
 
@@ -247,6 +273,50 @@ npx webview-preview / \
247
273
 
248
274
  On Windows PowerShell use the same double quotes; inside them, keep JavaScript strings in single quotes.
249
275
 
276
+ ## Macros
277
+
278
+ Steps you repeat — signing in, opening a dialog — go into the config under a name:
279
+
280
+ ```js
281
+ export default {
282
+ macros: {
283
+ signIn: [
284
+ "js:location.hash = '#/sign-in'",
285
+ "waitfor:input[name=email]",
286
+ "click:input[name=email]",
287
+ "type:demo@example.com",
288
+ "click:input[name=password]",
289
+ "type:secret",
290
+ "click:button[type=submit]",
291
+ "waitfor:[role=tab]"
292
+ ],
293
+ openSettings: ["macro:signIn", "click:text=Settings", "waitfor:[role=dialog]"]
294
+ }
295
+ }
296
+ ```
297
+
298
+ ```sh
299
+ npx webview-preview / macro:openSettings snap:settings
300
+ ```
301
+
302
+ A macro may run other macros; one that runs itself stops with an error. Prefer real clicks and typing over setting
303
+ values from code: the run then goes through the same handlers, focus and validation as for a person.
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
+
250
320
  ## Command line
251
321
 
252
322
  ```
@@ -256,7 +326,11 @@ webview-preview [route-or-url] [steps...] [options]
256
326
  | Option | |
257
327
  |---|---|
258
328
  | `--engine <name>` | `auto`, `webkitgtk`, `msedge`, `chrome`, `chromium`, `webkit` |
259
- | `--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 |
260
334
  | `--out <dir>` | Snapshot folder |
261
335
  | `--prefix <text>` | File name prefix |
262
336
  | `--scale <n>` | Device pixel ratio (Playwright engines) |
@@ -264,6 +338,7 @@ webview-preview [route-or-url] [steps...] [options]
264
338
  | `--snap-margin <px>` | Room around `snap:name@selector` |
265
339
  | `--wait-timeout <ms>` | How long `waitfor:` waits |
266
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`) |
267
342
  | `--compare` / `--update-baseline` | Compare snapshots with baselines / replace the baselines |
268
343
  | `--baseline <dir>` / `--max-diff-pixels <n>` / `--compare-tolerance <n>` | Baseline folder, allowed difference |
269
344
  | `--base-url <url>` | Base for routes |
@@ -271,6 +346,9 @@ webview-preview [route-or-url] [steps...] [options]
271
346
  | `--init-file <file>` | Same, from a file (repeatable) |
272
347
  | `--mock-file <file>` | Mocks from a `.json` or `.mjs` file (repeatable, added to the config's `mock`) |
273
348
  | `--log-requests` | Only list requests, answer nothing |
349
+ | `--tauri` | Stub the Tauri bridge (answers from `tauri.commands` in the config) |
350
+ | `--locale <tag>` | `navigator.language` for the page |
351
+ | `--fail-on-error` | Exit with 1 when the page logged `console.error` or threw |
274
352
  | `--config <file>` | Use this config file |
275
353
  | `--no-config` | Ignore config files |
276
354
  | `-h`, `--help` / `-v`, `--version` | |
@@ -309,6 +387,9 @@ result.failed; // false
309
387
  `preview()` reads the config file the same way the CLI does; pass `config: false` to skip it or `config: "path"` to
310
388
  choose one. Types are included.
311
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
+
312
393
  ## Login, backend and other things outside the page
313
394
 
314
395
  A real app rarely shows the screen you want straight away: it checks a session and sends you to a login page, it
@@ -321,8 +402,8 @@ Change what the page *receives* instead, only for the preview run:
321
402
  | A session or token kept in `localStorage`, `sessionStorage`, cookies | [`init`](#1-init--set-things-up-before-the-app-starts) |
322
403
  | Requests to your backend (`fetch`, `axios`, `XMLHttpRequest`) | [`mock`](#2-mock--answer-backend-requests) |
323
404
  | You don't know yet what the app requests | [`--log-requests`](#3-find-out-what-the-app-requests) |
324
- | Desktop APIs (Tauri `invoke`, window controls) | [`init`](#1-init--set-things-up-before-the-app-starts) with a stub, see [Tauri](#running-a-tauri-ui-outside-tauri) |
325
- | Login that lives only in memory, or a flow you want to click through | steps or helpers: `js:preview.signIn()` |
405
+ | Desktop APIs (Tauri `invoke`, Electron preload, Wails bindings) | [`tauri`, `globals`](#desktop-bridges-tauri-electron-wails) |
406
+ | Login that lives only in memory, or a flow you want to click through | steps, kept as a [macro](#macros): `macro:signIn` |
326
407
 
327
408
  ### 1. `init` — set things up before the app starts
328
409
 
@@ -419,92 +500,99 @@ With `mock` set, the same list shows which requests were answered: `GET /api/me
419
500
  desktop side does by itself (a Tauri command that calls a server from Rust). For desktop calls, stub the bridge in
420
501
  `init`; for the rest, run the real service locally.
421
502
 
422
- ## Running a Tauri UI outside Tauri
503
+ ### 4. Slow network and offline
423
504
 
424
- Your Tauri frontend calls `invoke` and window APIs. Outside the app there is no Tauri, so those calls fail — and the
425
- screen you want to see may never render.
505
+ How does the screen look while data is on its way, and what happens when the connection drops?
426
506
 
427
- **Quick way — a stub in `init`.** `@tauri-apps/api` talks to `window.__TAURI_INTERNALS__`; define it before the app
428
- starts and every `invoke` gets your answer:
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
+ ```
429
511
 
430
- ```js
431
- // preview/tauri.js
432
- const answers = { app_version: "0.0.1", get_settings: { theme: "dark" } }
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. |
433
516
 
434
- window.__TAURI_INTERNALS__ = {
435
- invoke: (command, args) => Promise.resolve(answers[command] ?? null),
436
- transformCallback: () => 0,
437
- metadata: { currentWindow: { label: "main" }, currentWebview: { label: "main" } }
438
- }
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
+
520
+
521
+ Your frontend calls the desktop side: Tauri's `invoke`, an Electron preload API, Wails bindings. Outside the app
522
+ there is no desktop side, so those calls fail — and the screen you want may never render. `webview-preview` puts a
523
+ stand-in in place before the page starts, so you open **your real entry page**, not a copy made for previews.
524
+
525
+ Every call to a stand-in is listed in `window.__webviewPreviewRequests`, next to `fetch` / XHR requests — print it
526
+ to see what the page asks for and what is still unanswered:
527
+
528
+ ```sh
529
+ npx webview-preview / wait:2000 "print:__webviewPreviewRequests.join('\n')"
530
+ # invoke plugin:app|version -> stub
531
+ # invoke get_settings -> null
532
+ # call window.api.onUpdate -> default
439
533
  ```
440
534
 
535
+ ### Tauri
536
+
441
537
  ```js
442
- // webview-preview.config.mjs
443
538
  export default {
444
539
  baseUrl: "http://localhost:1420",
445
540
  server: { command: "npx vite --port 1420 --strictPort", port: 1420 },
446
- init: { file: "preview/tauri.js" }
541
+ tauri: {
542
+ commands: {
543
+ "plugin:app|version": "1.4.0",
544
+ get_settings: { theme: "dark" },
545
+ greet: ({ name }) => `Hello, ${name}`
546
+ }
547
+ }
447
548
  }
448
549
  ```
449
550
 
450
- **Bigger apps — a preview entry page.** When you also want helpers (sign in, switch language, go offline) and typed
451
- stubs, give the preview its own entry:
452
-
453
- 1. **A stub** — set before anything imports `@tauri-apps/api`:
454
-
455
- ```ts
456
- // src/preview/tauri-stub.ts
457
- const answers: Record<string, unknown> = { app_version: "0.0.1" }
551
+ Or just `--tauri` on the command line. What you get (Tauri 2, `@tauri-apps/api`):
458
552
 
459
- Object.assign(window, {
460
- __TAURI_INTERNALS__: {
461
- invoke: (command: string) => Promise.resolve(answers[command] ?? null),
462
- transformCallback: () => 0,
463
- metadata: { currentWindow: { label: "main" }, currentWebview: { label: "main" } }
464
- }
465
- });
466
- ```
553
+ - `invoke` answers from `commands` — a value, or a function of the arguments (it may be `async`). A command not in
554
+ the list gets `null`. Plugin commands are named as `@tauri-apps/api` sends them: `plugin:<plugin>|<command>`.
555
+ - Built-in answers you can override: `plugin:app|version` / `name` / `tauri_version`, window state
556
+ (`plugin:window|is_maximized` → `false`, `is_focused` → `true`, `scale_factor`, `inner_size` …).
557
+ - Events work inside the page: `listen`, `once`, `emit`, `unlisten`. To act as the Rust side, emit from a step:
558
+ `js:__webviewPreview.emit("download-progress", { percent: 40 })`.
559
+ - `getCurrentWindow()` / `getCurrentWebview()` get the label `main` (`label` changes it), `isTauri()` is `true`,
560
+ `convertFileSrc` and `path.sep` work.
467
561
 
468
- 2. **An entry** that renders your app after the stub:
562
+ Functions in `commands` are copied into the page as source code, so they cannot use variables from the config
563
+ file — only their arguments and what is in the page.
469
564
 
470
- ```ts
471
- // src/preview/index.tsx
472
- import "./tauri-stub";
473
- import { createRoot } from "react-dom/client";
474
- import { App } from "../app";
565
+ ### Electron, Wails and other bridges — `globals`
475
566
 
476
- createRoot(document.getElementById("root")!).render(<App />);
477
- ```
567
+ `globals` puts objects on `window` by path before the page starts. `anyApi()` is a stand-in that answers **any**
568
+ call: awaiting it gives `null`, calling the result works too (handy for "subscribe" functions that return an
569
+ "unsubscribe"). Pass the members you care about:
478
570
 
479
- plus `src/preview/index.html` that loads it. With Vite, add the page to `build.rollupOptions.input` only if you
480
- want it in builds — the dev server serves it either way.
481
-
482
- 3. **Helpers** for states that are tedious to reach with clicks — signing in, switching language, going offline:
483
-
484
- ```ts
485
- Object.assign(window, {
486
- preview: {
487
- signIn: () => { /* fill the form, submit */ },
488
- language: (code: string) => i18n.changeLanguage(code)
489
- }
490
- });
491
- ```
571
+ ```js
572
+ import { anyApi, defineConfig } from "webview-preview";
492
573
 
493
- ```sh
494
- npx webview-preview /settings "js:preview.signIn()" wait:3000 snap:settings
495
- ```
574
+ export default defineConfig({
575
+ globals: {
576
+ // Electron: what your preload exposes with contextBridge.exposeInMainWorld("api", …)
577
+ "window.api": anyApi({
578
+ getVersion: async () => "1.4.0",
579
+ platform: "linux"
580
+ }),
581
+
582
+ // Wails: generated bindings and the runtime
583
+ "window.go": anyApi(),
584
+ "window.runtime": anyApi()
585
+ }
586
+ });
587
+ ```
496
588
 
497
- 4. **Config:**
589
+ Plain values work as well: `"window.__APP_CONFIG__": { apiUrl: "http://localhost:3000" }`.
498
590
 
499
- ```js
500
- export default {
501
- baseUrl: "http://localhost:1420/src/preview/index.html#",
502
- server: { command: "npx vite --port 1420 --strictPort", port: 1420 }
503
- }
504
- ```
591
+ ### What stays different
505
592
 
506
- Real Rust is not involved, so anything that depends on it shows what the stub answers. That is the trade-off for
507
- checking a screen in seconds.
593
+ Real Rust, Node or Go is not involved: anything that depends on it shows what the stand-in answers. Window chrome,
594
+ native menus and dialogs are not there. That is the trade-off for checking a screen in seconds without building
595
+ the app.
508
596
 
509
597
  ## Comparing with a baseline
510
598
 
@@ -526,6 +614,9 @@ npx webview-preview /settings snap:settings "snap:menu@[role=menu]" --compare
526
614
  The `.diff.png` shows the snapshot in grey with the changed pixels in red. More than `maxDiffPixels` changed
527
615
  (default `0`) → exit code `1`.
528
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.
529
620
 
530
621
  Snapshots differ between engines and screen scales, so the engine is part of the file name (`webkitgtk-…`,
531
622
  `msedge-…`) and each engine has its own baselines. `compareTolerance` (default `2` of 255 per channel) ignores
@@ -557,8 +648,38 @@ for engine in webkitgtk chrome; do npx webview-preview /settings --engine $engin
557
648
 
558
649
  ```sh
559
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
560
658
  ```
561
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
678
+ ```
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
+
562
683
  **Measure instead of looking** — sizes and positions are more reliable than eyes:
563
684
 
564
685
  ```sh
@@ -599,6 +720,8 @@ More of these — animations frame by frame, measuring, getting past login — i
599
720
  | `WebKitGTK engine needs Linux with python3-gi and WebKit2 4.1` | Install the [Linux packages](#linux--webkitgtk). |
600
721
  | `This engine needs playwright-core` | `npm i -D playwright-core`. |
601
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`. |
602
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. |
603
726
  | `"/route" is not a full URL` | Set `baseUrl` in the config or pass `--base-url`. |
604
727
  | Blank snapshot | The page needs more time or threw on start: add `wait:2000`, and `print:document.body.innerHTML.length`. |
@@ -619,6 +742,17 @@ agent to it (for example from `CLAUDE.md` or `AGENTS.md` in your project):
619
742
  UI changes: check them in the real engine with `npx webview-preview` — see node_modules/webview-preview/AGENTS.md.
620
743
  ```
621
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
+
622
756
  ## License
623
757
 
624
758
  MIT © n0t4u404