webview-preview 0.0.0-stage → 0.0.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 +207 -0
- package/LICENSE +21 -0
- package/README.md +623 -2
- package/bin/webview-preview.mjs +138 -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 +169 -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 +86 -0
- package/src/index.mjs +81 -0
- package/src/page/init.mjs +32 -0
- package/src/page/mock.mjs +117 -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,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
|
+
}
|