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/AGENTS.md +208 -0
- package/LICENSE +21 -0
- package/README.md +657 -2
- package/bin/webview-preview.mjs +161 -0
- package/package.json +54 -5
- package/src/core/config.mjs +16 -0
- package/src/core/server.mjs +57 -0
- package/src/core/steps.mjs +182 -0
- package/src/engines/index.mjs +17 -0
- package/src/engines/playwright.mjs +104 -0
- package/src/engines/webkitgtk.mjs +67 -0
- package/src/engines/webkitgtk.py +318 -0
- package/src/index.d.ts +101 -0
- package/src/index.mjs +101 -0
- package/src/page/globals.mjs +37 -0
- package/src/page/init.mjs +32 -0
- package/src/page/mock.mjs +117 -0
- package/src/page/source.mjs +55 -0
- package/src/page/tauri.mjs +95 -0
- package/src/snapshots/clean.mjs +36 -0
- package/src/snapshots/compare.mjs +72 -0
- package/src/snapshots/png.mjs +119 -0
package/README.md
CHANGED
|
@@ -1,3 +1,658 @@
|
|
|
1
|
-
#
|
|
1
|
+
# webview-preview
|
|
2
2
|
|
|
3
|
-
|
|
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
|