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,318 @@
1
+ import argparse
2
+ import json
3
+ import os
4
+ import sys
5
+
6
+ os.environ.setdefault("GDK_BACKEND", "x11")
7
+
8
+ import gi
9
+
10
+ gi.require_version("Gdk", "3.0")
11
+ gi.require_version("Gtk", "3.0")
12
+ gi.require_version("WebKit2", "4.1")
13
+
14
+ from gi.repository import Gdk, GLib, Gtk, WebKit2
15
+
16
+ INPUT_SETTLE_MS = 50
17
+ GDK_KEY_NAMES = {
18
+ "Tab": "Tab", "Enter": "Return", "Escape": "Escape", "Backspace": "BackSpace", "Delete": "Delete",
19
+ "Insert": "Insert", "Space": "space", "Home": "Home", "End": "End", "PageUp": "Page_Up", "PageDown": "Page_Down",
20
+ "ArrowUp": "Up", "ArrowDown": "Down", "ArrowLeft": "Left", "ArrowRight": "Right",
21
+ **{f"F{number}": f"F{number}" for number in range(1, 13)}
22
+ }
23
+ MODIFIER_KEYS = {"Shift": "Shift_L", "Control": "Control_L", "Alt": "Alt_L", "Meta": "Super_L"}
24
+ MODIFIER_MASKS = {
25
+ "Shift": Gdk.ModifierType.SHIFT_MASK,
26
+ "Control": Gdk.ModifierType.CONTROL_MASK,
27
+ "Alt": Gdk.ModifierType.MOD1_MASK,
28
+ "Meta": Gdk.ModifierType.SUPER_MASK
29
+ }
30
+
31
+
32
+ def emit(kind, value):
33
+ sys.stdout.write(f"{kind}\t{json.dumps(value)}\n")
34
+ sys.stdout.flush()
35
+
36
+
37
+ def parse_args():
38
+ parser = argparse.ArgumentParser()
39
+ parser.add_argument("url")
40
+ parser.add_argument("--steps", required=True)
41
+ parser.add_argument("--init", required=True)
42
+ parser.add_argument("--init-errors", required=True)
43
+ parser.add_argument("--out", required=True)
44
+ parser.add_argument("--prefix", required=True)
45
+ parser.add_argument("--width", type=int, required=True)
46
+ parser.add_argument("--height", type=int, required=True)
47
+ parser.add_argument("--snap-margin", type=int, required=True)
48
+ parser.add_argument("--headed", action="store_true")
49
+ parser.add_argument("--keep-storage", action="store_true")
50
+ parser.add_argument("--wait-for-timeout-ms", type=int, required=True)
51
+ parser.add_argument("--wait-for-poll-ms", type=int, required=True)
52
+ parser.add_argument("--load-settle-ms", type=int, required=True)
53
+ parser.add_argument("--js-settle-ms", type=int, required=True)
54
+ parser.add_argument("--timeout-ms", type=int, required=True)
55
+ return parser.parse_args()
56
+
57
+
58
+ def describe(value):
59
+ if value.is_string():
60
+ return value.to_string()
61
+ if value.is_undefined():
62
+ return "undefined"
63
+ return value.to_json(0) or value.to_string()
64
+
65
+
66
+ def keyval_for(key):
67
+ if key in GDK_KEY_NAMES:
68
+ return Gdk.keyval_from_name(GDK_KEY_NAMES[key])
69
+ return Gdk.unicode_to_keyval(ord(key))
70
+
71
+
72
+ def main():
73
+ args = parse_args()
74
+ steps = json.loads(args.steps)
75
+ init_scripts = json.loads(args.init)
76
+ os.makedirs(args.out, exist_ok=True)
77
+
78
+ window = Gtk.Window(title="webview-preview") if args.headed else Gtk.OffscreenWindow()
79
+ window.set_default_size(args.width, args.height)
80
+ window.set_size_request(args.width, args.height)
81
+ manager = WebKit2.UserContentManager()
82
+ for source in init_scripts:
83
+ manager.add_script(WebKit2.UserScript.new(
84
+ source,
85
+ WebKit2.UserContentInjectedFrames.TOP_FRAME,
86
+ WebKit2.UserScriptInjectionTime.START,
87
+ None,
88
+ None
89
+ ))
90
+ view = WebKit2.WebView(user_content_manager=manager, is_ephemeral=not args.keep_storage)
91
+ window.add(view)
92
+ window.show_all()
93
+
94
+ display = Gdk.Display.get_default()
95
+ seat = display.get_default_seat()
96
+ keymap = Gdk.Keymap.get_for_display(display)
97
+ state = {"index": 0, "failed": False, "started": False}
98
+
99
+ def finish(failed=False):
100
+ state["failed"] = state["failed"] or failed
101
+ Gtk.main_quit()
102
+ return False
103
+
104
+ def step_failed(message):
105
+ state["failed"] = True
106
+ emit("error", message)
107
+
108
+ def deliver(event):
109
+ if args.headed:
110
+ Gtk.main_do_event(event)
111
+ else:
112
+ window.event(event)
113
+
114
+ def focus_window():
115
+ event = Gdk.Event.new(Gdk.EventType.FOCUS_CHANGE)
116
+ event.focus_change.window = window.get_window()
117
+ event.focus_change.send_event = 1
118
+ event.focus_change.in_ = 1
119
+ event.set_device(seat.get_keyboard())
120
+ deliver(event)
121
+ view.grab_focus()
122
+
123
+ def key_event(kind, keyval, mask):
124
+ found, entries = keymap.get_entries_for_keyval(keyval)
125
+ event = Gdk.Event.new(kind)
126
+ event.key.window = window.get_window()
127
+ event.key.send_event = 1
128
+ event.key.time = Gtk.get_current_event_time()
129
+ event.key.keyval = keyval
130
+ event.key.state = mask
131
+ if found and entries:
132
+ event.key.hardware_keycode = entries[0].keycode
133
+ event.set_device(seat.get_keyboard())
134
+ deliver(event)
135
+
136
+ def press(keyval, modifiers=()):
137
+ mask = Gdk.ModifierType(0)
138
+ for modifier in modifiers:
139
+ key_event(Gdk.EventType.KEY_PRESS, Gdk.keyval_from_name(MODIFIER_KEYS[modifier]), mask)
140
+ mask |= MODIFIER_MASKS[modifier]
141
+ key_event(Gdk.EventType.KEY_PRESS, keyval, mask)
142
+ key_event(Gdk.EventType.KEY_RELEASE, keyval, mask)
143
+ for modifier in reversed(modifiers):
144
+ key_event(Gdk.EventType.KEY_RELEASE, Gdk.keyval_from_name(MODIFIER_KEYS[modifier]), mask)
145
+ mask &= ~MODIFIER_MASKS[modifier]
146
+
147
+ def mouse(kind, x, y):
148
+ event = Gdk.Event.new(kind)
149
+ fields = event.motion if kind == Gdk.EventType.MOTION_NOTIFY else event.button
150
+ if kind != Gdk.EventType.MOTION_NOTIFY:
151
+ fields.button = 1
152
+ fields.window = view.get_window()
153
+ fields.send_event = 1
154
+ fields.x = x
155
+ fields.y = y
156
+ fields.time = Gtk.get_current_event_time()
157
+ event.set_device(seat.get_pointer())
158
+ view.event(event)
159
+
160
+ def full_pixbuf():
161
+ if args.headed:
162
+ return Gdk.pixbuf_get_from_window(
163
+ view.get_window(), 0, 0, view.get_allocated_width(), view.get_allocated_height()
164
+ )
165
+ return window.get_pixbuf()
166
+
167
+ def save(name, pixbuf):
168
+ path = os.path.join(args.out, f"{args.prefix}{name}.png")
169
+ pixbuf.savev(path, "png", [], [])
170
+ emit("saved", path)
171
+
172
+ def evaluate(code, on_value, on_error):
173
+ def on_result(webview, result):
174
+ try:
175
+ on_value(describe(webview.evaluate_javascript_finish(result)))
176
+ except Exception as error:
177
+ on_error(str(error))
178
+
179
+ view.evaluate_javascript(code, -1, None, None, None, on_result)
180
+
181
+ def with_rect(step, use):
182
+ selector = step.get("selector") or step["value"]
183
+
184
+ def on_value(text):
185
+ rect = json.loads(text) if text and text != "null" else None
186
+ if not rect:
187
+ step_failed(f"{step['kind']}:{step['value']} failed: no element matches {selector}")
188
+ return next_step()
189
+ if rect["width"] <= 0 or rect["height"] <= 0:
190
+ step_failed(f"{step['kind']}:{step['value']} failed: {selector} has no size (hidden?)")
191
+ return next_step()
192
+ use(rect)
193
+
194
+ evaluate(step["rect"], on_value, lambda error: (step_failed(f"{step['kind']}:{step['value']} failed: {error}"), next_step()))
195
+
196
+ def snap_element(step, rect):
197
+ pixbuf = full_pixbuf()
198
+ width, height = view.get_allocated_width(), view.get_allocated_height()
199
+ scale = pixbuf.get_width() / width
200
+ margin = args.snap_margin
201
+ left = max(0, int(rect["x"] - margin))
202
+ top = max(0, int(rect["y"] - margin))
203
+ right = min(width, int(rect["x"] + rect["width"] + margin + 0.999))
204
+ bottom = min(height, int(rect["y"] + rect["height"] + margin + 0.999))
205
+ if right <= left or bottom <= top:
206
+ step_failed(f"snap:{step['value']} failed: element is outside the window")
207
+ return next_step()
208
+ cropped = pixbuf.new_subpixbuf(
209
+ int(left * scale), int(top * scale), int((right - left) * scale), int((bottom - top) * scale)
210
+ ).copy()
211
+ save(step["value"], cropped)
212
+ next_step()
213
+
214
+ def point_at(step, rect):
215
+ x = rect["x"] + rect["width"] / 2
216
+ y = rect["y"] + rect["height"] / 2
217
+ mouse(Gdk.EventType.MOTION_NOTIFY, x, y)
218
+ if step["kind"] == "click":
219
+ mouse(Gdk.EventType.BUTTON_PRESS, x, y)
220
+ mouse(Gdk.EventType.BUTTON_RELEASE, x, y)
221
+ GLib.timeout_add(INPUT_SETTLE_MS, run_step)
222
+
223
+ def wait_for(step, started):
224
+ def on_value(text):
225
+ if text == "true":
226
+ return next_step()
227
+ if (GLib.get_monotonic_time() - started) / 1000 >= args.wait_for_timeout_ms:
228
+ step_failed(f"waitfor:{step['value']} timed out after {args.wait_for_timeout_ms} ms")
229
+ return next_step()
230
+ GLib.timeout_add(args.wait_for_poll_ms, lambda: (wait_for(step, started), False)[1])
231
+
232
+ evaluate(step["check"], on_value, lambda error: (step_failed(f"waitfor:{step['value']} failed: {error}"), next_step()))
233
+
234
+ def next_step():
235
+ GLib.idle_add(run_step)
236
+
237
+ def run_step():
238
+ if state["index"] >= len(steps):
239
+ return finish()
240
+ step = steps[state["index"]]
241
+ state["index"] += 1
242
+ kind, value = step["kind"], step["value"]
243
+
244
+ if kind == "snap":
245
+ if step.get("selector"):
246
+ with_rect(step, lambda rect: snap_element(step, rect))
247
+ else:
248
+ save(value, full_pixbuf())
249
+ next_step()
250
+ elif kind in ("js", "jsnow"):
251
+ evaluate(value, lambda _: None, lambda error: emit("error", f"js failed: {error}"))
252
+ if kind == "js":
253
+ GLib.timeout_add(args.js_settle_ms, run_step)
254
+ else:
255
+ next_step()
256
+ elif kind == "wait":
257
+ GLib.timeout_add(int(value), run_step)
258
+ elif kind == "waitfor":
259
+ wait_for(step, GLib.get_monotonic_time())
260
+ elif kind == "resize":
261
+ width, height = (int(part) for part in value.split("x"))
262
+ window.set_size_request(width, height)
263
+ window.resize(width, height)
264
+ GLib.timeout_add(INPUT_SETTLE_MS, run_step)
265
+ elif kind == "print":
266
+ evaluate(
267
+ value,
268
+ lambda text: (emit("print", text), next_step()),
269
+ lambda error: (emit("error", f"print failed: {error}"), next_step())
270
+ )
271
+ elif kind in ("hover", "click"):
272
+ with_rect(step, lambda rect: point_at(step, rect))
273
+ elif kind == "key":
274
+ press(keyval_for(step["key"]), step["modifiers"])
275
+ GLib.timeout_add(INPUT_SETTLE_MS, run_step)
276
+ 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)
280
+ return False
281
+
282
+ def start_steps():
283
+ focus_window()
284
+ if not init_scripts:
285
+ return run_step()
286
+ evaluate(
287
+ args.init_errors,
288
+ lambda text: (emit("init-errors", text), next_step()),
289
+ lambda error: (emit("error", f"init check failed: {error}"), next_step())
290
+ )
291
+ return False
292
+
293
+ def on_load_changed(_view, event):
294
+ if event == WebKit2.LoadEvent.FINISHED and not state["started"]:
295
+ state["started"] = True
296
+ GLib.timeout_add(args.load_settle_ms, start_steps)
297
+
298
+ def on_load_failed(_view, _event, uri, error):
299
+ emit("error", f"failed to load {uri}: {error.message}")
300
+ return finish(failed=True)
301
+
302
+ def on_timeout():
303
+ emit("error", f"timed out after {args.timeout_ms} ms")
304
+ return finish(failed=True)
305
+
306
+ view.connect("load-changed", on_load_changed)
307
+ view.connect("load-failed", on_load_failed)
308
+ view.load_uri(args.url)
309
+ GLib.timeout_add(args.timeout_ms, on_timeout)
310
+ Gtk.main()
311
+
312
+ sys.stdout.flush()
313
+ sys.stderr.flush()
314
+ os._exit(1 if state["failed"] else 0)
315
+
316
+
317
+ if __name__ == "__main__":
318
+ main()
package/src/index.d.ts ADDED
@@ -0,0 +1,86 @@
1
+ export type Engine = "auto" | "webkitgtk" | "msedge" | "chrome" | "chromium" | "webkit";
2
+
3
+ export type Step =
4
+ | `js:${string}`
5
+ | `jsnow:${string}`
6
+ | `wait:${number}`
7
+ | `waitfor:${string}`
8
+ | `measure:${string}`
9
+ | `snap:${string}`
10
+ | `print:${string}`
11
+ | `resize:${number}x${number}`
12
+ | `key:${string}`
13
+ | `type:${string}`
14
+ | `hover:${string}`
15
+ | `click:${string}`;
16
+
17
+ export interface ServerOptions {
18
+ command: string;
19
+ port: number;
20
+ host?: string;
21
+ cwd?: string;
22
+ readyTimeoutMs?: number;
23
+ }
24
+
25
+ export interface PreviewConfig {
26
+ baseUrl?: string;
27
+ server?: ServerOptions;
28
+ engine?: Engine;
29
+ size?: `${number}x${number}`;
30
+ out?: string;
31
+ prefix?: string;
32
+ scale?: number;
33
+ init?: InitEntry | InitEntry[];
34
+ mock?: Record<string, MockResponse>;
35
+ logRequests?: boolean;
36
+ headed?: boolean;
37
+ keepStorage?: boolean;
38
+ waitTimeout?: number;
39
+ snapMargin?: number;
40
+ compare?: boolean;
41
+ updateBaseline?: boolean;
42
+ baseline?: string;
43
+ maxDiffPixels?: number;
44
+ compareTolerance?: number;
45
+ }
46
+
47
+ export interface MockResponse {
48
+ status?: number;
49
+ json?: unknown;
50
+ body?: string;
51
+ headers?: Record<string, string>;
52
+ delayMs?: number;
53
+ }
54
+
55
+ export type InitEntry = string | { file: string }
56
+
57
+ export interface PreviewOptions extends PreviewConfig {
58
+ target?: string;
59
+ steps?: Step[] | string[];
60
+ initFile?: string | string[];
61
+ mockFile?: string | string[];
62
+ config?: string | false;
63
+ log?: { info: (message: string) => void; error: (message: string) => void }
64
+ }
65
+
66
+ export interface PreviewResult {
67
+ engine: Exclude<Engine, "auto">;
68
+ url: string;
69
+ saved: string[];
70
+ failed: boolean;
71
+ }
72
+
73
+ export declare const ENGINES: Engine[];
74
+ export declare const REQUESTS_KEY: "__webviewPreviewRequests";
75
+ export declare function resolveUrl(target: string, baseUrl?: string): string;
76
+ export declare function preview(options?: PreviewOptions): Promise<PreviewResult>;
77
+ export declare function defineConfig(config: PreviewConfig): PreviewConfig;
78
+ export interface CleanOptions {
79
+ config?: string | false;
80
+ out?: string;
81
+ baseline?: string;
82
+ olderThan?: string;
83
+ dryRun?: boolean;
84
+ }
85
+
86
+ export declare function cleanSnapshots(options?: CleanOptions): Promise<{ removed: string[]; out: string }>;
package/src/index.mjs ADDED
@@ -0,0 +1,81 @@
1
+ import { mkdirSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { compareSnapshots } from "#src/snapshots/compare.mjs";
4
+ import { loadConfig } from "#src/core/config.mjs";
5
+ import { resolveEngine, runEngine } from "#src/engines/index.mjs";
6
+ import { resolveInit } from "#src/page/init.mjs";
7
+ import { compileMock, mockScript, resolveMockFiles } from "#src/page/mock.mjs";
8
+ import { startServer } from "#src/core/server.mjs";
9
+ import { parseSize, parseSteps } from "#src/core/steps.mjs";
10
+
11
+ export { ENGINES } from "#src/engines/index.mjs";
12
+ export { REQUESTS_KEY } from "#src/page/mock.mjs";
13
+ export { cleanSnapshots } from "#src/snapshots/clean.mjs";
14
+
15
+ const DEFAULTS = { engine: "auto", size: "1200x650", out: ".webview-preview", steps: ["snap:page"] }
16
+ const ABSOLUTE_URL = /^[a-z][a-z\d+.-]*:/i;
17
+
18
+ const consoleLog = {
19
+ info: message => console.log(message),
20
+ error: message => console.error(message)
21
+ }
22
+
23
+ export const resolveUrl = (target, baseUrl) => {
24
+ if (ABSOLUTE_URL.test(target)) return target;
25
+ if (!baseUrl) throw new Error(`"${target}" is not a full URL; set baseUrl to open routes`);
26
+ return `${baseUrl}${target.startsWith("/") ? target : `/${target}`}`;
27
+ }
28
+
29
+ export const preview = async (options = {}) => {
30
+ const config = options.config === false ? {} : await loadConfig(options.config);
31
+ const merged = { ...DEFAULTS, ...config, ...Object.fromEntries(Object.entries(options).filter(([, value]) => value !== undefined)) }
32
+ const baseDir = config.configDir ?? process.cwd();
33
+ const engine = resolveEngine(merged.engine);
34
+ const steps = parseSteps(merged.steps.length ? merged.steps : DEFAULTS.steps);
35
+ const out = path.resolve(baseDir, merged.out);
36
+ const url = resolveUrl(merged.target ?? merged.url ?? "/", merged.baseUrl);
37
+ parseSize(merged.size);
38
+ const mockFiles = await resolveMockFiles(options.mockFile, process.cwd());
39
+ const hasMock = config.mock !== undefined || options.mock !== undefined || mockFiles.length > 0
40
+ || !!(options.logRequests ?? config.logRequests);
41
+ const mockRules = compileMock([config.mock, ...mockFiles, options.mock]);
42
+ const init = (hasMock ? [mockScript(mockRules)] : []).concat(resolveInit([
43
+ { entries: config.init, baseDir },
44
+ { entries: options.init, baseDir: process.cwd() },
45
+ { entries: options.initFile && [].concat(options.initFile).map(file => ({ file })), baseDir: process.cwd() }
46
+ ], baseDir));
47
+ mkdirSync(out, { recursive: true });
48
+
49
+ const server = merged.server && { cwd: baseDir, ...merged.server }
50
+ const stopServer = await startServer(server);
51
+ try {
52
+ const result = await runEngine(engine, {
53
+ url,
54
+ steps,
55
+ init,
56
+ size: merged.size,
57
+ out,
58
+ prefix: merged.prefix ?? `${engine}-`,
59
+ scale: merged.scale === undefined ? undefined : Number(merged.scale),
60
+ snapMargin: Number(merged.snapMargin ?? 8),
61
+ isHeaded: !!merged.headed,
62
+ isStorageKept: !!merged.keepStorage,
63
+ waitForTimeoutMs: Number(merged.waitTimeout ?? 10000),
64
+ log: merged.log ?? consoleLog
65
+ });
66
+ const shouldCompare = !!(merged.compare || merged.updateBaseline);
67
+ const compareFailed = shouldCompare && compareSnapshots({
68
+ saved: result.saved,
69
+ baselineDir: path.resolve(baseDir, merged.baseline ?? "webview-preview-baseline"),
70
+ updateBaseline: !!merged.updateBaseline,
71
+ tolerance: Number(merged.compareTolerance ?? 2),
72
+ maxDiffPixels: Number(merged.maxDiffPixels ?? 0),
73
+ log: merged.log ?? consoleLog
74
+ });
75
+ return { engine, url, ...result, failed: result.failed || compareFailed }
76
+ } finally {
77
+ stopServer();
78
+ }
79
+ }
80
+
81
+ export const defineConfig = config => config;
@@ -0,0 +1,32 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+
4
+ const ERRORS_KEY = "__webviewPreviewInitErrors";
5
+ export const INIT_ERRORS_EXPRESSION = `JSON.stringify(window.${ERRORS_KEY} || [])`;
6
+ const toList = value => value === undefined || value === null ? [] : Array.isArray(value) ? value : [value];
7
+
8
+ const readEntry = (entry, baseDir, index) => {
9
+ if (typeof entry === "string") return { label: `init[${index}]`, source: entry }
10
+ if (entry && typeof entry.file === "string") {
11
+ const file = path.resolve(baseDir, entry.file);
12
+ if (!existsSync(file)) throw new Error(`init file not found: ${file}`);
13
+ return { label: path.basename(file), source: readFileSync(file, "utf8") }
14
+ }
15
+ throw new Error(`init entries must be code strings or { file: "path" }, got ${JSON.stringify(entry)}`);
16
+ }
17
+
18
+ const wrap = ({ label, source }) => `{
19
+ try {
20
+ ${source}
21
+ } catch (error) {
22
+ (window.${ERRORS_KEY} = window.${ERRORS_KEY} || []).push(${JSON.stringify(label)} + ": " + (error && error.message || error));
23
+ }
24
+ }`;
25
+
26
+ export const resolveInit = (groups, baseDir) => groups
27
+ .flatMap(group => toList(group.entries).map(entry => ({ entry, baseDir: group.baseDir })))
28
+ .map(({ entry, baseDir: dir }, index) => wrap(readEntry(entry, dir ?? baseDir, index)));
29
+
30
+ export const reportInitErrors = (json, log) => {
31
+ for (const message of JSON.parse(json || "[]")) log.error(`init failed: ${message}`);
32
+ }
@@ -0,0 +1,117 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+
5
+ const METHODS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]);
6
+ const RESPONSE_KEYS = new Set(["status", "json", "body", "headers", "delayMs"]);
7
+ export const REQUESTS_KEY = "__webviewPreviewRequests";
8
+
9
+ const globToRegExp = pattern => {
10
+ const source = pattern
11
+ .split(/(\*\*|\*)/)
12
+ .map(part => part === "**" ? ".*" : part === "*" ? "[^/]*" : part.replace(/[.+?^${}()|[\]\\]/g, "\\$&"))
13
+ .join("");
14
+ return `^${source}$`;
15
+ }
16
+
17
+ const parseKey = key => {
18
+ const [first, ...rest] = key.trim().split(/\s+/);
19
+ const hasMethod = METHODS.has(first.toUpperCase()) && rest.length > 0;
20
+ const pattern = hasMethod ? rest.join(" ") : key.trim();
21
+ return {
22
+ method: hasMethod ? first.toUpperCase() : null,
23
+ pattern,
24
+ regex: globToRegExp(pattern),
25
+ isFullUrl: /^https?:\/\//i.test(pattern),
26
+ withQuery: pattern.includes("?")
27
+ }
28
+ }
29
+
30
+ const parseResponse = (key, response) => {
31
+ if (!response || typeof response !== "object") throw new Error(`mock "${key}" must be an object like { json: … } or { status: 500 }`);
32
+ const unknown = Object.keys(response).filter(name => !RESPONSE_KEYS.has(name));
33
+ if (unknown.length) throw new Error(`mock "${key}": unknown field ${unknown.join(", ")}; use ${[...RESPONSE_KEYS].join(", ")}`);
34
+ if ("json" in response && "body" in response) throw new Error(`mock "${key}": use json or body, not both`);
35
+ const isJson = "json" in response;
36
+ return {
37
+ status: response.status ?? 200,
38
+ body: isJson ? JSON.stringify(response.json) : String(response.body ?? ""),
39
+ headers: { ...(isJson ? { "content-type": "application/json" } : {}), ...(response.headers ?? {}) },
40
+ delayMs: response.delayMs ?? 0
41
+ }
42
+ }
43
+
44
+ export const loadMockFile = async file => {
45
+ if (!existsSync(file)) throw new Error(`mock file not found: ${file}`);
46
+ if (file.endsWith(".json")) return JSON.parse(readFileSync(file, "utf8"));
47
+ const module = await import(pathToFileURL(file).href);
48
+ return module.default ?? module;
49
+ }
50
+
51
+ export const compileMock = mocks => Object.entries(Object.assign({}, ...mocks.filter(Boolean)))
52
+ .map(([key, response]) => ({ key, ...parseKey(key), ...parseResponse(key, response) }));
53
+
54
+ export const resolveMockFiles = async (files, baseDir) => Promise.all(
55
+ [].concat(files ?? []).map(file => loadMockFile(path.resolve(baseDir, file)))
56
+ );
57
+
58
+ export const mockScript = rules => `(() => {
59
+ const rules = ${JSON.stringify(rules)}.map(rule => ({ ...rule, regex: new RegExp(rule.regex) }));
60
+ const requests = window.${REQUESTS_KEY} = window.${REQUESTS_KEY} || [];
61
+ const find = (method, url) => {
62
+ const address = new URL(url, location.href);
63
+ return rules.find(rule => {
64
+ if (rule.method && rule.method !== method) return false;
65
+ const base = rule.isFullUrl ? address.origin + address.pathname : address.pathname;
66
+ return rule.regex.test(rule.withQuery ? base + address.search : base);
67
+ });
68
+ }
69
+
70
+ const record = (method, url, rule) => {
71
+ const address = new URL(url, location.href);
72
+ const shown = address.origin === location.origin ? address.pathname + address.search : address.href;
73
+ requests.push(method + " " + shown + (rule ? " -> mock " + rule.status : " -> network"));
74
+ }
75
+ const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
76
+
77
+ const originalFetch = window.fetch.bind(window);
78
+ window.fetch = async (input, init) => {
79
+ const url = typeof input === "string" || input instanceof URL ? String(input) : input.url;
80
+ const method = String(init && init.method || input && input.method || "GET").toUpperCase();
81
+ const rule = find(method, url);
82
+ record(method, url, rule);
83
+ if (!rule) return originalFetch(input, init);
84
+ if (rule.delayMs) await wait(rule.delayMs);
85
+ return new Response(rule.status === 204 || rule.status === 304 ? null : rule.body, { status: rule.status, headers: rule.headers });
86
+ }
87
+
88
+ const define = (target, name, value) => Object.defineProperty(target, name, { value, configurable: true });
89
+ const originalOpen = XMLHttpRequest.prototype.open;
90
+ const originalSend = XMLHttpRequest.prototype.send;
91
+ XMLHttpRequest.prototype.open = function (method, url, ...rest) {
92
+ this.__webviewPreview = { method: String(method).toUpperCase(), url: String(url) }
93
+ return originalOpen.call(this, method, url, ...rest);
94
+ }
95
+
96
+ XMLHttpRequest.prototype.send = function (body) {
97
+ const info = this.__webviewPreview;
98
+ const rule = info && find(info.method, info.url);
99
+ if (info) record(info.method, info.url, rule);
100
+ if (!rule) return originalSend.call(this, body);
101
+ const xhr = this;
102
+ const headers = Object.fromEntries(Object.entries(rule.headers).map(([name, value]) => [name.toLowerCase(), String(value)]));
103
+ setTimeout(() => {
104
+ define(xhr, "readyState", 4);
105
+ define(xhr, "status", rule.status);
106
+ define(xhr, "statusText", "");
107
+ define(xhr, "responseURL", new URL(info.url, location.href).href);
108
+ define(xhr, "responseText", rule.body);
109
+ define(xhr, "response", xhr.responseType === "json" ? (rule.body ? JSON.parse(rule.body) : null) : rule.body);
110
+ define(xhr, "getResponseHeader", name => headers[String(name).toLowerCase()] ?? null);
111
+ define(xhr, "getAllResponseHeaders", () => Object.entries(headers).map(([name, value]) => name + ": " + value).join("\\r\\n"));
112
+ xhr.dispatchEvent(new Event("readystatechange"));
113
+ xhr.dispatchEvent(new ProgressEvent("load"));
114
+ xhr.dispatchEvent(new ProgressEvent("loadend"));
115
+ }, rule.delayMs);
116
+ }
117
+ })();`;
@@ -0,0 +1,36 @@
1
+ import { existsSync, readdirSync, rmSync, statSync } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { loadConfig } from "#src/core/config.mjs";
5
+
6
+ const DURATION = /^(\d+)(m|h|d)$/;
7
+ const UNIT_MS = {
8
+ m: 60_000,
9
+ h: 3_600_000,
10
+ d: 86_400_000
11
+ }
12
+
13
+ const parseDuration = value => {
14
+ const match = DURATION.exec(String(value));
15
+ if (!match) throw new Error(`--older-than needs a duration like 30m, 2h or 7d, got "${value}"`);
16
+ return Number(match[1]) * UNIT_MS[match[2]];
17
+ }
18
+
19
+ export const cleanSnapshots = async (options = {}) => {
20
+ const config = options.config === false ? {} : await loadConfig(options.config);
21
+ const baseDir = config.configDir ?? process.cwd();
22
+ const out = path.resolve(baseDir, options.out ?? config.out ?? ".webview-preview");
23
+ const baseline = path.resolve(baseDir, options.baseline ?? config.baseline ?? "webview-preview-baseline");
24
+ const protectedDirs = [path.parse(out).root, os.homedir(), process.cwd(), baseDir, baseline].map(dir => path.resolve(dir));
25
+ if (protectedDirs.includes(out)) throw new Error(`refusing to clean ${out}: point "out" at a folder used only for snapshots`);
26
+ if (!existsSync(out)) return { removed: [], out }
27
+
28
+ const maxAge = options.olderThan ? parseDuration(options.olderThan) : 0;
29
+ const now = Date.now();
30
+ const removed = readdirSync(out)
31
+ .filter(name => name.toLowerCase().endsWith(".png"))
32
+ .map(name => path.join(out, name))
33
+ .filter(file => statSync(file).isFile() && now - statSync(file).mtimeMs >= maxAge);
34
+ if (!options.dryRun) for (const file of removed) rmSync(file);
35
+ return { removed, out }
36
+ }