@g9tm/app-pilot-cli 0.0.0-stage → 0.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.
- package/LICENSE +16 -0
- package/bin/g9pilot.mjs +1594 -0
- package/package.json +26 -4
- package/src/design.mjs +346 -0
- package/src/visual.mjs +401 -0
- package/README.md +0 -3
package/src/visual.mjs
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
// Visual comparison (plan §5.4): a screenshot against an approved reference, per platform, theme and language.
|
|
2
|
+
//
|
|
3
|
+
// No dependency, on purpose: the CLI has none. PNG is read and written with node:zlib, and the comparison is the
|
|
4
|
+
// pixelmatch method (YIQ colour distance with a perceptual threshold, anti-aliased edge pixels detected and not
|
|
5
|
+
// counted). The screenshot must be lossless: a JPEG's block noise would be "a difference" everywhere, so `capture`
|
|
6
|
+
// takes the OS's own PNG (adb screencap -p, simctl io screenshot) and never the bridge's JPEG ui.screenshot.
|
|
7
|
+
//
|
|
8
|
+
// Layout of the references (g9pilot.json "visual": { "dir", "variant", "threshold", "maxDiffRatio", "masks" }):
|
|
9
|
+
// <dir>/<variant>/<name>.png the approved reference
|
|
10
|
+
// <dir>/<variant>/<name>.new.png the last capture that did not match (or had no reference yet)
|
|
11
|
+
// <dir>/<variant>/<name>.diff.png what differed: red = counted, yellow = anti-aliasing, grey = the unchanged page
|
|
12
|
+
// Approving moves .new.png over .png. Nothing here commits.
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import zlib from 'node:zlib';
|
|
16
|
+
import { execFileSync } from 'node:child_process';
|
|
17
|
+
|
|
18
|
+
const SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10];
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------- PNG
|
|
21
|
+
|
|
22
|
+
/** PNG bytes → { width, height, data: RGBA Uint8Array }. 8-bit grey, grey+alpha, RGB, RGBA and palette; no interlace. */
|
|
23
|
+
export function decodePng(buffer) {
|
|
24
|
+
const buf = Buffer.from(buffer);
|
|
25
|
+
for (let i = 0; i < 8; i++) if (buf[i] !== SIGNATURE[i]) throw new Error('not a PNG file');
|
|
26
|
+
let width = 0;
|
|
27
|
+
let height = 0;
|
|
28
|
+
let depth = 0;
|
|
29
|
+
let colour = 0;
|
|
30
|
+
let interlace = 0;
|
|
31
|
+
let palette = null;
|
|
32
|
+
let paletteAlpha = null;
|
|
33
|
+
const idat = [];
|
|
34
|
+
for (let at = 8; at < buf.length; ) {
|
|
35
|
+
const length = buf.readUInt32BE(at);
|
|
36
|
+
const type = buf.toString('latin1', at + 4, at + 8);
|
|
37
|
+
const body = buf.subarray(at + 8, at + 8 + length);
|
|
38
|
+
if (type === 'IHDR') {
|
|
39
|
+
width = body.readUInt32BE(0);
|
|
40
|
+
height = body.readUInt32BE(4);
|
|
41
|
+
depth = body[8];
|
|
42
|
+
colour = body[9];
|
|
43
|
+
interlace = body[12];
|
|
44
|
+
} else if (type === 'PLTE') palette = body;
|
|
45
|
+
else if (type === 'tRNS') paletteAlpha = body;
|
|
46
|
+
else if (type === 'IDAT') idat.push(body);
|
|
47
|
+
else if (type === 'IEND') break;
|
|
48
|
+
at += 12 + length;
|
|
49
|
+
}
|
|
50
|
+
if (!width || !height) throw new Error('PNG without a header');
|
|
51
|
+
if (depth !== 8) throw new Error(`PNG bit depth ${depth} is not supported (8 only)`);
|
|
52
|
+
if (interlace) throw new Error('interlaced PNG is not supported');
|
|
53
|
+
const channels = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 }[colour];
|
|
54
|
+
if (!channels) throw new Error(`PNG colour type ${colour} is not supported`);
|
|
55
|
+
if (colour === 3 && !palette) throw new Error('palette PNG without PLTE');
|
|
56
|
+
const raw = zlib.inflateSync(Buffer.concat(idat));
|
|
57
|
+
const stride = width * channels;
|
|
58
|
+
const pixels = Buffer.alloc(stride * height);
|
|
59
|
+
let prev = Buffer.alloc(stride);
|
|
60
|
+
for (let y = 0; y < height; y++) {
|
|
61
|
+
const filter = raw[y * (stride + 1)];
|
|
62
|
+
const line = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1));
|
|
63
|
+
const out = pixels.subarray(y * stride, (y + 1) * stride);
|
|
64
|
+
for (let x = 0; x < stride; x++) {
|
|
65
|
+
const a = x >= channels ? out[x - channels] : 0;
|
|
66
|
+
const b = prev[x];
|
|
67
|
+
const c = x >= channels ? prev[x - channels] : 0;
|
|
68
|
+
let v = line[x];
|
|
69
|
+
if (filter === 1) v += a;
|
|
70
|
+
else if (filter === 2) v += b;
|
|
71
|
+
else if (filter === 3) v += (a + b) >> 1;
|
|
72
|
+
else if (filter === 4) v += paeth(a, b, c);
|
|
73
|
+
else if (filter !== 0) throw new Error(`PNG row filter ${filter} is unknown`);
|
|
74
|
+
out[x] = v & 255;
|
|
75
|
+
}
|
|
76
|
+
prev = out;
|
|
77
|
+
}
|
|
78
|
+
const data = new Uint8Array(width * height * 4);
|
|
79
|
+
for (let i = 0, p = 0; i < width * height; i++, p += channels) {
|
|
80
|
+
const o = i * 4;
|
|
81
|
+
if (colour === 6) {
|
|
82
|
+
data[o] = pixels[p]; data[o + 1] = pixels[p + 1]; data[o + 2] = pixels[p + 2]; data[o + 3] = pixels[p + 3];
|
|
83
|
+
} else if (colour === 2) {
|
|
84
|
+
data[o] = pixels[p]; data[o + 1] = pixels[p + 1]; data[o + 2] = pixels[p + 2]; data[o + 3] = 255;
|
|
85
|
+
} else if (colour === 0 || colour === 4) {
|
|
86
|
+
data[o] = data[o + 1] = data[o + 2] = pixels[p];
|
|
87
|
+
data[o + 3] = colour === 4 ? pixels[p + 1] : 255;
|
|
88
|
+
} else {
|
|
89
|
+
const k = pixels[p];
|
|
90
|
+
data[o] = palette[k * 3]; data[o + 1] = palette[k * 3 + 1]; data[o + 2] = palette[k * 3 + 2];
|
|
91
|
+
data[o + 3] = paletteAlpha && k < paletteAlpha.length ? paletteAlpha[k] : 255;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return { width, height, data };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function paeth(a, b, c) {
|
|
98
|
+
const p = a + b - c;
|
|
99
|
+
const pa = Math.abs(p - a);
|
|
100
|
+
const pb = Math.abs(p - b);
|
|
101
|
+
const pc = Math.abs(p - c);
|
|
102
|
+
return pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const CRC_TABLE = (() => {
|
|
106
|
+
const t = new Int32Array(256);
|
|
107
|
+
for (let n = 0; n < 256; n++) {
|
|
108
|
+
let c = n;
|
|
109
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
110
|
+
t[n] = c;
|
|
111
|
+
}
|
|
112
|
+
return t;
|
|
113
|
+
})();
|
|
114
|
+
|
|
115
|
+
function crc32(bytes) {
|
|
116
|
+
let c = -1;
|
|
117
|
+
for (const b of bytes) c = CRC_TABLE[(c ^ b) & 255] ^ (c >>> 8);
|
|
118
|
+
return (c ^ -1) >>> 0;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function chunk(type, body) {
|
|
122
|
+
const head = Buffer.alloc(8);
|
|
123
|
+
head.writeUInt32BE(body.length, 0);
|
|
124
|
+
head.write(type, 4, 'latin1');
|
|
125
|
+
const tail = Buffer.alloc(4);
|
|
126
|
+
tail.writeUInt32BE(crc32(Buffer.concat([head.subarray(4), body])), 0);
|
|
127
|
+
return Buffer.concat([head, body, tail]);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** { width, height, data: RGBA } → PNG bytes (RGBA, 8-bit). `filter` (0–4) is for tests of the reader. */
|
|
131
|
+
export function encodePng({ width, height, data }, { filter = 0 } = {}) {
|
|
132
|
+
const stride = width * 4;
|
|
133
|
+
const raw = Buffer.alloc((stride + 1) * height);
|
|
134
|
+
for (let y = 0; y < height; y++) {
|
|
135
|
+
raw[y * (stride + 1)] = filter;
|
|
136
|
+
for (let x = 0; x < stride; x++) {
|
|
137
|
+
const v = data[y * stride + x];
|
|
138
|
+
const a = x >= 4 ? data[y * stride + x - 4] : 0;
|
|
139
|
+
const b = y > 0 ? data[(y - 1) * stride + x] : 0;
|
|
140
|
+
const c = x >= 4 && y > 0 ? data[(y - 1) * stride + x - 4] : 0;
|
|
141
|
+
const pred = filter === 1 ? a : filter === 2 ? b : filter === 3 ? (a + b) >> 1 : filter === 4 ? paeth(a, b, c) : 0;
|
|
142
|
+
raw[y * (stride + 1) + 1 + x] = (v - pred) & 255;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const header = Buffer.alloc(13);
|
|
146
|
+
header.writeUInt32BE(width, 0);
|
|
147
|
+
header.writeUInt32BE(height, 4);
|
|
148
|
+
header[8] = 8;
|
|
149
|
+
header[9] = 6;
|
|
150
|
+
return Buffer.concat([Buffer.from(SIGNATURE), chunk('IHDR', header), chunk('IDAT', zlib.deflateSync(raw)), chunk('IEND', Buffer.alloc(0))]);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ---------------------------------------------------------------- comparison
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Masks → pixel rectangles. A mask is { x, y, w, h }: values of 1 or less are fractions of the image (a status bar is
|
|
157
|
+
* { x: 0, y: 0, w: 1, h: 0.04 }), larger ones are pixels. Masked pixels are never compared: a clock, a live map.
|
|
158
|
+
*/
|
|
159
|
+
export function maskRects(masks, width, height) {
|
|
160
|
+
const px = (v, whole) => (Math.abs(v) <= 1 ? Math.round(v * whole) : Math.round(v));
|
|
161
|
+
return (masks ?? []).map((m) => {
|
|
162
|
+
const x = Math.max(0, px(m.x ?? 0, width));
|
|
163
|
+
const y = Math.max(0, px(m.y ?? 0, height));
|
|
164
|
+
return { x, y, x2: Math.min(width, x + px(m.w ?? 0, width)), y2: Math.min(height, y + px(m.h ?? 0, height)) };
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Two RGBA images of one size → { diff, antialiased, masked, total, ratio, image }. `threshold` 0–1 is the colour
|
|
170
|
+
* distance a pixel may move unseen (0.1 = the pixelmatch default); `includeAA` counts anti-aliased pixels too.
|
|
171
|
+
* `image` is the diff picture (RGBA) to write with encodePng.
|
|
172
|
+
*/
|
|
173
|
+
export function compareImages(a, b, { threshold = 0.1, includeAA = false, masks = [] } = {}) {
|
|
174
|
+
if (a.width !== b.width || a.height !== b.height) {
|
|
175
|
+
const error = new Error(`sizes differ: reference ${a.width}×${a.height}, capture ${b.width}×${b.height}`);
|
|
176
|
+
error.code = 'SIZE';
|
|
177
|
+
throw error;
|
|
178
|
+
}
|
|
179
|
+
const { width, height } = a;
|
|
180
|
+
const rects = maskRects(masks, width, height);
|
|
181
|
+
const out = new Uint8Array(width * height * 4);
|
|
182
|
+
const maxDelta = 35215 * threshold * threshold;
|
|
183
|
+
let diff = 0;
|
|
184
|
+
let aa = 0;
|
|
185
|
+
let masked = 0;
|
|
186
|
+
for (let y = 0; y < height; y++) {
|
|
187
|
+
for (let x = 0; x < width; x++) {
|
|
188
|
+
const pos = (y * width + x) * 4;
|
|
189
|
+
if (rects.some((r) => x >= r.x && x < r.x2 && y >= r.y && y < r.y2)) {
|
|
190
|
+
masked++;
|
|
191
|
+
paint(out, pos, 70, 110, 220);
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
const delta = colourDelta(a.data, b.data, pos, pos, false);
|
|
195
|
+
if (Math.abs(delta) > maxDelta) {
|
|
196
|
+
if (!includeAA && (antialiased(a.data, x, y, width, height, b.data) || antialiased(b.data, x, y, width, height, a.data))) {
|
|
197
|
+
aa++;
|
|
198
|
+
paint(out, pos, 255, 210, 0);
|
|
199
|
+
} else {
|
|
200
|
+
diff++;
|
|
201
|
+
paint(out, pos, 230, 20, 20);
|
|
202
|
+
}
|
|
203
|
+
} else {
|
|
204
|
+
const grey = 255 - 0.1 * (255 - blend(luma(a.data[pos], a.data[pos + 1], a.data[pos + 2]), a.data[pos + 3] / 255));
|
|
205
|
+
paint(out, pos, grey, grey, grey);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
const total = width * height - masked;
|
|
210
|
+
return { diff, antialiased: aa, masked, total, ratio: total ? diff / total : 0, width, height, image: { width, height, data: out } };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function paint(out, pos, r, g, b) {
|
|
214
|
+
out[pos] = r;
|
|
215
|
+
out[pos + 1] = g;
|
|
216
|
+
out[pos + 2] = b;
|
|
217
|
+
out[pos + 3] = 255;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const blend = (c, alpha) => 255 + (c - 255) * alpha;
|
|
221
|
+
const luma = (r, g, b) => r * 0.29889531 + g * 0.58662247 + b * 0.11448223;
|
|
222
|
+
const chromaI = (r, g, b) => r * 0.59597799 - g * 0.2741761 - b * 0.32180189;
|
|
223
|
+
const chromaQ = (r, g, b) => r * 0.21147017 - g * 0.52261711 + b * 0.31114694;
|
|
224
|
+
|
|
225
|
+
/** The YIQ distance of two pixels over white (signed: lighter or darker), or only the brightness part. */
|
|
226
|
+
function colourDelta(img1, img2, k, m, yOnly) {
|
|
227
|
+
let r1 = img1[k];
|
|
228
|
+
let g1 = img1[k + 1];
|
|
229
|
+
let b1 = img1[k + 2];
|
|
230
|
+
let r2 = img2[m];
|
|
231
|
+
let g2 = img2[m + 1];
|
|
232
|
+
let b2 = img2[m + 2];
|
|
233
|
+
const a1 = img1[k + 3];
|
|
234
|
+
const a2 = img2[m + 3];
|
|
235
|
+
if (a1 === a2 && r1 === r2 && g1 === g2 && b1 === b2) return 0;
|
|
236
|
+
if (a1 < 255) {
|
|
237
|
+
r1 = blend(r1, a1 / 255);
|
|
238
|
+
g1 = blend(g1, a1 / 255);
|
|
239
|
+
b1 = blend(b1, a1 / 255);
|
|
240
|
+
}
|
|
241
|
+
if (a2 < 255) {
|
|
242
|
+
r2 = blend(r2, a2 / 255);
|
|
243
|
+
g2 = blend(g2, a2 / 255);
|
|
244
|
+
b2 = blend(b2, a2 / 255);
|
|
245
|
+
}
|
|
246
|
+
const y1 = luma(r1, g1, b1);
|
|
247
|
+
const y2 = luma(r2, g2, b2);
|
|
248
|
+
const dy = y1 - y2;
|
|
249
|
+
if (yOnly) return dy;
|
|
250
|
+
const di = chromaI(r1, g1, b1) - chromaI(r2, g2, b2);
|
|
251
|
+
const dq = chromaQ(r1, g1, b1) - chromaQ(r2, g2, b2);
|
|
252
|
+
const d = 0.5053 * dy * dy + 0.299 * di * di + 0.1957 * dq * dq;
|
|
253
|
+
return y1 > y2 ? -d : d;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Is (x1, y1) an anti-aliased edge pixel? Among its eight neighbours it has both a darker and a lighter extreme, few
|
|
258
|
+
* equal ones, and the extremes sit in a flat area in both images — the soft rim of a line, not a changed control.
|
|
259
|
+
*/
|
|
260
|
+
function antialiased(img, x1, y1, width, height, img2) {
|
|
261
|
+
const x0 = Math.max(x1 - 1, 0);
|
|
262
|
+
const y0 = Math.max(y1 - 1, 0);
|
|
263
|
+
const x2 = Math.min(x1 + 1, width - 1);
|
|
264
|
+
const y2 = Math.min(y1 + 1, height - 1);
|
|
265
|
+
const pos = (y1 * width + x1) * 4;
|
|
266
|
+
let zeroes = x1 === x0 || x1 === x2 || y1 === y0 || y1 === y2 ? 1 : 0;
|
|
267
|
+
let min = 0;
|
|
268
|
+
let max = 0;
|
|
269
|
+
let minX = 0;
|
|
270
|
+
let minY = 0;
|
|
271
|
+
let maxX = 0;
|
|
272
|
+
let maxY = 0;
|
|
273
|
+
for (let x = x0; x <= x2; x++) {
|
|
274
|
+
for (let y = y0; y <= y2; y++) {
|
|
275
|
+
if (x === x1 && y === y1) continue;
|
|
276
|
+
const delta = colourDelta(img, img, pos, (y * width + x) * 4, true);
|
|
277
|
+
if (delta === 0) {
|
|
278
|
+
zeroes++;
|
|
279
|
+
if (zeroes > 2) return false;
|
|
280
|
+
} else if (delta < min) {
|
|
281
|
+
min = delta;
|
|
282
|
+
minX = x;
|
|
283
|
+
minY = y;
|
|
284
|
+
} else if (delta > max) {
|
|
285
|
+
max = delta;
|
|
286
|
+
maxX = x;
|
|
287
|
+
maxY = y;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
if (min === 0 || max === 0) return false;
|
|
292
|
+
return (manySiblings(img, minX, minY, width, height) && manySiblings(img2, minX, minY, width, height))
|
|
293
|
+
|| (manySiblings(img, maxX, maxY, width, height) && manySiblings(img2, maxX, maxY, width, height));
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function manySiblings(img, x1, y1, width, height) {
|
|
297
|
+
const x0 = Math.max(x1 - 1, 0);
|
|
298
|
+
const y0 = Math.max(y1 - 1, 0);
|
|
299
|
+
const x2 = Math.min(x1 + 1, width - 1);
|
|
300
|
+
const y2 = Math.min(y1 + 1, height - 1);
|
|
301
|
+
const pos = (y1 * width + x1) * 4;
|
|
302
|
+
let zeroes = x1 === x0 || x1 === x2 || y1 === y0 || y1 === y2 ? 1 : 0;
|
|
303
|
+
for (let x = x0; x <= x2; x++) {
|
|
304
|
+
for (let y = y0; y <= y2; y++) {
|
|
305
|
+
if (x === x1 && y === y1) continue;
|
|
306
|
+
const p = (y * width + x) * 4;
|
|
307
|
+
if (img[pos] === img[p] && img[pos + 1] === img[p + 1] && img[pos + 2] === img[p + 2] && img[pos + 3] === img[p + 3]) zeroes++;
|
|
308
|
+
if (zeroes > 2) return true;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ---------------------------------------------------------------- references on disk
|
|
315
|
+
|
|
316
|
+
/** The "visual" settings of g9pilot.json, resolved against the config file's folder. */
|
|
317
|
+
export function visualSettings(config, baseDir, flags = {}) {
|
|
318
|
+
const v = config.visual ?? {};
|
|
319
|
+
const variant = String(flags.variant ?? v.variant ?? 'default');
|
|
320
|
+
if (!/^[A-Za-z0-9._-]+$/.test(variant)) throw new Error(`variant "${variant}": letters, digits, . _ - only`);
|
|
321
|
+
return {
|
|
322
|
+
dir: path.resolve(baseDir, String(flags.dir ?? v.dir ?? 'visual')),
|
|
323
|
+
variant,
|
|
324
|
+
threshold: Number(flags.threshold ?? v.threshold ?? 0.1),
|
|
325
|
+
maxDiffRatio: Number(flags['max-diff'] ?? v.maxDiffRatio ?? 0.001),
|
|
326
|
+
masks: v.masks ?? {},
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export function referencePaths(settings, name) {
|
|
331
|
+
if (!/^[A-Za-z0-9._-]+$/.test(name)) throw new Error(`name "${name}": letters, digits, . _ - only`);
|
|
332
|
+
const folder = path.join(settings.dir, settings.variant);
|
|
333
|
+
return { folder, reference: path.join(folder, `${name}.png`), capture: path.join(folder, `${name}.new.png`), diff: path.join(folder, `${name}.diff.png`) };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Compares a PNG capture with the reference `name` → { verdict: 'match' | 'differs' | 'new' | 'size', … }.
|
|
338
|
+
* 'new' (no reference yet) and 'differs' leave <name>.new.png to approve; 'differs' also <name>.diff.png.
|
|
339
|
+
*/
|
|
340
|
+
export function checkVisual(settings, name, png) {
|
|
341
|
+
const p = referencePaths(settings, name);
|
|
342
|
+
fs.mkdirSync(p.folder, { recursive: true });
|
|
343
|
+
const capture = decodePng(png);
|
|
344
|
+
if (!fs.existsSync(p.reference)) {
|
|
345
|
+
fs.writeFileSync(p.capture, png);
|
|
346
|
+
return { verdict: 'new', name, variant: settings.variant, capture: p.capture, width: capture.width, height: capture.height };
|
|
347
|
+
}
|
|
348
|
+
const reference = decodePng(fs.readFileSync(p.reference));
|
|
349
|
+
const masks = [...(settings.masks['*'] ?? []), ...(settings.masks[name] ?? [])];
|
|
350
|
+
let r;
|
|
351
|
+
try {
|
|
352
|
+
r = compareImages(reference, capture, { threshold: settings.threshold, masks });
|
|
353
|
+
} catch (e) {
|
|
354
|
+
if (e.code !== 'SIZE') throw e;
|
|
355
|
+
fs.writeFileSync(p.capture, png);
|
|
356
|
+
return { verdict: 'size', name, variant: settings.variant, message: e.message, capture: p.capture };
|
|
357
|
+
}
|
|
358
|
+
const summary = { name, variant: settings.variant, diff: r.diff, antialiased: r.antialiased, masked: r.masked, total: r.total, ratio: r.ratio, maxDiffRatio: settings.maxDiffRatio };
|
|
359
|
+
if (r.ratio <= settings.maxDiffRatio) {
|
|
360
|
+
for (const f of [p.capture, p.diff]) fs.rmSync(f, { force: true });
|
|
361
|
+
return { verdict: 'match', ...summary };
|
|
362
|
+
}
|
|
363
|
+
fs.writeFileSync(p.capture, png);
|
|
364
|
+
fs.writeFileSync(p.diff, encodePng(r.image));
|
|
365
|
+
return { verdict: 'differs', ...summary, capture: p.capture, diffImage: p.diff };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** The last capture of `name` becomes its reference (no commit; the reviewer looked at the .diff.png first). */
|
|
369
|
+
export function approveVisual(settings, name) {
|
|
370
|
+
const p = referencePaths(settings, name);
|
|
371
|
+
if (!fs.existsSync(p.capture)) throw new Error(`nothing to approve: ${p.capture} does not exist (run visual check first)`);
|
|
372
|
+
fs.renameSync(p.capture, p.reference);
|
|
373
|
+
fs.rmSync(p.diff, { force: true });
|
|
374
|
+
return p.reference;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export function listVisual(settings) {
|
|
378
|
+
if (!fs.existsSync(settings.dir)) return [];
|
|
379
|
+
const rows = [];
|
|
380
|
+
for (const variant of fs.readdirSync(settings.dir)) {
|
|
381
|
+
const folder = path.join(settings.dir, variant);
|
|
382
|
+
if (!fs.statSync(folder).isDirectory()) continue;
|
|
383
|
+
for (const f of fs.readdirSync(folder)) {
|
|
384
|
+
if (!f.endsWith('.png') || f.endsWith('.new.png') || f.endsWith('.diff.png')) continue;
|
|
385
|
+
const name = f.slice(0, -4);
|
|
386
|
+
rows.push({ variant, name, pending: fs.existsSync(path.join(folder, `${name}.new.png`)) });
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
return rows;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** A lossless screenshot from the OS: an adb serial (screencap -p) or a booted simulator (simctl io screenshot). */
|
|
393
|
+
export function captureOsPng({ serial, simulator, adb = 'adb' }) {
|
|
394
|
+
if (serial) return execFileSync(adb, ['-s', serial, 'exec-out', 'screencap', '-p'], { timeout: 30_000, maxBuffer: 64 * 1024 * 1024 });
|
|
395
|
+
if (simulator) {
|
|
396
|
+
const file = path.join(fs.mkdtempSync(path.join(process.env.TMPDIR ?? '/tmp', 'g9pilot-')), 'shot.png');
|
|
397
|
+
execFileSync('xcrun', ['simctl', 'io', String(simulator), 'screenshot', '--type=png', file], { timeout: 30_000 });
|
|
398
|
+
return fs.readFileSync(file);
|
|
399
|
+
}
|
|
400
|
+
throw new Error('a lossless capture needs --serial <adb serial>, --simulator <udid> or --file <png>');
|
|
401
|
+
}
|
package/README.md
DELETED