webview-preview 0.0.1 → 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 +7 -6
- package/README.md +102 -68
- package/bin/webview-preview.mjs +24 -1
- package/package.json +1 -1
- package/src/core/steps.mjs +14 -1
- package/src/engines/playwright.mjs +2 -2
- package/src/index.d.ts +16 -1
- package/src/index.mjs +24 -4
- package/src/page/globals.mjs +37 -0
- package/src/page/init.mjs +2 -2
- package/src/page/source.mjs +55 -0
- package/src/page/tauri.mjs +95 -0
package/AGENTS.md
CHANGED
|
@@ -60,8 +60,8 @@ element.getClientRects().length > 0
|
|
|
60
60
|
input.dispatchEvent(new Event('input', { bubbles: true })) }
|
|
61
61
|
```
|
|
62
62
|
|
|
63
|
-
If the project's
|
|
64
|
-
|
|
63
|
+
If the project's config has macros (`macros` in `webview-preview.config.mjs` — signing in, opening a dialog), use them
|
|
64
|
+
(`macro:signIn`): they are already correct. A flow you repeat more than twice belongs there too.
|
|
65
65
|
|
|
66
66
|
## Measuring
|
|
67
67
|
|
|
@@ -112,7 +112,7 @@ for y in range(320 * scale, 340 * scale):
|
|
|
112
112
|
|
|
113
113
|
The screen you need is often behind a login, or empty because the backend is not running. **Never change the app's
|
|
114
114
|
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`,
|
|
115
|
+
it goes into the preview config: `init`, `mock`, `tauri`, `globals`, `macros`. The app's code stays as it is.
|
|
116
116
|
|
|
117
117
|
1. **Find out what stands in the way.** Run with `--log-requests` and print the list:
|
|
118
118
|
|
|
@@ -133,9 +133,10 @@ it goes into the preview config: `init`, `mock`, helpers. The app's code stays a
|
|
|
133
133
|
- session in storage → `init`: `localStorage.setItem('<key>', '<value in the exact format the code parses>')`;
|
|
134
134
|
- session from a request → `mock` that request (`"GET /api/me": { json: { … } }`) and anything else the screen
|
|
135
135
|
loads; take field names from the types / interfaces in the code;
|
|
136
|
-
- desktop bridge
|
|
137
|
-
|
|
138
|
-
|
|
136
|
+
- desktop bridge → `tauri: { commands: { … } }` for Tauri, `globals` with `anyApi({ … })` for Electron's
|
|
137
|
+
preload API or Wails bindings; the request list shows `invoke <command> -> null` / `call <path> -> default`
|
|
138
|
+
for every call that still needs an answer;
|
|
139
|
+
- session only in memory (no storage, no request) → steps through the real login form, saved as a macro.
|
|
139
140
|
|
|
140
141
|
4. **Check that it worked:** print `location.href` (you stayed on the route) and the request list (every request
|
|
141
142
|
the screen needs is `-> mock …`, none fails), then snap.
|
package/README.md
CHANGED
|
@@ -15,15 +15,22 @@ npx webview-preview https://example.com snap:home
|
|
|
15
15
|
# → .webview-preview/webkitgtk-home.png (on Linux)
|
|
16
16
|
```
|
|
17
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
|
+
|
|
18
24
|
- [Which engine matches my app](#which-engine-matches-my-app)
|
|
19
25
|
- [Install](#install)
|
|
20
26
|
- [Quick start](#quick-start)
|
|
21
27
|
- [Config file](#config-file)
|
|
22
28
|
- [Steps](#steps)
|
|
29
|
+
- [Macros](#macros)
|
|
23
30
|
- [Command line](#command-line)
|
|
24
31
|
- [JavaScript API](#javascript-api)
|
|
25
32
|
- [Login, backend and other things outside the page](#login-backend-and-other-things-outside-the-page)
|
|
26
|
-
- [
|
|
33
|
+
- [Desktop bridges: Tauri, Electron, Wails](#desktop-bridges-tauri-electron-wails)
|
|
27
34
|
- [Comparing with a baseline](#comparing-with-a-baseline)
|
|
28
35
|
- [Cleaning up snapshots](#cleaning-up-snapshots)
|
|
29
36
|
- [Recipes](#recipes)
|
|
@@ -160,6 +167,10 @@ npx webview-preview /settings snap:settings
|
|
|
160
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). | — |
|
|
161
168
|
| `mock` | Answers for `fetch` / `XMLHttpRequest` requests: `{ "GET /api/me": { json: { … } } }`. See [below](#2-mock--answer-backend-requests). | — |
|
|
162
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). | — |
|
|
163
174
|
|
|
164
175
|
Command-line options win over the config file.
|
|
165
176
|
|
|
@@ -212,6 +223,7 @@ Steps run one after another, after the page has loaded and settled for 1.5 s.
|
|
|
212
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. |
|
|
213
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. |
|
|
214
225
|
| `resize:<W>x<H>` | Resizes the window. |
|
|
226
|
+
| `macro:<name>` | Runs the steps of a macro from the config. See [Macros](#macros). |
|
|
215
227
|
|
|
216
228
|
### Selectors
|
|
217
229
|
|
|
@@ -247,6 +259,35 @@ npx webview-preview / \
|
|
|
247
259
|
|
|
248
260
|
On Windows PowerShell use the same double quotes; inside them, keep JavaScript strings in single quotes.
|
|
249
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
|
+
|
|
250
291
|
## Command line
|
|
251
292
|
|
|
252
293
|
```
|
|
@@ -271,6 +312,8 @@ webview-preview [route-or-url] [steps...] [options]
|
|
|
271
312
|
| `--init-file <file>` | Same, from a file (repeatable) |
|
|
272
313
|
| `--mock-file <file>` | Mocks from a `.json` or `.mjs` file (repeatable, added to the config's `mock`) |
|
|
273
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 |
|
|
274
317
|
| `--config <file>` | Use this config file |
|
|
275
318
|
| `--no-config` | Ignore config files |
|
|
276
319
|
| `-h`, `--help` / `-v`, `--version` | |
|
|
@@ -321,8 +364,8 @@ Change what the page *receives* instead, only for the preview run:
|
|
|
321
364
|
| A session or token kept in `localStorage`, `sessionStorage`, cookies | [`init`](#1-init--set-things-up-before-the-app-starts) |
|
|
322
365
|
| Requests to your backend (`fetch`, `axios`, `XMLHttpRequest`) | [`mock`](#2-mock--answer-backend-requests) |
|
|
323
366
|
| You don't know yet what the app requests | [`--log-requests`](#3-find-out-what-the-app-requests) |
|
|
324
|
-
| Desktop APIs (Tauri `invoke`,
|
|
325
|
-
| Login that lives only in memory, or a flow you want to click through | steps
|
|
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` |
|
|
326
369
|
|
|
327
370
|
### 1. `init` — set things up before the app starts
|
|
328
371
|
|
|
@@ -419,92 +462,83 @@ With `mock` set, the same list shows which requests were answered: `GET /api/me
|
|
|
419
462
|
desktop side does by itself (a Tauri command that calls a server from Rust). For desktop calls, stub the bridge in
|
|
420
463
|
`init`; for the rest, run the real service locally.
|
|
421
464
|
|
|
422
|
-
##
|
|
423
|
-
|
|
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.
|
|
465
|
+
## Desktop bridges: Tauri, Electron, Wails
|
|
426
466
|
|
|
427
|
-
|
|
428
|
-
|
|
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.
|
|
429
470
|
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
const answers = { app_version: "0.0.1", get_settings: { theme: "dark" } }
|
|
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:
|
|
433
473
|
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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
|
|
439
479
|
```
|
|
440
480
|
|
|
481
|
+
### Tauri
|
|
482
|
+
|
|
441
483
|
```js
|
|
442
|
-
// webview-preview.config.mjs
|
|
443
484
|
export default {
|
|
444
485
|
baseUrl: "http://localhost:1420",
|
|
445
486
|
server: { command: "npx vite --port 1420 --strictPort", port: 1420 },
|
|
446
|
-
|
|
487
|
+
tauri: {
|
|
488
|
+
commands: {
|
|
489
|
+
"plugin:app|version": "1.4.0",
|
|
490
|
+
get_settings: { theme: "dark" },
|
|
491
|
+
greet: ({ name }) => `Hello, ${name}`
|
|
492
|
+
}
|
|
493
|
+
}
|
|
447
494
|
}
|
|
448
495
|
```
|
|
449
496
|
|
|
450
|
-
|
|
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" }
|
|
497
|
+
Or just `--tauri` on the command line. What you get (Tauri 2, `@tauri-apps/api`):
|
|
458
498
|
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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.
|
|
467
507
|
|
|
468
|
-
|
|
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.
|
|
469
510
|
|
|
470
|
-
|
|
471
|
-
// src/preview/index.tsx
|
|
472
|
-
import "./tauri-stub";
|
|
473
|
-
import { createRoot } from "react-dom/client";
|
|
474
|
-
import { App } from "../app";
|
|
511
|
+
### Electron, Wails and other bridges — `globals`
|
|
475
512
|
|
|
476
|
-
|
|
477
|
-
|
|
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:
|
|
478
516
|
|
|
479
|
-
|
|
480
|
-
|
|
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
|
-
```
|
|
517
|
+
```js
|
|
518
|
+
import { anyApi, defineConfig } from "webview-preview";
|
|
492
519
|
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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
|
+
```
|
|
496
534
|
|
|
497
|
-
|
|
535
|
+
Plain values work as well: `"window.__APP_CONFIG__": { apiUrl: "http://localhost:3000" }`.
|
|
498
536
|
|
|
499
|
-
|
|
500
|
-
export default {
|
|
501
|
-
baseUrl: "http://localhost:1420/src/preview/index.html#",
|
|
502
|
-
server: { command: "npx vite --port 1420 --strictPort", port: 1420 }
|
|
503
|
-
}
|
|
504
|
-
```
|
|
537
|
+
### What stays different
|
|
505
538
|
|
|
506
|
-
Real Rust is not involved
|
|
507
|
-
checking a screen in seconds
|
|
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.
|
|
508
542
|
|
|
509
543
|
## Comparing with a baseline
|
|
510
544
|
|
package/bin/webview-preview.mjs
CHANGED
|
@@ -2,7 +2,22 @@
|
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
3
|
import { cleanSnapshots, ENGINES, preview } from "#src/index.mjs";
|
|
4
4
|
|
|
5
|
-
const FLAGS = {
|
|
5
|
+
const FLAGS = {
|
|
6
|
+
"--engine": "engine",
|
|
7
|
+
"--size": "size",
|
|
8
|
+
"--out": "out",
|
|
9
|
+
"--prefix": "prefix",
|
|
10
|
+
"--scale": "scale",
|
|
11
|
+
"--config": "config",
|
|
12
|
+
"--base-url": "baseUrl",
|
|
13
|
+
"--snap-margin": "snapMargin",
|
|
14
|
+
"--baseline": "baseline",
|
|
15
|
+
"--max-diff-pixels": "maxDiffPixels",
|
|
16
|
+
"--compare-tolerance": "compareTolerance",
|
|
17
|
+
"--older-than": "olderThan",
|
|
18
|
+
"--wait-timeout": "waitTimeout",
|
|
19
|
+
"--locale": "locale"
|
|
20
|
+
}
|
|
6
21
|
const LIST_FLAGS = { "--init": "init", "--init-file": "initFile", "--mock-file": "mockFile" }
|
|
7
22
|
|
|
8
23
|
const HELP = `Usage: webview-preview [route-or-url] [steps...] [options]
|
|
@@ -25,6 +40,7 @@ Steps:
|
|
|
25
40
|
type:<text> type text with the keyboard
|
|
26
41
|
hover:<css selector> move the mouse to the element's centre
|
|
27
42
|
click:<css selector> click the element's centre with the mouse
|
|
43
|
+
macro:<name> run the steps of a macro from the config (macros: { name: [...] })
|
|
28
44
|
|
|
29
45
|
Options:
|
|
30
46
|
--engine <name> ${ENGINES.join(" | ")} (default: auto)
|
|
@@ -48,6 +64,9 @@ Options:
|
|
|
48
64
|
--mock-file <f> answer fetch/XHR requests from a .json/.mjs file of mocks (repeatable);
|
|
49
65
|
every request is listed in window.__webviewPreviewRequests
|
|
50
66
|
--log-requests only list fetch/XHR requests in window.__webviewPreviewRequests
|
|
67
|
+
--tauri stub the Tauri bridge (invoke, events, window); answers come from
|
|
68
|
+
tauri.commands in the config, anything else gets null
|
|
69
|
+
--locale <tag> navigator.language for the page: en-US, ru-RU … (Playwright: also Intl)
|
|
51
70
|
--config <file> config file (default: webview-preview.config.{mjs,js,json})
|
|
52
71
|
--no-config ignore config files
|
|
53
72
|
|
|
@@ -83,6 +102,10 @@ const parseArgs = argv => {
|
|
|
83
102
|
options.headed = true;
|
|
84
103
|
continue;
|
|
85
104
|
}
|
|
105
|
+
if (arg === "--tauri") {
|
|
106
|
+
options.tauri = true;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
86
109
|
if (arg === "--log-requests") {
|
|
87
110
|
options.logRequests = true;
|
|
88
111
|
continue;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webview-preview",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"description": "Open your UI in the engine your desktop app really runs on (WebKitGTK, Edge/WebView2, WebKit), drive it with steps and save PNG snapshots.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "n0t4u404",
|
package/src/core/steps.mjs
CHANGED
|
@@ -112,6 +112,19 @@ const parseKey = combo => {
|
|
|
112
112
|
return { key, modifiers }
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
export const expandMacros = (steps, macros = {}, trail = []) => steps.flatMap(step => {
|
|
116
|
+
if (!step.startsWith("macro:")) return [step];
|
|
117
|
+
const name = step.slice(6);
|
|
118
|
+
if (!Object.hasOwn(macros, name)) {
|
|
119
|
+
const known = Object.keys(macros);
|
|
120
|
+
throw new Error(`Unknown macro "${name}". Macros: ${known.length ? known.join(", ") : "none — add them to macros in the config"}`);
|
|
121
|
+
}
|
|
122
|
+
if (trail.includes(name)) throw new Error(`macro:${name} uses itself: ${[...trail, name].join(" → ")}`);
|
|
123
|
+
const body = macros[name];
|
|
124
|
+
if (!Array.isArray(body) || !body.every(item => typeof item === "string")) throw new Error(`macro "${name}" must be a list of steps`);
|
|
125
|
+
return expandMacros(body, macros, [...trail, name]);
|
|
126
|
+
})
|
|
127
|
+
|
|
115
128
|
export const parseSize = value => {
|
|
116
129
|
const match = /^(\d+)x(\d+)$/.exec(String(value));
|
|
117
130
|
if (!match) throw new Error(`Size must look like 1200x650, got "${value}"`);
|
|
@@ -122,7 +135,7 @@ export const parseSteps = steps => steps.map(step => {
|
|
|
122
135
|
const separator = step.indexOf(":");
|
|
123
136
|
const kind = separator === -1 ? step : step.slice(0, separator);
|
|
124
137
|
const value = separator === -1 ? "" : step.slice(separator + 1);
|
|
125
|
-
if (!STEP_KINDS.has(kind)) throw new Error(`Unknown step "${step}". Steps: ${[...STEP_KINDS].join(", ")}`);
|
|
138
|
+
if (!STEP_KINDS.has(kind)) throw new Error(`Unknown step "${step}". Steps: ${[...STEP_KINDS, "macro"].join(", ")}`);
|
|
126
139
|
if (kind === "wait" && !/^\d+$/.test(value)) throw new Error(`wait needs milliseconds, got "${step}"`);
|
|
127
140
|
if (kind === "resize") parseSize(value);
|
|
128
141
|
if (kind === "snap") {
|
|
@@ -16,7 +16,7 @@ const loadPlaywright = async () => {
|
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
export const runPlaywright = async ({ engine, url, steps, init = [], size, out, prefix, scale, snapMargin = 8, waitForTimeoutMs = WAIT_FOR_TIMEOUT_MS, log }) => {
|
|
19
|
+
export const runPlaywright = async ({ engine, url, steps, init = [], size, locale, out, prefix, scale, snapMargin = 8, waitForTimeoutMs = WAIT_FOR_TIMEOUT_MS, log }) => {
|
|
20
20
|
const playwright = await loadPlaywright();
|
|
21
21
|
const browserType = engine === "webkit" ? playwright.webkit : playwright.chromium;
|
|
22
22
|
const browser = await browserType.launch({ channel: CHANNELS[engine], headless: true });
|
|
@@ -24,7 +24,7 @@ export const runPlaywright = async ({ engine, url, steps, init = [], size, out,
|
|
|
24
24
|
let failed = false;
|
|
25
25
|
|
|
26
26
|
try {
|
|
27
|
-
const page = await browser.newPage({ viewport: parseSize(size), deviceScaleFactor: scale ?? 1 });
|
|
27
|
+
const page = await browser.newPage({ viewport: parseSize(size), deviceScaleFactor: scale ?? 1, locale });
|
|
28
28
|
page.setDefaultTimeout(TIMEOUT_MS);
|
|
29
29
|
for (const content of init) await page.addInitScript({ content });
|
|
30
30
|
await page.goto(url, { waitUntil: "load" });
|
package/src/index.d.ts
CHANGED
|
@@ -12,7 +12,8 @@ export type Step =
|
|
|
12
12
|
| `key:${string}`
|
|
13
13
|
| `type:${string}`
|
|
14
14
|
| `hover:${string}`
|
|
15
|
-
| `click:${string}
|
|
15
|
+
| `click:${string}`
|
|
16
|
+
| `macro:${string}`;
|
|
16
17
|
|
|
17
18
|
export interface ServerOptions {
|
|
18
19
|
command: string;
|
|
@@ -42,6 +43,19 @@ export interface PreviewConfig {
|
|
|
42
43
|
baseline?: string;
|
|
43
44
|
maxDiffPixels?: number;
|
|
44
45
|
compareTolerance?: number;
|
|
46
|
+
tauri?: boolean | TauriStub;
|
|
47
|
+
globals?: Record<string, unknown>;
|
|
48
|
+
locale?: string;
|
|
49
|
+
macros?: Record<string, Step[] | string[]>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface TauriStub {
|
|
53
|
+
commands?: Record<string, unknown | ((args: Record<string, unknown>) => unknown)>;
|
|
54
|
+
label?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface AnyApi {
|
|
58
|
+
readonly members: Record<string, unknown>;
|
|
45
59
|
}
|
|
46
60
|
|
|
47
61
|
export interface MockResponse {
|
|
@@ -75,6 +89,7 @@ export declare const REQUESTS_KEY: "__webviewPreviewRequests";
|
|
|
75
89
|
export declare function resolveUrl(target: string, baseUrl?: string): string;
|
|
76
90
|
export declare function preview(options?: PreviewOptions): Promise<PreviewResult>;
|
|
77
91
|
export declare function defineConfig(config: PreviewConfig): PreviewConfig;
|
|
92
|
+
export declare function anyApi(members?: Record<string, unknown>): AnyApi;
|
|
78
93
|
export interface CleanOptions {
|
|
79
94
|
config?: string | false;
|
|
80
95
|
out?: string;
|
package/src/index.mjs
CHANGED
|
@@ -3,14 +3,17 @@ import path from "node:path";
|
|
|
3
3
|
import { compareSnapshots } from "#src/snapshots/compare.mjs";
|
|
4
4
|
import { loadConfig } from "#src/core/config.mjs";
|
|
5
5
|
import { resolveEngine, runEngine } from "#src/engines/index.mjs";
|
|
6
|
-
import {
|
|
6
|
+
import { checkLocale, globalsScript, localeScript } from "#src/page/globals.mjs";
|
|
7
|
+
import { resolveInit, wrapInit } from "#src/page/init.mjs";
|
|
7
8
|
import { compileMock, mockScript, resolveMockFiles } from "#src/page/mock.mjs";
|
|
8
9
|
import { startServer } from "#src/core/server.mjs";
|
|
9
|
-
import { parseSize, parseSteps } from "#src/core/steps.mjs";
|
|
10
|
+
import { expandMacros, parseSize, parseSteps } from "#src/core/steps.mjs";
|
|
11
|
+
import { resolveTauri, tauriScript } from "#src/page/tauri.mjs";
|
|
10
12
|
|
|
11
13
|
export { ENGINES } from "#src/engines/index.mjs";
|
|
12
14
|
export { REQUESTS_KEY } from "#src/page/mock.mjs";
|
|
13
15
|
export { cleanSnapshots } from "#src/snapshots/clean.mjs";
|
|
16
|
+
export { anyApi } from "#src/page/source.mjs";
|
|
14
17
|
|
|
15
18
|
const DEFAULTS = { engine: "auto", size: "1200x650", out: ".webview-preview", steps: ["snap:page"] }
|
|
16
19
|
const ABSOLUTE_URL = /^[a-z][a-z\d+.-]*:/i;
|
|
@@ -31,7 +34,8 @@ export const preview = async (options = {}) => {
|
|
|
31
34
|
const merged = { ...DEFAULTS, ...config, ...Object.fromEntries(Object.entries(options).filter(([, value]) => value !== undefined)) }
|
|
32
35
|
const baseDir = config.configDir ?? process.cwd();
|
|
33
36
|
const engine = resolveEngine(merged.engine);
|
|
34
|
-
const
|
|
37
|
+
const macros = { ...config.macros, ...options.macros }
|
|
38
|
+
const steps = parseSteps(expandMacros(merged.steps.length ? merged.steps : DEFAULTS.steps, macros));
|
|
35
39
|
const out = path.resolve(baseDir, merged.out);
|
|
36
40
|
const url = resolveUrl(merged.target ?? merged.url ?? "/", merged.baseUrl);
|
|
37
41
|
parseSize(merged.size);
|
|
@@ -39,7 +43,22 @@ export const preview = async (options = {}) => {
|
|
|
39
43
|
const hasMock = config.mock !== undefined || options.mock !== undefined || mockFiles.length > 0
|
|
40
44
|
|| !!(options.logRequests ?? config.logRequests);
|
|
41
45
|
const mockRules = compileMock([config.mock, ...mockFiles, options.mock]);
|
|
42
|
-
const
|
|
46
|
+
const locale = merged.locale === undefined ? undefined : checkLocale(String(merged.locale));
|
|
47
|
+
const tauri = resolveTauri(config.tauri, options.tauri);
|
|
48
|
+
const globals = { ...config.globals, ...options.globals }
|
|
49
|
+
const stubs = [
|
|
50
|
+
locale && { label: "locale", source: localeScript(locale) },
|
|
51
|
+
tauri && { label: "tauri", source: tauriScript(tauri) },
|
|
52
|
+
Object.keys(globals).length && { label: "globals", source: globalsScript(globals) }
|
|
53
|
+
].filter(Boolean).map(stub => {
|
|
54
|
+
try {
|
|
55
|
+
new Function(stub.source);
|
|
56
|
+
} catch (error) {
|
|
57
|
+
throw new Error(`${stub.label}: the generated script does not parse (${error.message}); check the functions in your config`);
|
|
58
|
+
}
|
|
59
|
+
return wrapInit(stub);
|
|
60
|
+
});
|
|
61
|
+
const init = stubs.concat(hasMock ? [mockScript(mockRules)] : [], resolveInit([
|
|
43
62
|
{ entries: config.init, baseDir },
|
|
44
63
|
{ entries: options.init, baseDir: process.cwd() },
|
|
45
64
|
{ entries: options.initFile && [].concat(options.initFile).map(file => ({ file })), baseDir: process.cwd() }
|
|
@@ -54,6 +73,7 @@ export const preview = async (options = {}) => {
|
|
|
54
73
|
steps,
|
|
55
74
|
init,
|
|
56
75
|
size: merged.size,
|
|
76
|
+
locale,
|
|
57
77
|
out,
|
|
58
78
|
prefix: merged.prefix ?? `${engine}-`,
|
|
59
79
|
scale: merged.scale === undefined ? undefined : Number(merged.scale),
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { ANY_FUNCTION, toSource } from "#src/page/source.mjs";
|
|
2
|
+
|
|
3
|
+
const GLOBAL_PATH = /^(window\.)?[A-Za-z_$][\w$]*(\.[A-Za-z_$][\w$]*)*$/;
|
|
4
|
+
const LOCALE = /^[a-z]{2,3}(-[A-Za-z\d]{2,8})*$/i;
|
|
5
|
+
|
|
6
|
+
export const globalsScript = globals => {
|
|
7
|
+
const entries = Object.entries(globals).map(([path, value]) => {
|
|
8
|
+
if (!GLOBAL_PATH.test(path)) throw new Error(`globals: "${path}" must be a dotted path like window.api or window.go.main`);
|
|
9
|
+
const name = path.startsWith("window.") ? path : `window.${path}`;
|
|
10
|
+
return `assign(${JSON.stringify(name)}, ${toSource(value, name)});`;
|
|
11
|
+
});
|
|
12
|
+
return `(() => {
|
|
13
|
+
const __webviewPreviewAny = ${ANY_FUNCTION};
|
|
14
|
+
const assign = (path, value) => {
|
|
15
|
+
const parts = path.split(".").slice(1);
|
|
16
|
+
const last = parts.pop();
|
|
17
|
+
let target = window;
|
|
18
|
+
for (const part of parts) {
|
|
19
|
+
if (target[part] === undefined || target[part] === null) target[part] = {};
|
|
20
|
+
target = target[part];
|
|
21
|
+
}
|
|
22
|
+
target[last] = value;
|
|
23
|
+
}
|
|
24
|
+
${entries.join("\n\t")}
|
|
25
|
+
})();`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const checkLocale = locale => {
|
|
29
|
+
if (!LOCALE.test(locale)) throw new Error(`locale must look like en-US or ru, got "${locale}"`);
|
|
30
|
+
return locale;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const localeScript = locale => `(() => {
|
|
34
|
+
const language = ${JSON.stringify(checkLocale(locale))};
|
|
35
|
+
Object.defineProperty(Navigator.prototype, "language", { get: () => language, configurable: true });
|
|
36
|
+
Object.defineProperty(Navigator.prototype, "languages", { get: () => [language], configurable: true });
|
|
37
|
+
})();`;
|
package/src/page/init.mjs
CHANGED
|
@@ -15,7 +15,7 @@ const readEntry = (entry, baseDir, index) => {
|
|
|
15
15
|
throw new Error(`init entries must be code strings or { file: "path" }, got ${JSON.stringify(entry)}`);
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
const
|
|
18
|
+
export const wrapInit = ({ label, source }) => `{
|
|
19
19
|
try {
|
|
20
20
|
${source}
|
|
21
21
|
} catch (error) {
|
|
@@ -25,7 +25,7 @@ const wrap = ({ label, source }) => `{
|
|
|
25
25
|
|
|
26
26
|
export const resolveInit = (groups, baseDir) => groups
|
|
27
27
|
.flatMap(group => toList(group.entries).map(entry => ({ entry, baseDir: group.baseDir })))
|
|
28
|
-
.map(({ entry, baseDir: dir }, index) =>
|
|
28
|
+
.map(({ entry, baseDir: dir }, index) => wrapInit(readEntry(entry, dir ?? baseDir, index)));
|
|
29
29
|
|
|
30
30
|
export const reportInitErrors = (json, log) => {
|
|
31
31
|
for (const message of JSON.parse(json || "[]")) log.error(`init failed: ${message}`);
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
const ANY_API = Symbol.for("webview-preview.anyApi");
|
|
2
|
+
|
|
3
|
+
export const anyApi = (members = {}) => ({ [ANY_API]: true, members });
|
|
4
|
+
|
|
5
|
+
const asExpression = source => {
|
|
6
|
+
try {
|
|
7
|
+
new Function(`return (${source})`);
|
|
8
|
+
return source;
|
|
9
|
+
} catch {
|
|
10
|
+
return source.startsWith("async ") ? `async function ${source.slice(6)}` : `function ${source}`;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const toSource = (value, name = "value") => {
|
|
15
|
+
if (typeof value === "function") return `(${asExpression(value.toString())})`;
|
|
16
|
+
if (value === undefined) return "undefined";
|
|
17
|
+
if (Array.isArray(value)) return `[${value.map((item, index) => toSource(item, `${name}[${index}]`)).join(", ")}]`;
|
|
18
|
+
if (value && typeof value === "object") {
|
|
19
|
+
if (value[ANY_API]) return `__webviewPreviewAny(${toSource(value.members, name)}, ${JSON.stringify(name)})`;
|
|
20
|
+
const entries = Object.entries(value).map(([key, item]) => `${JSON.stringify(key)}: ${toSource(item, `${name}.${key}`)}`);
|
|
21
|
+
return `{ ${entries.join(", ")} }`;
|
|
22
|
+
}
|
|
23
|
+
return JSON.stringify(value);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const RECORD_FUNCTION = `call => (window.__webviewPreviewRequests = window.__webviewPreviewRequests || []).push(call)`;
|
|
27
|
+
|
|
28
|
+
export const ANY_FUNCTION = `(members, name) => {
|
|
29
|
+
const record = ${RECORD_FUNCTION};
|
|
30
|
+
const settled = () => new Proxy(function () {}, {
|
|
31
|
+
get: (_, key) => key === "then" ? resolve => resolve(null) : key === Symbol.toPrimitive ? () => "" : settled(),
|
|
32
|
+
apply: () => settled()
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const node = path => new Proxy(function () {}, {
|
|
36
|
+
get: (_, key) => {
|
|
37
|
+
if (typeof key === "symbol") return key === Symbol.toPrimitive ? () => "" : undefined;
|
|
38
|
+
if (key === "then" || key === "toJSON") return undefined;
|
|
39
|
+
if (path === name && Object.prototype.hasOwnProperty.call(members, key)) {
|
|
40
|
+
const member = members[key];
|
|
41
|
+
if (typeof member !== "function") return member;
|
|
42
|
+
return (...args) => {
|
|
43
|
+
record("call " + path + "." + key + " -> stub");
|
|
44
|
+
return member(...args);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return node(path + "." + key);
|
|
48
|
+
},
|
|
49
|
+
apply: () => {
|
|
50
|
+
record("call " + path + " -> default");
|
|
51
|
+
return settled();
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
return node(name);
|
|
55
|
+
}`;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { ANY_FUNCTION, RECORD_FUNCTION, toSource } from "#src/page/source.mjs";
|
|
2
|
+
|
|
3
|
+
const DEFAULT_COMMANDS = {
|
|
4
|
+
"plugin:app|version": "0.0.0",
|
|
5
|
+
"plugin:app|name": "app",
|
|
6
|
+
"plugin:app|tauri_version": "2.0.0",
|
|
7
|
+
"plugin:window|is_maximized": false,
|
|
8
|
+
"plugin:window|is_minimized": false,
|
|
9
|
+
"plugin:window|is_fullscreen": false,
|
|
10
|
+
"plugin:window|is_focused": true,
|
|
11
|
+
"plugin:window|is_visible": true,
|
|
12
|
+
"plugin:window|is_decorated": true,
|
|
13
|
+
"plugin:window|is_resizable": true,
|
|
14
|
+
"plugin:window|scale_factor": () => window.devicePixelRatio,
|
|
15
|
+
"plugin:window|inner_size": () => ({ width: window.innerWidth, height: window.innerHeight }),
|
|
16
|
+
"plugin:window|outer_size": () => ({ width: window.innerWidth, height: window.innerHeight })
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const resolveTauri = (fromConfig, fromOptions) => {
|
|
20
|
+
const pick = value => value === true ? {} : value && typeof value === "object" ? value : null;
|
|
21
|
+
const config = pick(fromConfig);
|
|
22
|
+
const options = pick(fromOptions);
|
|
23
|
+
if (!config && !options) return null;
|
|
24
|
+
return {
|
|
25
|
+
label: options?.label ?? config?.label ?? "main",
|
|
26
|
+
commands: { ...config?.commands, ...options?.commands }
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const tauriScript = ({ label, commands }) => `(() => {
|
|
31
|
+
const __webviewPreviewAny = ${ANY_FUNCTION};
|
|
32
|
+
const record = ${RECORD_FUNCTION};
|
|
33
|
+
const commands = Object.assign(${toSource(DEFAULT_COMMANDS)}, ${toSource(commands, "tauri")});
|
|
34
|
+
const callbacks = new Map();
|
|
35
|
+
const listeners = new Map();
|
|
36
|
+
let nextId = 1;
|
|
37
|
+
|
|
38
|
+
const transformCallback = (callback, once = false) => {
|
|
39
|
+
const id = nextId++;
|
|
40
|
+
callbacks.set(id, data => {
|
|
41
|
+
if (once) callbacks.delete(id);
|
|
42
|
+
return callback && callback(data);
|
|
43
|
+
});
|
|
44
|
+
return id;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const runCallback = (id, data) => {
|
|
48
|
+
const callback = callbacks.get(id);
|
|
49
|
+
if (callback) callback(data);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const removeListener = (event, id) => {
|
|
53
|
+
callbacks.delete(id);
|
|
54
|
+
const ids = listeners.get(event);
|
|
55
|
+
if (ids && ids.includes(id)) ids.splice(ids.indexOf(id), 1);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const emit = (event, payload) => {
|
|
59
|
+
for (const id of [...(listeners.get(event) || [])]) runCallback(id, { event, id, payload });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const events = {
|
|
63
|
+
"plugin:event|listen": ({ event, handler }) => {
|
|
64
|
+
if (!listeners.has(event)) listeners.set(event, []);
|
|
65
|
+
listeners.get(event).push(handler);
|
|
66
|
+
return handler;
|
|
67
|
+
},
|
|
68
|
+
"plugin:event|unlisten": ({ event, eventId }) => removeListener(event, eventId),
|
|
69
|
+
"plugin:event|emit": ({ event, payload }) => emit(event, payload),
|
|
70
|
+
"plugin:event|emit_to": ({ event, payload }) => emit(event, payload)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const invoke = async (command, args = {}) => {
|
|
74
|
+
if (events[command]) return events[command](args) ?? null;
|
|
75
|
+
const isKnown = Object.prototype.hasOwnProperty.call(commands, command);
|
|
76
|
+
record("invoke " + command + (isKnown ? " -> stub" : " -> null"));
|
|
77
|
+
if (!isKnown) return null;
|
|
78
|
+
const answer = commands[command];
|
|
79
|
+
return typeof answer === "function" ? answer(args) : answer;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
window.__TAURI_INTERNALS__ = {
|
|
83
|
+
invoke,
|
|
84
|
+
transformCallback,
|
|
85
|
+
unregisterCallback: id => callbacks.delete(id),
|
|
86
|
+
runCallback,
|
|
87
|
+
callbacks,
|
|
88
|
+
convertFileSrc: (path, protocol = "asset") => protocol + "://localhost/" + encodeURIComponent(path),
|
|
89
|
+
metadata: { currentWindow: { label: ${JSON.stringify(label)} }, currentWebview: { windowLabel: ${JSON.stringify(label)}, label: ${JSON.stringify(label)} } },
|
|
90
|
+
plugins: { path: { sep: "/", delimiter: ":" } }
|
|
91
|
+
}
|
|
92
|
+
window.__TAURI_EVENT_PLUGIN_INTERNALS__ = { unregisterListener: removeListener }
|
|
93
|
+
window.isTauri = true;
|
|
94
|
+
window.__webviewPreview = Object.assign(window.__webviewPreview || {}, { emit });
|
|
95
|
+
})();`;
|