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.
@@ -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 = { "--engine": "engine", "--size": "size", "--out": "out", "--prefix": "prefix", "--scale": "scale", "--config": "config", "--base-url": "baseUrl", "--snap-margin": "snapMargin", "--baseline": "baseline", "--max-diff-pixels": "maxDiffPixels", "--compare-tolerance": "compareTolerance", "--older-than": "olderThan", "--wait-timeout": "waitTimeout" }
5
+ const FLAGS = {
6
+ "--engine": "engine",
7
+ "--size": "size",
8
+ "--out": "out",
9
+ "--prefix": "prefix",
10
+ "--scale": "scale",
11
+ "--config": "config",
12
+ "--base-url": "baseUrl",
13
+ "--snap-margin": "snapMargin",
14
+ "--baseline": "baseline",
15
+ "--max-diff-pixels": "maxDiffPixels",
16
+ "--compare-tolerance": "compareTolerance",
17
+ "--older-than": "olderThan",
18
+ "--wait-timeout": "waitTimeout",
19
+ "--locale": "locale",
20
+ "--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 result = await preview(parsed.options);
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": "0.0.1",
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
  },
@@ -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
- 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) }
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
  }
@@ -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.map(step => {
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 ({ engine, url, steps, init = [], size, out, prefix, scale, snapMargin = 8, waitForTimeoutMs = WAIT_FOR_TIMEOUT_MS, log }) => {
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({ viewport: parseSize(size), deviceScaleFactor: scale ?? 1 });
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 = ({ 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) => {
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: ["ignore", "pipe", "pipe"],
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
  });
@@ -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
- steps = json.loads(args.steps)
75
- init_scripts = json.loads(args.init)
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
- press(keyval_for(step["key"]), step["modifiers"])
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():