mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
694 lines
33 KiB
JavaScript
694 lines
33 KiB
JavaScript
#!/usr/bin/env node
|
|
import { createHash } from 'node:crypto';
|
|
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
import { dirname, resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { launch } from '../serve.mjs';
|
|
import { assertFreshDemoBundle } from '../bundle-freshness.mjs';
|
|
import { goldenClip, prepareGoldenScenario } from './harness.mjs';
|
|
import { GOLDEN_MATRIX_VERSION, GOLDEN_SCENARIOS } from './matrix.mjs';
|
|
import {
|
|
assertGoldenInvocation,
|
|
GOLDEN_BASELINE_MANIFEST,
|
|
goldenRunFailed,
|
|
goldenScenarioSetsMatch,
|
|
} from './policy.mjs';
|
|
|
|
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
|
const mode = process.argv.find((arg) => arg.startsWith('--mode='))?.slice(7) || 'capture';
|
|
const scenarioFilter = process.argv.find((arg) => arg.startsWith('--scenario='))?.slice(11) || '';
|
|
assertGoldenInvocation(mode, scenarioFilter);
|
|
const scenarios = scenarioFilter
|
|
? GOLDEN_SCENARIOS.filter((scenario) => scenario.id === scenarioFilter)
|
|
: GOLDEN_SCENARIOS;
|
|
if (!scenarios.length) throw new Error(`unknown golden scenario: ${scenarioFilter}`);
|
|
|
|
const artifactRoot = resolve(ROOT, 'artifacts/golden');
|
|
const actualRoot = resolve(artifactRoot, 'actual');
|
|
const diffRoot = resolve(artifactRoot, 'diff');
|
|
const baselineRoot = resolve(ROOT, 'demo/golden/baselines');
|
|
mkdirSync(actualRoot, { recursive: true });
|
|
mkdirSync(diffRoot, { recursive: true });
|
|
|
|
const sha256 = (buffer) => createHash('sha256').update(buffer).digest('hex');
|
|
|
|
async function comparePng(page, actual, baseline, threshold) {
|
|
return page.evaluate(async ({ actual64, baseline64, threshold }) => {
|
|
const decode = async (base64) => {
|
|
const bytes = Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
|
|
return createImageBitmap(new Blob([bytes], { type: 'image/png' }));
|
|
};
|
|
const [actualImage, baselineImage] = await Promise.all([decode(actual64), decode(baseline64)]);
|
|
if (actualImage.width !== baselineImage.width || actualImage.height !== baselineImage.height) {
|
|
return {
|
|
dimensionsMatch: false,
|
|
actualSize: [actualImage.width, actualImage.height],
|
|
baselineSize: [baselineImage.width, baselineImage.height],
|
|
differingPixels: null,
|
|
diffRatio: 1,
|
|
maxObservedDelta: 255,
|
|
passed: false,
|
|
diffPngBase64: null,
|
|
};
|
|
}
|
|
const width = actualImage.width;
|
|
const height = actualImage.height;
|
|
const canvas = document.createElement('canvas');
|
|
const baselineCanvas = document.createElement('canvas');
|
|
const diffCanvas = document.createElement('canvas');
|
|
for (const item of [canvas, baselineCanvas, diffCanvas]) {
|
|
item.width = width;
|
|
item.height = height;
|
|
}
|
|
const context = canvas.getContext('2d', { willReadFrequently: true });
|
|
const baselineContext = baselineCanvas.getContext('2d', { willReadFrequently: true });
|
|
const diffContext = diffCanvas.getContext('2d');
|
|
context.drawImage(actualImage, 0, 0);
|
|
baselineContext.drawImage(baselineImage, 0, 0);
|
|
const actualData = context.getImageData(0, 0, width, height).data;
|
|
const baselineData = baselineContext.getImageData(0, 0, width, height).data;
|
|
const diff = diffContext.createImageData(width, height);
|
|
let differingPixels = 0;
|
|
let maxObservedDelta = 0;
|
|
for (let offset = 0; offset < actualData.length; offset += 4) {
|
|
let delta = 0;
|
|
for (let channel = 0; channel < 4; channel++)
|
|
delta = Math.max(delta, Math.abs(actualData[offset + channel] - baselineData[offset + channel]));
|
|
maxObservedDelta = Math.max(maxObservedDelta, delta);
|
|
if (delta > threshold.maxChannelDelta) {
|
|
differingPixels++;
|
|
diff.data[offset] = 255;
|
|
diff.data[offset + 1] = 0;
|
|
diff.data[offset + 2] = 180;
|
|
diff.data[offset + 3] = 255;
|
|
} else {
|
|
const gray = Math.round((actualData[offset] + actualData[offset + 1] + actualData[offset + 2]) / 9);
|
|
diff.data[offset] = gray;
|
|
diff.data[offset + 1] = gray;
|
|
diff.data[offset + 2] = gray;
|
|
diff.data[offset + 3] = 90;
|
|
}
|
|
}
|
|
diffContext.putImageData(diff, 0, 0);
|
|
const diffRatio = differingPixels / (width * height);
|
|
return {
|
|
dimensionsMatch: true,
|
|
actualSize: [width, height],
|
|
baselineSize: [width, height],
|
|
differingPixels,
|
|
diffRatio,
|
|
maxObservedDelta,
|
|
passed: diffRatio <= threshold.maxDiffRatio,
|
|
diffPngBase64: differingPixels
|
|
? diffCanvas.toDataURL('image/png').slice('data:image/png;base64,'.length)
|
|
: null,
|
|
};
|
|
}, {
|
|
actual64: actual.toString('base64'),
|
|
baseline64: baseline.toString('base64'),
|
|
threshold,
|
|
});
|
|
}
|
|
|
|
/** Capture the identical visual frame with only the rendered sun-ray SVG
|
|
* hidden. Comparing this control frame with the reviewed golden proves that
|
|
* the layer changes real browser pixels, not merely that its DOM exists. */
|
|
async function captureWithoutSunRays(page, screenshotOptions) {
|
|
const layerState = await page.evaluate(async () => {
|
|
const layer = window.__goldenCard?.renderRoot?.querySelector('.sunlayer');
|
|
if (!layer) throw new Error('semantic golden sun layer is missing');
|
|
const shapes = layer.querySelectorAll('path, polygon').length;
|
|
if (!shapes) throw new Error('semantic golden sun layer has no painted shapes');
|
|
const previous = layer.style.visibility;
|
|
layer.style.visibility = 'hidden';
|
|
await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
|
return { previous, shapes };
|
|
});
|
|
try {
|
|
return { png: await page.screenshot(screenshotOptions), shapes: layerState.shapes };
|
|
} finally {
|
|
await page.evaluate(async (previous) => {
|
|
const layer = window.__goldenCard?.renderRoot?.querySelector('.sunlayer');
|
|
if (layer) layer.style.visibility = previous;
|
|
await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
|
}, layerState.previous);
|
|
}
|
|
}
|
|
|
|
/** Capture a control frame with only the transient opening symbol hidden.
|
|
* Comparing it with the actual frame proves the preview paints browser pixels;
|
|
* the smoke test separately locks its DOM order above the wall body. */
|
|
async function captureWithoutOpeningPreview(page, screenshotOptions) {
|
|
const layerState = await page.evaluate(async () => {
|
|
const card = window.__goldenCard;
|
|
const parts = [...(card?.renderRoot?.querySelectorAll(
|
|
'.opening-preview, .opening-preview-dot',
|
|
) || [])];
|
|
if (!parts.length) throw new Error('semantic golden opening preview is missing');
|
|
const previous = parts.map((part) => part.style.visibility);
|
|
for (const part of parts) part.style.visibility = 'hidden';
|
|
await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
|
return { previous, count: parts.length };
|
|
});
|
|
try {
|
|
return { png: await page.screenshot(screenshotOptions), parts: layerState.count };
|
|
} finally {
|
|
await page.evaluate(async (previous) => {
|
|
const card = window.__goldenCard;
|
|
const parts = [...(card?.renderRoot?.querySelectorAll(
|
|
'.opening-preview, .opening-preview-dot',
|
|
) || [])];
|
|
parts.forEach((part, index) => { part.style.visibility = previous[index] || ''; });
|
|
await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
|
}, layerState.previous);
|
|
}
|
|
}
|
|
|
|
async function countChangedPixels(page, actual, control, spec) {
|
|
return page.evaluate(async ({ actual64, control64, minChannelDelta }) => {
|
|
const decode = async (base64) => {
|
|
const bytes = Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
|
|
return createImageBitmap(new Blob([bytes], { type: 'image/png' }));
|
|
};
|
|
const [actualImage, controlImage] = await Promise.all([decode(actual64), decode(control64)]);
|
|
if (actualImage.width !== controlImage.width || actualImage.height !== controlImage.height)
|
|
throw new Error('semantic golden control frame has different dimensions');
|
|
const canvas = document.createElement('canvas');
|
|
const controlCanvas = document.createElement('canvas');
|
|
canvas.width = controlCanvas.width = actualImage.width;
|
|
canvas.height = controlCanvas.height = actualImage.height;
|
|
const context = canvas.getContext('2d', { willReadFrequently: true });
|
|
const controlContext = controlCanvas.getContext('2d', { willReadFrequently: true });
|
|
context.drawImage(actualImage, 0, 0);
|
|
controlContext.drawImage(controlImage, 0, 0);
|
|
const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data;
|
|
const controlPixels = controlContext.getImageData(0, 0, canvas.width, canvas.height).data;
|
|
let changed = 0;
|
|
let maxDelta = 0;
|
|
for (let offset = 0; offset < pixels.length; offset += 4) {
|
|
const delta = Math.max(
|
|
Math.abs(pixels[offset] - controlPixels[offset]),
|
|
Math.abs(pixels[offset + 1] - controlPixels[offset + 1]),
|
|
Math.abs(pixels[offset + 2] - controlPixels[offset + 2]),
|
|
);
|
|
maxDelta = Math.max(maxDelta, delta);
|
|
if (delta >= minChannelDelta) changed++;
|
|
}
|
|
return { changed, maxDelta, size: [canvas.width, canvas.height] };
|
|
}, {
|
|
actual64: actual.toString('base64'),
|
|
control64: control.toString('base64'),
|
|
minChannelDelta: spec.minChannelDelta,
|
|
});
|
|
}
|
|
|
|
/** Count preview pixels which are actually painted over the physical wall
|
|
* fill. A global changed-pixel threshold can pass even when the complete
|
|
* symbol is accidentally hidden below masonry because its swing arc remains
|
|
* outside the body. */
|
|
async function countOpeningPreviewPixelsInsideWall(
|
|
page, actual, control, clip, minChannelDelta,
|
|
) {
|
|
return page.evaluate(async ({ actual64, control64, clipRect, minDelta }) => {
|
|
const decode = async (base64) => {
|
|
const bytes = Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
|
|
return createImageBitmap(new Blob([bytes], { type: 'image/png' }));
|
|
};
|
|
const [actualImage, controlImage] = await Promise.all([decode(actual64), decode(control64)]);
|
|
const canvas = document.createElement('canvas');
|
|
const controlCanvas = document.createElement('canvas');
|
|
canvas.width = controlCanvas.width = actualImage.width;
|
|
canvas.height = controlCanvas.height = actualImage.height;
|
|
const context = canvas.getContext('2d', { willReadFrequently: true });
|
|
const controlContext = controlCanvas.getContext('2d', { willReadFrequently: true });
|
|
context.drawImage(actualImage, 0, 0);
|
|
controlContext.drawImage(controlImage, 0, 0);
|
|
const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data;
|
|
const controls = controlContext.getImageData(0, 0, canvas.width, canvas.height).data;
|
|
const card = window.__goldenCard;
|
|
const preview = card?.renderRoot?.querySelector('.opening-preview');
|
|
const walls = [...(card?.renderRoot?.querySelectorAll('.wallbody-fill') || [])]
|
|
.map((wall) => {
|
|
const matrix = wall.getScreenCTM?.();
|
|
return matrix ? { wall, rect: wall.getBoundingClientRect(), inverse: matrix.inverse() } : null;
|
|
})
|
|
.filter(Boolean);
|
|
if (!preview || !walls.length) return { changed: 0, sampled: 0 };
|
|
const previewRect = preview.getBoundingClientRect();
|
|
const candidates = walls.filter(({ rect }) => rect.right >= previewRect.left
|
|
&& rect.left <= previewRect.right && rect.bottom >= previewRect.top
|
|
&& rect.top <= previewRect.bottom);
|
|
if (!candidates.length) return { changed: 0, sampled: 0 };
|
|
const left = Math.ceil(previewRect.left);
|
|
const top = Math.ceil(previewRect.top);
|
|
const right = Math.floor(previewRect.right);
|
|
const bottom = Math.floor(previewRect.bottom);
|
|
const originX = clipRect?.x || 0;
|
|
const originY = clipRect?.y || 0;
|
|
let changed = 0;
|
|
let sampled = 0;
|
|
for (let y = top; y <= bottom; y++) {
|
|
for (let x = left; x <= right; x++) {
|
|
const screenPoint = new DOMPoint(x + 0.5, y + 0.5);
|
|
const insideWall = candidates.some(({ wall, rect, inverse }) => {
|
|
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) return false;
|
|
return wall.isPointInFill(screenPoint.matrixTransform(inverse));
|
|
});
|
|
if (!insideWall) continue;
|
|
const px = Math.round(x - originX);
|
|
const py = Math.round(y - originY);
|
|
if (px < 0 || py < 0 || px >= canvas.width || py >= canvas.height) continue;
|
|
sampled += 1;
|
|
const offset = (py * canvas.width + px) * 4;
|
|
const delta = Math.max(
|
|
Math.abs(pixels[offset] - controls[offset]),
|
|
Math.abs(pixels[offset + 1] - controls[offset + 1]),
|
|
Math.abs(pixels[offset + 2] - controls[offset + 2]),
|
|
Math.abs(pixels[offset + 3] - controls[offset + 3]),
|
|
);
|
|
if (delta >= minDelta) changed += 1;
|
|
}
|
|
}
|
|
return { changed, sampled };
|
|
}, {
|
|
actual64: actual.toString('base64'),
|
|
control64: control.toString('base64'),
|
|
clipRect: clip || null,
|
|
minDelta: minChannelDelta,
|
|
});
|
|
}
|
|
|
|
/** Assert scenario semantics against the actual capture, not only its data.
|
|
* This protects a reviewed-but-empty baseline from becoming the reference. */
|
|
async function countWarmPixels(page, png, region) {
|
|
return page.evaluate(async ({ png64, region }) => {
|
|
const bytes = Uint8Array.from(atob(png64), (char) => char.charCodeAt(0));
|
|
const image = await createImageBitmap(new Blob([bytes], { type: 'image/png' }));
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = image.width;
|
|
canvas.height = image.height;
|
|
const context = canvas.getContext('2d', { willReadFrequently: true });
|
|
context.drawImage(image, 0, 0);
|
|
const left = Math.max(0, Math.min(image.width, Math.floor(region.x * image.width)));
|
|
const top = Math.max(0, Math.min(image.height, Math.floor(region.y * image.height)));
|
|
const right = Math.max(left, Math.min(image.width, Math.ceil((region.x + region.w) * image.width)));
|
|
const bottom = Math.max(top, Math.min(image.height, Math.ceil((region.y + region.h) * image.height)));
|
|
const pixels = context.getImageData(left, top, right - left, bottom - top).data;
|
|
let warm = 0;
|
|
for (let offset = 0; offset < pixels.length; offset += 4) {
|
|
if (pixels[offset] - pixels[offset + 2] > region.minRedBlueDelta) warm++;
|
|
}
|
|
return { warm, bounds: [left, top, right, bottom] };
|
|
}, { png64: png.toString('base64'), region });
|
|
}
|
|
|
|
/** Semantic guard for issue #68: the reviewed bubble must contain rendered
|
|
* glyph pixels, not just an empty surface or a stale open-state flag. */
|
|
async function countHelpTextPixels(page, png, clip, spec) {
|
|
return page.evaluate(async ({ png64, clip, spec }) => {
|
|
const card = window.__goldenCard;
|
|
const help = card?.renderRoot?.querySelector(`hp-help[data-help-key="${spec.key}"]`);
|
|
const surface = help?.renderRoot?.querySelector('.tooltip:popover-open')
|
|
|| card?.renderRoot?.querySelector('hp-dialog')?.renderRoot
|
|
?.querySelector('[data-hp-overlay="help"]')?.shadowRoot?.querySelector('.tooltip');
|
|
if (!surface) throw new Error(`semantic golden help missing: ${spec.key}`);
|
|
const bounds = surface.getBoundingClientRect();
|
|
const match = getComputedStyle(surface).color.match(/[\d.]+/g)?.slice(0, 3).map(Number);
|
|
if (!match || match.length !== 3) throw new Error(`semantic golden help has invalid text color: ${spec.key}`);
|
|
const bytes = Uint8Array.from(atob(png64), (char) => char.charCodeAt(0));
|
|
const image = await createImageBitmap(new Blob([bytes], { type: 'image/png' }));
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = image.width;
|
|
canvas.height = image.height;
|
|
const context = canvas.getContext('2d', { willReadFrequently: true });
|
|
context.drawImage(image, 0, 0);
|
|
const pixels = context.getImageData(0, 0, image.width, image.height).data;
|
|
const originX = clip?.x || 0, originY = clip?.y || 0;
|
|
const left = Math.max(0, Math.ceil(bounds.left - originX) + 5);
|
|
const top = Math.max(0, Math.ceil(bounds.top - originY) + 5);
|
|
const right = Math.min(image.width - 1, Math.floor(bounds.right - originX) - 5);
|
|
const bottom = Math.min(image.height - 1, Math.floor(bounds.bottom - originY) - 5);
|
|
let textPixels = 0;
|
|
for (let y = top; y <= bottom; y++) {
|
|
for (let x = left; x <= right; x++) {
|
|
const offset = (y * image.width + x) * 4;
|
|
const distance = Math.abs(pixels[offset] - match[0])
|
|
+ Math.abs(pixels[offset + 1] - match[1])
|
|
+ Math.abs(pixels[offset + 2] - match[2]);
|
|
if (pixels[offset + 3] > 240 && distance <= 90) textPixels++;
|
|
}
|
|
}
|
|
return { textPixels, bounds: [left, top, right, bottom] };
|
|
}, { png64: png.toString('base64'), clip, spec });
|
|
}
|
|
|
|
/** Detect one-pixel SVG seams inside a room-coloured opening tunnel. Sample a
|
|
* narrow strip around local y=0: this crosses the join between both tunnel
|
|
* half-faces at any opening angle while excluding legitimate outer-profile
|
|
* steps where adjacent wall intervals have different physical thicknesses. */
|
|
async function inspectTunnelContinuity(page, png, clip, spec) {
|
|
return page.evaluate(async ({ png64, clip, spec }) => {
|
|
const card = window.__goldenCard;
|
|
const tunnel = card?.renderRoot?.querySelector(
|
|
`.opening-tunnels[data-layer="data"] [data-hp="opening-tunnel"][data-id="${spec.openingId}"]`,
|
|
);
|
|
if (!tunnel) throw new Error(`semantic golden tunnel missing: ${spec.openingId}`);
|
|
const rect = tunnel.getBoundingClientRect();
|
|
const matrix = tunnel.getScreenCTM();
|
|
if (!matrix) throw new Error(`semantic golden tunnel has no screen transform: ${spec.openingId}`);
|
|
const inverse = matrix.inverse();
|
|
const localBounds = tunnel.getBBox();
|
|
const bytes = Uint8Array.from(atob(png64), (char) => char.charCodeAt(0));
|
|
const image = await createImageBitmap(new Blob([bytes], { type: 'image/png' }));
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = image.width;
|
|
canvas.height = image.height;
|
|
const context = canvas.getContext('2d', { willReadFrequently: true });
|
|
context.drawImage(image, 0, 0);
|
|
const originX = clip?.x || 0, originY = clip?.y || 0;
|
|
// Screenshots may be captured at DPR > 1. DOMRect/clip are CSS pixels,
|
|
// image coordinates are device pixels, so derive the scale instead of
|
|
// silently sampling the wrong strip at high DPI.
|
|
const cssWidth = clip?.width || document.documentElement.clientWidth;
|
|
const cssHeight = clip?.height || document.documentElement.clientHeight;
|
|
const scaleX = image.width / Math.max(1, cssWidth);
|
|
const scaleY = image.height / Math.max(1, cssHeight);
|
|
const insetX = Math.max(1, Math.round(spec.insetPx * scaleX));
|
|
const insetY = Math.max(1, Math.round(spec.insetPx * scaleY));
|
|
const left = Math.max(0, Math.ceil((rect.left - originX) * scaleX) + insetX);
|
|
const top = Math.max(0, Math.ceil((rect.top - originY) * scaleY) + insetY);
|
|
const right = Math.min(image.width - 1, Math.floor((rect.right - originX) * scaleX) - insetX);
|
|
const bottom = Math.min(image.height - 1, Math.floor((rect.bottom - originY) * scaleY) - insetY);
|
|
if (right - left < 3 || bottom - top < 3)
|
|
throw new Error(`semantic golden tunnel is too small: ${left},${top},${right},${bottom}`);
|
|
const pixels = context.getImageData(0, 0, image.width, image.height).data;
|
|
const rgb = (x, y) => {
|
|
const offset = (y * image.width + x) * 4;
|
|
return [pixels[offset], pixels[offset + 1], pixels[offset + 2]];
|
|
};
|
|
let maxJump = 0, maxPair = null, samplePairs = 0;
|
|
const compare = (a, b, x, y, direction) => {
|
|
const jump = Math.max(
|
|
Math.abs(a[0] - b[0]), Math.abs(a[1] - b[1]), Math.abs(a[2] - b[2]));
|
|
if (jump > maxJump) {
|
|
maxJump = jump;
|
|
maxPair = { x, y, direction, a, b };
|
|
}
|
|
samplePairs++;
|
|
};
|
|
const localXScale = Math.max(1e-6, Math.hypot(matrix.a, matrix.b));
|
|
const localYScale = Math.max(1e-6, Math.hypot(matrix.c, matrix.d));
|
|
const endInset = Math.max(1, spec.insetPx) / localXScale;
|
|
const axisBand = Math.max(1.5, spec.axisBandPx || 2.5) / localYScale;
|
|
const insideAxisBand = (x, y) => {
|
|
const cssX = originX + (x + 0.5) / scaleX;
|
|
const cssY = originY + (y + 0.5) / scaleY;
|
|
const local = new DOMPoint(cssX, cssY).matrixTransform(inverse);
|
|
return local.x >= localBounds.x + endInset
|
|
&& local.x <= localBounds.x + localBounds.width - endInset
|
|
&& Math.abs(local.y) <= axisBand;
|
|
};
|
|
for (let y = top; y <= bottom; y++) {
|
|
for (let x = left + 1; x <= right; x++) {
|
|
if (insideAxisBand(x - 1, y) && insideAxisBand(x, y))
|
|
compare(rgb(x - 1, y), rgb(x, y), x, y, 'horizontal');
|
|
}
|
|
}
|
|
for (let x = left; x <= right; x++) {
|
|
for (let y = top + 1; y <= bottom; y++) {
|
|
if (insideAxisBand(x, y - 1) && insideAxisBand(x, y))
|
|
compare(rgb(x, y - 1), rgb(x, y), x, y, 'vertical');
|
|
}
|
|
}
|
|
if (!samplePairs) throw new Error(`semantic golden tunnel axis strip is empty: ${spec.openingId}`);
|
|
return {
|
|
maxJump, maxPair, samplePairs, bounds: [left, top, right, bottom],
|
|
scale: [scaleX, scaleY],
|
|
};
|
|
}, { png64: png.toString('base64'), clip, spec });
|
|
}
|
|
|
|
let baselineManifest = null;
|
|
const baselineManifestPath = resolve(baselineRoot, GOLDEN_BASELINE_MANIFEST);
|
|
if (existsSync(baselineManifestPath)) {
|
|
try { baselineManifest = JSON.parse(readFileSync(baselineManifestPath, 'utf8')); }
|
|
catch { baselineManifest = { invalid: true }; }
|
|
}
|
|
|
|
const browserArgs = ['--force-color-profile=srgb', '--font-render-hinting=none', '--disable-lcd-text'];
|
|
const browserContext = { locale: 'en-US', timezoneId: 'UTC', colorScheme: 'dark', reducedMotion: 'reduce' };
|
|
const { page, browser } = await launch(
|
|
{ width: 1000, height: 900 },
|
|
1,
|
|
browserArgs,
|
|
browserContext,
|
|
);
|
|
const chromium = await browser.version();
|
|
const results = [];
|
|
const pageErrors = [];
|
|
page.on('pageerror', (error) => pageErrors.push(error.message));
|
|
let buildFingerprint = null;
|
|
try {
|
|
buildFingerprint = await assertFreshDemoBundle(page, ROOT);
|
|
for (const scenario of scenarios) {
|
|
const result = {
|
|
id: scenario.id,
|
|
threshold: scenario.threshold,
|
|
status: 'error',
|
|
actualSha256: null,
|
|
};
|
|
try {
|
|
pageErrors.length = 0;
|
|
result.runtime = await prepareGoldenScenario(page, scenario);
|
|
if (pageErrors.length) throw new Error(`browser exception: ${pageErrors.join(' | ')}`);
|
|
if (scenario.openingGeometry) {
|
|
result.openingGeometry = await page.evaluate((expected) => {
|
|
const card = window.__goldenCard;
|
|
const opening = card?.renderRoot?.querySelector(
|
|
`.opening[data-id="${CSS.escape(expected.id)}"]`,
|
|
);
|
|
if (!opening) return null;
|
|
const transform = opening.getAttribute('transform') || '';
|
|
const angle = Number(transform.match(/rotate\(([-+0-9.eE]+)\)/)?.[1]);
|
|
const painted = [...opening.querySelectorAll('.op-leaf, .op-arc, .op-glass')]
|
|
.map((part) => part.getBoundingClientRect())
|
|
.filter((rect) => rect.width > 0 || rect.height > 0);
|
|
const bounds = painted.length ? {
|
|
width: Math.max(...painted.map((rect) => rect.right))
|
|
- Math.min(...painted.map((rect) => rect.left)),
|
|
height: Math.max(...painted.map((rect) => rect.bottom))
|
|
- Math.min(...painted.map((rect) => rect.top)),
|
|
} : { width: 0, height: 0 };
|
|
return {
|
|
type: opening.getAttribute('data-kind'), angle,
|
|
width: bounds.width, height: bounds.height,
|
|
visibleParts: opening.querySelectorAll('.op-leaf, .op-arc, .op-glass').length,
|
|
};
|
|
}, scenario.openingGeometry);
|
|
const expected = scenario.openingGeometry;
|
|
const actualOpening = result.openingGeometry;
|
|
if (!actualOpening || actualOpening.type !== expected.type
|
|
|| Math.abs(actualOpening.angle - expected.angle) > 0.001
|
|
|| actualOpening.width <= 0 || actualOpening.height <= 0
|
|
|| actualOpening.visibleParts <= 0) {
|
|
throw new Error(
|
|
`semantic golden opening geometry failed for ${expected.id}: `
|
|
+ JSON.stringify(actualOpening),
|
|
);
|
|
}
|
|
}
|
|
const clip = await goldenClip(page, scenario.capture);
|
|
const screenshotOptions = {
|
|
...(clip ? { clip } : {}),
|
|
animations: 'disabled',
|
|
caret: 'hide',
|
|
scale: 'css',
|
|
};
|
|
const actual = await page.screenshot(screenshotOptions);
|
|
const actualPath = resolve(actualRoot, `${scenario.id}.png`);
|
|
writeFileSync(actualPath, actual);
|
|
result.actualSha256 = sha256(actual);
|
|
result.actual = actualPath;
|
|
if (scenario.sunRayPixels) {
|
|
const control = await captureWithoutSunRays(page, screenshotOptions);
|
|
const sample = await countChangedPixels(page, actual, control.png, scenario.sunRayPixels);
|
|
result.sunRayShapes = control.shapes;
|
|
result.sunRayChangedPixels = sample.changed;
|
|
result.sunRayMaxChannelDelta = sample.maxDelta;
|
|
if (sample.changed < scenario.sunRayPixels.minPixels) {
|
|
throw new Error(
|
|
`semantic golden assertion failed: sun rays paint ${sample.changed} pixels, expected at least `
|
|
+ `${scenario.sunRayPixels.minPixels}`,
|
|
);
|
|
}
|
|
}
|
|
if (scenario.openingPreviewPixels) {
|
|
const control = await captureWithoutOpeningPreview(page, screenshotOptions);
|
|
const sample = await countChangedPixels(
|
|
page, actual, control.png, scenario.openingPreviewPixels,
|
|
);
|
|
result.openingPreviewParts = control.parts;
|
|
result.openingPreviewChangedPixels = sample.changed;
|
|
result.openingPreviewMaxChannelDelta = sample.maxDelta;
|
|
const insideWall = await countOpeningPreviewPixelsInsideWall(
|
|
page, actual, control.png, clip, scenario.openingPreviewPixels.minChannelDelta,
|
|
);
|
|
result.openingPreviewPixelsInsideWall = insideWall.changed;
|
|
result.openingPreviewWallSamples = insideWall.sampled;
|
|
if (control.parts < 2) {
|
|
throw new Error(
|
|
`semantic golden assertion failed: opening preview is incomplete (${control.parts} parts)`,
|
|
);
|
|
}
|
|
if (sample.changed < scenario.openingPreviewPixels.minPixels) {
|
|
throw new Error(
|
|
`semantic golden assertion failed: opening preview paints ${sample.changed} pixels, `
|
|
+ `expected at least ${scenario.openingPreviewPixels.minPixels}`,
|
|
);
|
|
}
|
|
if (insideWall.changed < scenario.openingPreviewPixels.minInsideWallPixels) {
|
|
throw new Error(
|
|
`semantic golden assertion failed: opening preview paints ${insideWall.changed} pixels `
|
|
+ `inside the wall body, expected at least `
|
|
+ `${scenario.openingPreviewPixels.minInsideWallPixels}`,
|
|
);
|
|
}
|
|
}
|
|
if (scenario.warmPixelRegion) {
|
|
const sample = await countWarmPixels(page, actual, scenario.warmPixelRegion);
|
|
result.warmPixels = sample.warm;
|
|
result.warmPixelBounds = sample.bounds;
|
|
if (sample.warm < scenario.warmPixelRegion.minPixels) {
|
|
throw new Error(
|
|
`semantic golden assertion failed: ${sample.warm} warm pixels, expected at least `
|
|
+ `${scenario.warmPixelRegion.minPixels}`,
|
|
);
|
|
}
|
|
}
|
|
if (scenario.helpTextRegion) {
|
|
const sample = await countHelpTextPixels(page, actual, clip, scenario.helpTextRegion);
|
|
result.helpTextPixels = sample.textPixels;
|
|
result.helpPixelBounds = sample.bounds;
|
|
if (sample.textPixels < scenario.helpTextRegion.minPixels) {
|
|
throw new Error(
|
|
`semantic golden assertion failed: help ${scenario.helpTextRegion.key} contains `
|
|
+ `${sample.textPixels} text pixels, expected at least ${scenario.helpTextRegion.minPixels}`,
|
|
);
|
|
}
|
|
}
|
|
if (scenario.tunnelContinuity) {
|
|
const sample = await inspectTunnelContinuity(
|
|
page, actual, clip, scenario.tunnelContinuity,
|
|
);
|
|
result.tunnelMaxChannelJump = sample.maxJump;
|
|
result.tunnelMaxJumpPair = sample.maxPair;
|
|
result.tunnelSamplePairs = sample.samplePairs;
|
|
result.tunnelPixelBounds = sample.bounds;
|
|
result.tunnelImageScale = sample.scale;
|
|
if (sample.maxJump > scenario.tunnelContinuity.maxChannelJump) {
|
|
throw new Error(
|
|
`semantic golden assertion failed: opening ${scenario.tunnelContinuity.openingId} `
|
|
+ `has a ${sample.maxJump}-channel local jump, expected at most `
|
|
+ `${scenario.tunnelContinuity.maxChannelJump}`,
|
|
);
|
|
}
|
|
if (scenario.tunnelContinuity.dpr2) {
|
|
// A CSS-pixel capture cannot prove that half-device-pixel joins are
|
|
// clean. Run the same semantic assertion once at DPR 2 without
|
|
// adding a second reviewed baseline to the matrix.
|
|
const highDpi = await launch(
|
|
scenario.viewport, 2, browserArgs, browserContext,
|
|
);
|
|
try {
|
|
await assertFreshDemoBundle(highDpi.page, ROOT);
|
|
await prepareGoldenScenario(highDpi.page, scenario);
|
|
const highDpiClip = await goldenClip(highDpi.page, scenario.capture);
|
|
const highDpiPng = await highDpi.page.screenshot({
|
|
...(highDpiClip ? { clip: highDpiClip } : {}),
|
|
animations: 'disabled', caret: 'hide', scale: 'device',
|
|
});
|
|
const highDpiSample = await inspectTunnelContinuity(
|
|
highDpi.page, highDpiPng, highDpiClip, scenario.tunnelContinuity,
|
|
);
|
|
result.tunnelDpr2MaxChannelJump = highDpiSample.maxJump;
|
|
result.tunnelDpr2SamplePairs = highDpiSample.samplePairs;
|
|
result.tunnelDpr2PixelBounds = highDpiSample.bounds;
|
|
if (highDpiSample.maxJump > scenario.tunnelContinuity.maxChannelJump) {
|
|
throw new Error(
|
|
`semantic golden assertion failed at DPR 2: opening ${scenario.tunnelContinuity.openingId} `
|
|
+ `has a ${highDpiSample.maxJump}-channel local jump, expected at most `
|
|
+ `${scenario.tunnelContinuity.maxChannelJump}`,
|
|
);
|
|
}
|
|
} finally {
|
|
await highDpi.browser.close();
|
|
}
|
|
}
|
|
}
|
|
const baselinePath = resolve(baselineRoot, `${scenario.id}.png`);
|
|
result.baseline = baselinePath;
|
|
if (!existsSync(baselinePath)) {
|
|
result.status = 'missing-baseline';
|
|
} else {
|
|
const baseline = readFileSync(baselinePath);
|
|
const expectedBaselineSha256 = baselineManifest?.scenarios?.[scenario.id];
|
|
result.baselineSha256 = sha256(baseline);
|
|
if (!expectedBaselineSha256 || result.baselineSha256 !== expectedBaselineSha256) {
|
|
result.status = 'invalid-baseline';
|
|
result.error = expectedBaselineSha256
|
|
? 'baseline PNG does not match its reviewed manifest hash'
|
|
: 'baseline PNG is not listed in the reviewed manifest';
|
|
results.push(result);
|
|
console.log(`${result.status.padEnd(17)} ${scenario.id}`);
|
|
continue;
|
|
}
|
|
const comparison = await comparePng(page, actual, baseline, scenario.threshold);
|
|
Object.assign(result, comparison);
|
|
result.status = comparison.passed ? 'passed' : 'different';
|
|
if (comparison.diffPngBase64) {
|
|
const diffPath = resolve(diffRoot, `${scenario.id}.png`);
|
|
writeFileSync(diffPath, Buffer.from(comparison.diffPngBase64, 'base64'));
|
|
result.diff = diffPath;
|
|
}
|
|
delete result.diffPngBase64;
|
|
}
|
|
} catch (error) {
|
|
result.error = error instanceof Error ? error.message : String(error);
|
|
}
|
|
results.push(result);
|
|
console.log(`${result.status.padEnd(17)} ${scenario.id}`);
|
|
}
|
|
} finally {
|
|
await browser.close();
|
|
}
|
|
|
|
const expectedScenarioIds = GOLDEN_SCENARIOS.map((scenario) => scenario.id);
|
|
const indexedScenarioIds = Object.keys(baselineManifest?.scenarios || {});
|
|
const baselineScenarioIds = readdirSync(baselineRoot)
|
|
.filter((name) => name.endsWith('.png'))
|
|
.map((name) => name.slice(0, -'.png'.length));
|
|
const manifestValid = !!baselineManifest
|
|
&& !baselineManifest.invalid
|
|
&& baselineManifest.matrixVersion === GOLDEN_MATRIX_VERSION
|
|
&& baselineManifest.chromium === chromium
|
|
&& expectedScenarioIds.every((id) => typeof baselineManifest.scenarios?.[id] === 'string')
|
|
&& goldenScenarioSetsMatch(expectedScenarioIds, indexedScenarioIds, baselineScenarioIds);
|
|
const report = {
|
|
schema: 1,
|
|
mode,
|
|
generatedAt: new Date().toISOString(),
|
|
matrixVersion: GOLDEN_MATRIX_VERSION,
|
|
buildFingerprint,
|
|
chromium,
|
|
baselineManifest: baselineManifest ? {
|
|
present: true,
|
|
valid: manifestValid,
|
|
matrixVersion: baselineManifest.matrixVersion ?? null,
|
|
chromium: baselineManifest.chromium ?? null,
|
|
} : { present: false, valid: false, matrixVersion: null, chromium: null },
|
|
results,
|
|
};
|
|
writeFileSync(resolve(artifactRoot, 'golden-report.json'), `${JSON.stringify(report, null, 2)}\n`, 'utf8');
|
|
|
|
if (goldenRunFailed(mode, manifestValid, results)) process.exitCode = 1;
|