webview-preview 0.0.1 → 1.1.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 +47 -7
- package/README.md +203 -69
- package/bin/webview-preview.mjs +76 -4
- package/package.json +4 -1
- package/src/core/config.mjs +5 -4
- package/src/core/steps.mjs +89 -3
- package/src/core/watch.mjs +31 -0
- package/src/engines/playwright.mjs +30 -3
- package/src/engines/webkitgtk.mjs +15 -5
- package/src/engines/webkitgtk.py +89 -11
- package/src/index.d.ts +38 -2
- package/src/index.mjs +174 -29
- package/src/page/console.mjs +84 -0
- package/src/page/globals.mjs +37 -0
- package/src/page/init.mjs +2 -2
- package/src/page/mock.mjs +3 -2
- package/src/page/network.mjs +79 -0
- package/src/page/source.mjs +55 -0
- package/src/page/tauri.mjs +95 -0
- package/src/snapshots/compare.mjs +10 -2
- package/src/snapshots/film.mjs +51 -0
- package/src/snapshots/report.mjs +71 -0
package/bin/webview-preview.mjs
CHANGED
|
@@ -1,8 +1,25 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
|
-
import { cleanSnapshots, ENGINES, preview } from "#src/index.mjs";
|
|
3
|
+
import { cleanSnapshots, ENGINES, preview, watchPreview } from "#src/index.mjs";
|
|
4
4
|
|
|
5
|
-
const FLAGS = {
|
|
5
|
+
const FLAGS = {
|
|
6
|
+
"--engine": "engine",
|
|
7
|
+
"--size": "size",
|
|
8
|
+
"--out": "out",
|
|
9
|
+
"--prefix": "prefix",
|
|
10
|
+
"--scale": "scale",
|
|
11
|
+
"--config": "config",
|
|
12
|
+
"--base-url": "baseUrl",
|
|
13
|
+
"--snap-margin": "snapMargin",
|
|
14
|
+
"--baseline": "baseline",
|
|
15
|
+
"--max-diff-pixels": "maxDiffPixels",
|
|
16
|
+
"--compare-tolerance": "compareTolerance",
|
|
17
|
+
"--older-than": "olderThan",
|
|
18
|
+
"--wait-timeout": "waitTimeout",
|
|
19
|
+
"--locale": "locale",
|
|
20
|
+
"--color-scheme": "colorScheme",
|
|
21
|
+
"--latency": "latency"
|
|
22
|
+
}
|
|
6
23
|
const LIST_FLAGS = { "--init": "init", "--init-file": "initFile", "--mock-file": "mockFile" }
|
|
7
24
|
|
|
8
25
|
const HELP = `Usage: webview-preview [route-or-url] [steps...] [options]
|
|
@@ -19,16 +36,26 @@ Steps:
|
|
|
19
36
|
snap:<name> save <out>/<prefix><name>.png
|
|
20
37
|
print:<expr> print the value of an expression
|
|
21
38
|
measure:<css selector> print size, position, box, visibility, font and text of the element
|
|
39
|
+
assert:<expr> check that the expression is truthy; if not, print why and exit with 1
|
|
40
|
+
focusorder[:<n>] press Tab n times (default 20) and print where focus went; shows when it loops
|
|
41
|
+
film:<name>:<ms>[/<n>][@<css selector>] n frames (default 10) over ms, from now, into one
|
|
42
|
+
<out>/<prefix><name>.png, 5 per row; with a selector, cropped to where the element was
|
|
22
43
|
resize:<WxH> resize the window
|
|
23
44
|
snap:<name>@<css selector> save only that element (plus --snap-margin)
|
|
24
45
|
key:<key> press a key: Tab, Shift+Tab, Enter, Escape, ArrowDown, Control+a, x …
|
|
25
46
|
type:<text> type text with the keyboard
|
|
26
47
|
hover:<css selector> move the mouse to the element's centre
|
|
27
48
|
click:<css selector> click the element's centre with the mouse
|
|
49
|
+
offline / online go offline or back: navigator.onLine, online/offline events, fetch and XHR fail
|
|
50
|
+
latency:<ms> delay every fetch/XHR request by ms (0 turns it off)
|
|
51
|
+
macro:<name> run the steps of a macro from the config (macros: { name: [...] })
|
|
28
52
|
|
|
29
53
|
Options:
|
|
30
54
|
--engine <name> ${ENGINES.join(" | ")} (default: auto)
|
|
31
|
-
--size <WxH> window size in CSS pixels (default: 1200x650)
|
|
55
|
+
--size <WxH> window size in CSS pixels (default: 1200x650); several sizes, comma-separated
|
|
56
|
+
(1200x650,800x600), run the steps once per size into <prefix><WxH>-<name>.png
|
|
57
|
+
--color-scheme <light|dark> prefers-color-scheme for the page (default: the system's)
|
|
58
|
+
--reduced-motion prefers-reduced-motion: reduce
|
|
32
59
|
--out <dir> folder for snapshots (default: .webview-preview)
|
|
33
60
|
--prefix <text> file name prefix (default: "<engine>-")
|
|
34
61
|
--scale <n> device pixel ratio for Playwright engines (default: 1); WebKitGTK renders
|
|
@@ -37,6 +64,8 @@ Options:
|
|
|
37
64
|
--headed WebKitGTK: show a real window instead of rendering off-screen
|
|
38
65
|
--wait-timeout <ms> how long waitfor waits (default: 10000)
|
|
39
66
|
--keep-storage WebKitGTK: keep localStorage, cookies and cache between runs (default: start clean)
|
|
67
|
+
--report write <out>/report.html: the output and every snapshot, with baseline and
|
|
68
|
+
difference next to it after --compare
|
|
40
69
|
--compare compare every snapshot with the baseline of the same name (a missing one is created)
|
|
41
70
|
--update-baseline replace the baselines with this run's snapshots
|
|
42
71
|
--baseline <dir> baseline folder (default: webview-preview-baseline)
|
|
@@ -48,6 +77,14 @@ Options:
|
|
|
48
77
|
--mock-file <f> answer fetch/XHR requests from a .json/.mjs file of mocks (repeatable);
|
|
49
78
|
every request is listed in window.__webviewPreviewRequests
|
|
50
79
|
--log-requests only list fetch/XHR requests in window.__webviewPreviewRequests
|
|
80
|
+
--tauri stub the Tauri bridge (invoke, events, window); answers come from
|
|
81
|
+
tauri.commands in the config, anything else gets null
|
|
82
|
+
--locale <tag> navigator.language for the page: en-US, ru-RU … (Playwright: also Intl)
|
|
83
|
+
--offline start offline (the page itself still loads)
|
|
84
|
+
--latency <ms> delay every fetch/XHR request from the start
|
|
85
|
+
--watch run again whenever a file changes (the config's watch, default src/ or the config's folder,
|
|
86
|
+
plus the config file); the server stays up between runs
|
|
87
|
+
--fail-on-error exit with 1 when the page logged console.error or threw (they are always printed)
|
|
51
88
|
--config <file> config file (default: webview-preview.config.{mjs,js,json})
|
|
52
89
|
--no-config ignore config files
|
|
53
90
|
|
|
@@ -83,6 +120,30 @@ const parseArgs = argv => {
|
|
|
83
120
|
options.headed = true;
|
|
84
121
|
continue;
|
|
85
122
|
}
|
|
123
|
+
if (arg === "--report") {
|
|
124
|
+
options.report = true;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (arg === "--watch") {
|
|
128
|
+
options.isWatching = true;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (arg === "--offline") {
|
|
132
|
+
options.offline = true;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (arg === "--reduced-motion") {
|
|
136
|
+
options.reducedMotion = true;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (arg === "--fail-on-error") {
|
|
140
|
+
options.failOnError = true;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (arg === "--tauri") {
|
|
144
|
+
options.tauri = true;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
86
147
|
if (arg === "--log-requests") {
|
|
87
148
|
options.logRequests = true;
|
|
88
149
|
continue;
|
|
@@ -129,7 +190,18 @@ try {
|
|
|
129
190
|
console.log(`${cleanOptions.dryRun ? "would delete" : "deleted"} ${removed.length} snapshot(s) in ${out}`);
|
|
130
191
|
process.exit(0);
|
|
131
192
|
}
|
|
132
|
-
const
|
|
193
|
+
const { isWatching, ...previewOptions } = parsed.options;
|
|
194
|
+
if (isWatching) {
|
|
195
|
+
const stop = await watchPreview(previewOptions, result => {
|
|
196
|
+
for (const file of result.saved) console.log(file);
|
|
197
|
+
});
|
|
198
|
+
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) process.once(signal, () => {
|
|
199
|
+
stop();
|
|
200
|
+
process.exit(0);
|
|
201
|
+
});
|
|
202
|
+
await new Promise(() => {});
|
|
203
|
+
}
|
|
204
|
+
const result = await preview(previewOptions);
|
|
133
205
|
for (const file of result.saved) console.log(file);
|
|
134
206
|
process.exit(result.failed ? 1 : 0);
|
|
135
207
|
} catch (error) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webview-preview",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.1.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",
|
|
@@ -30,6 +30,9 @@
|
|
|
30
30
|
"imports": {
|
|
31
31
|
"#src/*": "./src/*"
|
|
32
32
|
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"test": "node --test"
|
|
35
|
+
},
|
|
33
36
|
"bin": {
|
|
34
37
|
"webview-preview": "bin/webview-preview.mjs"
|
|
35
38
|
},
|
package/src/core/config.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { pathToFileURL } from "node:url";
|
|
4
4
|
|
|
@@ -10,7 +10,8 @@ export const loadConfig = async (configPath, cwd = process.cwd()) => {
|
|
|
10
10
|
: CONFIG_FILES.map(name => path.join(cwd, name)).find(candidate => existsSync(candidate));
|
|
11
11
|
if (!file) return {}
|
|
12
12
|
if (!existsSync(file)) throw new Error(`Config not found: ${file}`);
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
const where = { configDir: path.dirname(file), configFile: file }
|
|
14
|
+
if (file.endsWith(".json")) return { ...JSON.parse(readFileSync(file, "utf8")), ...where }
|
|
15
|
+
const module = await import(`${pathToFileURL(file).href}?mtime=${statSync(file).mtimeMs}`);
|
|
16
|
+
return { ...(module.default ?? module), ...where }
|
|
16
17
|
}
|
package/src/core/steps.mjs
CHANGED
|
@@ -1,10 +1,19 @@
|
|
|
1
|
+
import { NETWORK_STEPS, networkStep } from "#src/page/network.mjs";
|
|
2
|
+
|
|
1
3
|
export const LOAD_SETTLE_MS = 1500;
|
|
2
4
|
export const JS_SETTLE_MS = 700;
|
|
3
5
|
export const TIMEOUT_MS = 30000;
|
|
4
6
|
export const WAIT_FOR_TIMEOUT_MS = 10000;
|
|
5
7
|
export const WAIT_FOR_POLL_MS = 50;
|
|
6
8
|
|
|
7
|
-
const STEP_KINDS = new Set(["js", "jsnow", "wait", "waitfor", "snap", "print", "measure", "resize", "key", "type", "hover", "click"]);
|
|
9
|
+
const STEP_KINDS = new Set(["js", "jsnow", "wait", "waitfor", "snap", "print", "measure", "assert", "focusorder", "resize", "key", "type", "hover", "click", "offline", "online", "latency", "film"]);
|
|
10
|
+
export const ASSERT_MARKER = "__webviewPreviewAssert:";
|
|
11
|
+
export const FOCUS_MARKER = "__webviewPreviewFocus:";
|
|
12
|
+
const FOCUS_ORDER_DEFAULT = 20;
|
|
13
|
+
const FOCUS_ORDER_MAX = 200;
|
|
14
|
+
const FILM = /^([^:@]+):(\d+)(?:\/(\d+))?(?:@(.+))?$/s;
|
|
15
|
+
const FILM_FRAMES_DEFAULT = 10;
|
|
16
|
+
const FILM_FRAMES_MAX = 60;
|
|
8
17
|
const MODIFIERS = { Shift: "Shift", Control: "Control", Ctrl: "Control", Alt: "Alt", Meta: "Meta" }
|
|
9
18
|
export const KEY_NAMES = [
|
|
10
19
|
"Tab", "Enter", "Escape", "Backspace", "Delete", "Insert", "Space", "Home", "End", "PageUp", "PageDown",
|
|
@@ -82,6 +91,53 @@ const MEASURE_FUNCTION = `(selector, query, isVisible) => {
|
|
|
82
91
|
].join(" · ");
|
|
83
92
|
}`;
|
|
84
93
|
|
|
94
|
+
const assertExpression = expression => `(() => {
|
|
95
|
+
const show = value => {
|
|
96
|
+
if (typeof value === "string") return JSON.stringify(value);
|
|
97
|
+
if (typeof Node !== "undefined" && value instanceof Node) return value.nodeType === 1 ? value.outerHTML.slice(0, 120) : String(value.nodeName);
|
|
98
|
+
try {
|
|
99
|
+
return JSON.stringify(value) ?? String(value);
|
|
100
|
+
} catch {
|
|
101
|
+
return String(value);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const report = problem => ${JSON.stringify(ASSERT_MARKER)} + JSON.stringify([${JSON.stringify(expression)}, problem]);
|
|
105
|
+
try {
|
|
106
|
+
const value = (${expression});
|
|
107
|
+
return report(value ? null : "got " + show(value));
|
|
108
|
+
} catch (error) {
|
|
109
|
+
return report("threw " + (error && error.message || error));
|
|
110
|
+
}
|
|
111
|
+
})()`;
|
|
112
|
+
|
|
113
|
+
const FOCUS_DESCRIBE = `(() => {
|
|
114
|
+
const element = document.activeElement;
|
|
115
|
+
if (!element || element === document.body || element === document.documentElement) return ${JSON.stringify(FOCUS_MARKER)} + JSON.stringify(["item", null]);
|
|
116
|
+
const tag = element.tagName.toLowerCase();
|
|
117
|
+
const role = element.getAttribute("role");
|
|
118
|
+
const name = element.getAttribute("name");
|
|
119
|
+
const type = tag === "input" ? element.getAttribute("type") : null;
|
|
120
|
+
const label = (element.getAttribute("aria-label") || (element.labels && element.labels[0] ? element.labels[0].innerText : "") || element.getAttribute("placeholder") || element.innerText || element.value || element.getAttribute("title") || "").replace(/\\s+/g, " ").trim().slice(0, 40);
|
|
121
|
+
const parts = [tag + (role ? "[role=" + role + "]" : "") + (name ? "[name=" + name + "]" : "") + (type ? "[type=" + type + "]" : "")];
|
|
122
|
+
if (element.id && !/^(:|_r_|«)/.test(element.id)) parts[0] += "#" + element.id;
|
|
123
|
+
if (label) parts.push(JSON.stringify(label));
|
|
124
|
+
const dialog = element.closest("[role=dialog], dialog");
|
|
125
|
+
if (dialog) parts.push("in dialog");
|
|
126
|
+
const path = [];
|
|
127
|
+
for (let node = element; node && node.nodeType === 1; node = node.parentElement) path.unshift(node.tagName + ":" + [...(node.parentElement ? node.parentElement.children : [])].indexOf(node));
|
|
128
|
+
return ${JSON.stringify(FOCUS_MARKER)} + JSON.stringify(["item", parts.join(" "), path.join("/")]);
|
|
129
|
+
})()`;
|
|
130
|
+
|
|
131
|
+
const focusOrderSteps = (value, step) => {
|
|
132
|
+
const count = value === "" ? FOCUS_ORDER_DEFAULT : Number(value);
|
|
133
|
+
if (!Number.isInteger(count) || count < 1 || count > FOCUS_ORDER_MAX) throw new Error(`focusorder takes how many Tab presses, 1-${FOCUS_ORDER_MAX}, got "${step}"`);
|
|
134
|
+
return [
|
|
135
|
+
{ kind: "print", value: `${JSON.stringify(FOCUS_MARKER)} + JSON.stringify(["start", ${count}])` },
|
|
136
|
+
...Array.from({ length: count }, () => [{ kind: "key", value: "Tab", key: "Tab", modifiers: [] }, { kind: "print", value: FOCUS_DESCRIBE }]).flat(),
|
|
137
|
+
{ kind: "print", value: `${JSON.stringify(FOCUS_MARKER)} + JSON.stringify(["end"])` }
|
|
138
|
+
];
|
|
139
|
+
}
|
|
140
|
+
|
|
85
141
|
const measureExpression = selector => `(${MEASURE_FUNCTION})(${JSON.stringify(selector)}, ${QUERY_FUNCTION}, ${VISIBLE_FUNCTION})`;
|
|
86
142
|
|
|
87
143
|
const rectExpression = (selector, isScrolled) => `(() => {
|
|
@@ -101,6 +157,16 @@ const rectExpression = (selector, isScrolled) => `(() => {
|
|
|
101
157
|
return JSON.stringify({ x: rect.left, y: rect.top, width: rect.width, height: rect.height });
|
|
102
158
|
})()`;
|
|
103
159
|
|
|
160
|
+
const filmStep = (value, step) => {
|
|
161
|
+
const match = FILM.exec(value);
|
|
162
|
+
if (!match) throw new Error(`film needs name:ms, like film:open:400@[role=dialog], got "${step}"`);
|
|
163
|
+
const [, name, durationMs, count, selector] = match;
|
|
164
|
+
const frames = Number(count ?? FILM_FRAMES_DEFAULT);
|
|
165
|
+
if (frames < 2 || frames > FILM_FRAMES_MAX) throw new Error(`film takes 2 to ${FILM_FRAMES_MAX} frames, got "${step}"`);
|
|
166
|
+
const film = { kind: "film", value: name, durationMs: Number(durationMs), frames }
|
|
167
|
+
return selector ? { ...film, selector, rect: rectExpression(selector, false) } : film;
|
|
168
|
+
}
|
|
169
|
+
|
|
104
170
|
const parseKey = combo => {
|
|
105
171
|
const parts = combo.split("+");
|
|
106
172
|
const key = parts.pop();
|
|
@@ -112,17 +178,30 @@ const parseKey = combo => {
|
|
|
112
178
|
return { key, modifiers }
|
|
113
179
|
}
|
|
114
180
|
|
|
181
|
+
export const expandMacros = (steps, macros = {}, trail = []) => steps.flatMap(step => {
|
|
182
|
+
if (!step.startsWith("macro:")) return [step];
|
|
183
|
+
const name = step.slice(6);
|
|
184
|
+
if (!Object.hasOwn(macros, name)) {
|
|
185
|
+
const known = Object.keys(macros);
|
|
186
|
+
throw new Error(`Unknown macro "${name}". Macros: ${known.length ? known.join(", ") : "none — add them to macros in the config"}`);
|
|
187
|
+
}
|
|
188
|
+
if (trail.includes(name)) throw new Error(`macro:${name} uses itself: ${[...trail, name].join(" → ")}`);
|
|
189
|
+
const body = macros[name];
|
|
190
|
+
if (!Array.isArray(body) || !body.every(item => typeof item === "string")) throw new Error(`macro "${name}" must be a list of steps`);
|
|
191
|
+
return expandMacros(body, macros, [...trail, name]);
|
|
192
|
+
})
|
|
193
|
+
|
|
115
194
|
export const parseSize = value => {
|
|
116
195
|
const match = /^(\d+)x(\d+)$/.exec(String(value));
|
|
117
196
|
if (!match) throw new Error(`Size must look like 1200x650, got "${value}"`);
|
|
118
197
|
return { width: Number(match[1]), height: Number(match[2]) }
|
|
119
198
|
}
|
|
120
199
|
|
|
121
|
-
export const parseSteps = steps => steps.
|
|
200
|
+
export const parseSteps = steps => steps.flatMap(step => {
|
|
122
201
|
const separator = step.indexOf(":");
|
|
123
202
|
const kind = separator === -1 ? step : step.slice(0, separator);
|
|
124
203
|
const value = separator === -1 ? "" : step.slice(separator + 1);
|
|
125
|
-
if (!STEP_KINDS.has(kind)) throw new Error(`Unknown step "${step}". Steps: ${[...STEP_KINDS].join(", ")}`);
|
|
204
|
+
if (!STEP_KINDS.has(kind)) throw new Error(`Unknown step "${step}". Steps: ${[...STEP_KINDS, "macro"].join(", ")}`);
|
|
126
205
|
if (kind === "wait" && !/^\d+$/.test(value)) throw new Error(`wait needs milliseconds, got "${step}"`);
|
|
127
206
|
if (kind === "resize") parseSize(value);
|
|
128
207
|
if (kind === "snap") {
|
|
@@ -145,6 +224,13 @@ export const parseSteps = steps => steps.map(step => {
|
|
|
145
224
|
if (!selector) throw new Error(`waitfor needs a selector, got "${step}"`);
|
|
146
225
|
return { kind, value, check: waitForExpression(selector, isGone) }
|
|
147
226
|
}
|
|
227
|
+
if (kind === "focusorder") return focusOrderSteps(value, step);
|
|
228
|
+
if (kind === "film") return filmStep(value, step);
|
|
229
|
+
if (NETWORK_STEPS.has(kind)) return networkStep(kind, value, step);
|
|
230
|
+
if (kind === "assert") {
|
|
231
|
+
if (!value) throw new Error(`assert needs an expression, got "${step}"`);
|
|
232
|
+
return { kind: "print", value: assertExpression(value), assert: value }
|
|
233
|
+
}
|
|
148
234
|
if (kind === "measure") {
|
|
149
235
|
if (!value) throw new Error(`measure needs a selector, got "${step}"`);
|
|
150
236
|
return { kind: "print", value: printExpression(measureExpression(value)) }
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { existsSync, statSync, watch } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
const IGNORED = /(^|[\\/])(node_modules|\.git)([\\/]|$)/;
|
|
5
|
+
const DEBOUNCE_MS = 300;
|
|
6
|
+
|
|
7
|
+
export const watchFiles = ({ targets, ignore, onChange }) => {
|
|
8
|
+
let timer;
|
|
9
|
+
let changed = null;
|
|
10
|
+
const watchers = targets.map(target => {
|
|
11
|
+
if (!existsSync(target)) throw new Error(`watch: ${target} does not exist`);
|
|
12
|
+
const isFile = statSync(target).isFile();
|
|
13
|
+
try {
|
|
14
|
+
return watch(target, { recursive: true }, (_, file) => {
|
|
15
|
+
const full = file && !isFile ? path.join(target, String(file)) : target;
|
|
16
|
+
if (IGNORED.test(full) || ignore.some(dir => full.startsWith(dir + path.sep))) return;
|
|
17
|
+
changed = full;
|
|
18
|
+
clearTimeout(timer);
|
|
19
|
+
timer = setTimeout(() => onChange(changed), DEBOUNCE_MS);
|
|
20
|
+
});
|
|
21
|
+
} catch (error) {
|
|
22
|
+
if (error.code === "ERR_FEATURE_UNAVAILABLE_ON_PLATFORM") throw new Error("--watch needs Node 20 or newer");
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
return () => {
|
|
28
|
+
clearTimeout(timer);
|
|
29
|
+
for (const watcher of watchers) watcher.close();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -16,15 +16,26 @@ const loadPlaywright = async () => {
|
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
export const runPlaywright = async ({
|
|
19
|
+
export const runPlaywright = async ({
|
|
20
|
+
engine, url, steps, init = [], size, locale,
|
|
21
|
+
colorScheme, isMotionReduced = false, out, prefix, scale,
|
|
22
|
+
snapMargin = 8, waitForTimeoutMs = WAIT_FOR_TIMEOUT_MS, log
|
|
23
|
+
}) => {
|
|
20
24
|
const playwright = await loadPlaywright();
|
|
21
25
|
const browserType = engine === "webkit" ? playwright.webkit : playwright.chromium;
|
|
22
26
|
const browser = await browserType.launch({ channel: CHANNELS[engine], headless: true });
|
|
23
27
|
const saved = [];
|
|
28
|
+
const films = [];
|
|
24
29
|
let failed = false;
|
|
25
30
|
|
|
26
31
|
try {
|
|
27
|
-
const page = await browser.newPage({
|
|
32
|
+
const page = await browser.newPage({
|
|
33
|
+
viewport: parseSize(size),
|
|
34
|
+
deviceScaleFactor: scale ?? 1,
|
|
35
|
+
locale,
|
|
36
|
+
colorScheme,
|
|
37
|
+
reducedMotion: isMotionReduced ? "reduce" : undefined
|
|
38
|
+
});
|
|
28
39
|
page.setDefaultTimeout(TIMEOUT_MS);
|
|
29
40
|
for (const content of init) await page.addInitScript({ content });
|
|
30
41
|
await page.goto(url, { waitUntil: "load" });
|
|
@@ -45,6 +56,20 @@ export const runPlaywright = async ({ engine, url, steps, init = [], size, out,
|
|
|
45
56
|
return parseRect(result.value, step.selector ?? step.value);
|
|
46
57
|
}
|
|
47
58
|
|
|
59
|
+
const film = async step => {
|
|
60
|
+
const frames = [];
|
|
61
|
+
const started = Date.now();
|
|
62
|
+
for (let index = 0; index < step.frames; index += 1) {
|
|
63
|
+
const due = started + index * step.durationMs / (step.frames - 1);
|
|
64
|
+
if (Date.now() < due) await page.waitForTimeout(due - Date.now());
|
|
65
|
+
const time = Date.now() - started;
|
|
66
|
+
const file = path.join(out, `${prefix}${step.value}.frame-${index}.png`);
|
|
67
|
+
const [, rect] = await Promise.all([page.screenshot({ path: file }), step.rect ? evaluate(step.rect) : {}]);
|
|
68
|
+
frames.push({ file, time, rect: rect.value && rect.value !== "null" ? JSON.parse(rect.value) : null });
|
|
69
|
+
}
|
|
70
|
+
films.push({ name: step.value, file: path.join(out, `${prefix}${step.value}.png`), scale: scale ?? 1, viewport: page.viewportSize(), frames });
|
|
71
|
+
}
|
|
72
|
+
|
|
48
73
|
const runStep = async step => {
|
|
49
74
|
const { kind, value } = step;
|
|
50
75
|
if (kind === "snap") {
|
|
@@ -52,6 +77,8 @@ export const runPlaywright = async ({ engine, url, steps, init = [], size, out,
|
|
|
52
77
|
const clip = step.selector ? clipRect(await elementRect(step), snapMargin, page.viewportSize()) : undefined;
|
|
53
78
|
await page.screenshot({ path: file, clip });
|
|
54
79
|
saved.push(file);
|
|
80
|
+
} else if (kind === "film") {
|
|
81
|
+
await film(step);
|
|
55
82
|
} else if (kind === "hover" || kind === "click") {
|
|
56
83
|
const rect = await elementRect(step);
|
|
57
84
|
const x = rect.x + rect.width / 2;
|
|
@@ -100,5 +127,5 @@ export const runPlaywright = async ({ engine, url, steps, init = [], size, out,
|
|
|
100
127
|
await browser.close();
|
|
101
128
|
}
|
|
102
129
|
|
|
103
|
-
return { saved, failed }
|
|
130
|
+
return { saved, failed, films }
|
|
104
131
|
}
|
|
@@ -9,7 +9,12 @@ const PROBE = "import gi; gi.require_version('Gtk', '3.0'); gi.require_version('
|
|
|
9
9
|
export const isWebkitGtkAvailable = () => process.platform === "linux"
|
|
10
10
|
&& spawnSync("python3", ["-c", PROBE], { stdio: "ignore" }).status === 0;
|
|
11
11
|
|
|
12
|
-
export const runWebkitGtk = ({
|
|
12
|
+
export const runWebkitGtk = ({
|
|
13
|
+
url, steps, init = [], size, colorScheme,
|
|
14
|
+
isMotionReduced = false, out, prefix, snapMargin = 8,
|
|
15
|
+
isHeaded = false, isStorageKept = false,
|
|
16
|
+
waitForTimeoutMs = WAIT_FOR_TIMEOUT_MS, timeoutMs = TIMEOUT_MS, log
|
|
17
|
+
}) => new Promise((resolve, reject) => {
|
|
13
18
|
if (!isWebkitGtkAvailable()) {
|
|
14
19
|
reject(new Error("WebKitGTK engine needs Linux with python3-gi and WebKit2 4.1 (gir1.2-webkit2-4.1)"));
|
|
15
20
|
return;
|
|
@@ -18,14 +23,14 @@ export const runWebkitGtk = ({ url, steps, init = [], size, out, prefix, snapMar
|
|
|
18
23
|
const { width, height } = parseSize(size);
|
|
19
24
|
const child = spawn("python3", [
|
|
20
25
|
SCRIPT, url,
|
|
21
|
-
"--steps", JSON.stringify(steps),
|
|
22
|
-
"--init", JSON.stringify(init),
|
|
23
26
|
"--init-errors", INIT_ERRORS_EXPRESSION,
|
|
24
27
|
"--out", out,
|
|
25
28
|
"--prefix", prefix,
|
|
26
29
|
"--width", String(width),
|
|
27
30
|
"--height", String(height),
|
|
28
31
|
"--snap-margin", String(snapMargin),
|
|
32
|
+
...(colorScheme ? ["--color-scheme", colorScheme] : []),
|
|
33
|
+
...(isMotionReduced ? ["--reduced-motion"] : []),
|
|
29
34
|
...(isHeaded ? ["--headed"] : []),
|
|
30
35
|
...(isStorageKept ? ["--keep-storage"] : []),
|
|
31
36
|
"--wait-for-timeout-ms", String(waitForTimeoutMs),
|
|
@@ -34,11 +39,15 @@ export const runWebkitGtk = ({ url, steps, init = [], size, out, prefix, snapMar
|
|
|
34
39
|
"--js-settle-ms", String(JS_SETTLE_MS),
|
|
35
40
|
"--timeout-ms", String(timeoutMs)
|
|
36
41
|
], {
|
|
37
|
-
stdio: ["
|
|
42
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
38
43
|
env: { PYTHONUNBUFFERED: "1", ...process.env }
|
|
39
44
|
});
|
|
40
45
|
|
|
46
|
+
child.stdin.on("error", () => undefined);
|
|
47
|
+
child.stdin.end(JSON.stringify({ steps, init }));
|
|
48
|
+
|
|
41
49
|
const saved = [];
|
|
50
|
+
const films = [];
|
|
42
51
|
let buffer = "";
|
|
43
52
|
child.stdout.setEncoding("utf8");
|
|
44
53
|
child.stdout.on("data", chunk => {
|
|
@@ -51,6 +60,7 @@ export const runWebkitGtk = ({ url, steps, init = [], size, out, prefix, snapMar
|
|
|
51
60
|
const kind = line.slice(0, tab);
|
|
52
61
|
const value = JSON.parse(line.slice(tab + 1));
|
|
53
62
|
if (kind === "saved") saved.push(value);
|
|
63
|
+
else if (kind === "film") films.push(value);
|
|
54
64
|
else if (kind === "print") log.info(value);
|
|
55
65
|
else if (kind === "error") log.error(value);
|
|
56
66
|
else if (kind === "init-errors") reportInitErrors(value, log);
|
|
@@ -63,5 +73,5 @@ export const runWebkitGtk = ({ url, steps, init = [], size, out, prefix, snapMar
|
|
|
63
73
|
}
|
|
64
74
|
});
|
|
65
75
|
child.once("error", reject);
|
|
66
|
-
child.once("exit", code => resolve({ saved, failed: code !== 0 }));
|
|
76
|
+
child.once("exit", code => resolve({ saved, failed: code !== 0, films }));
|
|
67
77
|
});
|
package/src/engines/webkitgtk.py
CHANGED
|
@@ -14,6 +14,11 @@ gi.require_version("WebKit2", "4.1")
|
|
|
14
14
|
from gi.repository import Gdk, GLib, Gtk, WebKit2
|
|
15
15
|
|
|
16
16
|
INPUT_SETTLE_MS = 50
|
|
17
|
+
UNSUPPORTED_RESULT = "Unsupported result type"
|
|
18
|
+
KEY_POLL_MS = 20
|
|
19
|
+
KEY_WAIT_MS = 2000
|
|
20
|
+
KEYUPS = "window.__webviewPreviewKeyups || 0"
|
|
21
|
+
KEYUP_COUNTER = "addEventListener('keyup', () => { window.__webviewPreviewKeyups = (window.__webviewPreviewKeyups || 0) + 1 }, true);"
|
|
17
22
|
GDK_KEY_NAMES = {
|
|
18
23
|
"Tab": "Tab", "Enter": "Return", "Escape": "Escape", "Backspace": "BackSpace", "Delete": "Delete",
|
|
19
24
|
"Insert": "Insert", "Space": "space", "Home": "Home", "End": "End", "PageUp": "Page_Up", "PageDown": "Page_Down",
|
|
@@ -37,14 +42,14 @@ def emit(kind, value):
|
|
|
37
42
|
def parse_args():
|
|
38
43
|
parser = argparse.ArgumentParser()
|
|
39
44
|
parser.add_argument("url")
|
|
40
|
-
parser.add_argument("--steps", required=True)
|
|
41
|
-
parser.add_argument("--init", required=True)
|
|
42
45
|
parser.add_argument("--init-errors", required=True)
|
|
43
46
|
parser.add_argument("--out", required=True)
|
|
44
47
|
parser.add_argument("--prefix", required=True)
|
|
45
48
|
parser.add_argument("--width", type=int, required=True)
|
|
46
49
|
parser.add_argument("--height", type=int, required=True)
|
|
47
50
|
parser.add_argument("--snap-margin", type=int, required=True)
|
|
51
|
+
parser.add_argument("--color-scheme", choices=("light", "dark"))
|
|
52
|
+
parser.add_argument("--reduced-motion", action="store_true")
|
|
48
53
|
parser.add_argument("--headed", action="store_true")
|
|
49
54
|
parser.add_argument("--keep-storage", action="store_true")
|
|
50
55
|
parser.add_argument("--wait-for-timeout-ms", type=int, required=True)
|
|
@@ -69,17 +74,28 @@ def keyval_for(key):
|
|
|
69
74
|
return Gdk.unicode_to_keyval(ord(key))
|
|
70
75
|
|
|
71
76
|
|
|
77
|
+
def apply_media(args):
|
|
78
|
+
settings = Gtk.Settings.get_default()
|
|
79
|
+
if args.color_scheme:
|
|
80
|
+
settings.set_property("gtk-theme-name", "Adwaita")
|
|
81
|
+
settings.set_property("gtk-application-prefer-dark-theme", args.color_scheme == "dark")
|
|
82
|
+
if args.reduced_motion:
|
|
83
|
+
settings.set_property("gtk-enable-animations", False)
|
|
84
|
+
|
|
85
|
+
|
|
72
86
|
def main():
|
|
73
87
|
args = parse_args()
|
|
74
|
-
|
|
75
|
-
|
|
88
|
+
apply_media(args)
|
|
89
|
+
payload = json.loads(sys.stdin.read())
|
|
90
|
+
steps = payload["steps"]
|
|
91
|
+
init_scripts = payload["init"]
|
|
76
92
|
os.makedirs(args.out, exist_ok=True)
|
|
77
93
|
|
|
78
94
|
window = Gtk.Window(title="webview-preview") if args.headed else Gtk.OffscreenWindow()
|
|
79
95
|
window.set_default_size(args.width, args.height)
|
|
80
96
|
window.set_size_request(args.width, args.height)
|
|
81
97
|
manager = WebKit2.UserContentManager()
|
|
82
|
-
for source in init_scripts:
|
|
98
|
+
for source in [KEYUP_COUNTER, *init_scripts]:
|
|
83
99
|
manager.add_script(WebKit2.UserScript.new(
|
|
84
100
|
source,
|
|
85
101
|
WebKit2.UserContentInjectedFrames.TOP_FRAME,
|
|
@@ -117,7 +133,9 @@ def main():
|
|
|
117
133
|
event.focus_change.send_event = 1
|
|
118
134
|
event.focus_change.in_ = 1
|
|
119
135
|
event.set_device(seat.get_keyboard())
|
|
136
|
+
display.error_trap_push()
|
|
120
137
|
deliver(event)
|
|
138
|
+
display.error_trap_pop_ignored()
|
|
121
139
|
view.grab_focus()
|
|
122
140
|
|
|
123
141
|
def key_event(kind, keyval, mask):
|
|
@@ -144,6 +162,29 @@ def main():
|
|
|
144
162
|
key_event(Gdk.EventType.KEY_RELEASE, Gdk.keyval_from_name(MODIFIER_KEYS[modifier]), mask)
|
|
145
163
|
mask &= ~MODIFIER_MASKS[modifier]
|
|
146
164
|
|
|
165
|
+
def press_all(presses):
|
|
166
|
+
def count(text):
|
|
167
|
+
return int(text) if text and text.isdigit() else 0
|
|
168
|
+
|
|
169
|
+
def send(text):
|
|
170
|
+
expected = count(text) + sum(1 + len(modifiers) for _, modifiers in presses)
|
|
171
|
+
for keyval, modifiers in presses:
|
|
172
|
+
press(keyval, modifiers)
|
|
173
|
+
started = GLib.get_monotonic_time()
|
|
174
|
+
|
|
175
|
+
def check():
|
|
176
|
+
def on_value(text):
|
|
177
|
+
if count(text) >= expected or (GLib.get_monotonic_time() - started) / 1000 >= KEY_WAIT_MS:
|
|
178
|
+
GLib.timeout_add(INPUT_SETTLE_MS, run_step)
|
|
179
|
+
else:
|
|
180
|
+
GLib.timeout_add(KEY_POLL_MS, lambda: (check(), False)[1])
|
|
181
|
+
|
|
182
|
+
evaluate(KEYUPS, on_value, lambda _: GLib.timeout_add(INPUT_SETTLE_MS, run_step))
|
|
183
|
+
|
|
184
|
+
check()
|
|
185
|
+
|
|
186
|
+
evaluate(KEYUPS, send, lambda _: send("0"))
|
|
187
|
+
|
|
147
188
|
def mouse(kind, x, y):
|
|
148
189
|
event = Gdk.Event.new(kind)
|
|
149
190
|
fields = event.motion if kind == Gdk.EventType.MOTION_NOTIFY else event.button
|
|
@@ -231,6 +272,44 @@ def main():
|
|
|
231
272
|
|
|
232
273
|
evaluate(step["check"], on_value, lambda error: (step_failed(f"waitfor:{step['value']} failed: {error}"), next_step()))
|
|
233
274
|
|
|
275
|
+
def film(step):
|
|
276
|
+
frames = []
|
|
277
|
+
started = GLib.get_monotonic_time() / 1000
|
|
278
|
+
interval = step["durationMs"] / (step["frames"] - 1)
|
|
279
|
+
|
|
280
|
+
def store_rect(frame, text):
|
|
281
|
+
frame["rect"] = json.loads(text) if text and text != "null" else None
|
|
282
|
+
|
|
283
|
+
def finish_film():
|
|
284
|
+
width, height = view.get_allocated_width(), view.get_allocated_height()
|
|
285
|
+
for index, frame in enumerate(frames):
|
|
286
|
+
path = os.path.join(args.out, f"{args.prefix}{step['value']}.frame-{index}.png")
|
|
287
|
+
frame.pop("pixbuf").savev(path, "png", [], [])
|
|
288
|
+
frame["file"] = path
|
|
289
|
+
emit("film", {
|
|
290
|
+
"name": step["value"],
|
|
291
|
+
"file": os.path.join(args.out, f"{args.prefix}{step['value']}.png"),
|
|
292
|
+
"scale": full_pixbuf().get_width() / width,
|
|
293
|
+
"viewport": {"width": width, "height": height},
|
|
294
|
+
"frames": frames
|
|
295
|
+
})
|
|
296
|
+
next_step()
|
|
297
|
+
|
|
298
|
+
def capture():
|
|
299
|
+
now = GLib.get_monotonic_time() / 1000
|
|
300
|
+
frame = {"pixbuf": full_pixbuf(), "time": round(now - started), "rect": None}
|
|
301
|
+
frames.append(frame)
|
|
302
|
+
if step.get("rect"):
|
|
303
|
+
evaluate(step["rect"], lambda text: store_rect(frame, text), lambda _: None)
|
|
304
|
+
if len(frames) < step["frames"]:
|
|
305
|
+
due = started + len(frames) * interval
|
|
306
|
+
GLib.timeout_add(max(0, int(due - GLib.get_monotonic_time() / 1000)), capture)
|
|
307
|
+
else:
|
|
308
|
+
evaluate("0", lambda _: finish_film(), lambda _: finish_film())
|
|
309
|
+
return False
|
|
310
|
+
|
|
311
|
+
capture()
|
|
312
|
+
|
|
234
313
|
def next_step():
|
|
235
314
|
GLib.idle_add(run_step)
|
|
236
315
|
|
|
@@ -247,8 +326,10 @@ def main():
|
|
|
247
326
|
else:
|
|
248
327
|
save(value, full_pixbuf())
|
|
249
328
|
next_step()
|
|
329
|
+
elif kind == "film":
|
|
330
|
+
film(step)
|
|
250
331
|
elif kind in ("js", "jsnow"):
|
|
251
|
-
evaluate(value, lambda _: None, lambda error: emit("error", f"js failed: {error}"))
|
|
332
|
+
evaluate(value, lambda _: None, lambda error: None if UNSUPPORTED_RESULT in error else emit("error", f"js failed: {error}"))
|
|
252
333
|
if kind == "js":
|
|
253
334
|
GLib.timeout_add(args.js_settle_ms, run_step)
|
|
254
335
|
else:
|
|
@@ -271,12 +352,9 @@ def main():
|
|
|
271
352
|
elif kind in ("hover", "click"):
|
|
272
353
|
with_rect(step, lambda rect: point_at(step, rect))
|
|
273
354
|
elif kind == "key":
|
|
274
|
-
|
|
275
|
-
GLib.timeout_add(INPUT_SETTLE_MS, run_step)
|
|
355
|
+
press_all([(keyval_for(step["key"]), step["modifiers"])])
|
|
276
356
|
elif kind == "type":
|
|
277
|
-
for character in value
|
|
278
|
-
press(Gdk.unicode_to_keyval(ord(character)))
|
|
279
|
-
GLib.timeout_add(INPUT_SETTLE_MS, run_step)
|
|
357
|
+
press_all([(Gdk.unicode_to_keyval(ord(character)), ()) for character in value])
|
|
280
358
|
return False
|
|
281
359
|
|
|
282
360
|
def start_steps():
|