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.
@@ -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:
@@ -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
- press(keyval_for(step["key"]), step["modifiers"])
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?: `${number}x${number}`;
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
- 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;