webview-preview 0.0.1 → 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.
@@ -0,0 +1,95 @@
1
+ import { ANY_FUNCTION, RECORD_FUNCTION, toSource } from "#src/page/source.mjs";
2
+
3
+ const DEFAULT_COMMANDS = {
4
+ "plugin:app|version": "0.0.0",
5
+ "plugin:app|name": "app",
6
+ "plugin:app|tauri_version": "2.0.0",
7
+ "plugin:window|is_maximized": false,
8
+ "plugin:window|is_minimized": false,
9
+ "plugin:window|is_fullscreen": false,
10
+ "plugin:window|is_focused": true,
11
+ "plugin:window|is_visible": true,
12
+ "plugin:window|is_decorated": true,
13
+ "plugin:window|is_resizable": true,
14
+ "plugin:window|scale_factor": () => window.devicePixelRatio,
15
+ "plugin:window|inner_size": () => ({ width: window.innerWidth, height: window.innerHeight }),
16
+ "plugin:window|outer_size": () => ({ width: window.innerWidth, height: window.innerHeight })
17
+ }
18
+
19
+ export const resolveTauri = (fromConfig, fromOptions) => {
20
+ const pick = value => value === true ? {} : value && typeof value === "object" ? value : null;
21
+ const config = pick(fromConfig);
22
+ const options = pick(fromOptions);
23
+ if (!config && !options) return null;
24
+ return {
25
+ label: options?.label ?? config?.label ?? "main",
26
+ commands: { ...config?.commands, ...options?.commands }
27
+ }
28
+ }
29
+
30
+ export const tauriScript = ({ label, commands }) => `(() => {
31
+ const __webviewPreviewAny = ${ANY_FUNCTION};
32
+ const record = ${RECORD_FUNCTION};
33
+ const commands = Object.assign(${toSource(DEFAULT_COMMANDS)}, ${toSource(commands, "tauri")});
34
+ const callbacks = new Map();
35
+ const listeners = new Map();
36
+ let nextId = 1;
37
+
38
+ const transformCallback = (callback, once = false) => {
39
+ const id = nextId++;
40
+ callbacks.set(id, data => {
41
+ if (once) callbacks.delete(id);
42
+ return callback && callback(data);
43
+ });
44
+ return id;
45
+ }
46
+
47
+ const runCallback = (id, data) => {
48
+ const callback = callbacks.get(id);
49
+ if (callback) callback(data);
50
+ }
51
+
52
+ const removeListener = (event, id) => {
53
+ callbacks.delete(id);
54
+ const ids = listeners.get(event);
55
+ if (ids && ids.includes(id)) ids.splice(ids.indexOf(id), 1);
56
+ }
57
+
58
+ const emit = (event, payload) => {
59
+ for (const id of [...(listeners.get(event) || [])]) runCallback(id, { event, id, payload });
60
+ }
61
+
62
+ const events = {
63
+ "plugin:event|listen": ({ event, handler }) => {
64
+ if (!listeners.has(event)) listeners.set(event, []);
65
+ listeners.get(event).push(handler);
66
+ return handler;
67
+ },
68
+ "plugin:event|unlisten": ({ event, eventId }) => removeListener(event, eventId),
69
+ "plugin:event|emit": ({ event, payload }) => emit(event, payload),
70
+ "plugin:event|emit_to": ({ event, payload }) => emit(event, payload)
71
+ }
72
+
73
+ const invoke = async (command, args = {}) => {
74
+ if (events[command]) return events[command](args) ?? null;
75
+ const isKnown = Object.prototype.hasOwnProperty.call(commands, command);
76
+ record("invoke " + command + (isKnown ? " -> stub" : " -> null"));
77
+ if (!isKnown) return null;
78
+ const answer = commands[command];
79
+ return typeof answer === "function" ? answer(args) : answer;
80
+ }
81
+
82
+ window.__TAURI_INTERNALS__ = {
83
+ invoke,
84
+ transformCallback,
85
+ unregisterCallback: id => callbacks.delete(id),
86
+ runCallback,
87
+ callbacks,
88
+ convertFileSrc: (path, protocol = "asset") => protocol + "://localhost/" + encodeURIComponent(path),
89
+ metadata: { currentWindow: { label: ${JSON.stringify(label)} }, currentWebview: { windowLabel: ${JSON.stringify(label)}, label: ${JSON.stringify(label)} } },
90
+ plugins: { path: { sep: "/", delimiter: ":" } }
91
+ }
92
+ window.__TAURI_EVENT_PLUGIN_INTERNALS__ = { unregisterListener: removeListener }
93
+ window.isTauri = true;
94
+ window.__webviewPreview = Object.assign(window.__webviewPreview || {}, { emit });
95
+ })();`;
@@ -37,12 +37,14 @@ export const diffImages = (actual, expected, tolerance) => {
37
37
  export const compareSnapshots = ({ saved, baselineDir, updateBaseline, tolerance, maxDiffPixels, log }) => {
38
38
  mkdirSync(baselineDir, { recursive: true });
39
39
  let failed = false;
40
+ const results = [];
40
41
  for (const file of saved) {
41
42
  const name = path.basename(file);
42
43
  const baseline = path.join(baselineDir, name);
43
44
  if (updateBaseline || !existsSync(baseline)) {
44
45
  copyFileSync(file, baseline);
45
46
  log.info(`compare ${name}: ${updateBaseline ? "baseline updated" : "new baseline"} → ${baseline}`);
47
+ results.push({ file, baseline, status: updateBaseline ? "baseline updated" : "new baseline" });
46
48
  continue;
47
49
  }
48
50
 
@@ -50,10 +52,12 @@ export const compareSnapshots = ({ saved, baselineDir, updateBaseline, tolerance
50
52
  if (result.sizeMismatch) {
51
53
  failed = true;
52
54
  log.error(`compare ${name}: size changed, baseline ${baseline}`);
55
+ results.push({ file, baseline, status: "size changed", isFailed: true });
53
56
  continue;
54
57
  }
55
58
  if (result.differentPixels === 0) {
56
59
  log.info(`compare ${name}: same`);
60
+ results.push({ file, baseline, status: "same" });
57
61
  continue;
58
62
  }
59
63
 
@@ -61,12 +65,16 @@ export const compareSnapshots = ({ saved, baselineDir, updateBaseline, tolerance
61
65
  writePng(diffFile, result.diff);
62
66
  const share = ((result.differentPixels / result.total) * 100).toFixed(2);
63
67
  const message = `compare ${name}: ${result.differentPixels} px differ (${share}%) → ${diffFile}`;
64
- if (result.differentPixels > maxDiffPixels) {
68
+ const isFailed = result.differentPixels > maxDiffPixels;
69
+
70
+ if (isFailed) {
65
71
  failed = true;
66
72
  log.error(message);
67
73
  } else {
68
74
  log.info(`${message} (within maxDiffPixels ${maxDiffPixels})`);
69
75
  }
76
+
77
+ results.push({ file, baseline, diffFile, status: `${result.differentPixels} px differ (${share}%)`, isFailed });
70
78
  }
71
- return failed;
79
+ return { failed, results }
72
80
  }
@@ -0,0 +1,51 @@
1
+ import { rmSync } from "node:fs";
2
+ import { clipRect } from "#src/core/steps.mjs";
3
+ import { readPng, writePng } from "#src/snapshots/png.mjs";
4
+
5
+ const COLUMNS = 5;
6
+ const GAP = 4;
7
+ const GAP_COLOR = [128, 128, 128, 255];
8
+
9
+ const unionRect = rects => {
10
+ const left = Math.min(...rects.map(rect => rect.x));
11
+ const top = Math.min(...rects.map(rect => rect.y));
12
+ const right = Math.max(...rects.map(rect => rect.x + rect.width));
13
+ const bottom = Math.max(...rects.map(rect => rect.y + rect.height));
14
+ return { x: left, y: top, width: right - left, height: bottom - top }
15
+ }
16
+
17
+ const cropArea = ({ frames, viewport }, margin) => {
18
+ const rects = frames.map(frame => frame.rect).filter(rect => rect && rect.width > 0 && rect.height > 0);
19
+ if (!rects.length) return { x: 0, y: 0, ...viewport }
20
+ return clipRect(unionRect(rects), margin, viewport);
21
+ }
22
+
23
+ export const stitchFilm = (film, margin) => {
24
+ const images = film.frames.map(frame => readPng(frame.file));
25
+ const area = cropArea(film, margin);
26
+ const left = Math.round(area.x * film.scale);
27
+ const top = Math.round(area.y * film.scale);
28
+ const width = Math.min(Math.round(area.width * film.scale), ...images.map(image => image.width - left));
29
+ const height = Math.min(Math.round(area.height * film.scale), ...images.map(image => image.height - top));
30
+ const columns = Math.min(COLUMNS, images.length);
31
+ const rows = Math.ceil(images.length / columns);
32
+ const sheet = {
33
+ width: columns * width + (columns - 1) * GAP,
34
+ height: rows * height + (rows - 1) * GAP
35
+ }
36
+ const rgba = Buffer.alloc(sheet.width * sheet.height * 4);
37
+ for (let offset = 0; offset < rgba.length; offset += 4) rgba.set(GAP_COLOR, offset);
38
+
39
+ images.forEach((image, index) => {
40
+ const x = (index % columns) * (width + GAP);
41
+ const y = Math.floor(index / columns) * (height + GAP);
42
+ for (let row = 0; row < height; row += 1) {
43
+ const from = ((top + row) * image.width + left) * 4;
44
+ image.rgba.copy(rgba, ((y + row) * sheet.width + x) * 4, from, from + width * 4);
45
+ }
46
+ });
47
+
48
+ writePng(film.file, { ...sheet, rgba });
49
+ for (const frame of film.frames) rmSync(frame.file, { force: true });
50
+ return `film ${film.name}: ${images.length} frames at ${film.frames.map(frame => frame.time).join(" ")} ms, ${columns} per row`;
51
+ }
@@ -0,0 +1,71 @@
1
+ import { writeFileSync } from "node:fs";
2
+ import path from "node:path";
3
+
4
+ const escape = value => String(value)
5
+ .replace(/&/g, "&amp;")
6
+ .replace(/</g, "&lt;")
7
+ .replace(/>/g, "&gt;")
8
+ .replace(/"/g, "&quot;");
9
+
10
+ const STYLE = `
11
+ :root { color-scheme: light dark; --bg: #f6f6f7; --panel: #fff; --text: #1d1d1f; --muted: #6e6e73; --line: #d9d9de; --ok: #1a7f37; --bad: #cf222e; --code: #efeff2 }
12
+ @media (prefers-color-scheme: dark) { :root { --bg: #111113; --panel: #1b1b1e; --text: #ededf0; --muted: #9a9aa2; --line: #2e2e33; --ok: #3fb950; --bad: #f85149; --code: #232327 } }
13
+ * { box-sizing: border-box }
14
+ body { margin: 0; padding: 24px 16px 48px; background: var(--bg); color: var(--text); font: 14px/1.5 system-ui, sans-serif }
15
+ main { max-width: 1400px; margin: 0 auto }
16
+ h1 { font-size: 20px; margin: 0 0 4px }
17
+ h2 { font-size: 15px; margin: 0 }
18
+ .meta { color: var(--muted); margin: 0 0 20px; overflow-wrap: anywhere }
19
+ .status { font-weight: 600 }
20
+ .ok { color: var(--ok) }
21
+ .bad { color: var(--bad) }
22
+ pre { background: var(--code); padding: 12px; border-radius: 8px; white-space: pre-wrap; overflow-wrap: anywhere; font: 12px/1.5 ui-monospace, monospace; margin: 0 0 24px }
23
+ pre .bad { color: var(--bad) }
24
+ section { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 16px; margin: 0 0 16px }
25
+ section header { display: flex; flex-wrap: wrap; gap: 4px 12px; align-items: baseline; margin: 0 0 12px }
26
+ .images { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 12px }
27
+ figure { margin: 0 }
28
+ figcaption { color: var(--muted); font-size: 12px; margin: 0 0 4px }
29
+ img { display: block; max-width: 100%; border: 1px solid var(--line); background: repeating-conic-gradient(#8882 0 25%, transparent 0 50%) 0 0 / 16px 16px }
30
+ a:focus-visible { outline: 2px solid currentColor; outline-offset: 2px }
31
+ `;
32
+
33
+ const figure = (caption, file, out) => {
34
+ const href = escape(path.relative(out, file).split(path.sep).join("/"));
35
+ return `<figure><figcaption>${escape(caption)}</figcaption><a href="${href}"><img src="${href}" alt="${escape(caption)}" loading="lazy"></a></figure>`;
36
+ }
37
+
38
+ const snapshotSection = (file, compared, out) => {
39
+ const images = compared?.baseline && compared.status !== "new baseline" && compared.status !== "baseline updated"
40
+ ? [figure("baseline", compared.baseline, out), figure("this run", file, out), ...(compared.diffFile ? [figure("difference (red)", compared.diffFile, out)] : [])]
41
+ : [figure("this run", file, out)];
42
+ const status = compared ? `<span class="status ${compared.isFailed ? "bad" : "ok"}">${escape(compared.status)}</span>` : "";
43
+ return `<section><header><h2>${escape(path.basename(file))}</h2>${status}</header><div class="images">${images.join("")}</div></section>`;
44
+ }
45
+
46
+ export const writeReport = ({ out, url, engine, sizes, lines, saved, compared = [], failed }) => {
47
+ const file = path.join(out, "report.html");
48
+ const byFile = new Map(compared.map(result => [result.file, result]));
49
+ const changed = compared.filter(result => result.isFailed).length;
50
+ const log = lines.map(([kind, text]) => kind === "error" ? `<span class="bad">${escape(text)}</span>` : escape(text)).join("\n");
51
+ const html = `<!doctype html>
52
+ <html lang="en">
53
+ <head>
54
+ <meta charset="utf-8">
55
+ <meta name="viewport" content="width=device-width, initial-scale=1">
56
+ <title>webview-preview report</title>
57
+ <style>${STYLE}</style>
58
+ </head>
59
+ <body>
60
+ <main>
61
+ <h1>webview-preview report <span class="status ${failed ? "bad" : "ok"}">${failed ? "failed" : "passed"}</span></h1>
62
+ <p class="meta">${escape(url)} · ${escape(engine)} · ${escape(sizes.join(", "))} · ${saved.length} snapshot(s)${compared.length ? ` · ${changed} changed` : ""} · ${escape(new Date().toLocaleString())}</p>
63
+ ${log ? `<pre>${log}</pre>` : ""}
64
+ ${saved.map(snapshot => snapshotSection(snapshot, byFile.get(snapshot), out)).join("\n")}
65
+ </main>
66
+ </body>
67
+ </html>
68
+ `;
69
+ writeFileSync(file, html);
70
+ return file;
71
+ }