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 +5 -3
- package/bin/webview-preview.mjs +18 -6
- package/package.json +1 -1
- package/src/core/server.mjs +12 -2
- package/src/core/steps.mjs +10 -0
- package/src/engines/playwright.mjs +7 -1
- package/src/engines/webkitgtk.py +23 -10
- package/src/index.d.ts +1 -0
- package/src/index.mjs +4 -1
- package/src/snapshots/clean.mjs +2 -2
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`)
|
|
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
|
|
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×. |
|
package/bin/webview-preview.mjs
CHANGED
|
@@ -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}
|
|
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
|
-
|
|
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.
|
|
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",
|
package/src/core/server.mjs
CHANGED
|
@@ -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
|
-
|
|
59
|
+
stop();
|
|
54
60
|
throw error;
|
|
55
61
|
}
|
|
56
|
-
return
|
|
62
|
+
return stop;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const stopServers = () => {
|
|
66
|
+
for (const stop of [...running]) stop();
|
|
57
67
|
}
|
package/src/core/steps.mjs
CHANGED
|
@@ -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) {
|
package/src/engines/webkitgtk.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
177
|
+
progress = {"keyups": start, "since": GLib.get_monotonic_time()}
|
|
174
178
|
|
|
175
179
|
def check():
|
|
176
180
|
def on_value(text):
|
|
177
|
-
|
|
181
|
+
keyups = count(text)
|
|
182
|
+
if keyups >= expected:
|
|
178
183
|
GLib.timeout_add(INPUT_SETTLE_MS, run_step)
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
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
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);
|
package/src/snapshots/clean.mjs
CHANGED
|
@@ -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
|
}
|