webview-preview 1.0.0 → 1.1.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.
@@ -16,15 +16,26 @@ const loadPlaywright = async () => {
16
16
  }
17
17
  }
18
18
 
19
- export const runPlaywright = async ({ engine, url, steps, init = [], size, locale, out, prefix, scale, snapMargin = 8, waitForTimeoutMs = WAIT_FOR_TIMEOUT_MS, log }) => {
19
+ export const runPlaywright = async ({
20
+ engine, url, steps, init = [], size, locale,
21
+ colorScheme, isMotionReduced = false, out, prefix, scale,
22
+ snapMargin = 8, waitForTimeoutMs = WAIT_FOR_TIMEOUT_MS, log
23
+ }) => {
20
24
  const playwright = await loadPlaywright();
21
25
  const browserType = engine === "webkit" ? playwright.webkit : playwright.chromium;
22
26
  const browser = await browserType.launch({ channel: CHANNELS[engine], headless: true });
23
27
  const saved = [];
28
+ const films = [];
24
29
  let failed = false;
25
30
 
26
31
  try {
27
- const page = await browser.newPage({ viewport: parseSize(size), deviceScaleFactor: scale ?? 1, locale });
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 = ({ url, steps, init = [], size, out, prefix, snapMargin = 8, isHeaded = false, isStorageKept = false, waitForTimeoutMs = WAIT_FOR_TIMEOUT_MS, timeoutMs = TIMEOUT_MS, log }) => new Promise((resolve, reject) => {
12
+ export const runWebkitGtk = ({
13
+ url, steps, init = [], size, colorScheme,
14
+ isMotionReduced = false, out, prefix, snapMargin = 8,
15
+ isHeaded = false, isStorageKept = false,
16
+ waitForTimeoutMs = WAIT_FOR_TIMEOUT_MS, timeoutMs = TIMEOUT_MS, log
17
+ }) => new Promise((resolve, reject) => {
13
18
  if (!isWebkitGtkAvailable()) {
14
19
  reject(new Error("WebKitGTK engine needs Linux with python3-gi and WebKit2 4.1 (gir1.2-webkit2-4.1)"));
15
20
  return;
@@ -18,14 +23,14 @@ export const runWebkitGtk = ({ url, steps, init = [], size, out, prefix, snapMar
18
23
  const { width, height } = parseSize(size);
19
24
  const child = spawn("python3", [
20
25
  SCRIPT, url,
21
- "--steps", JSON.stringify(steps),
22
- "--init", JSON.stringify(init),
23
26
  "--init-errors", INIT_ERRORS_EXPRESSION,
24
27
  "--out", out,
25
28
  "--prefix", prefix,
26
29
  "--width", String(width),
27
30
  "--height", String(height),
28
31
  "--snap-margin", String(snapMargin),
32
+ ...(colorScheme ? ["--color-scheme", colorScheme] : []),
33
+ ...(isMotionReduced ? ["--reduced-motion"] : []),
29
34
  ...(isHeaded ? ["--headed"] : []),
30
35
  ...(isStorageKept ? ["--keep-storage"] : []),
31
36
  "--wait-for-timeout-ms", String(waitForTimeoutMs),
@@ -34,11 +39,15 @@ export const runWebkitGtk = ({ url, steps, init = [], size, out, prefix, snapMar
34
39
  "--js-settle-ms", String(JS_SETTLE_MS),
35
40
  "--timeout-ms", String(timeoutMs)
36
41
  ], {
37
- stdio: ["ignore", "pipe", "pipe"],
42
+ stdio: ["pipe", "pipe", "pipe"],
38
43
  env: { PYTHONUNBUFFERED: "1", ...process.env }
39
44
  });
40
45
 
46
+ child.stdin.on("error", () => undefined);
47
+ child.stdin.end(JSON.stringify({ steps, init }));
48
+
41
49
  const saved = [];
50
+ const films = [];
42
51
  let buffer = "";
43
52
  child.stdout.setEncoding("utf8");
44
53
  child.stdout.on("data", chunk => {
@@ -51,6 +60,7 @@ export const runWebkitGtk = ({ url, steps, init = [], size, out, prefix, snapMar
51
60
  const kind = line.slice(0, tab);
52
61
  const value = JSON.parse(line.slice(tab + 1));
53
62
  if (kind === "saved") saved.push(value);
63
+ else if (kind === "film") films.push(value);
54
64
  else if (kind === "print") log.info(value);
55
65
  else if (kind === "error") log.error(value);
56
66
  else if (kind === "init-errors") reportInitErrors(value, log);
@@ -63,5 +73,5 @@ export const runWebkitGtk = ({ url, steps, init = [], size, out, prefix, snapMar
63
73
  }
64
74
  });
65
75
  child.once("error", reject);
66
- child.once("exit", code => resolve({ saved, failed: code !== 0 }));
76
+ child.once("exit", code => resolve({ saved, failed: code !== 0, films }));
67
77
  });
@@ -14,6 +14,11 @@ gi.require_version("WebKit2", "4.1")
14
14
  from gi.repository import Gdk, GLib, Gtk, WebKit2
15
15
 
16
16
  INPUT_SETTLE_MS = 50
17
+ UNSUPPORTED_RESULT = "Unsupported result type"
18
+ KEY_POLL_MS = 20
19
+ KEY_WAIT_MS = 2000
20
+ KEYUPS = "window.__webviewPreviewKeyups || 0"
21
+ KEYUP_COUNTER = "addEventListener('keyup', () => { window.__webviewPreviewKeyups = (window.__webviewPreviewKeyups || 0) + 1 }, true);"
17
22
  GDK_KEY_NAMES = {
18
23
  "Tab": "Tab", "Enter": "Return", "Escape": "Escape", "Backspace": "BackSpace", "Delete": "Delete",
19
24
  "Insert": "Insert", "Space": "space", "Home": "Home", "End": "End", "PageUp": "Page_Up", "PageDown": "Page_Down",
@@ -37,14 +42,14 @@ def emit(kind, value):
37
42
  def parse_args():
38
43
  parser = argparse.ArgumentParser()
39
44
  parser.add_argument("url")
40
- parser.add_argument("--steps", required=True)
41
- parser.add_argument("--init", required=True)
42
45
  parser.add_argument("--init-errors", required=True)
43
46
  parser.add_argument("--out", required=True)
44
47
  parser.add_argument("--prefix", required=True)
45
48
  parser.add_argument("--width", type=int, required=True)
46
49
  parser.add_argument("--height", type=int, required=True)
47
50
  parser.add_argument("--snap-margin", type=int, required=True)
51
+ parser.add_argument("--color-scheme", choices=("light", "dark"))
52
+ parser.add_argument("--reduced-motion", action="store_true")
48
53
  parser.add_argument("--headed", action="store_true")
49
54
  parser.add_argument("--keep-storage", action="store_true")
50
55
  parser.add_argument("--wait-for-timeout-ms", type=int, required=True)
@@ -69,17 +74,28 @@ def keyval_for(key):
69
74
  return Gdk.unicode_to_keyval(ord(key))
70
75
 
71
76
 
77
+ def apply_media(args):
78
+ settings = Gtk.Settings.get_default()
79
+ if args.color_scheme:
80
+ settings.set_property("gtk-theme-name", "Adwaita")
81
+ settings.set_property("gtk-application-prefer-dark-theme", args.color_scheme == "dark")
82
+ if args.reduced_motion:
83
+ settings.set_property("gtk-enable-animations", False)
84
+
85
+
72
86
  def main():
73
87
  args = parse_args()
74
- steps = json.loads(args.steps)
75
- init_scripts = json.loads(args.init)
88
+ apply_media(args)
89
+ payload = json.loads(sys.stdin.read())
90
+ steps = payload["steps"]
91
+ init_scripts = payload["init"]
76
92
  os.makedirs(args.out, exist_ok=True)
77
93
 
78
94
  window = Gtk.Window(title="webview-preview") if args.headed else Gtk.OffscreenWindow()
79
95
  window.set_default_size(args.width, args.height)
80
96
  window.set_size_request(args.width, args.height)
81
97
  manager = WebKit2.UserContentManager()
82
- for source in init_scripts:
98
+ for source in [KEYUP_COUNTER, *init_scripts]:
83
99
  manager.add_script(WebKit2.UserScript.new(
84
100
  source,
85
101
  WebKit2.UserContentInjectedFrames.TOP_FRAME,
@@ -117,7 +133,9 @@ def main():
117
133
  event.focus_change.send_event = 1
118
134
  event.focus_change.in_ = 1
119
135
  event.set_device(seat.get_keyboard())
136
+ display.error_trap_push()
120
137
  deliver(event)
138
+ display.error_trap_pop_ignored()
121
139
  view.grab_focus()
122
140
 
123
141
  def key_event(kind, keyval, mask):
@@ -144,6 +162,29 @@ def main():
144
162
  key_event(Gdk.EventType.KEY_RELEASE, Gdk.keyval_from_name(MODIFIER_KEYS[modifier]), mask)
145
163
  mask &= ~MODIFIER_MASKS[modifier]
146
164
 
165
+ def press_all(presses):
166
+ def count(text):
167
+ return int(text) if text and text.isdigit() else 0
168
+
169
+ def send(text):
170
+ expected = count(text) + sum(1 + len(modifiers) for _, modifiers in presses)
171
+ for keyval, modifiers in presses:
172
+ press(keyval, modifiers)
173
+ started = GLib.get_monotonic_time()
174
+
175
+ def check():
176
+ def on_value(text):
177
+ if count(text) >= expected or (GLib.get_monotonic_time() - started) / 1000 >= KEY_WAIT_MS:
178
+ GLib.timeout_add(INPUT_SETTLE_MS, run_step)
179
+ else:
180
+ GLib.timeout_add(KEY_POLL_MS, lambda: (check(), False)[1])
181
+
182
+ evaluate(KEYUPS, on_value, lambda _: GLib.timeout_add(INPUT_SETTLE_MS, run_step))
183
+
184
+ check()
185
+
186
+ evaluate(KEYUPS, send, lambda _: send("0"))
187
+
147
188
  def mouse(kind, x, y):
148
189
  event = Gdk.Event.new(kind)
149
190
  fields = event.motion if kind == Gdk.EventType.MOTION_NOTIFY else event.button
@@ -231,6 +272,44 @@ def main():
231
272
 
232
273
  evaluate(step["check"], on_value, lambda error: (step_failed(f"waitfor:{step['value']} failed: {error}"), next_step()))
233
274
 
275
+ def film(step):
276
+ frames = []
277
+ started = GLib.get_monotonic_time() / 1000
278
+ interval = step["durationMs"] / (step["frames"] - 1)
279
+
280
+ def store_rect(frame, text):
281
+ frame["rect"] = json.loads(text) if text and text != "null" else None
282
+
283
+ def finish_film():
284
+ width, height = view.get_allocated_width(), view.get_allocated_height()
285
+ for index, frame in enumerate(frames):
286
+ path = os.path.join(args.out, f"{args.prefix}{step['value']}.frame-{index}.png")
287
+ frame.pop("pixbuf").savev(path, "png", [], [])
288
+ frame["file"] = path
289
+ emit("film", {
290
+ "name": step["value"],
291
+ "file": os.path.join(args.out, f"{args.prefix}{step['value']}.png"),
292
+ "scale": full_pixbuf().get_width() / width,
293
+ "viewport": {"width": width, "height": height},
294
+ "frames": frames
295
+ })
296
+ next_step()
297
+
298
+ def capture():
299
+ now = GLib.get_monotonic_time() / 1000
300
+ frame = {"pixbuf": full_pixbuf(), "time": round(now - started), "rect": None}
301
+ frames.append(frame)
302
+ if step.get("rect"):
303
+ evaluate(step["rect"], lambda text: store_rect(frame, text), lambda _: None)
304
+ if len(frames) < step["frames"]:
305
+ due = started + len(frames) * interval
306
+ GLib.timeout_add(max(0, int(due - GLib.get_monotonic_time() / 1000)), capture)
307
+ else:
308
+ evaluate("0", lambda _: finish_film(), lambda _: finish_film())
309
+ return False
310
+
311
+ capture()
312
+
234
313
  def next_step():
235
314
  GLib.idle_add(run_step)
236
315
 
@@ -247,8 +326,10 @@ def main():
247
326
  else:
248
327
  save(value, full_pixbuf())
249
328
  next_step()
329
+ elif kind == "film":
330
+ film(step)
250
331
  elif kind in ("js", "jsnow"):
251
- evaluate(value, lambda _: None, lambda error: emit("error", f"js failed: {error}"))
332
+ evaluate(value, lambda _: None, lambda error: None if UNSUPPORTED_RESULT in error else emit("error", f"js failed: {error}"))
252
333
  if kind == "js":
253
334
  GLib.timeout_add(args.js_settle_ms, run_step)
254
335
  else:
@@ -259,9 +340,21 @@ def main():
259
340
  wait_for(step, GLib.get_monotonic_time())
260
341
  elif kind == "resize":
261
342
  width, height = (int(part) for part in value.split("x"))
262
- window.set_size_request(width, height)
343
+ window.set_size_request(-1, -1)
344
+ window.set_default_size(width, height)
263
345
  window.resize(width, height)
264
- GLib.timeout_add(INPUT_SETTLE_MS, run_step)
346
+ started = GLib.get_monotonic_time()
347
+
348
+ def check_size():
349
+ def on_value(text):
350
+ if text == f"{width}x{height}" or (GLib.get_monotonic_time() - started) / 1000 >= KEY_WAIT_MS:
351
+ GLib.timeout_add(INPUT_SETTLE_MS, run_step)
352
+ else:
353
+ GLib.timeout_add(KEY_POLL_MS, lambda: (check_size(), False)[1])
354
+
355
+ evaluate("innerWidth + 'x' + innerHeight", on_value, lambda _: GLib.timeout_add(INPUT_SETTLE_MS, run_step))
356
+
357
+ check_size()
265
358
  elif kind == "print":
266
359
  evaluate(
267
360
  value,
@@ -271,12 +364,9 @@ def main():
271
364
  elif kind in ("hover", "click"):
272
365
  with_rect(step, lambda rect: point_at(step, rect))
273
366
  elif kind == "key":
274
- press(keyval_for(step["key"]), step["modifiers"])
275
- GLib.timeout_add(INPUT_SETTLE_MS, run_step)
367
+ press_all([(keyval_for(step["key"]), step["modifiers"])])
276
368
  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)
369
+ press_all([(Gdk.unicode_to_keyval(ord(character)), ()) for character in value])
280
370
  return False
281
371
 
282
372
  def start_steps():
package/src/index.d.ts CHANGED
@@ -6,6 +6,16 @@ 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
+ | `film:${string}:${number}/${number}`
14
+ | `film:${string}:${number}@${string}`
15
+ | `film:${string}:${number}/${number}@${string}`
16
+ | "offline"
17
+ | "online"
18
+ | `latency:${number}`
9
19
  | `snap:${string}`
10
20
  | `print:${string}`
11
21
  | `resize:${number}x${number}`
@@ -27,7 +37,13 @@ export interface PreviewConfig {
27
37
  baseUrl?: string;
28
38
  server?: ServerOptions;
29
39
  engine?: Engine;
30
- size?: `${number}x${number}`;
40
+ size?: Size | Size[] | string;
41
+ colorScheme?: "light" | "dark";
42
+ reducedMotion?: boolean;
43
+ offline?: boolean;
44
+ latency?: number;
45
+ watch?: string | string[];
46
+ report?: boolean;
31
47
  out?: string;
32
48
  prefix?: string;
33
49
  scale?: number;
@@ -47,8 +63,11 @@ export interface PreviewConfig {
47
63
  globals?: Record<string, unknown>;
48
64
  locale?: string;
49
65
  macros?: Record<string, Step[] | string[]>;
66
+ failOnError?: boolean;
50
67
  }
51
68
 
69
+ export type Size = `${number}x${number}`;
70
+
52
71
  export interface TauriStub {
53
72
  commands?: Record<string, unknown | ((args: Record<string, unknown>) => unknown)>;
54
73
  label?: string;
@@ -80,7 +99,11 @@ export interface PreviewOptions extends PreviewConfig {
80
99
  export interface PreviewResult {
81
100
  engine: Exclude<Engine, "auto">;
82
101
  url: string;
102
+ sizes: string[];
83
103
  saved: string[];
104
+ consoleErrors: number;
105
+ failedAsserts: number;
106
+ report?: string;
84
107
  failed: boolean;
85
108
  }
86
109
 
@@ -88,6 +111,7 @@ export declare const ENGINES: Engine[];
88
111
  export declare const REQUESTS_KEY: "__webviewPreviewRequests";
89
112
  export declare function resolveUrl(target: string, baseUrl?: string): string;
90
113
  export declare function preview(options?: PreviewOptions): Promise<PreviewResult>;
114
+ export declare function watchPreview(options?: PreviewOptions, onRun?: (result: PreviewResult) => void): Promise<() => void>;
91
115
  export declare function defineConfig(config: PreviewConfig): PreviewConfig;
92
116
  export declare function anyApi(members?: Record<string, unknown>): AnyApi;
93
117
  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
- return `${baseUrl}${target.startsWith("/") ? target : `/${target}`}`;
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
- parseSize(merged.size);
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 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 => {
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 = 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));
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 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
- });
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 compareFailed = shouldCompare && compareSnapshots({
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: merged.log ?? consoleLog
94
- });
95
- return { engine, url, ...result, failed: result.failed || compareFailed }
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;