webview-preview 0.0.0-stage → 1.0.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 +208 -0
- package/LICENSE +21 -0
- package/README.md +657 -2
- package/bin/webview-preview.mjs +161 -0
- package/package.json +54 -5
- package/src/core/config.mjs +16 -0
- package/src/core/server.mjs +57 -0
- package/src/core/steps.mjs +182 -0
- package/src/engines/index.mjs +17 -0
- package/src/engines/playwright.mjs +104 -0
- package/src/engines/webkitgtk.mjs +67 -0
- package/src/engines/webkitgtk.py +318 -0
- package/src/index.d.ts +101 -0
- package/src/index.mjs +101 -0
- package/src/page/globals.mjs +37 -0
- package/src/page/init.mjs +32 -0
- package/src/page/mock.mjs +117 -0
- package/src/page/source.mjs +55 -0
- package/src/page/tauri.mjs +95 -0
- package/src/snapshots/clean.mjs +36 -0
- package/src/snapshots/compare.mjs +72 -0
- package/src/snapshots/png.mjs +119 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
|
|
5
|
+
const METHODS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]);
|
|
6
|
+
const RESPONSE_KEYS = new Set(["status", "json", "body", "headers", "delayMs"]);
|
|
7
|
+
export const REQUESTS_KEY = "__webviewPreviewRequests";
|
|
8
|
+
|
|
9
|
+
const globToRegExp = pattern => {
|
|
10
|
+
const source = pattern
|
|
11
|
+
.split(/(\*\*|\*)/)
|
|
12
|
+
.map(part => part === "**" ? ".*" : part === "*" ? "[^/]*" : part.replace(/[.+?^${}()|[\]\\]/g, "\\$&"))
|
|
13
|
+
.join("");
|
|
14
|
+
return `^${source}$`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const parseKey = key => {
|
|
18
|
+
const [first, ...rest] = key.trim().split(/\s+/);
|
|
19
|
+
const hasMethod = METHODS.has(first.toUpperCase()) && rest.length > 0;
|
|
20
|
+
const pattern = hasMethod ? rest.join(" ") : key.trim();
|
|
21
|
+
return {
|
|
22
|
+
method: hasMethod ? first.toUpperCase() : null,
|
|
23
|
+
pattern,
|
|
24
|
+
regex: globToRegExp(pattern),
|
|
25
|
+
isFullUrl: /^https?:\/\//i.test(pattern),
|
|
26
|
+
withQuery: pattern.includes("?")
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const parseResponse = (key, response) => {
|
|
31
|
+
if (!response || typeof response !== "object") throw new Error(`mock "${key}" must be an object like { json: … } or { status: 500 }`);
|
|
32
|
+
const unknown = Object.keys(response).filter(name => !RESPONSE_KEYS.has(name));
|
|
33
|
+
if (unknown.length) throw new Error(`mock "${key}": unknown field ${unknown.join(", ")}; use ${[...RESPONSE_KEYS].join(", ")}`);
|
|
34
|
+
if ("json" in response && "body" in response) throw new Error(`mock "${key}": use json or body, not both`);
|
|
35
|
+
const isJson = "json" in response;
|
|
36
|
+
return {
|
|
37
|
+
status: response.status ?? 200,
|
|
38
|
+
body: isJson ? JSON.stringify(response.json) : String(response.body ?? ""),
|
|
39
|
+
headers: { ...(isJson ? { "content-type": "application/json" } : {}), ...(response.headers ?? {}) },
|
|
40
|
+
delayMs: response.delayMs ?? 0
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const loadMockFile = async file => {
|
|
45
|
+
if (!existsSync(file)) throw new Error(`mock file not found: ${file}`);
|
|
46
|
+
if (file.endsWith(".json")) return JSON.parse(readFileSync(file, "utf8"));
|
|
47
|
+
const module = await import(pathToFileURL(file).href);
|
|
48
|
+
return module.default ?? module;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const compileMock = mocks => Object.entries(Object.assign({}, ...mocks.filter(Boolean)))
|
|
52
|
+
.map(([key, response]) => ({ key, ...parseKey(key), ...parseResponse(key, response) }));
|
|
53
|
+
|
|
54
|
+
export const resolveMockFiles = async (files, baseDir) => Promise.all(
|
|
55
|
+
[].concat(files ?? []).map(file => loadMockFile(path.resolve(baseDir, file)))
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
export const mockScript = rules => `(() => {
|
|
59
|
+
const rules = ${JSON.stringify(rules)}.map(rule => ({ ...rule, regex: new RegExp(rule.regex) }));
|
|
60
|
+
const requests = window.${REQUESTS_KEY} = window.${REQUESTS_KEY} || [];
|
|
61
|
+
const find = (method, url) => {
|
|
62
|
+
const address = new URL(url, location.href);
|
|
63
|
+
return rules.find(rule => {
|
|
64
|
+
if (rule.method && rule.method !== method) return false;
|
|
65
|
+
const base = rule.isFullUrl ? address.origin + address.pathname : address.pathname;
|
|
66
|
+
return rule.regex.test(rule.withQuery ? base + address.search : base);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const record = (method, url, rule) => {
|
|
71
|
+
const address = new URL(url, location.href);
|
|
72
|
+
const shown = address.origin === location.origin ? address.pathname + address.search : address.href;
|
|
73
|
+
requests.push(method + " " + shown + (rule ? " -> mock " + rule.status : " -> network"));
|
|
74
|
+
}
|
|
75
|
+
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
76
|
+
|
|
77
|
+
const originalFetch = window.fetch.bind(window);
|
|
78
|
+
window.fetch = async (input, init) => {
|
|
79
|
+
const url = typeof input === "string" || input instanceof URL ? String(input) : input.url;
|
|
80
|
+
const method = String(init && init.method || input && input.method || "GET").toUpperCase();
|
|
81
|
+
const rule = find(method, url);
|
|
82
|
+
record(method, url, rule);
|
|
83
|
+
if (!rule) return originalFetch(input, init);
|
|
84
|
+
if (rule.delayMs) await wait(rule.delayMs);
|
|
85
|
+
return new Response(rule.status === 204 || rule.status === 304 ? null : rule.body, { status: rule.status, headers: rule.headers });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const define = (target, name, value) => Object.defineProperty(target, name, { value, configurable: true });
|
|
89
|
+
const originalOpen = XMLHttpRequest.prototype.open;
|
|
90
|
+
const originalSend = XMLHttpRequest.prototype.send;
|
|
91
|
+
XMLHttpRequest.prototype.open = function (method, url, ...rest) {
|
|
92
|
+
this.__webviewPreview = { method: String(method).toUpperCase(), url: String(url) }
|
|
93
|
+
return originalOpen.call(this, method, url, ...rest);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
XMLHttpRequest.prototype.send = function (body) {
|
|
97
|
+
const info = this.__webviewPreview;
|
|
98
|
+
const rule = info && find(info.method, info.url);
|
|
99
|
+
if (info) record(info.method, info.url, rule);
|
|
100
|
+
if (!rule) return originalSend.call(this, body);
|
|
101
|
+
const xhr = this;
|
|
102
|
+
const headers = Object.fromEntries(Object.entries(rule.headers).map(([name, value]) => [name.toLowerCase(), String(value)]));
|
|
103
|
+
setTimeout(() => {
|
|
104
|
+
define(xhr, "readyState", 4);
|
|
105
|
+
define(xhr, "status", rule.status);
|
|
106
|
+
define(xhr, "statusText", "");
|
|
107
|
+
define(xhr, "responseURL", new URL(info.url, location.href).href);
|
|
108
|
+
define(xhr, "responseText", rule.body);
|
|
109
|
+
define(xhr, "response", xhr.responseType === "json" ? (rule.body ? JSON.parse(rule.body) : null) : rule.body);
|
|
110
|
+
define(xhr, "getResponseHeader", name => headers[String(name).toLowerCase()] ?? null);
|
|
111
|
+
define(xhr, "getAllResponseHeaders", () => Object.entries(headers).map(([name, value]) => name + ": " + value).join("\\r\\n"));
|
|
112
|
+
xhr.dispatchEvent(new Event("readystatechange"));
|
|
113
|
+
xhr.dispatchEvent(new ProgressEvent("load"));
|
|
114
|
+
xhr.dispatchEvent(new ProgressEvent("loadend"));
|
|
115
|
+
}, rule.delayMs);
|
|
116
|
+
}
|
|
117
|
+
})();`;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
const ANY_API = Symbol.for("webview-preview.anyApi");
|
|
2
|
+
|
|
3
|
+
export const anyApi = (members = {}) => ({ [ANY_API]: true, members });
|
|
4
|
+
|
|
5
|
+
const asExpression = source => {
|
|
6
|
+
try {
|
|
7
|
+
new Function(`return (${source})`);
|
|
8
|
+
return source;
|
|
9
|
+
} catch {
|
|
10
|
+
return source.startsWith("async ") ? `async function ${source.slice(6)}` : `function ${source}`;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const toSource = (value, name = "value") => {
|
|
15
|
+
if (typeof value === "function") return `(${asExpression(value.toString())})`;
|
|
16
|
+
if (value === undefined) return "undefined";
|
|
17
|
+
if (Array.isArray(value)) return `[${value.map((item, index) => toSource(item, `${name}[${index}]`)).join(", ")}]`;
|
|
18
|
+
if (value && typeof value === "object") {
|
|
19
|
+
if (value[ANY_API]) return `__webviewPreviewAny(${toSource(value.members, name)}, ${JSON.stringify(name)})`;
|
|
20
|
+
const entries = Object.entries(value).map(([key, item]) => `${JSON.stringify(key)}: ${toSource(item, `${name}.${key}`)}`);
|
|
21
|
+
return `{ ${entries.join(", ")} }`;
|
|
22
|
+
}
|
|
23
|
+
return JSON.stringify(value);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const RECORD_FUNCTION = `call => (window.__webviewPreviewRequests = window.__webviewPreviewRequests || []).push(call)`;
|
|
27
|
+
|
|
28
|
+
export const ANY_FUNCTION = `(members, name) => {
|
|
29
|
+
const record = ${RECORD_FUNCTION};
|
|
30
|
+
const settled = () => new Proxy(function () {}, {
|
|
31
|
+
get: (_, key) => key === "then" ? resolve => resolve(null) : key === Symbol.toPrimitive ? () => "" : settled(),
|
|
32
|
+
apply: () => settled()
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const node = path => new Proxy(function () {}, {
|
|
36
|
+
get: (_, key) => {
|
|
37
|
+
if (typeof key === "symbol") return key === Symbol.toPrimitive ? () => "" : undefined;
|
|
38
|
+
if (key === "then" || key === "toJSON") return undefined;
|
|
39
|
+
if (path === name && Object.prototype.hasOwnProperty.call(members, key)) {
|
|
40
|
+
const member = members[key];
|
|
41
|
+
if (typeof member !== "function") return member;
|
|
42
|
+
return (...args) => {
|
|
43
|
+
record("call " + path + "." + key + " -> stub");
|
|
44
|
+
return member(...args);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return node(path + "." + key);
|
|
48
|
+
},
|
|
49
|
+
apply: () => {
|
|
50
|
+
record("call " + path + " -> default");
|
|
51
|
+
return settled();
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
return node(name);
|
|
55
|
+
}`;
|
|
@@ -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
|
+
})();`;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { existsSync, readdirSync, rmSync, statSync } from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { loadConfig } from "#src/core/config.mjs";
|
|
5
|
+
|
|
6
|
+
const DURATION = /^(\d+)(m|h|d)$/;
|
|
7
|
+
const UNIT_MS = {
|
|
8
|
+
m: 60_000,
|
|
9
|
+
h: 3_600_000,
|
|
10
|
+
d: 86_400_000
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const parseDuration = value => {
|
|
14
|
+
const match = DURATION.exec(String(value));
|
|
15
|
+
if (!match) throw new Error(`--older-than needs a duration like 30m, 2h or 7d, got "${value}"`);
|
|
16
|
+
return Number(match[1]) * UNIT_MS[match[2]];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const cleanSnapshots = async (options = {}) => {
|
|
20
|
+
const config = options.config === false ? {} : await loadConfig(options.config);
|
|
21
|
+
const baseDir = config.configDir ?? process.cwd();
|
|
22
|
+
const out = path.resolve(baseDir, options.out ?? config.out ?? ".webview-preview");
|
|
23
|
+
const baseline = path.resolve(baseDir, options.baseline ?? config.baseline ?? "webview-preview-baseline");
|
|
24
|
+
const protectedDirs = [path.parse(out).root, os.homedir(), process.cwd(), baseDir, baseline].map(dir => path.resolve(dir));
|
|
25
|
+
if (protectedDirs.includes(out)) throw new Error(`refusing to clean ${out}: point "out" at a folder used only for snapshots`);
|
|
26
|
+
if (!existsSync(out)) return { removed: [], out }
|
|
27
|
+
|
|
28
|
+
const maxAge = options.olderThan ? parseDuration(options.olderThan) : 0;
|
|
29
|
+
const now = Date.now();
|
|
30
|
+
const removed = readdirSync(out)
|
|
31
|
+
.filter(name => name.toLowerCase().endsWith(".png"))
|
|
32
|
+
.map(name => path.join(out, name))
|
|
33
|
+
.filter(file => statSync(file).isFile() && now - statSync(file).mtimeMs >= maxAge);
|
|
34
|
+
if (!options.dryRun) for (const file of removed) rmSync(file);
|
|
35
|
+
return { removed, out }
|
|
36
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { copyFileSync, existsSync, mkdirSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { readPng, writePng } from "#src/snapshots/png.mjs";
|
|
4
|
+
|
|
5
|
+
export const diffImages = (actual, expected, tolerance) => {
|
|
6
|
+
if (actual.width !== expected.width || actual.height !== expected.height) {
|
|
7
|
+
return { differentPixels: Math.max(actual.width * actual.height, expected.width * expected.height), sizeMismatch: true }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const total = actual.width * actual.height;
|
|
11
|
+
const diff = Buffer.alloc(total * 4);
|
|
12
|
+
let differentPixels = 0;
|
|
13
|
+
for (let index = 0; index < total; index += 1) {
|
|
14
|
+
const offset = index * 4;
|
|
15
|
+
let isDifferent = false;
|
|
16
|
+
for (let channel = 0; channel < 4; channel += 1) {
|
|
17
|
+
if (Math.abs(actual.rgba[offset + channel] - expected.rgba[offset + channel]) > tolerance) {
|
|
18
|
+
isDifferent = true;
|
|
19
|
+
break;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (isDifferent) {
|
|
24
|
+
differentPixels += 1;
|
|
25
|
+
diff[offset] = 255;
|
|
26
|
+
diff[offset + 1] = 0;
|
|
27
|
+
diff[offset + 2] = 0;
|
|
28
|
+
} else {
|
|
29
|
+
const grey = Math.round((actual.rgba[offset] + actual.rgba[offset + 1] + actual.rgba[offset + 2]) / 3);
|
|
30
|
+
diff[offset] = diff[offset + 1] = diff[offset + 2] = 128 + (grey >> 2);
|
|
31
|
+
}
|
|
32
|
+
diff[offset + 3] = 255;
|
|
33
|
+
}
|
|
34
|
+
return { differentPixels, total, diff: { width: actual.width, height: actual.height, rgba: diff } }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const compareSnapshots = ({ saved, baselineDir, updateBaseline, tolerance, maxDiffPixels, log }) => {
|
|
38
|
+
mkdirSync(baselineDir, { recursive: true });
|
|
39
|
+
let failed = false;
|
|
40
|
+
for (const file of saved) {
|
|
41
|
+
const name = path.basename(file);
|
|
42
|
+
const baseline = path.join(baselineDir, name);
|
|
43
|
+
if (updateBaseline || !existsSync(baseline)) {
|
|
44
|
+
copyFileSync(file, baseline);
|
|
45
|
+
log.info(`compare ${name}: ${updateBaseline ? "baseline updated" : "new baseline"} → ${baseline}`);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const result = diffImages(readPng(file), readPng(baseline), tolerance);
|
|
50
|
+
if (result.sizeMismatch) {
|
|
51
|
+
failed = true;
|
|
52
|
+
log.error(`compare ${name}: size changed, baseline ${baseline}`);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (result.differentPixels === 0) {
|
|
56
|
+
log.info(`compare ${name}: same`);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const diffFile = file.replace(/\.png$/, ".diff.png");
|
|
61
|
+
writePng(diffFile, result.diff);
|
|
62
|
+
const share = ((result.differentPixels / result.total) * 100).toFixed(2);
|
|
63
|
+
const message = `compare ${name}: ${result.differentPixels} px differ (${share}%) → ${diffFile}`;
|
|
64
|
+
if (result.differentPixels > maxDiffPixels) {
|
|
65
|
+
failed = true;
|
|
66
|
+
log.error(message);
|
|
67
|
+
} else {
|
|
68
|
+
log.info(`${message} (within maxDiffPixels ${maxDiffPixels})`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return failed;
|
|
72
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import zlib from "node:zlib";
|
|
3
|
+
|
|
4
|
+
const SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
|
5
|
+
const CHANNELS = { 0: 1, 2: 3, 4: 2, 6: 4 }
|
|
6
|
+
|
|
7
|
+
const paeth = (left, up, upLeft) => {
|
|
8
|
+
const estimate = left + up - upLeft;
|
|
9
|
+
const toLeft = Math.abs(estimate - left);
|
|
10
|
+
const toUp = Math.abs(estimate - up);
|
|
11
|
+
const toUpLeft = Math.abs(estimate - upLeft);
|
|
12
|
+
if (toLeft <= toUp && toLeft <= toUpLeft) return left;
|
|
13
|
+
return toUp <= toUpLeft ? up : upLeft;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const readPng = file => {
|
|
17
|
+
const data = readFileSync(file);
|
|
18
|
+
if (!data.subarray(0, 8).equals(SIGNATURE)) throw new Error(`${file} is not a PNG`);
|
|
19
|
+
let offset = 8;
|
|
20
|
+
let header = null;
|
|
21
|
+
const chunks = [];
|
|
22
|
+
while (offset < data.length) {
|
|
23
|
+
const length = data.readUInt32BE(offset);
|
|
24
|
+
const type = data.toString("ascii", offset + 4, offset + 8);
|
|
25
|
+
const body = data.subarray(offset + 8, offset + 8 + length);
|
|
26
|
+
if (type === "IHDR") {
|
|
27
|
+
header = {
|
|
28
|
+
width: body.readUInt32BE(0),
|
|
29
|
+
height: body.readUInt32BE(4),
|
|
30
|
+
bitDepth: body[8],
|
|
31
|
+
colorType: body[9],
|
|
32
|
+
interlace: body[12]
|
|
33
|
+
}
|
|
34
|
+
} else if (type === "IDAT") {
|
|
35
|
+
chunks.push(body);
|
|
36
|
+
} else if (type === "IEND") {
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
offset += length + 12;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (!header) throw new Error(`${file}: no IHDR`);
|
|
43
|
+
const channels = CHANNELS[header.colorType];
|
|
44
|
+
if (header.bitDepth !== 8 || !channels || header.interlace !== 0) {
|
|
45
|
+
throw new Error(`${file}: only 8-bit, non-interlaced grey/RGB/RGBA PNGs are supported`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const raw = zlib.inflateSync(Buffer.concat(chunks));
|
|
49
|
+
const { width, height } = header;
|
|
50
|
+
const stride = width * channels;
|
|
51
|
+
const pixels = Buffer.alloc(stride * height);
|
|
52
|
+
for (let y = 0; y < height; y += 1) {
|
|
53
|
+
const filter = raw[y * (stride + 1)];
|
|
54
|
+
const line = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1));
|
|
55
|
+
const row = y * stride;
|
|
56
|
+
for (let x = 0; x < stride; x += 1) {
|
|
57
|
+
const left = x >= channels ? pixels[row + x - channels] : 0;
|
|
58
|
+
const up = y > 0 ? pixels[row - stride + x] : 0;
|
|
59
|
+
const upLeft = y > 0 && x >= channels ? pixels[row - stride + x - channels] : 0;
|
|
60
|
+
const predictor = filter === 1 ? left : filter === 2 ? up : filter === 3 ? (left + up) >> 1 : filter === 4 ? paeth(left, up, upLeft) : 0;
|
|
61
|
+
pixels[row + x] = (line[x] + predictor) & 255;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const rgba = Buffer.alloc(width * height * 4);
|
|
66
|
+
for (let index = 0; index < width * height; index += 1) {
|
|
67
|
+
const source = index * channels;
|
|
68
|
+
const target = index * 4;
|
|
69
|
+
if (channels >= 3) {
|
|
70
|
+
rgba[target] = pixels[source];
|
|
71
|
+
rgba[target + 1] = pixels[source + 1];
|
|
72
|
+
rgba[target + 2] = pixels[source + 2];
|
|
73
|
+
} else {
|
|
74
|
+
rgba[target] = rgba[target + 1] = rgba[target + 2] = pixels[source];
|
|
75
|
+
}
|
|
76
|
+
rgba[target + 3] = channels === 4 ? pixels[source + 3] : channels === 2 ? pixels[source + 1] : 255;
|
|
77
|
+
}
|
|
78
|
+
return { width, height, rgba }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const CRC_TABLE = Array.from({ length: 256 }, (_, index) => {
|
|
82
|
+
let value = index;
|
|
83
|
+
for (let bit = 0; bit < 8; bit += 1) value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
|
|
84
|
+
return value >>> 0;
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const crc32 = buffer => {
|
|
88
|
+
let value = 0xffffffff;
|
|
89
|
+
for (const byte of buffer) value = CRC_TABLE[(value ^ byte) & 255] ^ (value >>> 8);
|
|
90
|
+
return (value ^ 0xffffffff) >>> 0;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const chunk = (type, body) => {
|
|
94
|
+
const length = Buffer.alloc(4);
|
|
95
|
+
length.writeUInt32BE(body.length);
|
|
96
|
+
const typed = Buffer.concat([Buffer.from(type, "ascii"), body]);
|
|
97
|
+
const crc = Buffer.alloc(4);
|
|
98
|
+
crc.writeUInt32BE(crc32(typed));
|
|
99
|
+
return Buffer.concat([length, typed, crc]);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export const writePng = (file, { width, height, rgba }) => {
|
|
103
|
+
const header = Buffer.alloc(13);
|
|
104
|
+
header.writeUInt32BE(width, 0);
|
|
105
|
+
header.writeUInt32BE(height, 4);
|
|
106
|
+
header[8] = 8;
|
|
107
|
+
header[9] = 6;
|
|
108
|
+
const raw = Buffer.alloc((width * 4 + 1) * height);
|
|
109
|
+
for (let y = 0; y < height; y += 1) {
|
|
110
|
+
rgba.copy(raw, y * (width * 4 + 1) + 1, y * width * 4, (y + 1) * width * 4);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
writeFileSync(file, Buffer.concat([
|
|
114
|
+
SIGNATURE,
|
|
115
|
+
chunk("IHDR", header),
|
|
116
|
+
chunk("IDAT", zlib.deflateSync(raw)),
|
|
117
|
+
chunk("IEND", Buffer.alloc(0))
|
|
118
|
+
]));
|
|
119
|
+
}
|