webview-preview 1.1.1 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -170,6 +170,7 @@ npx webview-preview /settings snap:settings
170
170
  | `headed` | WebKitGTK: show a real window instead of rendering off-screen. | `false` |
171
171
  | `snapMargin` | Room in CSS pixels around `snap:name@selector`, so focus rings and shadows fit. | `8` |
172
172
  | `waitTimeout` | How long `waitfor:` waits, ms. | `10000` |
173
+ | `timeout` | Limit for the whole run, ms; `0` turns it off. The run stops with `timed out after … ms` and keeps the snapshots taken so far. | 30 s plus a budget per step: its own time for `wait:` / `film:`, `waitTimeout` for `waitfor:`, 2 s + 0.1 s per character for `type:`, 2 s for any other step |
173
174
  | `keepStorage` | WebKitGTK: keep `localStorage`, cookies and cache between runs. By default every run starts clean, like Playwright engines always do. | `false` |
174
175
  | `compare` / `updateBaseline` / `baseline` / `maxDiffPixels` / `compareTolerance` | See [Comparing with a baseline](#comparing-with-a-baseline). | — |
175
176
  | `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). | — |
@@ -337,6 +338,7 @@ webview-preview [route-or-url] [steps...] [options]
337
338
  | `--headed` | WebKitGTK: show a real window |
338
339
  | `--snap-margin <px>` | Room around `snap:name@selector` |
339
340
  | `--wait-timeout <ms>` | How long `waitfor:` waits |
341
+ | `--timeout <ms>` | Limit for the whole run; `0` = none |
340
342
  | `--keep-storage` | WebKitGTK: keep storage between runs |
341
343
  | `--report` | Write `<out>/report.html` with the output and all snapshots (baseline and difference after `--compare`) |
342
344
  | `--compare` / `--update-baseline` | Compare snapshots with baselines / replace the baselines |
@@ -627,13 +629,13 @@ invisible rounding noise; raise it a little if antialiasing differs between mach
627
629
  Snapshots pile up in the snapshot folder. Clear them when you no longer need them:
628
630
 
629
631
  ```sh
630
- npx webview-preview clean # delete every *.png in the snapshot folder
632
+ npx webview-preview clean # delete every *.png and report.html in the snapshot folder
631
633
  npx webview-preview clean --dry-run # only list what would be deleted
632
634
  npx webview-preview clean --older-than 2h # only files older than 2 hours (30m, 7d …)
633
635
  ```
634
636
 
635
637
  `clean` reads the same config (`out`, `baseline`) as a normal run. It deletes only `*.png` files directly in the
636
- snapshot folder (including `.diff.png`), never the baselines, and refuses to run when the snapshot folder is the
638
+ snapshot folder (including `.diff.png`) and `report.html`, never the baselines, and refuses to run when the snapshot folder is the
637
639
  project folder, your home folder, the disk root or the baseline folder.
638
640
 
639
641
  ## Recipes
@@ -725,7 +727,7 @@ More of these — animations frame by frame, measuring, getting past login — i
725
727
  | `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. |
726
728
  | `"/route" is not a full URL` | Set `baseUrl` in the config or pass `--base-url`. |
727
729
  | Blank snapshot | The page needs more time or threw on start: add `wait:2000`, and `print:document.body.innerHTML.length`. |
728
- | `timed out after 30000 ms` (WebKitGTK) | A run longer than 30 s; split it into several runs. |
730
+ | `timed out after … ms` | The run took longer than its limit (30 s plus a budget per step). A step is stuck — check the last output before the message — or the steps really need more: raise `--timeout`, or `--timeout 0` for no limit. |
729
731
  | 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. |
730
732
  | `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. |
731
733
  | `--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×. |
@@ -1,7 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFileSync } from "node:fs";
3
+ import { stopServers } from "#src/core/server.mjs";
3
4
  import { cleanSnapshots, ENGINES, preview, watchPreview } from "#src/index.mjs";
4
5
 
