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 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 preview page exposes helpers (`window.preview.signIn()`, `window.preview.type(name, value)`), use
64
- them — they are faster and already correct.
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`, helpers. The app's code stays as it is.
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 (Tauri `invoke`) → a stub of `window.__TAURI_INTERNALS__` in `init`;
137
- - session only in memory (no storage, no request) → steps through the real login form, or a helper the project
138
- already exposes (`window.preview.signIn()`).
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
- - [Running a Tauri UI outside Tauri](#running-a-tauri-ui-outside-tauri)
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`, window controls) | [`init`](#1-init--set-things-up-before-the-app-starts) with a stub, see [Tauri](#running-a-tauri-ui-outside-tauri) |
325
- | Login that lives only in memory, or a flow you want to click through | steps or helpers: `js:preview.signIn()` |
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
- ## Running a Tauri UI outside Tauri
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
- **Quick way — a stub in `init`.** `@tauri-apps/api` talks to `window.__TAURI_INTERNALS__`; define it before the app
428
- starts and every `invoke` gets your answer:
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
- ```js
431
- // preview/tauri.js
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
- window.__TAURI_INTERNALS__ = {
435
- invoke: (command, args) => Promise.resolve(answers[command] ?? null),
436
- transformCallback: () => 0,
437
- metadata: { currentWindow: { label: "main" }, currentWebview: { label: "main" } }
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
- init: { file: "preview/tauri.js" }
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
- **Bigger apps — a preview entry page.** When you also want helpers (sign in, switch language, go offline) and typed
451
- stubs, give the preview its own entry:
452
-
453
- 1. **A stub** — set before anything imports `@tauri-apps/api`:
454
-
455
- ```ts
456
- // src/preview/tauri-stub.ts
457
- const answers: Record<string, unknown> = { app_version: "0.0.1" }
497
+ Or just `--tauri` on the command line. What you get (Tauri 2, `@tauri-apps/api`):
458
498
 
459
- Object.assign(window, {
460
- __TAURI_INTERNALS__: {
461
- invoke: (command: string) => Promise.resolve(answers[command] ?? null),
462
- transformCallback: () => 0,
463
- metadata: { currentWindow: { label: "main" }, currentWebview: { label: "main" } }
464
- }
465
- });
466
- ```
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
- 2. **An entry** that renders your app after the stub:
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
- ```ts
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
- createRoot(document.getElementById("root")!).render(<App />);
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
- plus `src/preview/index.html` that loads it. With Vite, add the page to `build.rollupOptions.input` only if you
480
- want it in builds — the dev server serves it either way.
481
-
482
- 3. **Helpers** for states that are tedious to reach with clicks — signing in, switching language, going offline:
483
-
484
- ```ts
485
- Object.assign(window, {
486
- preview: {
487
- signIn: () => { /* fill the form, submit */ },
488
- language: (code: string) => i18n.changeLanguage(code)
489
- }
490
- });
491
- ```
517
+ ```js
518
+ import { anyApi, defineConfig } from "webview-preview";
492
519
 
493
- ```sh
494
- npx webview-preview /settings "js:preview.signIn()" wait:3000 snap:settings
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
- 4. **Config:**
535
+ Plain values work as well: `"window.__APP_CONFIG__": { apiUrl: "http://localhost:3000" }`.
498
536
 
499
- ```js
500
- export default {
501
- baseUrl: "http://localhost:1420/src/preview/index.html#",
502
- server: { command: "npx vite --port 1420 --strictPort", port: 1420 }
503
- }
504
- ```
537
+ ### What stays different
505
538
 
506
- Real Rust is not involved, so anything that depends on it shows what the stub answers. That is the trade-off for
507
- checking a screen in seconds.
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
 
@@ -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 = { "--engine": "engine", "--size": "size", "--out": "out", "--prefix": "prefix", "--scale": "scale", "--config": "config", "--base-url": "baseUrl", "--snap-margin": "snapMargin", "--baseline": "baseline", "--max-diff-pixels": "maxDiffPixels", "--compare-tolerance": "compareTolerance", "--older-than": "olderThan", "--wait-timeout": "waitTimeout" }
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.1",
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",
@@ -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 { resolveInit } from "#src/page/init.mjs";
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 steps = parseSteps(merged.steps.length ? merged.steps : DEFAULTS.steps);
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 init = (hasMock ? [mockScript(mockRules)] : []).concat(resolveInit([
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 wrap = ({ label, source }) => `{
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) => wrap(readEntry(entry, dir ?? baseDir, 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
+ })();`;