basis-design 0.1.0 → 0.2.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/README.md +5 -3
- package/capture.html +12 -0
- package/package.json +72 -68
- package/server/agent.ts +33 -12
- package/server/brief.ts +32 -3
- package/server/capture.ts +128 -0
- package/server/claude.ts +72 -0
- package/server/folders.ts +125 -0
- package/server/index.ts +134 -3
- package/server/project.ts +9 -1
- package/server/setup.ts +201 -219
- package/server/template.ts +168 -0
- package/server/verify-kit/capture.mjs +206 -0
- package/server/verify-kit/compare.d.mts +39 -0
- package/server/verify-kit/compare.mjs +307 -0
- package/server/verify.ts +383 -0
- package/server/workspace.ts +19 -1
- package/shared/capture.ts +75 -0
- package/shared/protocol.ts +45 -2
- package/src/Workbench.tsx +4 -1
- package/src/bridge.ts +173 -163
- package/src/capture/capture.css +50 -0
- package/src/capture/main.tsx +146 -0
- package/src/chat/ChatPanel.tsx +10 -1
- package/src/preview/specimens.tsx +21 -0
- package/src/shell/ExportDialog.tsx +41 -4
- package/src/shell/FolderField.tsx +131 -0
- package/src/shell/ProjectMenu.tsx +58 -21
- package/src/shell/Shell.module.css +1146 -1067
- package/src/shell/VerifyKit.tsx +133 -0
- package/template/CHANGES.md +25 -0
- package/template/components/Field.module.css +10 -0
- package/template/components/Field.tsx +6 -0
- package/template/components/specimen.ts +24 -1
- package/template/history.json +123 -0
- package/template/patterns/Navbar.module.css +38 -12
- package/template/patterns/Navbar.specimen.tsx +1 -1
- package/template/patterns/Navbar.tsx +27 -3
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/*
|
|
3
|
+
* Takes the verification kit's screenshots on this machine, with the settings Basis used (basis-verify.json):
|
|
4
|
+
*
|
|
5
|
+
* node basis-verify/capture.mjs design [--out <dir>] the design, into expected/
|
|
6
|
+
* node basis-verify/capture.mjs app [--targets <file>] [--out <dir>] the implementation, into actual/
|
|
7
|
+
*
|
|
8
|
+
* Options for both: --only <view>[/<screen>] to capture part of the kit, --channel chrome|msedge to use an
|
|
9
|
+
* installed browser instead of Playwright's Chromium.
|
|
10
|
+
*
|
|
11
|
+
* Needs Playwright in the project (`npm i -D playwright` and `npx playwright install chromium`). Capture both
|
|
12
|
+
* sides with the same machine and browser: fonts and antialiasing differ between systems, so only images taken
|
|
13
|
+
* the same way can match pixel for pixel. Compare them with compare.mjs.
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync } from "node:fs";
|
|
16
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
17
|
+
import http from "node:http";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
|
|
21
|
+
const kit = path.dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
const args = process.argv.slice(2);
|
|
23
|
+
const mode = args[0];
|
|
24
|
+
|
|
25
|
+
function option(name, fallback) {
|
|
26
|
+
const index = args.indexOf(name);
|
|
27
|
+
return index === -1 ? fallback : args[index + 1];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function playwright() {
|
|
31
|
+
for (const name of ["playwright", "playwright-core", "@playwright/test"]) {
|
|
32
|
+
try {
|
|
33
|
+
const module = await import(name);
|
|
34
|
+
return module.chromium ?? module.default?.chromium;
|
|
35
|
+
} catch {
|
|
36
|
+
// Not installed here; try the next one.
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
throw new Error(
|
|
40
|
+
"Playwright is not installed in this project. Run `npm i -D playwright` and `npx playwright install chromium`.",
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const TYPES = {
|
|
45
|
+
".html": "text/html; charset=utf-8",
|
|
46
|
+
".js": "text/javascript; charset=utf-8",
|
|
47
|
+
".css": "text/css; charset=utf-8",
|
|
48
|
+
".json": "application/json",
|
|
49
|
+
".svg": "image/svg+xml",
|
|
50
|
+
".png": "image/png",
|
|
51
|
+
".jpg": "image/jpeg",
|
|
52
|
+
".woff2": "font/woff2",
|
|
53
|
+
".woff": "font/woff",
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** Serves the kit's copy of the design on a free local port. */
|
|
57
|
+
async function serveDesign() {
|
|
58
|
+
const root = path.join(kit, "design");
|
|
59
|
+
const server = http.createServer(async (req, res) => {
|
|
60
|
+
const pathname = decodeURIComponent(new URL(req.url ?? "/", "http://local").pathname);
|
|
61
|
+
const file = path.join(root, pathname === "/" ? "index.html" : pathname);
|
|
62
|
+
if (!file.startsWith(root + path.sep)) {
|
|
63
|
+
res.writeHead(403).end();
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
const body = await readFile(file);
|
|
68
|
+
res.writeHead(200, { "Content-Type": TYPES[path.extname(file)] ?? "application/octet-stream" });
|
|
69
|
+
res.end(body);
|
|
70
|
+
} catch {
|
|
71
|
+
res.writeHead(404).end();
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
75
|
+
return { server, url: `http://127.0.0.1:${server.address().port}/index.html` };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Waits until fonts are loaded, images decoded and the layout keeps its size, as the design's page does. */
|
|
79
|
+
async function settle(page) {
|
|
80
|
+
await page.evaluate(async () => {
|
|
81
|
+
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
82
|
+
await document.fonts.ready;
|
|
83
|
+
await Promise.all([...document.images].map((image) => image.decode().catch(() => undefined)));
|
|
84
|
+
let last = "";
|
|
85
|
+
for (let attempt = 0; attempt < 40; attempt++) {
|
|
86
|
+
await wait(50);
|
|
87
|
+
const size = `${document.documentElement.scrollWidth}x${document.documentElement.scrollHeight}`;
|
|
88
|
+
if (size === last) break;
|
|
89
|
+
last = size;
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function targetFor(targets, view, screen) {
|
|
95
|
+
const entry = targets.screens?.[`${view}/${screen}`];
|
|
96
|
+
if (!entry) return undefined;
|
|
97
|
+
const target = typeof entry === "string" ? { url: entry } : entry;
|
|
98
|
+
return { ...target, url: new URL(target.url, targets.baseUrl ?? undefined).href };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function main() {
|
|
102
|
+
if (mode !== "design" && mode !== "app") {
|
|
103
|
+
console.error("Usage: node capture.mjs design|app [--targets <file>] [--out <dir>] [--only <view>[/<screen>]]");
|
|
104
|
+
process.exitCode = 2;
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const manifest = JSON.parse(await readFile(path.join(kit, "basis-verify.json"), "utf8"));
|
|
108
|
+
const { render } = manifest;
|
|
109
|
+
const out = option("--out")
|
|
110
|
+
? path.resolve(option("--out"))
|
|
111
|
+
: path.join(kit, mode === "design" ? "expected" : "actual");
|
|
112
|
+
const only = option("--only");
|
|
113
|
+
let targets = {};
|
|
114
|
+
const targetsFile = option("--targets") ? path.resolve(option("--targets")) : path.join(kit, "targets.json");
|
|
115
|
+
if (mode === "app") {
|
|
116
|
+
if (!existsSync(targetsFile)) {
|
|
117
|
+
throw new Error(`No targets file at ${targetsFile}. See README.md, "Capture the implementation".`);
|
|
118
|
+
}
|
|
119
|
+
targets = JSON.parse(await readFile(targetsFile, "utf8"));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const chromium = await playwright();
|
|
123
|
+
const browser = await chromium.launch({ headless: true, args: render.args, channel: option("--channel") });
|
|
124
|
+
console.log(
|
|
125
|
+
`Browser: ${browser.version()}${browser.version() === manifest.browser.version ? "" : ` (Basis used ${manifest.browser.version})`}`,
|
|
126
|
+
);
|
|
127
|
+
const design = mode === "design" ? await serveDesign() : undefined;
|
|
128
|
+
const context = await browser.newContext({
|
|
129
|
+
deviceScaleFactor: render.deviceScaleFactor,
|
|
130
|
+
reducedMotion: render.reducedMotion,
|
|
131
|
+
colorScheme: render.colorScheme,
|
|
132
|
+
viewport: { width: 1440, height: 900 },
|
|
133
|
+
...(mode === "app" && targets.storageState
|
|
134
|
+
? { storageState: path.resolve(path.dirname(targetsFile), targets.storageState) }
|
|
135
|
+
: {}),
|
|
136
|
+
});
|
|
137
|
+
const page = await context.newPage();
|
|
138
|
+
let taken = 0;
|
|
139
|
+
let skipped = 0;
|
|
140
|
+
const unstable = [];
|
|
141
|
+
try {
|
|
142
|
+
for (const view of manifest.views) {
|
|
143
|
+
for (const screen of view.screens) {
|
|
144
|
+
const key = `${view.id}/${screen.id}`;
|
|
145
|
+
if (only && only !== view.id && only !== key) continue;
|
|
146
|
+
const target = mode === "app" ? targetFor(targets, view.id, screen.id) : undefined;
|
|
147
|
+
if (mode === "app" && !target) {
|
|
148
|
+
console.log(`skip ${key}: no target in targets.json`);
|
|
149
|
+
skipped++;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
for (const shot of screen.shots) {
|
|
153
|
+
await page.setViewportSize({ width: shot.width, height: shot.viewportHeight });
|
|
154
|
+
await page.emulateMedia({ colorScheme: shot.theme });
|
|
155
|
+
const take = async () => {
|
|
156
|
+
if (design) {
|
|
157
|
+
const query = `?view=${encodeURIComponent(view.id)}&screen=${encodeURIComponent(screen.id)}&theme=${shot.theme}`;
|
|
158
|
+
await page.goto(design.url + query);
|
|
159
|
+
await page.waitForSelector(manifest.design.ready, { state: "attached", timeout: 60_000 });
|
|
160
|
+
} else {
|
|
161
|
+
await page.goto(target.url, { waitUntil: "networkidle" });
|
|
162
|
+
if (target.waitFor) await page.waitForSelector(target.waitFor, { timeout: 60_000 });
|
|
163
|
+
if (targets.themeAttribute) {
|
|
164
|
+
await page.evaluate(
|
|
165
|
+
([name, value]) => document.documentElement.setAttribute(name, value),
|
|
166
|
+
[targets.themeAttribute, shot.theme],
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
await page.addStyleTag({ content: render.neutralCss });
|
|
170
|
+
await settle(page);
|
|
171
|
+
}
|
|
172
|
+
return page.screenshot({ fullPage: shot.fullPage, animations: "disabled", caret: "hide" });
|
|
173
|
+
};
|
|
174
|
+
const first = await take();
|
|
175
|
+
const second = await take();
|
|
176
|
+
const name = shot.file.replace(/^screens\//, "");
|
|
177
|
+
const file = path.join(out, name);
|
|
178
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
179
|
+
await writeFile(file, first);
|
|
180
|
+
taken++;
|
|
181
|
+
const stable = first.equals(second);
|
|
182
|
+
if (!stable) unstable.push(name);
|
|
183
|
+
console.log(`${stable ? "ok " : "UNSTABLE"} ${name}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
} finally {
|
|
188
|
+
await context.close();
|
|
189
|
+
await browser.close();
|
|
190
|
+
design?.server.close();
|
|
191
|
+
}
|
|
192
|
+
console.log(
|
|
193
|
+
`\n${taken} shots in ${path.relative(process.cwd(), out) || "."}${skipped ? `, ${skipped} screens without a target` : ""}.`,
|
|
194
|
+
);
|
|
195
|
+
if (unstable.length) {
|
|
196
|
+
console.log(
|
|
197
|
+
`${unstable.length} shots differed between two captures, so they can never match exactly: ${unstable.join(", ")}. ` +
|
|
198
|
+
"Look for clocks, random values, animations or content that loads late.",
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
main().catch((error) => {
|
|
204
|
+
console.error(error instanceof Error ? error.message : error);
|
|
205
|
+
process.exitCode = 2;
|
|
206
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export interface Image {
|
|
2
|
+
width: number;
|
|
3
|
+
height: number;
|
|
4
|
+
data: Buffer;
|
|
5
|
+
}
|
|
6
|
+
export interface Region {
|
|
7
|
+
x: number;
|
|
8
|
+
y: number;
|
|
9
|
+
width: number;
|
|
10
|
+
height: number;
|
|
11
|
+
}
|
|
12
|
+
export interface Exception {
|
|
13
|
+
name: string;
|
|
14
|
+
reason?: string;
|
|
15
|
+
view: string;
|
|
16
|
+
screen?: string;
|
|
17
|
+
widths?: number[];
|
|
18
|
+
themes?: string[];
|
|
19
|
+
region?: Region;
|
|
20
|
+
}
|
|
21
|
+
export function decodePng(buffer: Buffer): Image;
|
|
22
|
+
export function encodePng(image: Image): Buffer;
|
|
23
|
+
export function compareImages(
|
|
24
|
+
expected: Image,
|
|
25
|
+
actual: Image,
|
|
26
|
+
options?: { tolerance?: number; regions?: Region[] },
|
|
27
|
+
): {
|
|
28
|
+
width: number;
|
|
29
|
+
height: number;
|
|
30
|
+
sameSize: boolean;
|
|
31
|
+
differing: number;
|
|
32
|
+
counted: number;
|
|
33
|
+
percent: number;
|
|
34
|
+
diff: Image;
|
|
35
|
+
};
|
|
36
|
+
export function exceptionsFor<T extends Exception>(
|
|
37
|
+
exceptions: T[],
|
|
38
|
+
shot: { view: string; screen: string; width: number; theme: string },
|
|
39
|
+
): T[];
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/*
|
|
3
|
+
* Compares the implementation's screenshots with the design's, pixel for pixel. Part of the verification kit
|
|
4
|
+
* Basis exports; it needs Node 22 and nothing else.
|
|
5
|
+
*
|
|
6
|
+
* node basis-verify/compare.mjs [--expected <dir>] [--actual <dir>] [--diff <dir>] [--max-diff <percent>]
|
|
7
|
+
* [--tolerance <0-255>]
|
|
8
|
+
*
|
|
9
|
+
* Expected images come from expected/ (the design captured on this machine by `capture.mjs design`), or from
|
|
10
|
+
* screens/ (captured by Basis) when expected/ has none. Actual images come from actual/ (`capture.mjs app`).
|
|
11
|
+
* Every shot in basis-verify.json is compared; named exceptions in exceptions.json leave regions, or whole
|
|
12
|
+
* shots, out. The command exits with 1 when any shot differs by more than --max-diff (default 0).
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync } from "node:fs";
|
|
15
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
18
|
+
import { crc32, deflateSync, inflateSync } from "node:zlib";
|
|
19
|
+
|
|
20
|
+
const SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
|
21
|
+
const CHANNELS = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 };
|
|
22
|
+
|
|
23
|
+
function paeth(a, b, c) {
|
|
24
|
+
const p = a + b - c;
|
|
25
|
+
const pa = Math.abs(p - a);
|
|
26
|
+
const pb = Math.abs(p - b);
|
|
27
|
+
const pc = Math.abs(p - c);
|
|
28
|
+
return pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Decodes an 8-bit, non-interlaced PNG (what browsers' screenshots are) into RGBA pixels. */
|
|
32
|
+
export function decodePng(buffer) {
|
|
33
|
+
if (!buffer.subarray(0, 8).equals(SIGNATURE)) throw new Error("Not a PNG file.");
|
|
34
|
+
let width = 0;
|
|
35
|
+
let height = 0;
|
|
36
|
+
let depth = 0;
|
|
37
|
+
let type = 0;
|
|
38
|
+
let interlace = 0;
|
|
39
|
+
let palette;
|
|
40
|
+
let transparency;
|
|
41
|
+
const idat = [];
|
|
42
|
+
for (let offset = 8; offset < buffer.length;) {
|
|
43
|
+
const length = buffer.readUInt32BE(offset);
|
|
44
|
+
const kind = buffer.toString("ascii", offset + 4, offset + 8);
|
|
45
|
+
const data = buffer.subarray(offset + 8, offset + 8 + length);
|
|
46
|
+
offset += 12 + length;
|
|
47
|
+
if (kind === "IHDR") {
|
|
48
|
+
width = data.readUInt32BE(0);
|
|
49
|
+
height = data.readUInt32BE(4);
|
|
50
|
+
depth = data[8];
|
|
51
|
+
type = data[9];
|
|
52
|
+
interlace = data[12];
|
|
53
|
+
} else if (kind === "PLTE") palette = data;
|
|
54
|
+
else if (kind === "tRNS") transparency = data;
|
|
55
|
+
else if (kind === "IDAT") idat.push(data);
|
|
56
|
+
else if (kind === "IEND") break;
|
|
57
|
+
}
|
|
58
|
+
const channels = CHANNELS[type];
|
|
59
|
+
if (depth !== 8 || interlace !== 0 || !channels) {
|
|
60
|
+
throw new Error("Only 8-bit, non-interlaced PNG files are supported.");
|
|
61
|
+
}
|
|
62
|
+
const stride = width * channels;
|
|
63
|
+
const raw = inflateSync(Buffer.concat(idat));
|
|
64
|
+
const pixels = Buffer.alloc(stride * height);
|
|
65
|
+
let previous = Buffer.alloc(stride);
|
|
66
|
+
for (let y = 0; y < height; y++) {
|
|
67
|
+
const filter = raw[y * (stride + 1)];
|
|
68
|
+
const line = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1));
|
|
69
|
+
const out = pixels.subarray(y * stride, (y + 1) * stride);
|
|
70
|
+
for (let i = 0; i < stride; i++) {
|
|
71
|
+
const a = i >= channels ? out[i - channels] : 0;
|
|
72
|
+
const b = previous[i];
|
|
73
|
+
const c = i >= channels ? previous[i - channels] : 0;
|
|
74
|
+
const x = line[i];
|
|
75
|
+
out[i] =
|
|
76
|
+
filter === 0
|
|
77
|
+
? x
|
|
78
|
+
: filter === 1
|
|
79
|
+
? x + a
|
|
80
|
+
: filter === 2
|
|
81
|
+
? x + b
|
|
82
|
+
: filter === 3
|
|
83
|
+
? x + ((a + b) >> 1)
|
|
84
|
+
: x + paeth(a, b, c);
|
|
85
|
+
}
|
|
86
|
+
previous = out;
|
|
87
|
+
}
|
|
88
|
+
const data = Buffer.alloc(width * height * 4);
|
|
89
|
+
for (let p = 0; p < width * height; p++) {
|
|
90
|
+
const s = p * channels;
|
|
91
|
+
const d = p * 4;
|
|
92
|
+
if (type === 6) pixels.copy(data, d, s, s + 4);
|
|
93
|
+
else if (type === 2) {
|
|
94
|
+
data[d] = pixels[s];
|
|
95
|
+
data[d + 1] = pixels[s + 1];
|
|
96
|
+
data[d + 2] = pixels[s + 2];
|
|
97
|
+
data[d + 3] = 255;
|
|
98
|
+
} else if (type === 0 || type === 4) {
|
|
99
|
+
data[d] = data[d + 1] = data[d + 2] = pixels[s];
|
|
100
|
+
data[d + 3] = type === 4 ? pixels[s + 1] : 255;
|
|
101
|
+
} else {
|
|
102
|
+
const index = pixels[s];
|
|
103
|
+
data[d] = palette[index * 3];
|
|
104
|
+
data[d + 1] = palette[index * 3 + 1];
|
|
105
|
+
data[d + 2] = palette[index * 3 + 2];
|
|
106
|
+
data[d + 3] = transparency && index < transparency.length ? transparency[index] : 255;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return { width, height, data };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function chunk(kind, data) {
|
|
113
|
+
const head = Buffer.alloc(8);
|
|
114
|
+
head.writeUInt32BE(data.length, 0);
|
|
115
|
+
head.write(kind, 4, "ascii");
|
|
116
|
+
const crc = Buffer.alloc(4);
|
|
117
|
+
crc.writeUInt32BE(crc32(Buffer.concat([head.subarray(4), data])) >>> 0, 0);
|
|
118
|
+
return Buffer.concat([head, data, crc]);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Encodes RGBA pixels as a PNG. */
|
|
122
|
+
export function encodePng({ width, height, data }) {
|
|
123
|
+
const stride = width * 4;
|
|
124
|
+
const raw = Buffer.alloc((stride + 1) * height);
|
|
125
|
+
for (let y = 0; y < height; y++) data.copy(raw, y * (stride + 1) + 1, y * stride, (y + 1) * stride);
|
|
126
|
+
const header = Buffer.alloc(13);
|
|
127
|
+
header.writeUInt32BE(width, 0);
|
|
128
|
+
header.writeUInt32BE(height, 4);
|
|
129
|
+
header[8] = 8;
|
|
130
|
+
header[9] = 6;
|
|
131
|
+
return Buffer.concat([
|
|
132
|
+
SIGNATURE,
|
|
133
|
+
chunk("IHDR", header),
|
|
134
|
+
chunk("IDAT", deflateSync(raw)),
|
|
135
|
+
chunk("IEND", Buffer.alloc(0)),
|
|
136
|
+
]);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Compares two images on a canvas as large as both. A pixel differs when any channel differs by more than
|
|
141
|
+
* `tolerance`; where only one image has pixels, they differ. Pixels inside `regions` are left out of the count.
|
|
142
|
+
* The diff image shows the expected image faded, differing pixels red and left-out regions blue.
|
|
143
|
+
*/
|
|
144
|
+
export function compareImages(expected, actual, { tolerance = 0, regions = [] } = {}) {
|
|
145
|
+
const width = Math.max(expected.width, actual.width);
|
|
146
|
+
const height = Math.max(expected.height, actual.height);
|
|
147
|
+
const diff = Buffer.alloc(width * height * 4);
|
|
148
|
+
const inRegion = (x, y) => regions.some((r) => x >= r.x && x < r.x + r.width && y >= r.y && y < r.y + r.height);
|
|
149
|
+
let differing = 0;
|
|
150
|
+
let excluded = 0;
|
|
151
|
+
for (let y = 0; y < height; y++) {
|
|
152
|
+
for (let x = 0; x < width; x++) {
|
|
153
|
+
const d = (y * width + x) * 4;
|
|
154
|
+
const inExpected = x < expected.width && y < expected.height;
|
|
155
|
+
const inActual = x < actual.width && y < actual.height;
|
|
156
|
+
const e = (y * expected.width + x) * 4;
|
|
157
|
+
const a = (y * actual.width + x) * 4;
|
|
158
|
+
if (inRegion(x, y)) {
|
|
159
|
+
excluded++;
|
|
160
|
+
diff[d] = 120;
|
|
161
|
+
diff[d + 1] = 160;
|
|
162
|
+
diff[d + 2] = 255;
|
|
163
|
+
diff[d + 3] = 255;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
let same = inExpected && inActual;
|
|
167
|
+
for (let c = 0; same && c < 4; c++) {
|
|
168
|
+
if (Math.abs(expected.data[e + c] - actual.data[a + c]) > tolerance) same = false;
|
|
169
|
+
}
|
|
170
|
+
if (same) {
|
|
171
|
+
const gray = Math.round(
|
|
172
|
+
(expected.data[e] * 0.299 + expected.data[e + 1] * 0.587 + expected.data[e + 2] * 0.114) * 0.25 + 191,
|
|
173
|
+
);
|
|
174
|
+
diff[d] = diff[d + 1] = diff[d + 2] = gray;
|
|
175
|
+
} else {
|
|
176
|
+
differing++;
|
|
177
|
+
diff[d] = 230;
|
|
178
|
+
diff[d + 1] = 20;
|
|
179
|
+
diff[d + 2] = 60;
|
|
180
|
+
}
|
|
181
|
+
diff[d + 3] = 255;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const counted = width * height - excluded;
|
|
185
|
+
return {
|
|
186
|
+
width,
|
|
187
|
+
height,
|
|
188
|
+
sameSize: expected.width === actual.width && expected.height === actual.height,
|
|
189
|
+
differing,
|
|
190
|
+
counted,
|
|
191
|
+
percent: counted ? (differing / counted) * 100 : 0,
|
|
192
|
+
diff: { width, height, data: diff },
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The exceptions that apply to one shot: those naming its view, and its screen, widths and themes if they say. */
|
|
197
|
+
export function exceptionsFor(exceptions, shot) {
|
|
198
|
+
return exceptions.filter(
|
|
199
|
+
(exception) =>
|
|
200
|
+
exception.view === shot.view &&
|
|
201
|
+
(exception.screen === undefined || exception.screen === shot.screen) &&
|
|
202
|
+
(exception.widths === undefined || exception.widths.includes(shot.width)) &&
|
|
203
|
+
(exception.themes === undefined || exception.themes.includes(shot.theme)),
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function option(args, name, fallback) {
|
|
208
|
+
const index = args.indexOf(name);
|
|
209
|
+
return index === -1 ? fallback : args[index + 1];
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function main() {
|
|
213
|
+
const kit = path.dirname(fileURLToPath(import.meta.url));
|
|
214
|
+
const args = process.argv.slice(2);
|
|
215
|
+
const resolve = (name, fallback) => {
|
|
216
|
+
const value = option(args, name);
|
|
217
|
+
return value ? path.resolve(value) : path.join(kit, fallback);
|
|
218
|
+
};
|
|
219
|
+
const expectedDir = resolve("--expected", "expected");
|
|
220
|
+
const shippedDir = path.join(kit, "screens");
|
|
221
|
+
const actualDir = resolve("--actual", "actual");
|
|
222
|
+
const diffDir = resolve("--diff", "diff");
|
|
223
|
+
const maxDiff = Number(option(args, "--max-diff", "0"));
|
|
224
|
+
const tolerance = Number(option(args, "--tolerance", "0"));
|
|
225
|
+
const manifest = JSON.parse(await readFile(path.join(kit, "basis-verify.json"), "utf8"));
|
|
226
|
+
const exceptionsFile = path.join(kit, "exceptions.json");
|
|
227
|
+
const exceptions = existsSync(exceptionsFile)
|
|
228
|
+
? (JSON.parse(await readFile(exceptionsFile, "utf8")).exceptions ?? [])
|
|
229
|
+
: [];
|
|
230
|
+
|
|
231
|
+
const results = [];
|
|
232
|
+
for (const view of manifest.views) {
|
|
233
|
+
for (const screen of view.screens) {
|
|
234
|
+
for (const shot of screen.shots) {
|
|
235
|
+
const file = shot.file;
|
|
236
|
+
const name = file.replace(/^screens\//, "");
|
|
237
|
+
const own = path.join(expectedDir, name);
|
|
238
|
+
const expectedPath = existsSync(own) ? own : path.join(shippedDir, name);
|
|
239
|
+
const actualPath = path.join(actualDir, name);
|
|
240
|
+
const result = { file: name, view: view.id, screen: screen.id, width: shot.width, theme: shot.theme };
|
|
241
|
+
const applying = exceptionsFor(exceptions, result);
|
|
242
|
+
const whole = applying.find((exception) => !exception.region);
|
|
243
|
+
if (whole) {
|
|
244
|
+
results.push({ ...result, status: "excepted", exception: whole.name });
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
if (!existsSync(actualPath)) {
|
|
248
|
+
results.push({ ...result, status: "missing", message: `No ${path.relative(process.cwd(), actualPath)}` });
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
const expected = decodePng(await readFile(expectedPath));
|
|
252
|
+
const actual = decodePng(await readFile(actualPath));
|
|
253
|
+
const outcome = compareImages(expected, actual, {
|
|
254
|
+
tolerance,
|
|
255
|
+
regions: applying.map((exception) => exception.region),
|
|
256
|
+
});
|
|
257
|
+
const passed = outcome.sameSize && outcome.percent <= maxDiff;
|
|
258
|
+
if (!passed) {
|
|
259
|
+
const target = path.join(diffDir, name);
|
|
260
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
261
|
+
await writeFile(target, encodePng(outcome.diff));
|
|
262
|
+
}
|
|
263
|
+
results.push({
|
|
264
|
+
...result,
|
|
265
|
+
status: passed ? "pass" : "fail",
|
|
266
|
+
percent: Number(outcome.percent.toFixed(4)),
|
|
267
|
+
differingPixels: outcome.differing,
|
|
268
|
+
expectedSize: `${expected.width}x${expected.height}`,
|
|
269
|
+
actualSize: `${actual.width}x${actual.height}`,
|
|
270
|
+
exceptions: applying.map((exception) => exception.name),
|
|
271
|
+
reference: path.relative(kit, expectedPath).split(path.sep).join("/"),
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
for (const result of results) {
|
|
278
|
+
const label = result.status.toUpperCase().padEnd(8);
|
|
279
|
+
let detail = "";
|
|
280
|
+
if (result.status === "excepted") detail = `exception "${result.exception}"`;
|
|
281
|
+
else if (result.status === "missing") detail = result.message;
|
|
282
|
+
else {
|
|
283
|
+
detail = `${result.percent.toFixed(4)}% (${result.differingPixels} px)`;
|
|
284
|
+
if (result.expectedSize !== result.actualSize) {
|
|
285
|
+
detail += `, size ${result.actualSize}, expected ${result.expectedSize}`;
|
|
286
|
+
}
|
|
287
|
+
if (result.exceptions.length) detail += `, regions left out: ${result.exceptions.join(", ")}`;
|
|
288
|
+
}
|
|
289
|
+
console.log(`${label} ${result.file.padEnd(48)} ${detail}`);
|
|
290
|
+
}
|
|
291
|
+
const failed = results.filter((result) => result.status === "fail" || result.status === "missing");
|
|
292
|
+
await mkdir(diffDir, { recursive: true });
|
|
293
|
+
await writeFile(path.join(diffDir, "report.json"), `${JSON.stringify({ maxDiff, tolerance, results }, null, 2)}\n`);
|
|
294
|
+
console.log(
|
|
295
|
+
failed.length
|
|
296
|
+
? `\n${failed.length} of ${results.length} shots do not match. Diff images are in ${path.relative(process.cwd(), diffDir) || "."}.`
|
|
297
|
+
: `\nAll ${results.length} shots match.`,
|
|
298
|
+
);
|
|
299
|
+
process.exitCode = failed.length ? 1 : 0;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
|
303
|
+
main().catch((error) => {
|
|
304
|
+
console.error(error instanceof Error ? error.message : error);
|
|
305
|
+
process.exitCode = 2;
|
|
306
|
+
});
|
|
307
|
+
}
|