webview-preview 0.0.0-stage → 1.0.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 +208 -0
- package/LICENSE +21 -0
- package/README.md +657 -2
- package/bin/webview-preview.mjs +161 -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 +182 -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 +101 -0
- package/src/index.mjs +101 -0
- package/src/page/globals.mjs +37 -0
- package/src/page/init.mjs +32 -0
- package/src/page/mock.mjs +117 -0
- package/src/page/source.mjs +55 -0
- package/src/page/tauri.mjs +95 -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,101 @@
|
|
|
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
|
+
| `macro:${string}`;
|
|
17
|
+
|
|
18
|
+
export interface ServerOptions {
|
|
19
|
+
command: string;
|
|
20
|
+
port: number;
|
|
21
|
+
host?: string;
|
|
22
|
+
cwd?: string;
|
|
23
|
+
readyTimeoutMs?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface PreviewConfig {
|
|
27
|
+
baseUrl?: string;
|
|
28
|
+
server?: ServerOptions;
|
|
29
|
+
engine?: Engine;
|
|
30
|
+
size?: `${number}x${number}`;
|
|
31
|
+
out?: string;
|
|
32
|
+
prefix?: string;
|
|
33
|
+
scale?: number;
|
|
34
|
+
init?: InitEntry | InitEntry[];
|
|
35
|
+
mock?: Record<string, MockResponse>;
|
|
36
|
+
logRequests?: boolean;
|
|
37
|
+
headed?: boolean;
|
|
38
|
+
keepStorage?: boolean;
|
|
39
|
+
waitTimeout?: number;
|
|
40
|
+
snapMargin?: number;
|
|
41
|
+
compare?: boolean;
|
|
42
|
+
updateBaseline?: boolean;
|
|
43
|
+
baseline?: string;
|
|
44
|
+
maxDiffPixels?: number;
|
|
45
|
+
compareTolerance?: number;
|
|
46
|
+
tauri?: boolean | TauriStub;
|
|
47
|
+
globals?: Record<string, unknown>;
|
|
48
|
+
locale?: string;
|
|
49
|
+
macros?: Record<string, Step[] | string[]>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface TauriStub {
|
|
53
|
+
commands?: Record<string, unknown | ((args: Record<string, unknown>) => unknown)>;
|
|
54
|
+
label?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface AnyApi {
|
|
58
|
+
readonly members: Record<string, unknown>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface MockResponse {
|
|
62
|
+
status?: number;
|
|
63
|
+
json?: unknown;
|
|
64
|
+
body?: string;
|
|
65
|
+
headers?: Record<string, string>;
|
|
66
|
+
delayMs?: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export type InitEntry = string | { file: string }
|
|
70
|
+
|
|
71
|
+
export interface PreviewOptions extends PreviewConfig {
|
|
72
|
+
target?: string;
|
|
73
|
+
steps?: Step[] | string[];
|
|
74
|
+
initFile?: string | string[];
|
|
75
|
+
mockFile?: string | string[];
|
|
76
|
+
config?: string | false;
|
|
77
|
+
log?: { info: (message: string) => void; error: (message: string) => void }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface PreviewResult {
|
|
81
|
+
engine: Exclude<Engine, "auto">;
|
|
82
|
+
url: string;
|
|
83
|
+
saved: string[];
|
|
84
|
+
failed: boolean;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export declare const ENGINES: Engine[];
|
|
88
|
+
export declare const REQUESTS_KEY: "__webviewPreviewRequests";
|
|
89
|
+
export declare function resolveUrl(target: string, baseUrl?: string): string;
|
|
90
|
+
export declare function preview(options?: PreviewOptions): Promise<PreviewResult>;
|
|
91
|
+
export declare function defineConfig(config: PreviewConfig): PreviewConfig;
|
|
92
|
+
export declare function anyApi(members?: Record<string, unknown>): AnyApi;
|
|
93
|
+
export interface CleanOptions {
|
|
94
|
+
config?: string | false;
|
|
95
|
+
out?: string;
|
|
96
|
+
baseline?: string;
|
|
97
|
+
olderThan?: string;
|
|
98
|
+
dryRun?: boolean;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export declare function cleanSnapshots(options?: CleanOptions): Promise<{ removed: string[]; out: string }>;
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
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 { checkLocale, globalsScript, localeScript } from "#src/page/globals.mjs";
|
|
7
|
+
import { resolveInit, wrapInit } from "#src/page/init.mjs";
|
|
8
|
+
import { compileMock, mockScript, resolveMockFiles } from "#src/page/mock.mjs";
|
|
9
|
+
import { startServer } from "#src/core/server.mjs";
|
|
10
|
+
import { expandMacros, parseSize, parseSteps } from "#src/core/steps.mjs";
|
|
11
|
+
import { resolveTauri, tauriScript } from "#src/page/tauri.mjs";
|
|
12
|
+
|
|
13
|
+
export { ENGINES } from "#src/engines/index.mjs";
|
|
14
|
+
export { REQUESTS_KEY } from "#src/page/mock.mjs";
|
|
15
|
+
export { cleanSnapshots } from "#src/snapshots/clean.mjs";
|
|
16
|
+
export { anyApi } from "#src/page/source.mjs";
|
|
17
|
+
|
|
18
|
+
const DEFAULTS = { engine: "auto", size: "1200x650", out: ".webview-preview", steps: ["snap:page"] }
|
|
19
|
+
const ABSOLUTE_URL = /^[a-z][a-z\d+.-]*:/i;
|
|
20
|
+
|
|
21
|
+
const consoleLog = {
|
|
22
|
+
info: message => console.log(message),
|
|
23
|
+
error: message => console.error(message)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const resolveUrl = (target, baseUrl) => {
|
|
27
|
+
if (ABSOLUTE_URL.test(target)) return target;
|
|
28
|
+
if (!baseUrl) throw new Error(`"${target}" is not a full URL; set baseUrl to open routes`);
|
|
29
|
+
return `${baseUrl}${target.startsWith("/") ? target : `/${target}`}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const preview = async (options = {}) => {
|
|
33
|
+
const config = options.config === false ? {} : await loadConfig(options.config);
|
|
34
|
+
const merged = { ...DEFAULTS, ...config, ...Object.fromEntries(Object.entries(options).filter(([, value]) => value !== undefined)) }
|
|
35
|
+
const baseDir = config.configDir ?? process.cwd();
|
|
36
|
+
const engine = resolveEngine(merged.engine);
|
|
37
|
+
const macros = { ...config.macros, ...options.macros }
|
|
38
|
+
const steps = parseSteps(expandMacros(merged.steps.length ? merged.steps : DEFAULTS.steps, macros));
|
|
39
|
+
const out = path.resolve(baseDir, merged.out);
|
|
40
|
+
const url = resolveUrl(merged.target ?? merged.url ?? "/", merged.baseUrl);
|
|
41
|
+
parseSize(merged.size);
|
|
42
|
+
const mockFiles = await resolveMockFiles(options.mockFile, process.cwd());
|
|
43
|
+
const hasMock = config.mock !== undefined || options.mock !== undefined || mockFiles.length > 0
|
|
44
|
+
|| !!(options.logRequests ?? config.logRequests);
|
|
45
|
+
const mockRules = compileMock([config.mock, ...mockFiles, options.mock]);
|
|
46
|
+
const locale = merged.locale === undefined ? undefined : checkLocale(String(merged.locale));
|
|
47
|
+
const tauri = resolveTauri(config.tauri, options.tauri);
|
|
48
|
+
const globals = { ...config.globals, ...options.globals }
|
|
49
|
+
const stubs = [
|
|
50
|
+
locale && { label: "locale", source: localeScript(locale) },
|
|
51
|
+
tauri && { label: "tauri", source: tauriScript(tauri) },
|
|
52
|
+
Object.keys(globals).length && { label: "globals", source: globalsScript(globals) }
|
|
53
|
+
].filter(Boolean).map(stub => {
|
|
54
|
+
try {
|
|
55
|
+
new Function(stub.source);
|
|
56
|
+
} catch (error) {
|
|
57
|
+
throw new Error(`${stub.label}: the generated script does not parse (${error.message}); check the functions in your config`);
|
|
58
|
+
}
|
|
59
|
+
return wrapInit(stub);
|
|
60
|
+
});
|
|
61
|
+
const init = stubs.concat(hasMock ? [mockScript(mockRules)] : [], resolveInit([
|
|
62
|
+
{ entries: config.init, baseDir },
|
|
63
|
+
{ entries: options.init, baseDir: process.cwd() },
|
|
64
|
+
{ entries: options.initFile && [].concat(options.initFile).map(file => ({ file })), baseDir: process.cwd() }
|
|
65
|
+
], baseDir));
|
|
66
|
+
mkdirSync(out, { recursive: true });
|
|
67
|
+
|
|
68
|
+
const server = merged.server && { cwd: baseDir, ...merged.server }
|
|
69
|
+
const stopServer = await startServer(server);
|
|
70
|
+
try {
|
|
71
|
+
const result = await runEngine(engine, {
|
|
72
|
+
url,
|
|
73
|
+
steps,
|
|
74
|
+
init,
|
|
75
|
+
size: merged.size,
|
|
76
|
+
locale,
|
|
77
|
+
out,
|
|
78
|
+
prefix: merged.prefix ?? `${engine}-`,
|
|
79
|
+
scale: merged.scale === undefined ? undefined : Number(merged.scale),
|
|
80
|
+
snapMargin: Number(merged.snapMargin ?? 8),
|
|
81
|
+
isHeaded: !!merged.headed,
|
|
82
|
+
isStorageKept: !!merged.keepStorage,
|
|
83
|
+
waitForTimeoutMs: Number(merged.waitTimeout ?? 10000),
|
|
84
|
+
log: merged.log ?? consoleLog
|
|
85
|
+
});
|
|
86
|
+
const shouldCompare = !!(merged.compare || merged.updateBaseline);
|
|
87
|
+
const compareFailed = shouldCompare && compareSnapshots({
|
|
88
|
+
saved: result.saved,
|
|
89
|
+
baselineDir: path.resolve(baseDir, merged.baseline ?? "webview-preview-baseline"),
|
|
90
|
+
updateBaseline: !!merged.updateBaseline,
|
|
91
|
+
tolerance: Number(merged.compareTolerance ?? 2),
|
|
92
|
+
maxDiffPixels: Number(merged.maxDiffPixels ?? 0),
|
|
93
|
+
log: merged.log ?? consoleLog
|
|
94
|
+
});
|
|
95
|
+
return { engine, url, ...result, failed: result.failed || compareFailed }
|
|
96
|
+
} finally {
|
|
97
|
+
stopServer();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export const defineConfig = config => config;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { ANY_FUNCTION, toSource } from "#src/page/source.mjs";
|
|
2
|
+
|
|
3
|
+
const GLOBAL_PATH = /^(window\.)?[A-Za-z_$][\w$]*(\.[A-Za-z_$][\w$]*)*$/;
|
|
4
|
+
const LOCALE = /^[a-z]{2,3}(-[A-Za-z\d]{2,8})*$/i;
|
|
5
|
+
|
|
6
|
+
export const globalsScript = globals => {
|
|
7
|
+
const entries = Object.entries(globals).map(([path, value]) => {
|
|
8
|
+
if (!GLOBAL_PATH.test(path)) throw new Error(`globals: "${path}" must be a dotted path like window.api or window.go.main`);
|
|
9
|
+
const name = path.startsWith("window.") ? path : `window.${path}`;
|
|
10
|
+
return `assign(${JSON.stringify(name)}, ${toSource(value, name)});`;
|
|
11
|
+
});
|
|
12
|
+
return `(() => {
|
|
13
|
+
const __webviewPreviewAny = ${ANY_FUNCTION};
|
|
14
|
+
const assign = (path, value) => {
|
|
15
|
+
const parts = path.split(".").slice(1);
|
|
16
|
+
const last = parts.pop();
|
|
17
|
+
let target = window;
|
|
18
|
+
for (const part of parts) {
|
|
19
|
+
if (target[part] === undefined || target[part] === null) target[part] = {};
|
|
20
|
+
target = target[part];
|
|
21
|
+
}
|
|
22
|
+
target[last] = value;
|
|
23
|
+
}
|
|
24
|
+
${entries.join("\n\t")}
|
|
25
|
+
})();`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const checkLocale = locale => {
|
|
29
|
+
if (!LOCALE.test(locale)) throw new Error(`locale must look like en-US or ru, got "${locale}"`);
|
|
30
|
+
return locale;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const localeScript = locale => `(() => {
|
|
34
|
+
const language = ${JSON.stringify(checkLocale(locale))};
|
|
35
|
+
Object.defineProperty(Navigator.prototype, "language", { get: () => language, configurable: true });
|
|
36
|
+
Object.defineProperty(Navigator.prototype, "languages", { get: () => [language], configurable: true });
|
|
37
|
+
})();`;
|
|
@@ -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
|
+
export const wrapInit = ({ 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) => wrapInit(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
|
+
}
|