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.
- 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 +103 -13
- package/src/index.d.ts +25 -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 +13 -4
- package/src/snapshots/film.mjs +51 -0
- package/src/snapshots/report.mjs +71 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { ASSERT_MARKER, FOCUS_MARKER } from "#src/core/steps.mjs";
|
|
2
|
+
|
|
3
|
+
export const CONSOLE_KEY = "__webviewPreviewConsole";
|
|
4
|
+
const MARKER = "__webviewPreviewConsole:";
|
|
5
|
+
|
|
6
|
+
export const consoleScript = () => `(() => {
|
|
7
|
+
const entries = window.${CONSOLE_KEY} = window.${CONSOLE_KEY} || [];
|
|
8
|
+
const text = value => {
|
|
9
|
+
if (value instanceof Error) {
|
|
10
|
+
const head = value.name + ": " + value.message;
|
|
11
|
+
const stack = String(value.stack || "");
|
|
12
|
+
const frame = stack.split("\\n").find(line => line && !line.startsWith(head)) || "";
|
|
13
|
+
return frame ? head + " — " + frame.trim() : head;
|
|
14
|
+
}
|
|
15
|
+
if (typeof value === "string") return value;
|
|
16
|
+
try {
|
|
17
|
+
return JSON.stringify(value);
|
|
18
|
+
} catch {
|
|
19
|
+
return String(value);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const add = (kind, message) => {
|
|
23
|
+
const last = entries[entries.length - 1];
|
|
24
|
+
if (last && last.kind === kind && last.message === message) last.count += 1;
|
|
25
|
+
else entries.push({ kind, message: message.slice(0, 2000), count: 1 });
|
|
26
|
+
}
|
|
27
|
+
const originalError = console.error;
|
|
28
|
+
console.error = (...args) => {
|
|
29
|
+
add("console.error", args.map(text).join(" "));
|
|
30
|
+
return originalError.apply(console, args);
|
|
31
|
+
}
|
|
32
|
+
window.addEventListener("error", event => {
|
|
33
|
+
const where = event.filename ? " (" + event.filename.replace(location.origin, "") + ":" + event.lineno + ")" : "";
|
|
34
|
+
add("uncaught", event.error ? text(event.error) : event.message + where);
|
|
35
|
+
});
|
|
36
|
+
window.addEventListener("unhandledrejection", event => add("unhandled rejection", text(event.reason)));
|
|
37
|
+
})();`;
|
|
38
|
+
|
|
39
|
+
export const consoleStep = { kind: "print", value: `"${MARKER}" + JSON.stringify(window.${CONSOLE_KEY} || [])`, isInternal: true }
|
|
40
|
+
|
|
41
|
+
export const watchOutput = log => {
|
|
42
|
+
const entries = [];
|
|
43
|
+
let failedAsserts = 0;
|
|
44
|
+
let focus = null;
|
|
45
|
+
const printFocus = () => {
|
|
46
|
+
const { presses, items } = focus;
|
|
47
|
+
const firstPath = items[0]?.path;
|
|
48
|
+
const loopAt = items.findIndex((item, index) => index > 0 && item.path !== undefined && item.path === firstPath);
|
|
49
|
+
const shown = loopAt === -1 ? items : items.slice(0, loopAt);
|
|
50
|
+
log.info(`focus order (Tab ×${presses}):`);
|
|
51
|
+
shown.forEach((item, index) => log.info(` ${index + 1}. ${item.label ?? "(nothing focused)"}`));
|
|
52
|
+
if (loopAt !== -1) log.info(` → back to 1 after ${loopAt} stop${loopAt === 1 ? "" : "s"}`);
|
|
53
|
+
}
|
|
54
|
+
const info = message => {
|
|
55
|
+
if (typeof message !== "string") return log.info(message);
|
|
56
|
+
if (message.startsWith(MARKER)) {
|
|
57
|
+
entries.push(...JSON.parse(message.slice(MARKER.length)));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (message.startsWith(FOCUS_MARKER)) {
|
|
62
|
+
const [type, value, path] = JSON.parse(message.slice(FOCUS_MARKER.length));
|
|
63
|
+
if (type === "start") focus = { presses: value, items: [] }
|
|
64
|
+
else if (type === "item" && focus) focus.items.push({ label: value, path });
|
|
65
|
+
else if (type === "end" && focus) {
|
|
66
|
+
printFocus();
|
|
67
|
+
focus = null;
|
|
68
|
+
}
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (message.startsWith(ASSERT_MARKER)) {
|
|
72
|
+
const [expression, problem] = JSON.parse(message.slice(ASSERT_MARKER.length));
|
|
73
|
+
if (problem === null) return log.info(`assert ok: ${expression}`);
|
|
74
|
+
failedAsserts += 1;
|
|
75
|
+
return log.error(`assert failed: ${expression} — ${problem}`);
|
|
76
|
+
}
|
|
77
|
+
log.info(message);
|
|
78
|
+
}
|
|
79
|
+
const report = () => {
|
|
80
|
+
for (const { kind, message, count } of entries) log.error(`${kind}: ${message}${count > 1 ? ` (×${count})` : ""}`);
|
|
81
|
+
return { consoleErrors: entries.length, failedAsserts }
|
|
82
|
+
}
|
|
83
|
+
return { log: { ...log, info }, report }
|
|
84
|
+
}
|
package/src/page/mock.mjs
CHANGED
|
@@ -58,18 +58,19 @@ export const resolveMockFiles = async (files, baseDir) => Promise.all(
|
|
|
58
58
|
export const mockScript = rules => `(() => {
|
|
59
59
|
const rules = ${JSON.stringify(rules)}.map(rule => ({ ...rule, regex: new RegExp(rule.regex) }));
|
|
60
60
|
const requests = window.${REQUESTS_KEY} = window.${REQUESTS_KEY} || [];
|
|
61
|
+
const pathOf = address => address.protocol === "file:" ? address.pathname.replace(/^\\/[A-Za-z]:/, "") : address.pathname;
|
|
61
62
|
const find = (method, url) => {
|
|
62
63
|
const address = new URL(url, location.href);
|
|
63
64
|
return rules.find(rule => {
|
|
64
65
|
if (rule.method && rule.method !== method) return false;
|
|
65
|
-
const base = rule.isFullUrl ? address.origin + address.pathname : address
|
|
66
|
+
const base = rule.isFullUrl ? address.origin + address.pathname : pathOf(address);
|
|
66
67
|
return rule.regex.test(rule.withQuery ? base + address.search : base);
|
|
67
68
|
});
|
|
68
69
|
}
|
|
69
70
|
|
|
70
71
|
const record = (method, url, rule) => {
|
|
71
72
|
const address = new URL(url, location.href);
|
|
72
|
-
const shown = address.origin === location.origin ? address
|
|
73
|
+
const shown = address.origin === location.origin ? pathOf(address) + address.search : address.href;
|
|
73
74
|
requests.push(method + " " + shown + (rule ? " -> mock " + rule.status : " -> network"));
|
|
74
75
|
}
|
|
75
76
|
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { REQUESTS_KEY } from "#src/page/mock.mjs";
|
|
2
|
+
|
|
3
|
+
export const NETWORK_STEPS = new Set(["offline", "online", "latency"]);
|
|
4
|
+
|
|
5
|
+
export const checkLatency = value => {
|
|
6
|
+
if (value === undefined) return 0;
|
|
7
|
+
if (!/^\d+$/.test(String(value))) throw new Error(`latency needs milliseconds, got "${value}"`);
|
|
8
|
+
return Number(value);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const networkStep = (kind, value, step) => {
|
|
12
|
+
if (kind === "latency" && !/^\d+$/.test(value)) throw new Error(`latency needs milliseconds, got "${step}"`);
|
|
13
|
+
if (kind !== "latency" && value) throw new Error(`${kind} takes no value, got "${step}"`);
|
|
14
|
+
const state = kind === "latency" ? { latencyMs: Number(value) } : { isOffline: kind === "offline" }
|
|
15
|
+
return { kind: "js", value: `window.__webviewPreview.network(${JSON.stringify(state)})` }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const networkScript = ({ isOffline, latencyMs }) => `(() => {
|
|
19
|
+
const state = { isOffline: ${!!isOffline}, latencyMs: ${latencyMs} }
|
|
20
|
+
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
21
|
+
const record = (method, url) => {
|
|
22
|
+
const requests = window.${REQUESTS_KEY};
|
|
23
|
+
if (!requests) return;
|
|
24
|
+
const address = new URL(url, location.href);
|
|
25
|
+
const shown = address.origin === location.origin ? address.pathname + address.search : address.href;
|
|
26
|
+
requests.push(method + " " + shown + " -> offline");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const onLine = Object.getOwnPropertyDescriptor(Navigator.prototype, "onLine");
|
|
30
|
+
Object.defineProperty(Navigator.prototype, "onLine", {
|
|
31
|
+
configurable: true,
|
|
32
|
+
get() {
|
|
33
|
+
return state.isOffline ? false : onLine.get.call(this);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const network = next => {
|
|
38
|
+
const wasOffline = state.isOffline;
|
|
39
|
+
Object.assign(state, next);
|
|
40
|
+
if (wasOffline !== state.isOffline) window.dispatchEvent(new Event(state.isOffline ? "offline" : "online"));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const originalFetch = window.fetch;
|
|
44
|
+
window.fetch = async function (input, init) {
|
|
45
|
+
if (state.latencyMs) await wait(state.latencyMs);
|
|
46
|
+
if (state.isOffline) {
|
|
47
|
+
const url = typeof input === "string" || input instanceof URL ? String(input) : input.url;
|
|
48
|
+
record(String(init && init.method || input && input.method || "GET").toUpperCase(), url);
|
|
49
|
+
throw new TypeError("Failed to fetch");
|
|
50
|
+
}
|
|
51
|
+
return originalFetch.call(this, input, init);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const define = (target, name, value) => Object.defineProperty(target, name, { value, configurable: true });
|
|
55
|
+
const originalOpen = XMLHttpRequest.prototype.open;
|
|
56
|
+
const originalSend = XMLHttpRequest.prototype.send;
|
|
57
|
+
XMLHttpRequest.prototype.open = function (method, url, ...rest) {
|
|
58
|
+
this.__webviewPreviewNetwork = { method: String(method).toUpperCase(), url: String(url) }
|
|
59
|
+
return originalOpen.call(this, method, url, ...rest);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
XMLHttpRequest.prototype.send = function (body) {
|
|
63
|
+
const xhr = this;
|
|
64
|
+
const info = xhr.__webviewPreviewNetwork;
|
|
65
|
+
const send = () => {
|
|
66
|
+
if (!state.isOffline) return originalSend.call(xhr, body);
|
|
67
|
+
if (info) record(info.method, info.url);
|
|
68
|
+
define(xhr, "readyState", 4);
|
|
69
|
+
define(xhr, "status", 0);
|
|
70
|
+
xhr.dispatchEvent(new Event("readystatechange"));
|
|
71
|
+
xhr.dispatchEvent(new ProgressEvent("error"));
|
|
72
|
+
xhr.dispatchEvent(new ProgressEvent("loadend"));
|
|
73
|
+
}
|
|
74
|
+
if (state.latencyMs || state.isOffline) setTimeout(send, state.latencyMs);
|
|
75
|
+
else send();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
window.__webviewPreview = Object.assign(window.__webviewPreview || {}, { network });
|
|
79
|
+
})();`;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { copyFileSync, existsSync, mkdirSync } from "node:fs";
|
|
1
|
+
import { copyFileSync, existsSync, mkdirSync, rmSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { readPng, writePng } from "#src/snapshots/png.mjs";
|
|
4
4
|
|
|
@@ -37,12 +37,16 @@ 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);
|
|
44
|
+
const diffFile = file.replace(/\.png$/, ".diff.png");
|
|
45
|
+
rmSync(diffFile, { force: true });
|
|
43
46
|
if (updateBaseline || !existsSync(baseline)) {
|
|
44
47
|
copyFileSync(file, baseline);
|
|
45
48
|
log.info(`compare ${name}: ${updateBaseline ? "baseline updated" : "new baseline"} → ${baseline}`);
|
|
49
|
+
results.push({ file, baseline, status: updateBaseline ? "baseline updated" : "new baseline" });
|
|
46
50
|
continue;
|
|
47
51
|
}
|
|
48
52
|
|
|
@@ -50,23 +54,28 @@ export const compareSnapshots = ({ saved, baselineDir, updateBaseline, tolerance
|
|
|
50
54
|
if (result.sizeMismatch) {
|
|
51
55
|
failed = true;
|
|
52
56
|
log.error(`compare ${name}: size changed, baseline ${baseline}`);
|
|
57
|
+
results.push({ file, baseline, status: "size changed", isFailed: true });
|
|
53
58
|
continue;
|
|
54
59
|
}
|
|
55
60
|
if (result.differentPixels === 0) {
|
|
56
61
|
log.info(`compare ${name}: same`);
|
|
62
|
+
results.push({ file, baseline, status: "same" });
|
|
57
63
|
continue;
|
|
58
64
|
}
|
|
59
65
|
|
|
60
|
-
const diffFile = file.replace(/\.png$/, ".diff.png");
|
|
61
66
|
writePng(diffFile, result.diff);
|
|
62
67
|
const share = ((result.differentPixels / result.total) * 100).toFixed(2);
|
|
63
68
|
const message = `compare ${name}: ${result.differentPixels} px differ (${share}%) → ${diffFile}`;
|
|
64
|
-
|
|
69
|
+
const isFailed = result.differentPixels > maxDiffPixels;
|
|
70
|
+
|
|
71
|
+
if (isFailed) {
|
|
65
72
|
failed = true;
|
|
66
73
|
log.error(message);
|
|
67
74
|
} else {
|
|
68
75
|
log.info(`${message} (within maxDiffPixels ${maxDiffPixels})`);
|
|
69
76
|
}
|
|
77
|
+
|
|
78
|
+
results.push({ file, baseline, diffFile, status: `${result.differentPixels} px differ (${share}%)`, isFailed });
|
|
70
79
|
}
|
|
71
|
-
return failed
|
|
80
|
+
return { failed, results }
|
|
72
81
|
}
|
|
@@ -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, "&")
|
|
6
|
+
.replace(/</g, "<")
|
|
7
|
+
.replace(/>/g, ">")
|
|
8
|
+
.replace(/"/g, """);
|
|
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
|
+
}
|