webview-preview 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,658 @@
1
- # Temporary Holding Version
1
+ # webview-preview
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ See your UI the way your desktop app really renders it. `webview-preview` opens a page in the **same engine your app
4
+ runs on** — WebKitGTK on Linux, Edge (the WebView2 engine) on Windows, WebKit on macOS — runs a list of steps
5
+ (click, type, wait, measure) and saves PNG snapshots.
6
+
7
+ It is made for apps built on a system webview: **Tauri**, **Wails**, **Neutralinojs**. Their UI looks right in Chrome
8
+ during development and then breaks in the real window — a header that stops sticking, a font that falls back, a
9
+ backdrop blur that renders nothing, a date picker that looks nothing like the design. With `webview-preview` you
10
+ (or an AI agent working on your code) can check the real engine from the command line, without building and
11
+ starting the app.
12
+
13
+ ```sh
14
+ npx webview-preview https://example.com snap:home
15
+ # → .webview-preview/webkitgtk-home.png (on Linux)
16
+ ```
17
+
18
+ **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,
19
+ press keys, measure, snap, compare with yesterday. Steps stay one-liners you can type in a terminal; mocks and
20
+ desktop stubs only say *what to answer* so the screen renders. It is not a test runner: no test suites, no stateful
21
+ mocks, no `expect` library, no retries. For end-to-end tests use Playwright Test or WebdriverIO — the stubs here are
22
+ plain init scripts and fit there too.
23
+
24
+ - [Which engine matches my app](#which-engine-matches-my-app)
25
+ - [Install](#install)
26
+ - [Quick start](#quick-start)
27
+ - [Config file](#config-file)
28
+ - [Steps](#steps)
29
+ - [Macros](#macros)
30
+ - [Command line](#command-line)
31
+ - [JavaScript API](#javascript-api)
32
+ - [Login, backend and other things outside the page](#login-backend-and-other-things-outside-the-page)
33
+ - [Desktop bridges: Tauri, Electron, Wails](#desktop-bridges-tauri-electron-wails)
34
+ - [Comparing with a baseline](#comparing-with-a-baseline)
35
+ - [Cleaning up snapshots](#cleaning-up-snapshots)
36
+ - [Recipes](#recipes)
37
+ - [Troubleshooting](#troubleshooting)
38
+ - [For AI agents](#for-ai-agents)
39
+
40
+ ## Which engine matches my app
41
+
42
+ | Your app runs on | Engine it uses | `--engine` | How close |
43
+ |---|---|---|---|
44
+ | Tauri / Wails on **Linux** | WebKitGTK | `webkitgtk` | The same library your app loads — exact |
45
+ | Tauri / Wails on **Windows** | WebView2 (Edge, Chromium) | `msedge` | The same browser engine as WebView2 |
46
+ | Tauri / Wails on **macOS** | WKWebView (WebKit) | `webkit` | Playwright's WebKit build — close, not the system one |
47
+ | Electron, or just Chrome | Chromium | `chrome` / `chromium` | Exact for Chrome |
48
+
49
+ `--engine auto` (the default) picks the first row that fits the current computer: `webkitgtk` on Linux, `msedge` on
50
+ Windows, `webkit` on macOS.
51
+
52
+ To check another platform's engine from your machine, pass it explicitly — for example `--engine chrome` on Linux
53
+ gives you a Chromium picture close to what Windows users see.
54
+
55
+ ## Install
56
+
57
+ ```sh
58
+ npm i -D webview-preview
59
+ ```
60
+
61
+ Then the engine for your system.
62
+
63
+ ### Linux — WebKitGTK
64
+
65
+ WebKitGTK is driven through Python (GObject introspection). Install the bindings once:
66
+
67
+ ```sh
68
+ # Debian, Ubuntu, Mint
69
+ sudo apt install python3-gi gir1.2-gtk-3.0 gir1.2-webkit2-4.1
70
+
71
+ # Fedora
72
+ sudo dnf install python3-gobject gtk3 webkit2gtk4.1
73
+
74
+ # Arch
75
+ sudo pacman -S python-gobject gtk3 webkit2gtk-4.1
76
+ ```
77
+
78
+ These are the same `webkit2gtk-4.1` libraries Tauri 2 needs, so on a Tauri dev machine they are usually there already.
79
+ Check:
80
+
81
+ ```sh
82
+ python3 -c "import gi; gi.require_version('WebKit2', '4.1'); print('ok')"
83
+ ```
84
+
85
+ The page renders **off-screen** — no window shows up — but GTK still needs a display connection (under Wayland it uses
86
+ XWayland). On a server or CI without one, wrap the command in `xvfb-run`. Pass `--headed` to watch a real window.
87
+
88
+ ### Windows — Edge
89
+
90
+ ```sh
91
+ npm i -D playwright-core
92
+ ```
93
+
94
+ Microsoft Edge is already installed on Windows 10 and 11; nothing else to download. Runs headless — no window shows.
95
+
96
+ ### macOS — WebKit
97
+
98
+ ```sh
99
+ npm i -D playwright-core
100
+ npx playwright install webkit
101
+ ```
102
+
103
+ ### Chrome or Chromium (any system)
104
+
105
+ `--engine chrome` uses your installed Google Chrome (`playwright-core` needed). `--engine chromium` uses Playwright's
106
+ own build: `npx playwright install chromium`.
107
+
108
+ ## Quick start
109
+
110
+ A public page:
111
+
112
+ ```sh
113
+ npx webview-preview https://example.com snap:home
114
+ ```
115
+
116
+ Your app's dev server — say Vite on port 5173:
117
+
118
+ ```sh
119
+ npx webview-preview http://localhost:5173 "js:document.querySelector('button').click()" snap:after-click
120
+ ```
121
+
122
+ Each `snap:<name>` saves `<out>/<engine>-<name>.png` and prints its path. Each `print:<expression>` prints a value.
123
+ Exit code is `0` when every step ran and `1` otherwise.
124
+
125
+ ## Config file
126
+
127
+ Put the things you repeat into `webview-preview.config.mjs` (or `.js`, `.json`) in the folder you run the command
128
+ from. Then you pass only a route and steps:
129
+
130
+ ```js
131
+ // webview-preview.config.mjs
132
+ import { defineConfig } from "webview-preview";
133
+
134
+ export default defineConfig({
135
+ baseUrl: "http://localhost:5173/#",
136
+ server: {
137
+ command: "npx vite --port 5173 --strictPort",
138
+ port: 5173
139
+ },
140
+ size: "1200x650",
141
+ out: ".tmp/preview"
142
+ });
143
+ ```
144
+
145
+ ```sh
146
+ npx webview-preview /settings snap:settings
147
+ ```
148
+
149
+ | Field | What it does | Default |
150
+ |---|---|---|
151
+ | `baseUrl` | Put in front of a route that is not a full URL: `baseUrl + "/settings"`. End it with `#` for hash routers. | — |
152
+ | `server.command` | Started (through the shell) when nothing listens on `server.port`, stopped after the run. Already running? It is reused and left alone. | — |
153
+ | `server.port` | Port to wait for. | — |
154
+ | `server.host` | Host to wait on. | `localhost` |
155
+ | `server.cwd` | Folder for `server.command`. | the config's folder |
156
+ | `server.readyTimeoutMs` | How long to wait for the port. | `30000` |
157
+ | `engine` | See [engines](#which-engine-matches-my-app). | `auto` |
158
+ | `size` | Window size in CSS pixels, `WxH`. | `1200x650` |
159
+ | `out` | Snapshot folder, relative to the config's folder. | `.webview-preview` |
160
+ | `prefix` | File name prefix. | `<engine>-` |
161
+ | `scale` | Device pixel ratio for Playwright engines (`2` = HiDPI). WebKitGTK: off-screen snapshots are 1×, `--headed` ones follow your screen. | `1` |
162
+ | `headed` | WebKitGTK: show a real window instead of rendering off-screen. | `false` |
163
+ | `snapMargin` | Room in CSS pixels around `snap:name@selector`, so focus rings and shadows fit. | `8` |
164
+ | `waitTimeout` | How long `waitfor:` waits, ms. | `10000` |
165
+ | `keepStorage` | WebKitGTK: keep `localStorage`, cookies and cache between runs. By default every run starts clean, like Playwright engines always do. | `false` |
166
+ | `compare` / `updateBaseline` / `baseline` / `maxDiffPixels` / `compareTolerance` | See [Comparing with a baseline](#comparing-with-a-baseline). | — |
167
+ | `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). | — |
168
+ | `mock` | Answers for `fetch` / `XMLHttpRequest` requests: `{ "GET /api/me": { json: { … } } }`. See [below](#2-mock--answer-backend-requests). | — |
169
+ | `logRequests` | List every `fetch` / `XMLHttpRequest` request in `window.__webviewPreviewRequests` (also on whenever `mock` is set). | `false` |
170
+ | `tauri` | Stub the Tauri bridge: `true`, or `{ commands: { … }, label: "main" }`. See [Desktop bridges](#desktop-bridges-tauri-electron-wails). | — |
171
+ | `globals` | Objects to put on `window` before the page starts, by path: `{ "window.api": anyApi({ … }) }`. See [Desktop bridges](#desktop-bridges-tauri-electron-wails). | — |
172
+ | `locale` | `navigator.language` for the page, `en-US`, `ru-RU` … Playwright engines also switch `Intl` and `Accept-Language`; WebKitGTK only `navigator.language`. | system |
173
+ | `macros` | Named lists of steps, run with `macro:<name>`. See [Macros](#macros). | — |
174
+
175
+ Command-line options win over the config file.
176
+
177
+ ### Examples
178
+
179
+ **Tauri + Vite** (Tauri's default dev port):
180
+
181
+ ```js
182
+ export default {
183
+ baseUrl: "http://localhost:1420",
184
+ server: { command: "npx vite --port 1420 --strictPort", port: 1420 }
185
+ }
186
+ ```
187
+
188
+ **Wails** (`wails dev` serves the frontend on 34115):
189
+
190
+ ```js
191
+ export default {
192
+ baseUrl: "http://localhost:34115",
193
+ server: { command: "wails dev", port: 34115, readyTimeoutMs: 120000 }
194
+ }
195
+ ```
196
+
197
+ **A built site from a folder:**
198
+
199
+ ```js
200
+ export default {
201
+ baseUrl: "http://localhost:4173",
202
+ server: { command: "npx vite preview --port 4173 --strictPort", port: 4173 }
203
+ }
204
+ ```
205
+
206
+ ## Steps
207
+
208
+ Steps run one after another, after the page has loaded and settled for 1.5 s.
209
+
210
+ | Step | What it does |
211
+ |---|---|
212
+ | `js:<code>` | Runs JavaScript in the page, then waits 700 ms so animations and re-renders finish. |
213
+ | `jsnow:<code>` | Runs JavaScript and goes on at once — for measuring something right after an action. |
214
+ | `wait:<ms>` | Waits. |
215
+ | `waitfor:<selector>` | Waits until a matching element is **visible and has stopped moving** — same position, size and opacity in two checks 50 ms apart, so an element that slides or fades in is caught at its final place. Gives up after `--wait-timeout` (10 s) with an error. |
216
+ | `waitfor:!<selector>` | Waits until no matching element is visible — a spinner or skeleton is gone. |
217
+ | `snap:<name>` | Saves a PNG of the window. |
218
+ | `snap:<name>@<selector>` | Saves only the element matched by the CSS selector, plus `snapMargin` (8 px) around it. |
219
+ | `key:<key>` | Presses a key with the real keyboard: `Tab`, `Shift+Tab`, `Enter`, `Escape`, `ArrowDown`, `Control+a`, `x`. Keys: a character or `Tab Enter Escape Backspace Delete Insert Space Home End PageUp PageDown ArrowUp ArrowDown ArrowLeft ArrowRight F1–F12`; modifiers: `Shift Control Alt Meta`. |
220
+ | `type:<text>` | Types the text key by key (any language). Click or focus a field first. |
221
+ | `hover:<selector>` | Moves the real mouse to the middle of the element (scrolls it into view first). `:hover` styles and `mouseover` fire. |
222
+ | `click:<selector>` | Clicks the middle of the element with the real mouse — focus moves, `:active` and `:focus` apply like for a person. |
223
+ | `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
+ | `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. |
225
+ | `resize:<W>x<H>` | Resizes the window. |
226
+ | `macro:<name>` | Runs the steps of a macro from the config. See [Macros](#macros). |
227
+
228
+ ### Selectors
229
+
230
+ Every step that takes a selector (`waitfor`, `measure`, `click`, `hover`, `snap:name@…`) accepts:
231
+
232
+ - **CSS**: `[role=dialog]`, `button[type=submit]`, `[class*=card]` (stable part of a hashed CSS-module class);
233
+ - **text**: `text=Save` — the innermost element whose whole text (spaces collapsed) is exactly `Save`. Handy for
234
+ buttons, tabs and options; for a snapshot of the whole control prefer a CSS selector, because `text=` finds the
235
+ label inside it.
236
+
237
+ When several elements match, the steps pick what a person would see: the first **visible** one (has a size, not
238
+ `visibility: hidden`, no fully transparent ancestor) and, for `click` / `hover` / `snap`, preferably the one that is
239
+ **on top** at its centre — not a copy hidden behind a dialog or inside a closed menu.
240
+
241
+ A failing `js:` or `print:` writes `js failed: …` / `print failed: …` to stderr and the run goes on. A selector that
242
+ matches nothing (or a hidden element) in `snap@`, `hover:` or `click:` writes an error, the run goes on, and the exit
243
+ code becomes `1`.
244
+
245
+ **`js:` vs the real input steps.** `js:document.querySelector('button').click()` runs the click handler, but no mouse
246
+ moves and no key is pressed: no `:hover`, no focus change, no `:focus-visible`. `click:`, `hover:`, `key:` and `type:`
247
+ go through the engine's real input path (GTK events in WebKitGTK, Playwright's mouse and keyboard elsewhere), so
248
+ focus moves on `Tab`, `:hover` styles apply and focus rings are painted — as for a person. The two engines produce the
249
+ same DOM events: `Shift+Tab` gives `keydown` `Shift`, then `Tab` with `shiftKey: true`.
250
+
251
+ Quote steps in the shell — they contain spaces, brackets and quotes:
252
+
253
+ ```sh
254
+ npx webview-preview / \
255
+ "js:document.querySelector('[data-test=open]').click()" \
256
+ "print:document.querySelectorAll('[role=dialog]').length" \
257
+ snap:dialog
258
+ ```
259
+
260
+ On Windows PowerShell use the same double quotes; inside them, keep JavaScript strings in single quotes.
261
+
262
+ ## Macros
263
+
264
+ Steps you repeat — signing in, opening a dialog — go into the config under a name:
265
+
266
+ ```js
267
+ export default {
268
+ macros: {
269
+ signIn: [
270
+ "js:location.hash = '#/sign-in'",
271
+ "waitfor:input[name=email]",
272
+ "click:input[name=email]",
273
+ "type:demo@example.com",
274
+ "click:input[name=password]",
275
+ "type:secret",
276
+ "click:button[type=submit]",
277
+ "waitfor:[role=tab]"
278
+ ],
279
+ openSettings: ["macro:signIn", "click:text=Settings", "waitfor:[role=dialog]"]
280
+ }
281
+ }
282
+ ```
283
+
284
+ ```sh
285
+ npx webview-preview / macro:openSettings snap:settings
286
+ ```
287
+
288
+ A macro may run other macros; one that runs itself stops with an error. Prefer real clicks and typing over setting
289
+ values from code: the run then goes through the same handlers, focus and validation as for a person.
290
+
291
+ ## Command line
292
+
293
+ ```
294
+ webview-preview [route-or-url] [steps...] [options]
295
+ ```
296
+
297
+ | Option | |
298
+ |---|---|
299
+ | `--engine <name>` | `auto`, `webkitgtk`, `msedge`, `chrome`, `chromium`, `webkit` |
300
+ | `--size <WxH>` | Window size |
301
+ | `--out <dir>` | Snapshot folder |
302
+ | `--prefix <text>` | File name prefix |
303
+ | `--scale <n>` | Device pixel ratio (Playwright engines) |
304
+ | `--headed` | WebKitGTK: show a real window |
305
+ | `--snap-margin <px>` | Room around `snap:name@selector` |
306
+ | `--wait-timeout <ms>` | How long `waitfor:` waits |
307
+ | `--keep-storage` | WebKitGTK: keep storage between runs |
308
+ | `--compare` / `--update-baseline` | Compare snapshots with baselines / replace the baselines |
309
+ | `--baseline <dir>` / `--max-diff-pixels <n>` / `--compare-tolerance <n>` | Baseline folder, allowed difference |
310
+ | `--base-url <url>` | Base for routes |
311
+ | `--init <code>` | Run code before the page's scripts (repeatable) |
312
+ | `--init-file <file>` | Same, from a file (repeatable) |
313
+ | `--mock-file <file>` | Mocks from a `.json` or `.mjs` file (repeatable, added to the config's `mock`) |
314
+ | `--log-requests` | Only list requests, answer nothing |
315
+ | `--tauri` | Stub the Tauri bridge (answers from `tauri.commands` in the config) |
316
+ | `--locale <tag>` | `navigator.language` for the page |
317
+ | `--config <file>` | Use this config file |
318
+ | `--no-config` | Ignore config files |
319
+ | `-h`, `--help` / `-v`, `--version` | |
320
+
321
+ Add it to `package.json` so everyone runs it the same way:
322
+
323
+ ```json
324
+ {
325
+ "scripts": {
326
+ "preview:ui": "webview-preview"
327
+ }
328
+ }
329
+ ```
330
+
331
+ ```sh
332
+ npm run preview:ui -- /settings snap:settings
333
+ ```
334
+
335
+ ## JavaScript API
336
+
337
+ ```js
338
+ import { preview } from "webview-preview";
339
+
340
+ const result = await preview({
341
+ target: "/settings",
342
+ steps: ["js:document.querySelector('#dark').click()", "snap:dark"],
343
+ engine: "auto",
344
+ log: { info: line => lines.push(line), error: console.error }
345
+ });
346
+
347
+ result.engine; // "webkitgtk"
348
+ result.saved; // ["/abs/path/.webview-preview/webkitgtk-dark.png"]
349
+ result.failed; // false
350
+ ```
351
+
352
+ `preview()` reads the config file the same way the CLI does; pass `config: false` to skip it or `config: "path"` to
353
+ choose one. Types are included.
354
+
355
+ ## Login, backend and other things outside the page
356
+
357
+ A real app rarely shows the screen you want straight away: it checks a session and sends you to a login page, it
358
+ loads data from a server that may not be running, it calls the desktop side (Tauri's `invoke`). Do **not** add
359
+ "skip login" switches to the app for this — sooner or later such a switch ships in a release, and that is a hole.
360
+ Change what the page *receives* instead, only for the preview run:
361
+
362
+ | What stands in the way | Use |
363
+ |---|---|
364
+ | A session or token kept in `localStorage`, `sessionStorage`, cookies | [`init`](#1-init--set-things-up-before-the-app-starts) |
365
+ | Requests to your backend (`fetch`, `axios`, `XMLHttpRequest`) | [`mock`](#2-mock--answer-backend-requests) |
366
+ | You don't know yet what the app requests | [`--log-requests`](#3-find-out-what-the-app-requests) |
367
+ | Desktop APIs (Tauri `invoke`, Electron preload, Wails bindings) | [`tauri`, `globals`](#desktop-bridges-tauri-electron-wails) |
368
+ | Login that lives only in memory, or a flow you want to click through | steps, kept as a [macro](#macros): `macro:signIn` |
369
+
370
+ ### 1. `init` — set things up before the app starts
371
+
372
+ `init` code runs in the page **before any of its scripts**, on every load and reload. Whatever it puts in place, the
373
+ app finds there on start — as if the user had logged in yesterday.
374
+
375
+ ```js
376
+ // webview-preview.config.mjs
377
+ export default {
378
+ baseUrl: "http://localhost:5173",
379
+ init: [
380
+ "localStorage.setItem('token', 'preview-token')",
381
+ { file: "preview/session.js" }
382
+ ]
383
+ }
384
+ ```
385
+
386
+ ```js
387
+ // preview/session.js — a file is easier for anything longer than a line
388
+ localStorage.setItem("user", JSON.stringify({ id: 1, nickname: "bodya", role: "admin" }));
389
+ document.cookie = "session=preview; path=/";
390
+ ```
391
+
392
+ From the command line: `--init "<code>"` and `--init-file <file>`, both repeatable, added after the config's `init`.
393
+
394
+ - Each entry runs in its own block: `const` inside does not leak. To share something, put it on `window`.
395
+ - An entry that throws does not stop the run: you get `init failed: <entry>: <message>` before the first step.
396
+ - File paths in the config are relative to the config's folder; on the command line, to the current folder.
397
+
398
+ ### 2. `mock` — answer backend requests
399
+
400
+ `mock` replaces `fetch` and `XMLHttpRequest` in the page (from `init`, before the app starts) and answers the requests
401
+ that match. Everything else goes to the network as usual.
402
+
403
+ ```js
404
+ export default {
405
+ baseUrl: "http://localhost:5173",
406
+ init: "localStorage.setItem('token', 'preview-token')",
407
+ mock: {
408
+ "GET /api/me": { json: { nickname: "bodya", role: "admin" } },
409
+ "GET /api/instances": { json: [{ id: 1, name: "Survival" }, { id: 2, name: "Creative" }] },
410
+ "GET /api/instances/*": { json: { id: 1, name: "Survival", players: 12 } },
411
+ "POST /api/login": { status: 401, json: { error: "wrong password" } },
412
+ "DELETE /api/instances/*": { status: 204 },
413
+ "/api/news": { json: [], delayMs: 1500 },
414
+ "GET https://api.example.com/v1/**": { json: { ok: true } }
415
+ }
416
+ }
417
+ ```
418
+
419
+ **Keys** — `"METHOD path"` or just `"path"` for any method.
420
+
421
+ - The path is compared with the request's path; the query string is ignored unless the key has a `?`.
422
+ - `*` matches one path segment (`/api/items/*` → `/api/items/42`), `**` matches any number (`/api/**`).
423
+ - A key starting with `http://` / `https://` matches the full address (another origin).
424
+ - The first matching key wins.
425
+
426
+ **Answers** — an object with any of:
427
+
428
+ | Field | |
429
+ |---|---|
430
+ | `json` | Body as JSON, `content-type: application/json` is added |
431
+ | `body` | Body as text (instead of `json`) |
432
+ | `status` | HTTP status, default `200` |
433
+ | `headers` | Extra response headers |
434
+ | `delayMs` | Answer after this many milliseconds — for loading states |
435
+
436
+ Keep big mocks in a file: `--mock-file preview/mocks.json` (or `.mjs` with `export default { … }`). Files add to the
437
+ config's `mock`; for the same key, the later one wins.
438
+
439
+ **Use it to test the unhappy paths too:** a `500` for the error message, `delayMs: 3000` for the skeleton, an empty
440
+ list for the empty state, a `401` for the expired session.
441
+
442
+ ### 3. Find out what the app requests
443
+
444
+ Before writing mocks, look at what the screen asks for:
445
+
446
+ ```sh
447
+ npx webview-preview /dashboard --log-requests wait:2000 "print:__webviewPreviewRequests.join('\n')"
448
+ ```
449
+
450
+ ```
451
+ GET /api/me -> network
452
+ GET /api/instances?limit=20 -> network
453
+ POST /api/telemetry -> network
454
+ ```
455
+
456
+ With `mock` set, the same list shows which requests were answered: `GET /api/me -> mock 200`. A request still marked
457
+ `-> network` that fails is the next one to mock.
458
+
459
+ ### What `mock` does not cover
460
+
461
+ `WebSocket` and `EventSource`, requests made by service workers, `<img>` / `<script>` / CSS loads, and anything your
462
+ desktop side does by itself (a Tauri command that calls a server from Rust). For desktop calls, stub the bridge in
463
+ `init`; for the rest, run the real service locally.
464
+
465
+ ## Desktop bridges: Tauri, Electron, Wails
466
+
467
+ Your frontend calls the desktop side: Tauri's `invoke`, an Electron preload API, Wails bindings. Outside the app
468
+ there is no desktop side, so those calls fail — and the screen you want may never render. `webview-preview` puts a
469
+ stand-in in place before the page starts, so you open **your real entry page**, not a copy made for previews.
470
+
471
+ Every call to a stand-in is listed in `window.__webviewPreviewRequests`, next to `fetch` / XHR requests — print it
472
+ to see what the page asks for and what is still unanswered:
473
+
474
+ ```sh
475
+ npx webview-preview / wait:2000 "print:__webviewPreviewRequests.join('\n')"
476
+ # invoke plugin:app|version -> stub
477
+ # invoke get_settings -> null
478
+ # call window.api.onUpdate -> default
479
+ ```
480
+
481
+ ### Tauri
482
+
483
+ ```js
484
+ export default {
485
+ baseUrl: "http://localhost:1420",
486
+ server: { command: "npx vite --port 1420 --strictPort", port: 1420 },
487
+ tauri: {
488
+ commands: {
489
+ "plugin:app|version": "1.4.0",
490
+ get_settings: { theme: "dark" },
491
+ greet: ({ name }) => `Hello, ${name}`
492
+ }
493
+ }
494
+ }
495
+ ```
496
+
497
+ Or just `--tauri` on the command line. What you get (Tauri 2, `@tauri-apps/api`):
498
+
499
+ - `invoke` answers from `commands` — a value, or a function of the arguments (it may be `async`). A command not in
500
+ the list gets `null`. Plugin commands are named as `@tauri-apps/api` sends them: `plugin:<plugin>|<command>`.
501
+ - Built-in answers you can override: `plugin:app|version` / `name` / `tauri_version`, window state
502
+ (`plugin:window|is_maximized` → `false`, `is_focused` → `true`, `scale_factor`, `inner_size` …).
503
+ - Events work inside the page: `listen`, `once`, `emit`, `unlisten`. To act as the Rust side, emit from a step:
504
+ `js:__webviewPreview.emit("download-progress", { percent: 40 })`.
505
+ - `getCurrentWindow()` / `getCurrentWebview()` get the label `main` (`label` changes it), `isTauri()` is `true`,
506
+ `convertFileSrc` and `path.sep` work.
507
+
508
+ Functions in `commands` are copied into the page as source code, so they cannot use variables from the config
509
+ file — only their arguments and what is in the page.
510
+
511
+ ### Electron, Wails and other bridges — `globals`
512
+
513
+ `globals` puts objects on `window` by path before the page starts. `anyApi()` is a stand-in that answers **any**
514
+ call: awaiting it gives `null`, calling the result works too (handy for "subscribe" functions that return an
515
+ "unsubscribe"). Pass the members you care about:
516
+
517
+ ```js
518
+ import { anyApi, defineConfig } from "webview-preview";
519
+
520
+ export default defineConfig({
521
+ globals: {
522
+ // Electron: what your preload exposes with contextBridge.exposeInMainWorld("api", …)
523
+ "window.api": anyApi({
524
+ getVersion: async () => "1.4.0",
525
+ platform: "linux"
526
+ }),
527
+
528
+ // Wails: generated bindings and the runtime
529
+ "window.go": anyApi(),
530
+ "window.runtime": anyApi()
531
+ }
532
+ });
533
+ ```
534
+
535
+ Plain values work as well: `"window.__APP_CONFIG__": { apiUrl: "http://localhost:3000" }`.
536
+
537
+ ### What stays different
538
+
539
+ Real Rust, Node or Go is not involved: anything that depends on it shows what the stand-in answers. Window chrome,
540
+ native menus and dialogs are not there. That is the trade-off for checking a screen in seconds without building
541
+ the app.
542
+
543
+ ## Comparing with a baseline
544
+
545
+ Save how a screen looks once, then let every later run tell you whether it changed:
546
+
547
+ ```sh
548
+ npx webview-preview /settings snap:settings "snap:menu@[role=menu]" --compare
549
+ ```
550
+
551
+ - **First run**: no baseline yet → the snapshots are copied to `webview-preview-baseline/` (next to the config).
552
+ Commit that folder.
553
+ - **Next runs**: each snapshot is compared pixel by pixel with the baseline of the same name:
554
+
555
+ ```
556
+ compare webkitgtk-settings.png: same
557
+ compare webkitgtk-menu.png: 499 px differ (0.06%) → .webview-preview/webkitgtk-menu.diff.png
558
+ ```
559
+
560
+ The `.diff.png` shows the snapshot in grey with the changed pixels in red. More than `maxDiffPixels` changed
561
+ (default `0`) → exit code `1`.
562
+ - **The change is intended**: `--update-baseline` replaces the baselines with this run's snapshots.
563
+
564
+ Snapshots differ between engines and screen scales, so the engine is part of the file name (`webkitgtk-…`,
565
+ `msedge-…`) and each engine has its own baselines. `compareTolerance` (default `2` of 255 per channel) ignores
566
+ invisible rounding noise; raise it a little if antialiasing differs between machines.
567
+
568
+ ## Cleaning up snapshots
569
+
570
+ Snapshots pile up in the snapshot folder. Clear them when you no longer need them:
571
+
572
+ ```sh
573
+ npx webview-preview clean # delete every *.png in the snapshot folder
574
+ npx webview-preview clean --dry-run # only list what would be deleted
575
+ npx webview-preview clean --older-than 2h # only files older than 2 hours (30m, 7d …)
576
+ ```
577
+
578
+ `clean` reads the same config (`out`, `baseline`) as a normal run. It deletes only `*.png` files directly in the
579
+ snapshot folder (including `.diff.png`), never the baselines, and refuses to run when the snapshot folder is the
580
+ project folder, your home folder, the disk root or the baseline folder.
581
+
582
+ ## Recipes
583
+
584
+ **Same screen in every engine you ship to** — compare side by side:
585
+
586
+ ```sh
587
+ for engine in webkitgtk chrome; do npx webview-preview /settings --engine $engine snap:settings; done
588
+ ```
589
+
590
+ **Different window sizes:**
591
+
592
+ ```sh
593
+ npx webview-preview / snap:wide resize:900x500 wait:300 snap:narrow
594
+ ```
595
+
596
+ **Measure instead of looking** — sizes and positions are more reliable than eyes:
597
+
598
+ ```sh
599
+ npx webview-preview / "print:JSON.stringify(document.querySelector('header').getBoundingClientRect())"
600
+ ```
601
+
602
+ **Type into a React-controlled input** (setting `.value` alone does not reach React):
603
+
604
+ ```sh
605
+ "js:const input = document.querySelector('input[name=email]'); Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set.call(input, 'a@b.c'); input.dispatchEvent(new Event('input', { bubbles: true }))"
606
+ ```
607
+
608
+ **Keyboard path through a form** — check the focus order and that the ring is not clipped:
609
+
610
+ ```sh
611
+ npx webview-preview /signup "click:input[name=email]" type:me@example.com key:Tab type:secret key:Tab "print:document.activeElement.textContent" "snap:submit@button[type=submit]"
612
+ ```
613
+
614
+ **A flow, step by step, the way a person does it** — no fixed waits:
615
+
616
+ ```sh
617
+ npx webview-preview / "click:text=Sign in" "waitfor:[role=dialog]" "click:input[name=email]" type:me@example.com \
618
+ key:Enter "waitfor:!text=Signing in…" "waitfor:text=Dashboard" "measure:[data-test=sidebar]" snap:dashboard
619
+ ```
620
+
621
+ **Hover state of one element:**
622
+
623
+ ```sh
624
+ npx webview-preview / "hover:[data-test=card]" wait:300 "snap:card-hover@[data-test=card]"
625
+ ```
626
+
627
+ More of these — animations frame by frame, measuring, getting past login — in [AGENTS.md](./AGENTS.md).
628
+
629
+ ## Troubleshooting
630
+
631
+ | Problem | Fix |
632
+ |---|---|
633
+ | `WebKitGTK engine needs Linux with python3-gi and WebKit2 4.1` | Install the [Linux packages](#linux--webkitgtk). |
634
+ | `This engine needs playwright-core` | `npm i -D playwright-core`. |
635
+ | `Executable doesn't exist` (Playwright) | `npx playwright install webkit` (or `chromium`); for `msedge` / `chrome` install the browser itself. |
636
+ | `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
+ | `"/route" is not a full URL` | Set `baseUrl` in the config or pass `--base-url`. |
638
+ | Blank snapshot | The page needs more time or threw on start: add `wait:2000`, and `print:document.body.innerHTML.length`. |
639
+ | `timed out after 30000 ms` (WebKitGTK) | A run longer than 30 s; split it into several runs. |
640
+ | The app starts in its default state every time | Runs start with empty storage on purpose. Set what you need with [`init`](#1-init--set-things-up-before-the-app-starts), or pass `--keep-storage` (WebKitGTK) to carry it over. |
641
+ | `waitfor:… timed out` | The element never became visible, or keeps moving (an endless animation). Check with `measure:`; raise `--wait-timeout`; use `wait:` for elements that never stop moving. |
642
+ | `--headed` Linux snapshot is twice the size | A real window follows your screen's scale (HiDPI): PNG pixels = CSS pixels × scale. Off-screen snapshots are 1×. |
643
+ | Off-screen page is half as wide as `--size` | `GDK_SCALE` is set in your environment; it shrinks the off-screen window. Unset it for the run. |
644
+ | `cannot open display` (Linux) | No display at all (server, CI): run under `xvfb-run`. |
645
+
646
+ ## For AI agents
647
+
648
+ [AGENTS.md](./AGENTS.md) is written for an AI coding agent that checks its own UI changes with this tool: how to
649
+ read results, measure layout, step through animations, show focus rings, and the traps that waste time. Point your
650
+ agent to it (for example from `CLAUDE.md` or `AGENTS.md` in your project):
651
+
652
+ ```md
653
+ UI changes: check them in the real engine with `npx webview-preview` — see node_modules/webview-preview/AGENTS.md.
654
+ ```
655
+
656
+ ## License
657
+
658
+ MIT © n0t4u404