webview-preview 0.0.0-stage → 0.0.1

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.
@@ -0,0 +1,138 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from "node:fs";
3
+ import { cleanSnapshots, ENGINES, preview } from "#src/index.mjs";
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" }
6
+ const LIST_FLAGS = { "--init": "init", "--init-file": "initFile", "--mock-file": "mockFile" }
7
+
8
+ const HELP = `Usage: webview-preview [route-or-url] [steps...] [options]
9
+ webview-preview clean [--dry-run] [--older-than 2h] [--out <dir>] [--config <file>]
10
+
11
+ Opens the page in a real browser engine, runs the steps in order and saves PNG snapshots.
12
+
13
+ Steps:
14
+ js:<code> run JavaScript, then wait 700 ms
15
+ jsnow:<code> run JavaScript, go on right away
16
+ wait:<ms> wait
17
+ waitfor:<css selector> wait until a matching element is visible (up to --wait-timeout)
18
+ waitfor:!<css selector> wait until no matching element is visible
19
+ snap:<name> save <out>/<prefix><name>.png
20
+ print:<expr> print the value of an expression
21
+ measure:<css selector> print size, position, box, visibility, font and text of the element
22
+ resize:<WxH> resize the window
23
+ snap:<name>@<css selector> save only that element (plus --snap-margin)
24
+ key:<key> press a key: Tab, Shift+Tab, Enter, Escape, ArrowDown, Control+a, x …
25
+ type:<text> type text with the keyboard
26
+ hover:<css selector> move the mouse to the element's centre
27
+ click:<css selector> click the element's centre with the mouse
28
+
29
+ Options:
30
+ --engine <name> ${ENGINES.join(" | ")} (default: auto)
31
+ --size <WxH> window size in CSS pixels (default: 1200x650)
32
+ --out <dir> folder for snapshots (default: .webview-preview)
33
+ --prefix <text> file name prefix (default: "<engine>-")
34
+ --scale <n> device pixel ratio for Playwright engines (default: 1); WebKitGTK renders
35
+ 1x off-screen and at the screen's scale with --headed
36
+ --snap-margin <px> room around snap:name@selector (default: 8)
37
+ --headed WebKitGTK: show a real window instead of rendering off-screen
38
+ --wait-timeout <ms> how long waitfor waits (default: 10000)
39
+ --keep-storage WebKitGTK: keep localStorage, cookies and cache between runs (default: start clean)
40
+ --compare compare every snapshot with the baseline of the same name (a missing one is created)
41
+ --update-baseline replace the baselines with this run's snapshots
42
+ --baseline <dir> baseline folder (default: webview-preview-baseline)
43
+ --max-diff-pixels <n> pixels allowed to differ before the run fails (default: 0)
44
+ --compare-tolerance <n> per-channel difference ignored, 0-255 (default: 2)
45
+ --base-url <url> prepended to routes that are not full URLs
46
+ --init <code> run code before the page's own scripts, on every load (repeatable)
47
+ --init-file <f> same, from a file (repeatable)
48
+ --mock-file <f> answer fetch/XHR requests from a .json/.mjs file of mocks (repeatable);
49
+ every request is listed in window.__webviewPreviewRequests
50
+ --log-requests only list fetch/XHR requests in window.__webviewPreviewRequests
51
+ --config <file> config file (default: webview-preview.config.{mjs,js,json})
52
+ --no-config ignore config files
53
+
54
+ Clean:
55
+ clean delete the snapshots (*.png, also *.diff.png) in the snapshot folder;
56
+ baselines are never touched
57
+ --dry-run only list what would be deleted
58
+ --older-than <t> only files older than 30m, 2h, 7d …
59
+
60
+ -h, --help show this help
61
+ -v, --version show the version`;
62
+
63
+ const parseArgs = argv => {
64
+ const options = { steps: [] }
65
+ const positional = [];
66
+ for (let index = 0; index < argv.length; index += 1) {
67
+ const arg = argv[index];
68
+ if (arg === "-h" || arg === "--help") return { help: true }
69
+ if (arg === "-v" || arg === "--version") return { version: true }
70
+ if (arg === "--keep-storage") {
71
+ options.keepStorage = true;
72
+ continue;
73
+ }
74
+ if (arg === "--dry-run") {
75
+ options.dryRun = true;
76
+ continue;
77
+ }
78
+ if (arg === "--compare" || arg === "--update-baseline") {
79
+ options[arg === "--compare" ? "compare" : "updateBaseline"] = true;
80
+ continue;
81
+ }
82
+ if (arg === "--headed") {
83
+ options.headed = true;
84
+ continue;
85
+ }
86
+ if (arg === "--log-requests") {
87
+ options.logRequests = true;
88
+ continue;
89
+ }
90
+ if (arg === "--no-config") {
91
+ options.config = false;
92
+ continue;
93
+ }
94
+ const [flag, inline] = arg.split(/=(.*)/s, 2);
95
+ if (flag in LIST_FLAGS) {
96
+ const value = inline ?? argv[++index];
97
+ if (value === undefined) throw new Error(`${flag} needs a value`);
98
+ (options[LIST_FLAGS[flag]] ??= []).push(value);
99
+ continue;
100
+ }
101
+ if (flag in FLAGS) {
102
+ const value = inline ?? argv[++index];
103
+ if (value === undefined) throw new Error(`${flag} needs a value`);
104
+ options[FLAGS[flag]] = value;
105
+ continue;
106
+ }
107
+ if (arg.startsWith("--")) throw new Error(`Unknown option ${arg}`);
108
+ positional.push(arg);
109
+ }
110
+ const [target, ...steps] = positional;
111
+ return { options: { ...options, target, steps } }
112
+ }
113
+
114
+ try {
115
+ const parsed = parseArgs(process.argv.slice(2));
116
+ if (parsed.help) {
117
+ console.log(HELP);
118
+ process.exit(0);
119
+ }
120
+ if (parsed.version) {
121
+ console.log(JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version);
122
+ process.exit(0);
123
+ }
124
+ if (parsed.options.target === "clean") {
125
+ const { target, steps, ...cleanOptions } = parsed.options;
126
+ if (steps.length) throw new Error(`clean takes no steps, got ${steps.join(" ")}`);
127
+ const { removed, out } = await cleanSnapshots(cleanOptions);
128
+ for (const file of removed) console.log(file);
129
+ console.log(`${cleanOptions.dryRun ? "would delete" : "deleted"} ${removed.length} snapshot(s) in ${out}`);
130
+ process.exit(0);
131
+ }
132
+ const result = await preview(parsed.options);
133
+ for (const file of result.saved) console.log(file);
134
+ process.exit(result.failed ? 1 : 0);
135
+ } catch (error) {
136
+ console.error(`webview-preview: ${error.message}`);
137
+ process.exit(1);
138
+ }
package/package.json CHANGED
@@ -1,6 +1,55 @@
1
1
  {
2
- "name": "webview-preview",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "webview-preview",
3
+ "version": "0.0.1",
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
+ "license": "MIT",
6
+ "author": "n0t4u404",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/n0t4u404/webview-preview.git"
10
+ },
11
+ "keywords": [
12
+ "tauri",
13
+ "wails",
14
+ "webview",
15
+ "webkitgtk",
16
+ "webview2",
17
+ "screenshot",
18
+ "visual-testing",
19
+ "playwright"
20
+ ],
21
+ "type": "module",
22
+ "main": "./src/index.mjs",
23
+ "types": "./src/index.d.ts",
24
+ "exports": {
25
+ ".": {
26
+ "types": "./src/index.d.ts",
27
+ "default": "./src/index.mjs"
28
+ }
29
+ },
30
+ "imports": {
31
+ "#src/*": "./src/*"
32
+ },
33
+ "bin": {
34
+ "webview-preview": "bin/webview-preview.mjs"
35
+ },
36
+ "files": [
37
+ "bin",
38
+ "src",
39
+ "AGENTS.md"
40
+ ],
41
+ "engines": {
42
+ "node": ">=18"
43
+ },
44
+ "peerDependencies": {
45
+ "playwright-core": "^1.40.0"
46
+ },
47
+ "peerDependenciesMeta": {
48
+ "playwright-core": {
49
+ "optional": true
50
+ }
51
+ },
52
+ "devDependencies": {
53
+ "playwright-core": "^1.64.1"
54
+ }
55
+ }
@@ -0,0 +1,16 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+
5
+ const CONFIG_FILES = ["webview-preview.config.mjs", "webview-preview.config.js", "webview-preview.config.json"];
6
+
7
+ export const loadConfig = async (configPath, cwd = process.cwd()) => {
8
+ const file = configPath
9
+ ? path.resolve(cwd, configPath)
10
+ : CONFIG_FILES.map(name => path.join(cwd, name)).find(candidate => existsSync(candidate));
11
+ if (!file) return {}
12
+ if (!existsSync(file)) throw new Error(`Config not found: ${file}`);
13
+ if (file.endsWith(".json")) return { ...JSON.parse(readFileSync(file, "utf8")), configDir: path.dirname(file) }
14
+ const module = await import(pathToFileURL(file).href);
15
+ return { ...(module.default ?? module), configDir: path.dirname(file) }
16
+ }
@@ -0,0 +1,57 @@
1
+ import { spawn } from "node:child_process";
2
+ import net from "node:net";
3
+
4
+ const POLL_MS = 250;
5
+ const DEFAULT_READY_TIMEOUT_MS = 30000;
6
+
7
+ const isPortOpen = (port, host) => new Promise(resolve => {
8
+ const socket = net.connect(port, host);
9
+ socket.once("connect", () => {
10
+ socket.end();
11
+ resolve(true);
12
+ });
13
+ socket.once("error", () => resolve(false));
14
+ });
15
+
16
+ const waitForPort = async (port, host, timeoutMs) => {
17
+ const deadline = Date.now() + timeoutMs;
18
+ while (Date.now() < deadline) {
19
+ if (await isPortOpen(port, host)) return;
20
+ await new Promise(resolve => setTimeout(resolve, POLL_MS));
21
+ }
22
+ throw new Error(`Server did not open port ${port} within ${timeoutMs} ms`);
23
+ }
24
+
25
+ const stopProcessTree = child => {
26
+ if (child.exitCode !== null) return;
27
+ if (process.platform === "win32") {
28
+ spawn("taskkill", ["/pid", String(child.pid), "/t", "/f"], { stdio: "ignore" });
29
+ return;
30
+ }
31
+ try {
32
+ process.kill(-child.pid, "SIGTERM");
33
+ } catch {
34
+ child.kill("SIGTERM");
35
+ }
36
+ }
37
+
38
+ export const startServer = async server => {
39
+ if (!server) return () => {}
40
+ const { command, port, host = "localhost", cwd = process.cwd(), readyTimeoutMs = DEFAULT_READY_TIMEOUT_MS } = server;
41
+ if (!command || !port) throw new Error("server needs both command and port");
42
+ if (await isPortOpen(port, host)) return () => {}
43
+
44
+ const child = spawn(command, {
45
+ cwd,
46
+ shell: true,
47
+ stdio: "ignore",
48
+ detached: process.platform !== "win32"
49
+ });
50
+ try {
51
+ await waitForPort(port, host, readyTimeoutMs);
52
+ } catch (error) {
53
+ stopProcessTree(child);
54
+ throw error;
55
+ }
56
+ return () => stopProcessTree(child);
57
+ }
@@ -0,0 +1,169 @@
1
+ export const LOAD_SETTLE_MS = 1500;
2
+ export const JS_SETTLE_MS = 700;
3
+ export const TIMEOUT_MS = 30000;
4
+ export const WAIT_FOR_TIMEOUT_MS = 10000;
5
+ export const WAIT_FOR_POLL_MS = 50;
6
+
7
+ const STEP_KINDS = new Set(["js", "jsnow", "wait", "waitfor", "snap", "print", "measure", "resize", "key", "type", "hover", "click"]);
8
+ const MODIFIERS = { Shift: "Shift", Control: "Control", Ctrl: "Control", Alt: "Alt", Meta: "Meta" }
9
+ export const KEY_NAMES = [
10
+ "Tab", "Enter", "Escape", "Backspace", "Delete", "Insert", "Space", "Home", "End", "PageUp", "PageDown",
11
+ "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", ...Array.from({ length: 12 }, (_, index) => `F${index + 1}`)
12
+ ];
13
+
14
+ const PRINT_FORMAT = `(value => {
15
+ if (typeof value === "string") return value;
16
+ if (value === undefined) return "undefined";
17
+ if (typeof Node !== "undefined" && value instanceof Node) return value.nodeType === 1 ? value.outerHTML.slice(0, 200) : String(value.nodeName);
18
+ try {
19
+ return JSON.stringify(value) ?? String(value);
20
+ } catch {
21
+ return String(value);
22
+ }
23
+ })`;
24
+
25
+ export const printExpression = expression => `${PRINT_FORMAT}((${expression}))`;
26
+
27
+ const QUERY_FUNCTION = `selector => {
28
+ if (!selector.startsWith("text=")) return [...document.querySelectorAll(selector)];
29
+ const normalize = value => value.replace(/\\s+/g, " ").trim();
30
+ const wanted = normalize(selector.slice(5));
31
+ return [...document.body.querySelectorAll("*")].filter(element =>
32
+ normalize(element.textContent) === wanted
33
+ && ![...element.children].some(child => normalize(child.textContent) === wanted));
34
+ }`;
35
+
36
+ const VISIBLE_FUNCTION = `element => {
37
+ if (element.getClientRects().length === 0) return false;
38
+ if (getComputedStyle(element).visibility === "hidden") return false;
39
+ for (let node = element; node && node.nodeType === 1; node = node.parentElement) {
40
+ if (getComputedStyle(node).opacity === "0") return false;
41
+ }
42
+ return true;
43
+ }`;
44
+
45
+ const waitForExpression = (selector, isGone) => `(() => {
46
+ const query = ${QUERY_FUNCTION};
47
+ const isVisible = ${VISIBLE_FUNCTION};
48
+ const visible = query(${JSON.stringify(selector)}).filter(isVisible);
49
+ if (${isGone ? "true" : "false"}) return visible.length === 0;
50
+ if (visible.length === 0) return false;
51
+ const key = "__webviewPreviewWaitFor:" + ${JSON.stringify(selector)};
52
+ const rects = JSON.stringify(visible.map(element => {
53
+ const rect = element.getBoundingClientRect();
54
+ let opacity = 1;
55
+ for (let node = element; node && node.nodeType === 1; node = node.parentElement) opacity *= Number(getComputedStyle(node).opacity);
56
+ return [rect.left, rect.top, rect.width, rect.height, Math.round(opacity * 1000)];
57
+ }));
58
+ const isStill = window[key] === rects;
59
+ window[key] = rects;
60
+ return isStill;
61
+ })()`;
62
+
63
+ const MEASURE_FUNCTION = `(selector, query, isVisible) => {
64
+ const all = query(selector);
65
+ if (all.length === 0) return "measure " + selector + ": no match";
66
+ const element = all.find(isVisible) ?? all[0];
67
+ const rect = element.getBoundingClientRect();
68
+ const style = getComputedStyle(element);
69
+ const round = value => Math.round(value * 100) / 100;
70
+ const box = name => {
71
+ const sides = ["top", "right", "bottom", "left"].map(side => parseFloat(style.getPropertyValue(name + "-" + side + (name === "border" ? "-width" : ""))) || 0);
72
+ return sides.every(side => side === sides[0]) ? String(sides[0]) : sides.join(" ");
73
+ };
74
+ const text = (element.innerText || element.value || "").replace(/\\s+/g, " ").trim().slice(0, 40);
75
+ return [
76
+ "measure " + selector + ": " + all.length + " match" + (all.length === 1 ? "" : "es") + (all.length > 1 ? ", showing " + (all.indexOf(element) + 1) : ""),
77
+ "x " + round(rect.left) + " y " + round(rect.top) + " w " + round(rect.width) + " h " + round(rect.height),
78
+ "margin " + box("margin") + " padding " + box("padding") + " border " + box("border"),
79
+ (isVisible(element) ? "visible" : "hidden") + " display " + style.display + " opacity " + style.opacity,
80
+ "font " + style.fontSize + "/" + style.fontWeight + " color " + style.color + " bg " + style.backgroundColor,
81
+ ...(text ? ['text "' + text + '"'] : [])
82
+ ].join(" · ");
83
+ }`;
84
+
85
+ const measureExpression = selector => `(${MEASURE_FUNCTION})(${JSON.stringify(selector)}, ${QUERY_FUNCTION}, ${VISIBLE_FUNCTION})`;
86
+
87
+ const rectExpression = (selector, isScrolled) => `(() => {
88
+ const query = ${QUERY_FUNCTION};
89
+ const isVisible = ${VISIBLE_FUNCTION};
90
+ const all = query(${JSON.stringify(selector)});
91
+ const visible = all.filter(isVisible);
92
+ const isOnTop = element => {
93
+ const rect = element.getBoundingClientRect();
94
+ const hit = document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2);
95
+ return !!hit && (element === hit || element.contains(hit));
96
+ };
97
+ const element = visible.find(isOnTop) ?? visible[0] ?? all[0];
98
+ if (!element) return "null";
99
+ ${isScrolled ? "element.scrollIntoView({ block: \"nearest\", inline: \"nearest\" });" : ""}
100
+ const rect = element.getBoundingClientRect();
101
+ return JSON.stringify({ x: rect.left, y: rect.top, width: rect.width, height: rect.height });
102
+ })()`;
103
+
104
+ const parseKey = combo => {
105
+ const parts = combo.split("+");
106
+ const key = parts.pop();
107
+ const modifiers = parts.map(part => {
108
+ if (!(part in MODIFIERS)) throw new Error(`Unknown modifier "${part}" in key:${combo}. Modifiers: Shift, Control, Alt, Meta`);
109
+ return MODIFIERS[part];
110
+ });
111
+ if (!key || (key.length > 1 && !KEY_NAMES.includes(key))) throw new Error(`Unknown key "${key}" in key:${combo}. Keys: a single character or ${KEY_NAMES.join(", ")}`);
112
+ return { key, modifiers }
113
+ }
114
+
115
+ export const parseSize = value => {
116
+ const match = /^(\d+)x(\d+)$/.exec(String(value));
117
+ if (!match) throw new Error(`Size must look like 1200x650, got "${value}"`);
118
+ return { width: Number(match[1]), height: Number(match[2]) }
119
+ }
120
+
121
+ export const parseSteps = steps => steps.map(step => {
122
+ const separator = step.indexOf(":");
123
+ const kind = separator === -1 ? step : step.slice(0, separator);
124
+ const value = separator === -1 ? "" : step.slice(separator + 1);
125
+ if (!STEP_KINDS.has(kind)) throw new Error(`Unknown step "${step}". Steps: ${[...STEP_KINDS].join(", ")}`);
126
+ if (kind === "wait" && !/^\d+$/.test(value)) throw new Error(`wait needs milliseconds, got "${step}"`);
127
+ if (kind === "resize") parseSize(value);
128
+ if (kind === "snap") {
129
+ const at = value.indexOf("@");
130
+ const name = at === -1 ? value : value.slice(0, at);
131
+ if (!name) throw new Error(`snap needs a name, got "${step}"`);
132
+ if (at === -1) return { kind, value: name }
133
+ const selector = value.slice(at + 1);
134
+ if (!selector) throw new Error(`snap:name@selector needs a selector, got "${step}"`);
135
+ return { kind, value: name, selector, rect: rectExpression(selector, false) }
136
+ }
137
+ if (kind === "hover" || kind === "click") {
138
+ if (!value) throw new Error(`${kind} needs a selector, got "${step}"`);
139
+ return { kind, value, rect: rectExpression(value, true) }
140
+ }
141
+ if (kind === "key") return { kind, value, ...parseKey(value) }
142
+ if (kind === "waitfor") {
143
+ const isGone = value.startsWith("!");
144
+ const selector = isGone ? value.slice(1) : value;
145
+ if (!selector) throw new Error(`waitfor needs a selector, got "${step}"`);
146
+ return { kind, value, check: waitForExpression(selector, isGone) }
147
+ }
148
+ if (kind === "measure") {
149
+ if (!value) throw new Error(`measure needs a selector, got "${step}"`);
150
+ return { kind: "print", value: printExpression(measureExpression(value)) }
151
+ }
152
+ if (kind === "type" && !value) throw new Error(`type needs text, got "${step}"`);
153
+ return { kind, value: kind === "print" ? printExpression(value) : value }
154
+ });
155
+ export const parseRect = (json, selector) => {
156
+ const rect = json && json !== "null" ? JSON.parse(json) : null;
157
+ if (!rect) throw new Error(`no element matches ${selector}`);
158
+ if (rect.width <= 0 || rect.height <= 0) throw new Error(`${selector} has no size (hidden?)`);
159
+ return rect;
160
+ }
161
+
162
+ export const clipRect = (rect, margin, viewport) => {
163
+ const left = Math.max(0, Math.floor(rect.x - margin));
164
+ const top = Math.max(0, Math.floor(rect.y - margin));
165
+ const right = Math.min(viewport.width, Math.ceil(rect.x + rect.width + margin));
166
+ const bottom = Math.min(viewport.height, Math.ceil(rect.y + rect.height + margin));
167
+ if (right <= left || bottom <= top) throw new Error("element is outside the window");
168
+ return { x: left, y: top, width: right - left, height: bottom - top }
169
+ }
@@ -0,0 +1,17 @@
1
+ import { isWebkitGtkAvailable, runWebkitGtk } from "#src/engines/webkitgtk.mjs";
2
+ import { runPlaywright } from "#src/engines/playwright.mjs";
3
+
4
+ export const ENGINES = ["auto", "webkitgtk", "msedge", "chrome", "chromium", "webkit"];
5
+
6
+ export const resolveEngine = engine => {
7
+ if (!ENGINES.includes(engine)) throw new Error(`Unknown engine "${engine}". Engines: ${ENGINES.join(", ")}`);
8
+ if (engine !== "auto") return engine;
9
+ if (isWebkitGtkAvailable()) return "webkitgtk";
10
+ if (process.platform === "win32") return "msedge";
11
+ if (process.platform === "darwin") return "webkit";
12
+ throw new Error("No engine found for auto: install WebKitGTK for Python (python3-gi, gir1.2-webkit2-4.1) or pass --engine chrome / chromium");
13
+ }
14
+
15
+ export const runEngine = (engine, options) => engine === "webkitgtk"
16
+ ? runWebkitGtk(options)
17
+ : runPlaywright({ engine, ...options });
@@ -0,0 +1,104 @@
1
+ import path from "node:path";
2
+ import { INIT_ERRORS_EXPRESSION, reportInitErrors } from "#src/page/init.mjs";
3
+ import { clipRect, JS_SETTLE_MS, LOAD_SETTLE_MS, parseRect, parseSize, TIMEOUT_MS, WAIT_FOR_POLL_MS, WAIT_FOR_TIMEOUT_MS } from "#src/core/steps.mjs";
4
+
5
+ const CHANNELS = {
6
+ msedge: "msedge",
7
+ chrome: "chrome",
8
+ chromium: undefined
9
+ }
10
+
11
+ const loadPlaywright = async () => {
12
+ try {
13
+ return await import("playwright-core");
14
+ } catch {
15
+ throw new Error("This engine needs playwright-core: npm i -D playwright-core");
16
+ }
17
+ }
18
+
19
+ export const runPlaywright = async ({ engine, url, steps, init = [], size, out, prefix, scale, snapMargin = 8, waitForTimeoutMs = WAIT_FOR_TIMEOUT_MS, log }) => {
20
+ const playwright = await loadPlaywright();
21
+ const browserType = engine === "webkit" ? playwright.webkit : playwright.chromium;
22
+ const browser = await browserType.launch({ channel: CHANNELS[engine], headless: true });
23
+ const saved = [];
24
+ let failed = false;
25
+
26
+ try {
27
+ const page = await browser.newPage({ viewport: parseSize(size), deviceScaleFactor: scale ?? 1 });
28
+ page.setDefaultTimeout(TIMEOUT_MS);
29
+ for (const content of init) await page.addInitScript({ content });
30
+ await page.goto(url, { waitUntil: "load" });
31
+ await page.waitForTimeout(LOAD_SETTLE_MS);
32
+ if (init.length) reportInitErrors(await page.evaluate(INIT_ERRORS_EXPRESSION), log);
33
+
34
+ const evaluate = async code => {
35
+ try {
36
+ return { value: await page.evaluate(code) }
37
+ } catch (error) {
38
+ return { error: error.message.split("\n")[0] }
39
+ }
40
+ }
41
+
42
+ const elementRect = async step => {
43
+ const result = await evaluate(step.rect);
44
+ if (result.error) throw new Error(`${step.kind}:${step.value} failed: ${result.error}`);
45
+ return parseRect(result.value, step.selector ?? step.value);
46
+ }
47
+
48
+ const runStep = async step => {
49
+ const { kind, value } = step;
50
+ if (kind === "snap") {
51
+ const file = path.join(out, `${prefix}${value}.png`);
52
+ const clip = step.selector ? clipRect(await elementRect(step), snapMargin, page.viewportSize()) : undefined;
53
+ await page.screenshot({ path: file, clip });
54
+ saved.push(file);
55
+ } else if (kind === "hover" || kind === "click") {
56
+ const rect = await elementRect(step);
57
+ const x = rect.x + rect.width / 2;
58
+ const y = rect.y + rect.height / 2;
59
+ if (kind === "hover") await page.mouse.move(x, y);
60
+ else await page.mouse.click(x, y);
61
+ } else if (kind === "key") {
62
+ await page.keyboard.press([...step.modifiers, step.key === " " ? "Space" : step.key].join("+"));
63
+ } else if (kind === "type") {
64
+ await page.keyboard.type(value);
65
+ } else if (kind === "js" || kind === "jsnow") {
66
+ const result = await evaluate(value);
67
+ if (result.error) log.error(`js failed: ${result.error}`);
68
+ if (kind === "js") await page.waitForTimeout(JS_SETTLE_MS);
69
+ } else if (kind === "waitfor") {
70
+ try {
71
+ await page.waitForFunction(step.check, undefined, { timeout: waitForTimeoutMs, polling: WAIT_FOR_POLL_MS });
72
+ } catch (error) {
73
+ throw new Error(/Timeout/i.test(error.message)
74
+ ? `waitfor:${value} timed out after ${waitForTimeoutMs} ms`
75
+ : `waitfor:${value} failed: ${error.message.split("\n")[0]}`);
76
+ }
77
+ } else if (kind === "wait") {
78
+ await page.waitForTimeout(Number(value));
79
+ } else if (kind === "resize") {
80
+ await page.setViewportSize(parseSize(value));
81
+ } else if (kind === "print") {
82
+ const result = await evaluate(value);
83
+ if (result.error) log.error(`print failed: ${result.error}`);
84
+ else log.info(String(result.value));
85
+ }
86
+ }
87
+
88
+ for (const step of steps) {
89
+ try {
90
+ await runStep(step);
91
+ } catch (error) {
92
+ failed = true;
93
+ log.error(error.message.split("\n")[0]);
94
+ }
95
+ }
96
+ } catch (error) {
97
+ failed = true;
98
+ log.error(error.message);
99
+ } finally {
100
+ await browser.close();
101
+ }
102
+
103
+ return { saved, failed }
104
+ }
@@ -0,0 +1,67 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import { fileURLToPath } from "node:url";
3
+ import { INIT_ERRORS_EXPRESSION, reportInitErrors } from "#src/page/init.mjs";
4
+ import { JS_SETTLE_MS, LOAD_SETTLE_MS, parseSize, TIMEOUT_MS, WAIT_FOR_POLL_MS, WAIT_FOR_TIMEOUT_MS } from "#src/core/steps.mjs";
5
+
6
+ const SCRIPT = fileURLToPath(new URL("./webkitgtk.py", import.meta.url));
7
+ const PROBE = "import gi; gi.require_version('Gtk', '3.0'); gi.require_version('WebKit2', '4.1')";
8
+
9
+ export const isWebkitGtkAvailable = () => process.platform === "linux"
10
+ && spawnSync("python3", ["-c", PROBE], { stdio: "ignore" }).status === 0;
11
+
12
+ export const runWebkitGtk = ({ url, steps, init = [], size, out, prefix, snapMargin = 8, isHeaded = false, isStorageKept = false, waitForTimeoutMs = WAIT_FOR_TIMEOUT_MS, timeoutMs = TIMEOUT_MS, log }) => new Promise((resolve, reject) => {
13
+ if (!isWebkitGtkAvailable()) {
14
+ reject(new Error("WebKitGTK engine needs Linux with python3-gi and WebKit2 4.1 (gir1.2-webkit2-4.1)"));
15
+ return;
16
+ }
17
+
18
+ const { width, height } = parseSize(size);
19
+ const child = spawn("python3", [
20
+ SCRIPT, url,
21
+ "--steps", JSON.stringify(steps),
22
+ "--init", JSON.stringify(init),
23
+ "--init-errors", INIT_ERRORS_EXPRESSION,
24
+ "--out", out,
25
+ "--prefix", prefix,
26
+ "--width", String(width),
27
+ "--height", String(height),
28
+ "--snap-margin", String(snapMargin),
29
+ ...(isHeaded ? ["--headed"] : []),
30
+ ...(isStorageKept ? ["--keep-storage"] : []),
31
+ "--wait-for-timeout-ms", String(waitForTimeoutMs),
32
+ "--wait-for-poll-ms", String(WAIT_FOR_POLL_MS),
33
+ "--load-settle-ms", String(LOAD_SETTLE_MS),
34
+ "--js-settle-ms", String(JS_SETTLE_MS),
35
+ "--timeout-ms", String(timeoutMs)
36
+ ], {
37
+ stdio: ["ignore", "pipe", "pipe"],
38
+ env: { PYTHONUNBUFFERED: "1", ...process.env }
39
+ });
40
+
41
+ const saved = [];
42
+ let buffer = "";
43
+ child.stdout.setEncoding("utf8");
44
+ child.stdout.on("data", chunk => {
45
+ buffer += chunk;
46
+ const lines = buffer.split("\n");
47
+ buffer = lines.pop() ?? "";
48
+ for (const line of lines) {
49
+ const tab = line.indexOf("\t");
50
+ if (tab === -1) continue;
51
+ const kind = line.slice(0, tab);
52
+ const value = JSON.parse(line.slice(tab + 1));
53
+ if (kind === "saved") saved.push(value);
54
+ else if (kind === "print") log.info(value);
55
+ else if (kind === "error") log.error(value);
56
+ else if (kind === "init-errors") reportInitErrors(value, log);
57
+ }
58
+ });
59
+ child.stderr.setEncoding("utf8");
60
+ child.stderr.on("data", chunk => {
61
+ for (const line of chunk.split("\n")) {
62
+ if (line.trim() && !/Gdk-(WARNING|CRITICAL)/.test(line)) process.stderr.write(`${line}\n`);
63
+ }
64
+ });
65
+ child.once("error", reject);
66
+ child.once("exit", code => resolve({ saved, failed: code !== 0 }));
67
+ });