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.
- package/AGENTS.md +207 -0
- package/LICENSE +21 -0
- package/README.md +623 -2
- package/bin/webview-preview.mjs +138 -0
- package/package.json +54 -5
- package/src/core/config.mjs +16 -0
- package/src/core/server.mjs +57 -0
- package/src/core/steps.mjs +169 -0
- package/src/engines/index.mjs +17 -0
- package/src/engines/playwright.mjs +104 -0
- package/src/engines/webkitgtk.mjs +67 -0
- package/src/engines/webkitgtk.py +318 -0
- package/src/index.d.ts +86 -0
- package/src/index.mjs +81 -0
- package/src/page/init.mjs +32 -0
- package/src/page/mock.mjs +117 -0
- package/src/snapshots/clean.mjs +36 -0
- package/src/snapshots/compare.mjs +72 -0
- package/src/snapshots/png.mjs +119 -0
|
@@ -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
|
+
}
|