6
+ const PARENT_POLL_MS = 1000;
7
+
5
8
  const FLAGS = {
6
9
  "--engine": "engine",
7
10
  "--size": "size",
@@ -16,6 +19,7 @@ const FLAGS = {
16
19
  "--compare-tolerance": "compareTolerance",
17
20
  "--older-than": "olderThan",
18
21
  "--wait-timeout": "waitTimeout",
22
+ "--timeout": "timeout",
19
23
  "--locale": "locale",
20
24
  "--color-scheme": "colorScheme",
21
25
  "--latency": "latency"
@@ -63,6 +67,7 @@ Options:
63
67
  --snap-margin <px> room around snap:name@selector (default: 8)
64
68
  --headed WebKitGTK: show a real window instead of rendering off-screen
65
69
  --wait-timeout <ms> how long waitfor waits (default: 10000)
70
+ --timeout <ms> limit for the whole run; 0 = none (default: 30 s plus a budget per step)
66
71
  --keep-storage WebKitGTK: keep localStorage, cookies and cache between runs (default: start clean)
67
72
  --report write <out>/report.html: the output and every snapshot, with baseline and
68
73
  difference next to it after --compare
@@ -187,18 +192,25 @@ try {
187
192
  if (steps.length) throw new Error(`clean takes no steps, got ${steps.join(" ")}`);
188
193
  const { removed, out } = await cleanSnapshots(cleanOptions);
189
194
  for (const file of removed) console.log(file);
190
- console.log(`${cleanOptions.dryRun ? "would delete" : "deleted"} ${removed.length} snapshot(s) in ${out}`);
195
+ console.log(`${cleanOptions.dryRun ? "would delete" : "deleted"} ${removed.length} file(s) in ${out}`);
191
196
  process.exit(0);
192
197
  }
193
198
  const { isWatching, ...previewOptions } = parsed.options;
199
+ let stopWatching = () => {}
200
+ const exitCleanly = code => {
201
+ stopWatching();
202
+ stopServers();
203
+ process.exit(code);
204
+ }
205
+ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) process.once(signal, () => exitCleanly(isWatching ? 0 : 1));
206
+ const parentPid = process.ppid;
207
+ setInterval(() => {
208
+ if (process.ppid !== parentPid) exitCleanly(1);
209
+ }, PARENT_POLL_MS).unref();
194
210
  if (isWatching) {
195
- const stop = await watchPreview(previewOptions, result => {
211
+ stopWatching = await watchPreview(previewOptions, result => {
196
212
  for (const file of result.saved) console.log(file);
197
213
  });
198
- for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) process.once(signal, () => {
199
- stop();
200
- process.exit(0);
201
- });
202
214
  await new Promise(() => {});
203
215
  }
204
216
  const result = await preview(previewOptions);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webview-preview",
3
- "version": "1.1.1",
3
+ "version": "1.1.2",
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",
@@ -3,6 +3,7 @@ import net from "node:net";
3
3
 
4
4
  const POLL_MS = 250;
5
5
  const DEFAULT_READY_TIMEOUT_MS = 30000;
6
+ const running = new Set();
6
7
 
7
8
  const isPortOpen = (port, host) => new Promise(resolve => {
8
9
  const socket = net.connect(port, host);
@@ -47,11 +48,20 @@ export const startServer = async server => {
47
48
  stdio: "ignore",
48
49
  detached: process.platform !== "win32"
49
50
  });
51
+ const stop = () => {
52
+ running.delete(stop);
53
+ stopProcessTree(child);
54
+ }
55
+ running.add(stop);
50
56
  try {
51
57
  await waitForPort(port, host, readyTimeoutMs);
52
58
  } catch (error) {
53
- stopProcessTree(child);
59
+ stop();
54
60
  throw error;
55
61
  }
56
- return () => stopProcessTree(child);
62
+ return stop;
63
+ }
64
+
65
+ export const stopServers = () => {
66
+ for (const stop of [...running]) stop();
57
67
  }
@@ -5,6 +5,16 @@ export const JS_SETTLE_MS = 700;
5
5
  export const TIMEOUT_MS = 30000;
6
6
  export const WAIT_FOR_TIMEOUT_MS = 10000;
7
7
  export const WAIT_FOR_POLL_MS = 50;
8
+ const STEP_BUDGET_MS = 2000;
9
+ const TYPE_KEY_BUDGET_MS = 100;
10
+
11
+ export const runTimeoutMs = (steps, waitForTimeoutMs) => TIMEOUT_MS + steps.reduce((total, step) => total + (
12
+ step.kind === "wait" ? Number(step.value)
13
+ : step.kind === "film" ? step.durationMs + STEP_BUDGET_MS
14
+ : step.kind === "waitfor" ? waitForTimeoutMs
15
+ : step.kind === "type" ? STEP_BUDGET_MS + TYPE_KEY_BUDGET_MS * [...step.value].length
16
+ : STEP_BUDGET_MS
17
+ ), 0);
8
18
 
9
19
  const STEP_KINDS = new Set(["js", "jsnow", "wait", "waitfor", "snap", "print", "measure", "assert", "focusorder", "resize", "key", "type", "hover", "click", "offline", "online", "latency", "film"]);
10
20
  export const ASSERT_MARKER = "__webviewPreviewAssert:";
@@ -19,8 +19,9 @@ const loadPlaywright = async () => {
19
19
  export const runPlaywright = async ({
20
20
  engine, url, steps, init = [], size, locale,
21
21
  colorScheme, isMotionReduced = false, out, prefix, scale,
22
- snapMargin = 8, waitForTimeoutMs = WAIT_FOR_TIMEOUT_MS, log
22
+ snapMargin = 8, waitForTimeoutMs = WAIT_FOR_TIMEOUT_MS, timeoutMs = TIMEOUT_MS, log
23
23
  }) => {
24
+ const deadline = timeoutMs > 0 ? Date.now() + timeoutMs : Infinity;
24
25
  const playwright = await loadPlaywright();
25
26
  const browserType = engine === "webkit" ? playwright.webkit : playwright.chromium;
26
27
  const browser = await browserType.launch({ channel: CHANNELS[engine], headless: true });
@@ -113,6 +114,11 @@ export const runPlaywright = async ({
113
114
  }
114
115
 
115
116
  for (const step of steps) {
117
+ if (Date.now() > deadline) {
118
+ failed = true;
119
+ log.error(`timed out after ${timeoutMs} ms`);
120
+ break;
121
+ }
116
122
  try {
117
123
  await runStep(step);
118
124
  } catch (error) {
@@ -149,7 +149,10 @@ def main():
149
149
  if found and entries:
150
150
  event.key.hardware_keycode = entries[0].keycode
151
151
  event.set_device(seat.get_keyboard())
152
- deliver(event)
152
+ if kind == Gdk.EventType.KEY_RELEASE:
153
+ Gtk.main_do_event(event)
154
+ else:
155
+ deliver(event)
153
156
 
154
157
  def press(keyval, modifiers=()):
155
158
  mask = Gdk.ModifierType(0)
@@ -162,22 +165,31 @@ def main():
162
165
  key_event(Gdk.EventType.KEY_RELEASE, Gdk.keyval_from_name(MODIFIER_KEYS[modifier]), mask)
163
166
  mask &= ~MODIFIER_MASKS[modifier]
164
167
 
165
- def press_all(presses):
168
+ def press_all(label, presses):
166
169
  def count(text):
167
170
  return int(text) if text and text.isdigit() else 0
168
171
 
169
172
  def send(text):
170
- expected = count(text) + sum(1 + len(modifiers) for _, modifiers in presses)
173
+ start = count(text)
174
+ expected = start + sum(1 + len(modifiers) for _, modifiers in presses)
171
175
  for keyval, modifiers in presses:
172
176
  press(keyval, modifiers)
173
- started = GLib.get_monotonic_time()
177
+ progress = {"keyups": start, "since": GLib.get_monotonic_time()}
174
178
 
175
179
  def check():
176
180
  def on_value(text):
177
- if count(text) >= expected or (GLib.get_monotonic_time() - started) / 1000 >= KEY_WAIT_MS:
181
+ keyups = count(text)
182
+ if keyups >= expected:
178
183
  GLib.timeout_add(INPUT_SETTLE_MS, run_step)
179
- else:
180
- GLib.timeout_add(KEY_POLL_MS, lambda: (check(), False)[1])
184
+ return
185
+ now = GLib.get_monotonic_time()
186
+ if keyups > progress["keyups"]:
187
+ progress.update(keyups=keyups, since=now)
188
+ elif (now - progress["since"]) / 1000 >= KEY_WAIT_MS:
189
+ step_failed(f"{label}: only {keyups - start} of {expected - start} key presses reached the page")
190
+ GLib.timeout_add(INPUT_SETTLE_MS, run_step)
191
+ return
192
+ GLib.timeout_add(KEY_POLL_MS, lambda: (check(), False)[1])
181
193
 
182
194
  evaluate(KEYUPS, on_value, lambda _: GLib.timeout_add(INPUT_SETTLE_MS, run_step))
183
195
 
@@ -364,9 +376,9 @@ def main():
364
376
  elif kind in ("hover", "click"):
365
377
  with_rect(step, lambda rect: point_at(step, rect))
366
378
  elif kind == "key":
367
- press_all([(keyval_for(step["key"]), step["modifiers"])])
379
+ press_all(f"key:{value}", [(keyval_for(step["key"]), step["modifiers"])])
368
380
  elif kind == "type":
369
- press_all([(Gdk.unicode_to_keyval(ord(character)), ()) for character in value])
381
+ press_all("type", [(Gdk.unicode_to_keyval(ord(character)), ()) for character in value])
370
382
  return False
371
383
 
372
384
  def start_steps():
@@ -396,7 +408,8 @@ def main():
396
408
  view.connect("load-changed", on_load_changed)
397
409
  view.connect("load-failed", on_load_failed)
398
410
  view.load_uri(args.url)
399
- GLib.timeout_add(args.timeout_ms, on_timeout)
411
+ if args.timeout_ms > 0:
412
+ GLib.timeout_add(args.timeout_ms, on_timeout)
400
413
  Gtk.main()
401
414
 
402
415
  sys.stdout.flush()
package/src/index.d.ts CHANGED
@@ -53,6 +53,7 @@ export interface PreviewConfig {
53
53
  headed?: boolean;
54
54
  keepStorage?: boolean;
55
55
  waitTimeout?: number;
56
+ timeout?: number;
56
57
  snapMargin?: number;
57
58
  compare?: boolean;
58
59
  updateBaseline?: boolean;
package/src/index.mjs CHANGED
@@ -11,7 +11,7 @@ import { resolveInit, wrapInit } from "#src/page/init.mjs";
11
11
  import { compileMock, mockScript, resolveMockFiles } from "#src/page/mock.mjs";
12
12
  import { startServer } from "#src/core/server.mjs";
13
13
  import { watchFiles } from "#src/core/watch.mjs";
14
- import { expandMacros, parseSize, parseSteps } from "#src/core/steps.mjs";
14
+ import { expandMacros, parseSize, parseSteps, runTimeoutMs } from "#src/core/steps.mjs";
15
15
  import { checkLatency, networkScript } from "#src/page/network.mjs";
16
16
  import { resolveTauri, tauriScript } from "#src/page/tauri.mjs";
17
17
 
@@ -130,6 +130,9 @@ export const preview = async (options = {}) => {
130
130
  isHeaded: !!merged.headed,
131
131
  isStorageKept: !!merged.keepStorage,
132
132
  waitForTimeoutMs: Number(merged.waitTimeout ?? 10000),
133
+ timeoutMs: merged.timeout === undefined
134
+ ? runTimeoutMs([...steps, consoleStep], Number(merged.waitTimeout ?? 10000))
135
+ : Number(merged.timeout),
133
136
  log: output.log
134
137
  });
135
138
  result.saved.push(...run.saved);
@@ -28,9 +28,9 @@ export const cleanSnapshots = async (options = {}) => {
28
28
  const maxAge = options.olderThan ? parseDuration(options.olderThan) : 0;
29
29
  const now = Date.now();
30
30
  const removed = readdirSync(out)
31
- .filter(name => name.toLowerCase().endsWith(".png"))
31
+ .filter(name => name.toLowerCase().endsWith(".png") || name === "report.html")
32
32
  .map(name => path.join(out, name))
33
- .filter(file => statSync(file).isFile() && now - statSync(file).mtimeMs >= maxAge);
33
+ .filter(file => statSync(file).isFile() && (maxAge === 0 || now - statSync(file).mtimeMs >= maxAge));
34
34
  if (!options.dryRun) for (const file of removed) rmSync(file);
35
35
  return { removed, out }
36
36
  }