webview-preview 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +40 -1
- package/README.md +103 -3
- package/bin/webview-preview.mjs +53 -4
- package/package.json +4 -1
- package/src/core/config.mjs +5 -4
- package/src/core/steps.mjs +75 -2
- package/src/core/watch.mjs +31 -0
- package/src/engines/playwright.mjs +30 -3
- package/src/engines/webkitgtk.mjs +15 -5
- package/src/engines/webkitgtk.py +89 -11
- package/src/index.d.ts +22 -1
- package/src/index.mjs +157 -32
- package/src/page/console.mjs +84 -0
- package/src/page/mock.mjs +3 -2
- package/src/page/network.mjs +79 -0
- package/src/snapshots/compare.mjs +10 -2
- package/src/snapshots/film.mjs +51 -0
- package/src/snapshots/report.mjs +71 -0
|
@@ -16,15 +16,26 @@ const loadPlaywright = async () => {
|
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
export const runPlaywright = async ({
|
|
19
|
+
export const runPlaywright = async ({
|
|
20
|
+
engine, url, steps, init = [], size, locale,
|
|
21
|
+
colorScheme, isMotionReduced = false, out, prefix, scale,
|
|
22
|
+
snapMargin = 8, waitForTimeoutMs = WAIT_FOR_TIMEOUT_MS, log
|
|
23
|
+
}) => {
|
|
20
24
|
const playwright = await loadPlaywright();
|
|
21
25
|
const browserType = engine === "webkit" ? playwright.webkit : playwright.chromium;
|
|
22
26
|
const browser = await browserType.launch({ channel: CHANNELS[engine], headless: true });
|
|
23
27
|
const saved = [];
|
|
28
|
+
const films = [];
|
|
24
29
|
let failed = false;
|
|
25
30
|
|
|
26
31
|
try {
|
|
27
|
-
const page = await browser.newPage({
|
|
32
|
+
const page = await browser.newPage({
|
|
33
|
+
viewport: parseSize(size),
|
|
34
|
+
deviceScaleFactor: scale ?? 1,
|
|
35
|
+
locale,
|
|
36
|
+
colorScheme,
|
|
37
|
+
reducedMotion: isMotionReduced ? "reduce" : undefined
|
|
38
|
+
});
|
|
28
39
|
page.setDefaultTimeout(TIMEOUT_MS);
|
|
29
40
|
for (const content of init) await page.addInitScript({ content });
|
|
30
41
|
await page.goto(url, { waitUntil: "load" });
|
|
@@ -45,6 +56,20 @@ export const runPlaywright = async ({ engine, url, steps, init = [], size, local
|
|
|
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, local
|
|
|
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, local
|
|
|
100
127
|
await browser.close();
|
|
101
128
|
}
|
|
102
129
|
|
|
103
|
-
return { saved, failed }
|
|
130
|
+
return { saved, failed, films }
|
|
104
131
|
}
|
|
@@ -9,7 +9,12 @@ const PROBE = "import gi; gi.require_version('Gtk', '3.0'); gi.require_version('
|
|
|
9
9
|
export const isWebkitGtkAvailable = () => process.platform === "linux"
|
|
10
10
|
&& spawnSync("python3", ["-c", PROBE], { stdio: "ignore" }).status === 0;
|
|
11
11
|
|
|
12
|
-
export const runWebkitGtk = ({
|
|
12
|
+
export const runWebkitGtk = ({
|
|
13
|
+
url, steps, init = [], size, colorScheme,
|
|
14
|
+
isMotionReduced = false, out, prefix, snapMargin = 8,
|
|
15
|
+
isHeaded = false, isStorageKept = false,
|
|
16
|
+
waitForTimeoutMs = WAIT_FOR_TIMEOUT_MS, timeoutMs = TIMEOUT_MS, log
|
|
17
|
+
}) => new Promise((resolve, reject) => {
|
|
13
18
|
if (!isWebkitGtkAvailable()) {
|
|
14
19
|
reject(new Error("WebKitGTK engine needs Linux with python3-gi and WebKit2 4.1 (gir1.2-webkit2-4.1)"));
|
|
15
20
|
return;
|
|
@@ -18,14 +23,14 @@ export const runWebkitGtk = ({ url, steps, init = [], size, out, prefix, snapMar
|
|
|
18
23
|
const { width, height } = parseSize(size);
|
|
19
24
|
const child = spawn("python3", [
|
|
20
25
|
SCRIPT, url,
|
|
21
|
-
"--steps", JSON.stringify(steps),
|
|
22
|
-
"--init", JSON.stringify(init),
|
|
23
26
|
"--init-errors", INIT_ERRORS_EXPRESSION,
|
|
24
27
|
"--out", out,
|
|
25
28
|
"--prefix", prefix,
|
|
26
29
|
"--width", String(width),
|
|
27
30
|
"--height", String(height),
|
|
28
31
|
"--snap-margin", String(snapMargin),
|
|
32
|
+
...(colorScheme ? ["--color-scheme", colorScheme] : []),
|
|
33
|
+
...(isMotionReduced ? ["--reduced-motion"] : []),
|
|
29
34
|
...(isHeaded ? ["--headed"] : []),
|
|
30
35
|
...(isStorageKept ? ["--keep-storage"] : []),
|
|
31
36
|
"--wait-for-timeout-ms", String(waitForTimeoutMs),
|
|
@@ -34,11 +39,15 @@ export const runWebkitGtk = ({ url, steps, init = [], size, out, prefix, snapMar
|
|
|
34
39
|
"--js-settle-ms", String(JS_SETTLE_MS),
|
|
35
40
|
"--timeout-ms", String(timeoutMs)
|
|
36
41
|
], {
|
|
37
|
-
stdio: ["
|
|
42
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
38
43
|
env: { PYTHONUNBUFFERED: "1", ...process.env }
|
|
39
44
|
});
|
|
40
45
|
|
|
46
|
+
child.stdin.on("error", () => undefined);
|
|
47
|
+
child.stdin.end(JSON.stringify({ steps, init }));
|
|
48
|
+
|
|
41
49
|
const saved = [];
|
|
50
|
+
const films = [];
|
|
42
51
|
let buffer = "";
|
|
43
52
|
child.stdout.setEncoding("utf8");
|
|
44
53
|
child.stdout.on("data", chunk => {
|
|
@@ -51,6 +60,7 @@ export const runWebkitGtk = ({ url, steps, init = [], size, out, prefix, snapMar
|
|
|
51
60
|
const kind = line.slice(0, tab);
|
|
52
61
|
const value = JSON.parse(line.slice(tab + 1));
|
|
53
62
|
if (kind === "saved") saved.push(value);
|
|
63
|
+
else if (kind === "film") films.push(value);
|
|
54
64
|
else if (kind === "print") log.info(value);
|
|
55
65
|
else if (kind === "error") log.error(value);
|
|
56
66
|
else if (kind === "init-errors") reportInitErrors(value, log);
|
|
@@ -63,5 +73,5 @@ export const runWebkitGtk = ({ url, steps, init = [], size, out, prefix, snapMar
|
|
|
63
73
|
}
|
|
64
74
|
});
|
|
65
75
|
child.once("error", reject);
|
|
66
|
-
child.once("exit", code => resolve({ saved, failed: code !== 0 }));
|
|
76
|
+
child.once("exit", code => resolve({ saved, failed: code !== 0, films }));
|
|
67
77
|
});
|
package/src/engines/webkitgtk.py
CHANGED
|
@@ -14,6 +14,11 @@ gi.require_version("WebKit2", "4.1")
|
|
|
14
14
|
from gi.repository import Gdk, GLib, Gtk, WebKit2
|
|
15
15
|
|
|
16
16
|
INPUT_SETTLE_MS = 50
|
|
17
|
+
UNSUPPORTED_RESULT = "Unsupported result type"
|
|
18
|
+
KEY_POLL_MS = 20
|
|
19
|
+
KEY_WAIT_MS = 2000
|
|
20
|
+
KEYUPS = "window.__webviewPreviewKeyups || 0"
|
|
21
|
+
KEYUP_COUNTER = "addEventListener('keyup', () => { window.__webviewPreviewKeyups = (window.__webviewPreviewKeyups || 0) + 1 }, true);"
|
|
17
22
|
GDK_KEY_NAMES = {
|
|
18
23
|
"Tab": "Tab", "Enter": "Return", "Escape": "Escape", "Backspace": "BackSpace", "Delete": "Delete",
|
|
19
24
|
"Insert": "Insert", "Space": "space", "Home": "Home", "End": "End", "PageUp": "Page_Up", "PageDown": "Page_Down",
|
|
@@ -37,14 +42,14 @@ def emit(kind, value):
|
|
|
37
42
|
def parse_args():
|
|
38
43
|
parser = argparse.ArgumentParser()
|
|
39
44
|
parser.add_argument("url")
|
|
40
|
-
parser.add_argument("--steps", required=True)
|
|
41
|
-
parser.add_argument("--init", required=True)
|
|
42
45
|
parser.add_argument("--init-errors", required=True)
|
|
43
46
|
parser.add_argument("--out", required=True)
|
|
44
47
|
parser.add_argument("--prefix", required=True)
|
|
45
48
|
parser.add_argument("--width", type=int, required=True)
|
|
46
49
|
parser.add_argument("--height", type=int, required=True)
|
|
47
50
|
parser.add_argument("--snap-margin", type=int, required=True)
|
|
51
|
+
parser.add_argument("--color-scheme", choices=("light", "dark"))
|
|
52
|
+
parser.add_argument("--reduced-motion", action="store_true")
|
|
48
53
|
parser.add_argument("--headed", action="store_true")
|
|
49
54
|
parser.add_argument("--keep-storage", action="store_true")
|
|
50
55
|
parser.add_argument("--wait-for-timeout-ms", type=int, required=True)
|
|
@@ -69,17 +74,28 @@ def keyval_for(key):
|
|
|
69
74
|
return Gdk.unicode_to_keyval(ord(key))
|
|
70
75
|
|
|
71
76
|
|
|
77
|
+
def apply_media(args):
|
|
78
|
+
settings = Gtk.Settings.get_default()
|
|
79
|
+
if args.color_scheme:
|
|
80
|
+
settings.set_property("gtk-theme-name", "Adwaita")
|
|
81
|
+
settings.set_property("gtk-application-prefer-dark-theme", args.color_scheme == "dark")
|
|
82
|
+
if args.reduced_motion:
|
|
83
|
+
settings.set_property("gtk-enable-animations", False)
|
|
84
|
+
|
|
85
|
+
|
|
72
86
|
def main():
|
|
73
87
|
args = parse_args()
|
|
74
|
-
|
|
75
|
-
|
|
88
|
+
apply_media(args)
|
|
89
|
+
payload = json.loads(sys.stdin.read())
|
|
90
|
+
steps = payload["steps"]
|
|
91
|
+
init_scripts = payload["init"]
|
|
76
92
|
os.makedirs(args.out, exist_ok=True)
|
|
77
93
|
|
|
78
94
|
window = Gtk.Window(title="webview-preview") if args.headed else Gtk.OffscreenWindow()
|
|
79
95
|
window.set_default_size(args.width, args.height)
|
|
80
96
|
window.set_size_request(args.width, args.height)
|
|
81
97
|
manager = WebKit2.UserContentManager()
|
|
82
|
-
for source in init_scripts:
|
|
98
|
+
for source in [KEYUP_COUNTER, *init_scripts]:
|
|
83
99
|
manager.add_script(WebKit2.UserScript.new(
|
|
84
100
|
source,
|
|
85
101
|
WebKit2.UserContentInjectedFrames.TOP_FRAME,
|
|
@@ -117,7 +133,9 @@ def main():
|
|
|
117
133
|
event.focus_change.send_event = 1
|
|
118
134
|
event.focus_change.in_ = 1
|
|
119
135
|
event.set_device(seat.get_keyboard())
|
|
136
|
+
display.error_trap_push()
|
|
120
137
|
deliver(event)
|
|
138
|
+
display.error_trap_pop_ignored()
|
|
121
139
|
view.grab_focus()
|
|
122
140
|
|
|
123
141
|
def key_event(kind, keyval, mask):
|
|
@@ -144,6 +162,29 @@ def main():
|
|
|
144
162
|
key_event(Gdk.EventType.KEY_RELEASE, Gdk.keyval_from_name(MODIFIER_KEYS[modifier]), mask)
|
|
145
163
|
mask &= ~MODIFIER_MASKS[modifier]
|
|
146
164
|
|
|
165
|
+
def press_all(presses):
|
|
166
|
+
def count(text):
|
|
167
|
+
return int(text) if text and text.isdigit() else 0
|
|
168
|
+
|
|
169
|
+
def send(text):
|
|
170
|
+
expected = count(text) + sum(1 + len(modifiers) for _, modifiers in presses)
|
|
171
|
+
for keyval, modifiers in presses:
|
|
172
|
+
press(keyval, modifiers)
|
|
173
|
+
started = GLib.get_monotonic_time()
|
|
174
|
+
|
|
175
|
+
def check():
|
|
176
|
+
def on_value(text):
|
|
177
|
+
if count(text) >= expected or (GLib.get_monotonic_time() - started) / 1000 >= KEY_WAIT_MS:
|
|
178
|
+
GLib.timeout_add(INPUT_SETTLE_MS, run_step)
|
|
179
|
+
else:
|
|
180
|
+
GLib.timeout_add(KEY_POLL_MS, lambda: (check(), False)[1])
|
|
181
|
+
|
|
182
|
+
evaluate(KEYUPS, on_value, lambda _: GLib.timeout_add(INPUT_SETTLE_MS, run_step))
|
|
183
|
+
|
|
184
|
+
check()
|
|
185
|
+
|
|
186
|
+
evaluate(KEYUPS, send, lambda _: send("0"))
|
|
187
|
+
|
|
147
188
|
def mouse(kind, x, y):
|
|
148
189
|
event = Gdk.Event.new(kind)
|
|
149
190
|
fields = event.motion if kind == Gdk.EventType.MOTION_NOTIFY else event.button
|
|
@@ -231,6 +272,44 @@ def main():
|
|
|
231
272
|
|
|
232
273
|
evaluate(step["check"], on_value, lambda error: (step_failed(f"waitfor:{step['value']} failed: {error}"), next_step()))
|
|
233
274
|
|
|
275
|
+
def film(step):
|
|
276
|
+
frames = []
|
|
277
|
+
started = GLib.get_monotonic_time() / 1000
|
|
278
|
+
interval = step["durationMs"] / (step["frames"] - 1)
|
|
279
|
+
|
|
280
|
+
def store_rect(frame, text):
|
|
281
|
+
frame["rect"] = json.loads(text) if text and text != "null" else None
|
|
282
|
+
|
|
283
|
+
def finish_film():
|
|
284
|
+
width, height = view.get_allocated_width(), view.get_allocated_height()
|
|
285
|
+
for index, frame in enumerate(frames):
|
|
286
|
+
path = os.path.join(args.out, f"{args.prefix}{step['value']}.frame-{index}.png")
|
|
287
|
+
frame.pop("pixbuf").savev(path, "png", [], [])
|
|
288
|
+
frame["file"] = path
|
|
289
|
+
emit("film", {
|
|
290
|
+
"name": step["value"],
|
|
291
|
+
"file": os.path.join(args.out, f"{args.prefix}{step['value']}.png"),
|
|
292
|
+
"scale": full_pixbuf().get_width() / width,
|
|
293
|
+
"viewport": {"width": width, "height": height},
|
|
294
|
+
"frames": frames
|
|
295
|
+
})
|
|
296
|
+
next_step()
|
|
297
|
+
|
|
298
|
+
def capture():
|
|
299
|
+
now = GLib.get_monotonic_time() / 1000
|
|
300
|
+
frame = {"pixbuf": full_pixbuf(), "time": round(now - started), "rect": None}
|
|
301
|
+
frames.append(frame)
|
|
302
|
+
if step.get("rect"):
|
|
303
|
+
evaluate(step["rect"], lambda text: store_rect(frame, text), lambda _: None)
|
|
304
|
+
if len(frames) < step["frames"]:
|
|
305
|
+
due = started + len(frames) * interval
|
|
306
|
+
GLib.timeout_add(max(0, int(due - GLib.get_monotonic_time() / 1000)), capture)
|
|
307
|
+
else:
|
|
308
|
+
evaluate("0", lambda _: finish_film(), lambda _: finish_film())
|
|
309
|
+
return False
|
|
310
|
+
|
|
311
|
+
capture()
|
|
312
|
+
|
|
234
313
|
def next_step():
|
|
235
314
|
GLib.idle_add(run_step)
|
|
236
315
|
|
|
@@ -247,8 +326,10 @@ def main():
|
|
|
247
326
|
else:
|
|
248
327
|
save(value, full_pixbuf())
|
|
249
328
|
next_step()
|
|
329
|
+
elif kind == "film":
|
|
330
|
+
film(step)
|
|
250
331
|
elif kind in ("js", "jsnow"):
|
|
251
|
-
evaluate(value, lambda _: None, lambda error: emit("error", f"js failed: {error}"))
|
|
332
|
+
evaluate(value, lambda _: None, lambda error: None if UNSUPPORTED_RESULT in error else emit("error", f"js failed: {error}"))
|
|
252
333
|
if kind == "js":
|
|
253
334
|
GLib.timeout_add(args.js_settle_ms, run_step)
|
|
254
335
|
else:
|
|
@@ -271,12 +352,9 @@ def main():
|
|
|
271
352
|
elif kind in ("hover", "click"):
|
|
272
353
|
with_rect(step, lambda rect: point_at(step, rect))
|
|
273
354
|
elif kind == "key":
|
|
274
|
-
|
|
275
|
-
GLib.timeout_add(INPUT_SETTLE_MS, run_step)
|
|
355
|
+
press_all([(keyval_for(step["key"]), step["modifiers"])])
|
|
276
356
|
elif kind == "type":
|
|
277
|
-
for character in value
|
|
278
|
-
press(Gdk.unicode_to_keyval(ord(character)))
|
|
279
|
-
GLib.timeout_add(INPUT_SETTLE_MS, run_step)
|
|
357
|
+
press_all([(Gdk.unicode_to_keyval(ord(character)), ()) for character in value])
|
|
280
358
|
return False
|
|
281
359
|
|
|
282
360
|
def start_steps():
|
package/src/index.d.ts
CHANGED
|
@@ -6,6 +6,13 @@ export type Step =
|
|
|
6
6
|
| `wait:${number}`
|
|
7
7
|
| `waitfor:${string}`
|
|
8
8
|
| `measure:${string}`
|
|
9
|
+
| `assert:${string}`
|
|
10
|
+
| "focusorder"
|
|
11
|
+
| `focusorder:${number}`
|
|
12
|
+
| `film:${string}:${number}`
|
|
13
|
+
| "offline"
|
|
14
|
+
| "online"
|
|
15
|
+
| `latency:${number}`
|
|
9
16
|
| `snap:${string}`
|
|
10
17
|
| `print:${string}`
|
|
11
18
|
| `resize:${number}x${number}`
|
|
@@ -27,7 +34,13 @@ export interface PreviewConfig {
|
|
|
27
34
|
baseUrl?: string;
|
|
28
35
|
server?: ServerOptions;
|
|
29
36
|
engine?: Engine;
|
|
30
|
-
size?:
|
|
37
|
+
size?: Size | Size[] | string;
|
|
38
|
+
colorScheme?: "light" | "dark";
|
|
39
|
+
reducedMotion?: boolean;
|
|
40
|
+
offline?: boolean;
|
|
41
|
+
latency?: number;
|
|
42
|
+
watch?: string | string[];
|
|
43
|
+
report?: boolean;
|
|
31
44
|
out?: string;
|
|
32
45
|
prefix?: string;
|
|
33
46
|
scale?: number;
|
|
@@ -47,8 +60,11 @@ export interface PreviewConfig {
|
|
|
47
60
|
globals?: Record<string, unknown>;
|
|
48
61
|
locale?: string;
|
|
49
62
|
macros?: Record<string, Step[] | string[]>;
|
|
63
|
+
failOnError?: boolean;
|
|
50
64
|
}
|
|
51
65
|
|
|
66
|
+
export type Size = `${number}x${number}`;
|
|
67
|
+
|
|
52
68
|
export interface TauriStub {
|
|
53
69
|
commands?: Record<string, unknown | ((args: Record<string, unknown>) => unknown)>;
|
|
54
70
|
label?: string;
|
|
@@ -80,7 +96,11 @@ export interface PreviewOptions extends PreviewConfig {
|
|
|
80
96
|
export interface PreviewResult {
|
|
81
97
|
engine: Exclude<Engine, "auto">;
|
|
82
98
|
url: string;
|
|
99
|
+
sizes: string[];
|
|
83
100
|
saved: string[];
|
|
101
|
+
consoleErrors: number;
|
|
102
|
+
failedAsserts: number;
|
|
103
|
+
report?: string;
|
|
84
104
|
failed: boolean;
|
|
85
105
|
}
|
|
86
106
|
|
|
@@ -88,6 +108,7 @@ export declare const ENGINES: Engine[];
|
|
|
88
108
|
export declare const REQUESTS_KEY: "__webviewPreviewRequests";
|
|
89
109
|
export declare function resolveUrl(target: string, baseUrl?: string): string;
|
|
90
110
|
export declare function preview(options?: PreviewOptions): Promise<PreviewResult>;
|
|
111
|
+
export declare function watchPreview(options?: PreviewOptions, onRun?: (result: PreviewResult) => void): Promise<() => void>;
|
|
91
112
|
export declare function defineConfig(config: PreviewConfig): PreviewConfig;
|
|
92
113
|
export declare function anyApi(members?: Record<string, unknown>): AnyApi;
|
|
93
114
|
export interface CleanOptions {
|
package/src/index.mjs
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
|
-
import { mkdirSync } from "node:fs";
|
|
1
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { compareSnapshots } from "#src/snapshots/compare.mjs";
|
|
4
|
+
import { stitchFilm } from "#src/snapshots/film.mjs";
|
|
5
|
+
import { writeReport } from "#src/snapshots/report.mjs";
|
|
4
6
|
import { loadConfig } from "#src/core/config.mjs";
|
|
5
7
|
import { resolveEngine, runEngine } from "#src/engines/index.mjs";
|
|
8
|
+
import { consoleScript, consoleStep, watchOutput } from "#src/page/console.mjs";
|
|
6
9
|
import { checkLocale, globalsScript, localeScript } from "#src/page/globals.mjs";
|
|
7
10
|
import { resolveInit, wrapInit } from "#src/page/init.mjs";
|
|
8
11
|
import { compileMock, mockScript, resolveMockFiles } from "#src/page/mock.mjs";
|
|
9
12
|
import { startServer } from "#src/core/server.mjs";
|
|
13
|
+
import { watchFiles } from "#src/core/watch.mjs";
|
|
10
14
|
import { expandMacros, parseSize, parseSteps } from "#src/core/steps.mjs";
|
|
15
|
+
import { checkLatency, networkScript } from "#src/page/network.mjs";
|
|
11
16
|
import { resolveTauri, tauriScript } from "#src/page/tauri.mjs";
|
|
12
17
|
|
|
13
18
|
export { ENGINES } from "#src/engines/index.mjs";
|
|
@@ -26,7 +31,23 @@ const consoleLog = {
|
|
|
26
31
|
export const resolveUrl = (target, baseUrl) => {
|
|
27
32
|
if (ABSOLUTE_URL.test(target)) return target;
|
|
28
33
|
if (!baseUrl) throw new Error(`"${target}" is not a full URL; set baseUrl to open routes`);
|
|
29
|
-
|
|
34
|
+
const route = target.startsWith("/") ? target : `/${target}`;
|
|
35
|
+
return baseUrl.endsWith("/") ? `${baseUrl}${route.slice(1)}` : `${baseUrl}${route}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const COLOR_SCHEMES = ["light", "dark"];
|
|
39
|
+
|
|
40
|
+
const parseSizes = value => {
|
|
41
|
+
const sizes = [].concat(value).flatMap(item => String(item).split(",")).map(item => item.trim()).filter(Boolean);
|
|
42
|
+
if (!sizes.length) throw new Error("size needs at least one WxH");
|
|
43
|
+
for (const size of sizes) parseSize(size);
|
|
44
|
+
return sizes;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const checkColorScheme = value => {
|
|
48
|
+
if (value === undefined) return undefined;
|
|
49
|
+
if (!COLOR_SCHEMES.includes(value)) throw new Error(`colorScheme must be ${COLOR_SCHEMES.join(" or ")}, got "${value}"`);
|
|
50
|
+
return value;
|
|
30
51
|
}
|
|
31
52
|
|
|
32
53
|
export const preview = async (options = {}) => {
|
|
@@ -38,7 +59,9 @@ export const preview = async (options = {}) => {
|
|
|
38
59
|
const steps = parseSteps(expandMacros(merged.steps.length ? merged.steps : DEFAULTS.steps, macros));
|
|
39
60
|
const out = path.resolve(baseDir, merged.out);
|
|
40
61
|
const url = resolveUrl(merged.target ?? merged.url ?? "/", merged.baseUrl);
|
|
41
|
-
|
|
62
|
+
const sizes = parseSizes(merged.size);
|
|
63
|
+
const snapMargin = Number(merged.snapMargin ?? 8);
|
|
64
|
+
const colorScheme = checkColorScheme(merged.colorScheme);
|
|
42
65
|
const mockFiles = await resolveMockFiles(options.mockFile, process.cwd());
|
|
43
66
|
const hasMock = config.mock !== undefined || options.mock !== undefined || mockFiles.length > 0
|
|
44
67
|
|| !!(options.logRequests ?? config.logRequests);
|
|
@@ -46,11 +69,8 @@ export const preview = async (options = {}) => {
|
|
|
46
69
|
const locale = merged.locale === undefined ? undefined : checkLocale(String(merged.locale));
|
|
47
70
|
const tauri = resolveTauri(config.tauri, options.tauri);
|
|
48
71
|
const globals = { ...config.globals, ...options.globals }
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
tauri && { label: "tauri", source: tauriScript(tauri) },
|
|
52
|
-
Object.keys(globals).length && { label: "globals", source: globalsScript(globals) }
|
|
53
|
-
].filter(Boolean).map(stub => {
|
|
72
|
+
const network = { isOffline: !!merged.offline, latencyMs: checkLatency(merged.latency) }
|
|
73
|
+
const checked = stubs => stubs.filter(Boolean).map(stub => {
|
|
54
74
|
try {
|
|
55
75
|
new Function(stub.source);
|
|
56
76
|
} catch (error) {
|
|
@@ -58,44 +78,149 @@ export const preview = async (options = {}) => {
|
|
|
58
78
|
}
|
|
59
79
|
return wrapInit(stub);
|
|
60
80
|
});
|
|
61
|
-
const init =
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
81
|
+
const init = [
|
|
82
|
+
...checked([
|
|
83
|
+
{ label: "console", source: consoleScript() },
|
|
84
|
+
locale && { label: "locale", source: localeScript(locale) },
|
|
85
|
+
tauri && { label: "tauri", source: tauriScript(tauri) },
|
|
86
|
+
Object.keys(globals).length && { label: "globals", source: globalsScript(globals) }
|
|
87
|
+
]),
|
|
88
|
+
...checked([hasMock && { label: "mock", source: mockScript(mockRules) }]),
|
|
89
|
+
...checked([{ label: "network", source: networkScript(network) }]),
|
|
90
|
+
...resolveInit([
|
|
91
|
+
{ entries: config.init, baseDir },
|
|
92
|
+
{ entries: options.init, baseDir: process.cwd() },
|
|
93
|
+
{ entries: options.initFile && [].concat(options.initFile).map(file => ({ file })), baseDir: process.cwd() }
|
|
94
|
+
], baseDir)
|
|
95
|
+
];
|
|
66
96
|
mkdirSync(out, { recursive: true });
|
|
67
97
|
|
|
98
|
+
const baseLog = merged.log ?? consoleLog;
|
|
99
|
+
const lines = [];
|
|
100
|
+
const log = {
|
|
101
|
+
info: message => {
|
|
102
|
+
lines.push(["info", message]);
|
|
103
|
+
baseLog.info(message);
|
|
104
|
+
},
|
|
105
|
+
error: message => {
|
|
106
|
+
lines.push(["error", message]);
|
|
107
|
+
baseLog.error(message);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const output = watchOutput(log);
|
|
68
111
|
const server = merged.server && { cwd: baseDir, ...merged.server }
|
|
69
112
|
const stopServer = await startServer(server);
|
|
70
113
|
try {
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
114
|
+
const prefix = merged.prefix ?? `${engine}-`;
|
|
115
|
+
const result = { saved: [], failed: false }
|
|
116
|
+
for (const size of sizes) {
|
|
117
|
+
if (sizes.length > 1) output.log.info(`size ${size}`);
|
|
118
|
+
const run = await runEngine(engine, {
|
|
119
|
+
url,
|
|
120
|
+
steps: [...steps, consoleStep],
|
|
121
|
+
init,
|
|
122
|
+
size,
|
|
123
|
+
locale,
|
|
124
|
+
colorScheme,
|
|
125
|
+
isMotionReduced: !!merged.reducedMotion,
|
|
126
|
+
out,
|
|
127
|
+
prefix: sizes.length > 1 ? `${prefix}${size}-` : prefix,
|
|
128
|
+
scale: merged.scale === undefined ? undefined : Number(merged.scale),
|
|
129
|
+
snapMargin,
|
|
130
|
+
isHeaded: !!merged.headed,
|
|
131
|
+
isStorageKept: !!merged.keepStorage,
|
|
132
|
+
waitForTimeoutMs: Number(merged.waitTimeout ?? 10000),
|
|
133
|
+
log: output.log
|
|
134
|
+
});
|
|
135
|
+
result.saved.push(...run.saved);
|
|
136
|
+
for (const film of run.films ?? []) {
|
|
137
|
+
try {
|
|
138
|
+
output.log.info(stitchFilm(film, snapMargin));
|
|
139
|
+
result.saved.push(film.file);
|
|
140
|
+
} catch (error) {
|
|
141
|
+
output.log.error(`film:${film.name} failed: ${error.message}`);
|
|
142
|
+
result.failed = true;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
result.failed ||= run.failed;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const { consoleErrors, failedAsserts } = output.report();
|
|
86
149
|
const shouldCompare = !!(merged.compare || merged.updateBaseline);
|
|
87
|
-
const
|
|
150
|
+
const compared = shouldCompare ? compareSnapshots({
|
|
88
151
|
saved: result.saved,
|
|
89
152
|
baselineDir: path.resolve(baseDir, merged.baseline ?? "webview-preview-baseline"),
|
|
90
153
|
updateBaseline: !!merged.updateBaseline,
|
|
91
154
|
tolerance: Number(merged.compareTolerance ?? 2),
|
|
92
155
|
maxDiffPixels: Number(merged.maxDiffPixels ?? 0),
|
|
93
|
-
log
|
|
94
|
-
})
|
|
95
|
-
|
|
156
|
+
log
|
|
157
|
+
}) : { failed: false, results: [] }
|
|
158
|
+
const consoleFailed = !!merged.failOnError && consoleErrors > 0;
|
|
159
|
+
const failed = result.failed || compared.failed || consoleFailed || failedAsserts > 0;
|
|
160
|
+
const report = merged.report
|
|
161
|
+
? writeReport({ out, url, engine, sizes, lines, saved: result.saved, compared: compared.results, failed })
|
|
162
|
+
: undefined;
|
|
163
|
+
if (report) baseLog.info(`report → ${report}`);
|
|
164
|
+
return { engine, url, sizes, ...result, consoleErrors, failedAsserts, report, failed }
|
|
96
165
|
} finally {
|
|
97
166
|
stopServer();
|
|
98
167
|
}
|
|
99
168
|
}
|
|
100
169
|
|
|
170
|
+
export const watchPreview = async (options = {}, onRun = () => {}) => {
|
|
171
|
+
const config = options.config === false ? {} : await loadConfig(options.config);
|
|
172
|
+
const baseDir = config.configDir ?? process.cwd();
|
|
173
|
+
const log = options.log ?? consoleLog;
|
|
174
|
+
const entries = [].concat(options.watch ?? config.watch ?? (existsSync(path.join(baseDir, "src")) ? "src" : "."));
|
|
175
|
+
const folders = entries.map(entry => path.resolve(baseDir, entry));
|
|
176
|
+
const isConfigWatched = !config.configFile || folders.some(folder => config.configFile === folder || config.configFile.startsWith(folder + path.sep));
|
|
177
|
+
const targets = isConfigWatched ? folders : [...folders, config.configFile];
|
|
178
|
+
const ignore = [
|
|
179
|
+
path.resolve(baseDir, options.out ?? config.out ?? DEFAULTS.out),
|
|
180
|
+
path.resolve(baseDir, options.baseline ?? config.baseline ?? "webview-preview-baseline")
|
|
181
|
+
];
|
|
182
|
+
const server = options.server ?? config.server;
|
|
183
|
+
const stopServer = await startServer(server && { cwd: baseDir, ...server });
|
|
184
|
+
let isRunning = false;
|
|
185
|
+
let next = null;
|
|
186
|
+
|
|
187
|
+
const run = async reason => {
|
|
188
|
+
if (isRunning) {
|
|
189
|
+
next = reason;
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
isRunning = true;
|
|
193
|
+
log.info(reason);
|
|
194
|
+
try {
|
|
195
|
+
onRun(await preview(options));
|
|
196
|
+
} catch (error) {
|
|
197
|
+
log.error(`webview-preview: ${error.message}`);
|
|
198
|
+
}
|
|
199
|
+
isRunning = false;
|
|
200
|
+
if (next) {
|
|
201
|
+
const queued = next;
|
|
202
|
+
next = null;
|
|
203
|
+
await run(queued);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
let stopWatching;
|
|
208
|
+
try {
|
|
209
|
+
stopWatching = watchFiles({
|
|
210
|
+
targets,
|
|
211
|
+
ignore,
|
|
212
|
+
onChange: file => run(`\nchanged ${path.relative(baseDir, file) || file}, running again`)
|
|
213
|
+
});
|
|
214
|
+
} catch (error) {
|
|
215
|
+
stopServer();
|
|
216
|
+
throw error;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
await run(`watching ${targets.map(target => path.relative(baseDir, target) || ".").join(", ")}; Ctrl+C to stop`);
|
|
220
|
+
return () => {
|
|
221
|
+
stopWatching();
|
|
222
|
+
stopServer();
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
101
226
|
export const defineConfig = config => config;
|