blob: 33dd1c420871922321aa3bc2bd90af4678e679cd [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/*
* Local before/after pixel diff for the chat surface stories (#5794).
*
* This is the evidence loop for the A1 layout refactors tracked in #5793:
* run `capture` on the tree before the change and once on the change, then
* `compare` prints which stories moved pixels and writes a magenta-on-dim
* diff image for each one that did.
*
* npm --workspace @maka/desktop run build-storybook
* node scripts/storybook-visual-diff.mjs capture --out /tmp/maka-shots/before
* # ... make the layout change, rebuild storybook ...
* node scripts/storybook-visual-diff.mjs capture --out /tmp/maka-shots/after
* node scripts/storybook-visual-diff.mjs compare /tmp/maka-shots/before /tmp/maka-shots/after
*
* Keep both captures on the same machine: the font stack starts with
* -apple-system, so a capture from another OS diffs on every glyph. Nothing
* here runs in CI and no captured image belongs in the repository.
*/
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import {
installStorybookRenderProbe,
startStaticServer,
storyUrl,
storyViewport,
} from './storybook-visual-smoke.mjs';
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const DEFAULT_STORYBOOK_DIR = 'apps/desktop/storybook-static';
// One frozen instant for every shot so the session rail's relative times
// ("3mo") and any date-derived copy are identical between runs.
const FIXED_TIME = '2025-06-02T12:00:00Z';
const COLOR_SCHEMES = Object.freeze(['light', 'dark']);
const SETTLE_MS = 250;
const STORY_TIMEOUT_MS = 15_000;
const CONCURRENCY = 4;
const MANIFEST_FILE = 'manifest.json';
// The chat surfaces the A1 refactors touch first (#5794). Product story ids,
// not display names, so `--stories` filters and shot filenames stay stable.
const DEFAULT_STORY_IDS = Object.freeze([
'product-shell-official-appshell--native-conversation',
'product-shell-official-appshell--streaming-turn',
'product-shell-official-appshell--failed-turn-with-tool-error',
'product-shell-official-appshell--provider-retrying',
'product-shell-official-appshell--wide-assistant-prose',
'product-shell-official-appshell--waiting-for-permission',
'product-shell-official-appshell--new-chat-composer',
'product-shell-official-appshell--empty-home',
'product-markdown--transcript-turn',
'product-markdown--rich-assistant-answer',
'product-tool-activity--errors-and-permission-denied',
'product-tool-activity--requires-bypass-recovery',
'product-tool-activity--dense-mixed-results',
'product-tool-activity--shell-command-surface',
'product-tool-activity--edit-write-diff-rows',
'product-tool-activity--edit-write-diff-details',
'product-tool-activity--expanded-web-search-row',
'product-tool-activity--contiguous-group',
'product-tool-activity--contiguous-diff-group',
'product-tool-activity--long-intent-group-narrow',
]);
// What `animations: 'disabled'` does in toHaveScreenshot, applied by hand
// because page.screenshot takes no such option. scroll-behavior joins the
// list because a mid-smooth-scroll capture lands on a different frame.
const STABLE_PAINT_CSS = `*, *::before, *::after {
animation-delay: 0s !important;
animation-duration: 0s !important;
caret-color: transparent !important;
scroll-behavior: auto !important;
transition-delay: 0s !important;
transition-duration: 0s !important;
}`;
function installStablePaint(css) {
const style = document.createElement('style');
style.id = 'maka-shot-stable-paint';
style.textContent = css;
(document.head ?? document.documentElement).appendChild(style);
}
export function shotFileName(storyId, colorScheme) {
return `${storyId}.${colorScheme}.png`;
}
/** Story × colour-scheme jobs; `stories` filters story ids by substring. */
export function shotJobs(storyIds, { stories, schemes } = {}) {
const filters = Array.isArray(stories) && stories.length > 0 ? stories : null;
const wanted = filters
? storyIds.filter((storyId) => filters.some((filter) => storyId.includes(filter)))
: storyIds;
const activeSchemes = Array.isArray(schemes) && schemes.length > 0 ? schemes : COLOR_SCHEMES;
for (const scheme of activeSchemes) {
if (!COLOR_SCHEMES.includes(scheme)) {
throw new Error(
`Unknown color scheme "${scheme}" (expected one of ${COLOR_SCHEMES.join(', ')})`,
);
}
}
return wanted.flatMap((storyId) =>
activeSchemes.map((colorScheme) => ({ storyId, colorScheme })),
);
}
async function readStoryIndex(storybookDir) {
const indexPath = join(storybookDir, 'index.json');
let index;
try {
index = JSON.parse(await readFile(indexPath, 'utf8'));
} catch {
throw new Error(
`No built Storybook at ${storybookDir}. Build one first:\n` +
' npm --workspace @maka/desktop run build-storybook\n' +
'or pass --storybook <dir> pointing at an existing build.',
);
}
const entries = index?.entries;
if (!entries || typeof entries !== 'object') {
throw new Error(`${indexPath} has no story entries`);
}
return new Set(
Object.values(entries)
.filter((entry) => entry?.type === 'story')
.map((entry) => entry.id),
);
}
async function captureShot(browser, baseUrl, job, outDir) {
const viewport = storyViewport(job.storyId);
// A fresh context per shot: the stories share an origin, so reuse would let
// one story's localStorage or session state reach the next one's paint.
const context = await browser.newContext({
viewport,
colorScheme: job.colorScheme,
reducedMotion: 'reduce',
deviceScaleFactor: 1,
});
try {
const page = await context.newPage();
// Fixed Date, real timers: setFixedTime pins Date.now/new Date without
// pausing setTimeout, so play functions and render gates still complete.
await page.clock.setFixedTime(FIXED_TIME);
await page.addInitScript(installStorybookRenderProbe, { storyId: job.storyId });
await page.addInitScript(installStablePaint, STABLE_PAINT_CSS);
await page.emulateMedia({ colorScheme: job.colorScheme, reducedMotion: 'reduce' });
await page.goto(
storyUrl(baseUrl, { storyId: job.storyId, colorScheme: job.colorScheme, palette: 'default' }),
{ waitUntil: 'load' },
);
const finished = await page
.waitForFunction(
() => {
const smoke = window.__makaStorybookSmoke;
return smoke?.finished === true || (smoke?.failures.length ?? 0) > 0;
},
undefined,
{ timeout: STORY_TIMEOUT_MS },
)
.then(() => true)
.catch(() => false);
const failures = await page.evaluate(
() => window.__makaStorybookSmoke?.failures ?? ['render probe missing'],
);
if (failures.length > 0) throw new Error(failures.join('; '));
if (!finished) throw new Error(`story did not finish rendering within ${STORY_TIMEOUT_MS}ms`);
await page.evaluate(() => document.fonts.ready.then(() => undefined));
await page.waitForTimeout(SETTLE_MS);
const file = shotFileName(job.storyId, job.colorScheme);
await page.screenshot({ path: join(outDir, file) });
return { file, storyId: job.storyId, colorScheme: job.colorScheme, viewport };
} finally {
await context.close();
}
}
async function runCapture(options) {
const outDir = resolve(requiredOption(options, 'out'));
const storybookDir = resolve(options.storybook ?? join(REPO_ROOT, DEFAULT_STORYBOOK_DIR));
const storyIndex = await readStoryIndex(storybookDir);
const filters = listOption(options.stories);
const candidates = filters ? [...storyIndex] : [...DEFAULT_STORY_IDS];
const jobs = shotJobs(candidates, {
stories: filters,
schemes: listOption(options.schemes) ?? undefined,
});
const missingDefaults = DEFAULT_STORY_IDS.filter((id) => !storyIndex.has(id));
if (!filters && missingDefaults.length > 0) {
process.stderr.write(
`warning: catalog is missing default stories: ${missingDefaults.join(', ')}\n`,
);
}
if (jobs.length === 0) {
throw new Error(
filters
? `No catalog story matched --stories ${filters.join(',')}`
: 'The default story set matched nothing in this catalog',
);
}
const { chromium } = await import('@playwright/test');
const browser = await chromium.launch();
const server = await startStaticServer(storybookDir);
const shots = [];
const failures = [];
try {
await mkdir(outDir, { recursive: true });
const queue = [...jobs];
const workers = Array.from({ length: Math.min(CONCURRENCY, queue.length) }, async () => {
for (let job = queue.shift(); job; job = queue.shift()) {
try {
const shot = await captureShot(browser, server.baseUrl, job, outDir);
shots.push(shot);
process.stdout.write(`✓ ${shot.file}\n`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
failures.push({ job, message });
process.stderr.write(`✗ ${job.storyId} (${job.colorScheme}): ${message}\n`);
}
}
});
await Promise.all(workers);
} finally {
await server.close();
await browser.close();
}
// Only the fields compare guards on: a capture from another OS or another
// Chromium rasterizes different glyphs, and the warning below is the only
// thing standing between that and a page of meaningless diffs. `shots` is
// the authoritative list of what this run wrote — compare treats a PNG on
// disk that is not listed as a leftover from an earlier capture — while
// `expected` is the full job set, so a shot that failed in both captures
// surfaces as not-captured instead of vanishing from the report.
const manifest = {
platform: process.platform,
arch: process.arch,
chromium: browser.version(),
expected: jobs.map((job) => shotFileName(job.storyId, job.colorScheme)).sort(),
shots: shots.map((shot) => shot.file).sort(),
};
await writeFile(join(outDir, MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}\n`);
process.stdout.write(
`Captured ${shots.length} shot(s) into ${outDir}` +
(failures.length > 0 ? `; ${failures.length} failed` : '') +
'\n',
);
if (failures.length > 0) process.exitCode = 1;
}
/*
* Pixel comparison. PNG decode/encode goes through the same Chromium that
* captured the shots — no second codec, no extra dependency. A pixel counts
* as changed when any RGBA channel differs at all; there is no tolerance,
* because a 1px move is exactly what this tool exists to catch.
*/
function diffImages() {
// Runs inside the browser via page.evaluate; keep it free of outer refs.
return async ({ beforeB64, afterB64 }) => {
const decode = (b64) =>
new Promise((resolvePromise, reject) => {
const img = new Image();
img.onload = () => resolvePromise(img);
img.onerror = () => reject(new Error('PNG decode failed'));
img.src = `data:image/png;base64,${b64}`;
});
const [before, after] = await Promise.all([decode(beforeB64), decode(afterB64)]);
if (before.width !== after.width || before.height !== after.height) {
return {
sizeMismatch: true,
beforeSize: [before.width, before.height],
afterSize: [after.width, after.height],
};
}
const { width, height } = before;
const pixels = (img) => {
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
ctx.drawImage(img, 0, 0);
return ctx.getImageData(0, 0, width, height).data;
};
const a = pixels(before);
const b = pixels(after);
const diffCanvas = document.createElement('canvas');
diffCanvas.width = width;
diffCanvas.height = height;
const diffCtx = diffCanvas.getContext('2d');
const diff = diffCtx.createImageData(width, height);
let changed = 0;
const box = { left: width, top: height, right: -1, bottom: -1 };
for (let i = 0; i < a.length; i += 4) {
const same =
a[i] === b[i] && a[i + 1] === b[i + 1] && a[i + 2] === b[i + 2] && a[i + 3] === b[i + 3];
if (same) {
// Dimmed grayscale of the after image: context without noise.
const gray = Math.round(0.299 * b[i] + 0.587 * b[i + 1] + 0.114 * b[i + 2]);
const dim = 96 + Math.round(gray * 0.35);
diff.data[i] = dim;
diff.data[i + 1] = dim;
diff.data[i + 2] = dim;
diff.data[i + 3] = 255;
continue;
}
changed += 1;
const pixel = i / 4;
const x = pixel % width;
const y = (pixel - x) / width;
if (x < box.left) box.left = x;
if (x > box.right) box.right = x;
if (y < box.top) box.top = y;
if (y > box.bottom) box.bottom = y;
diff.data[i] = 255;
diff.data[i + 1] = 0;
diff.data[i + 2] = 255;
diff.data[i + 3] = 255;
}
if (changed === 0) return { changed: 0, width, height };
diffCtx.putImageData(diff, 0, 0);
return {
changed,
width,
height,
box,
diffPngB64: diffCanvas.toDataURL('image/png').slice('data:image/png;base64,'.length),
};
};
}
async function readManifest(dir) {
try {
return JSON.parse(await readFile(join(dir, MANIFEST_FILE), 'utf8'));
} catch {
return null;
}
}
export async function listShots(dir) {
try {
// *.diff.png belongs to a --diff-dir report, not to the shot set; counting
// it would read report artifacts as missing shots on the next compare.
return (await readdir(dir)).filter(
(name) => name.endsWith('.png') && !name.endsWith('.diff.png'),
);
} catch (error) {
throw new Error(
`Cannot read shots from ${dir}: ${error instanceof Error ? error.message : error}`,
);
}
}
/** Shot files on disk that the capture's manifest did not produce this run. */
export function staleShots(files, manifest) {
if (!Array.isArray(manifest?.shots)) return [];
return files.filter((file) => !manifest.shots.includes(file));
}
/*
* Per-file disposition before any pixel work. Only `compare` rows count as
* evidence; everything else lands in the skipped bucket and fails the run:
* missing-before / missing-after — on disk on one side only;
* stale — on disk but absent from that capture's manifest `shots`;
* not-captured — expected by a manifest but produced by neither capture.
*/
export function planShots(beforeFiles, afterFiles, beforeManifest, afterManifest) {
const expected = new Set([
...(Array.isArray(beforeManifest?.expected) ? beforeManifest.expected : []),
...(Array.isArray(afterManifest?.expected) ? afterManifest.expected : []),
]);
const stale = new Set([
...staleShots([...beforeFiles], beforeManifest),
...staleShots([...afterFiles], afterManifest),
]);
const files = [...new Set([...beforeFiles, ...afterFiles, ...expected])].sort();
return files.map((file) => {
const inBefore = beforeFiles.has(file);
const inAfter = afterFiles.has(file);
let status = 'compare';
if (!inBefore && !inAfter) status = 'not-captured';
else if (stale.has(file)) status = 'stale';
else if (!inBefore) status = 'missing-before';
else if (!inAfter) status = 'missing-after';
return { file, status };
});
}
export async function runCompare(beforeArg, afterArg, options) {
const beforeDir = resolve(beforeArg);
const afterDir = resolve(afterArg);
const diffDir = resolve(options['diff-dir'] ?? `${afterDir}-diff`);
const [beforeManifest, afterManifest] = await Promise.all([
readManifest(beforeDir),
readManifest(afterDir),
]);
for (const key of ['platform', 'arch', 'chromium']) {
if (
beforeManifest?.[key] &&
afterManifest?.[key] &&
beforeManifest[key] !== afterManifest[key]
) {
process.stderr.write(
`warning: captures disagree on ${key} (${beforeManifest[key]} vs ${afterManifest[key]}); ` +
'font and raster differences will read as pixel diffs.\n',
);
}
}
const beforeFiles = new Set(await listShots(beforeDir));
const afterFiles = new Set(await listShots(afterDir));
const results = planShots(beforeFiles, afterFiles, beforeManifest, afterManifest);
if (results.length === 0) {
throw new Error(
`No .png screenshots found in ${beforeDir} or ${afterDir} — nothing to compare.`,
);
}
const toCompare = results.filter((row) => row.status === 'compare');
if (toCompare.length > 0) {
const { chromium } = await import('@playwright/test');
const browser = await chromium.launch();
try {
const page = await browser.newPage();
const differ = diffImages();
for (const row of toCompare) {
const [beforePng, afterPng] = await Promise.all([
readFile(join(beforeDir, row.file)),
readFile(join(afterDir, row.file)),
]);
const result = await page.evaluate(differ, {
beforeB64: beforePng.toString('base64'),
afterB64: afterPng.toString('base64'),
});
if (result.sizeMismatch) {
Object.assign(row, { status: 'size-mismatch', ...result });
continue;
}
if (result.changed === 0) {
Object.assign(row, { status: 'identical', width: result.width, height: result.height });
continue;
}
const diffPath = join(diffDir, `${row.file}.diff.png`);
await mkdir(diffDir, { recursive: true });
await writeFile(diffPath, Buffer.from(result.diffPngB64, 'base64'));
Object.assign(row, { status: 'changed', ...result, diffPngB64: undefined });
}
} finally {
await browser.close();
}
}
const changed = results.filter((row) => row.status === 'changed');
const identical = results.filter((row) => row.status === 'identical');
const skipped = results.filter((row) => row.status !== 'changed' && row.status !== 'identical');
for (const row of identical) process.stdout.write(`= ${row.file}\n`);
for (const row of changed) {
const pct = ((row.changed / (row.width * row.height)) * 100).toFixed(3);
process.stdout.write(
`≠ ${row.file} — ${row.changed.toLocaleString()} px (${pct}%), ` +
`box ${row.box.right - row.box.left + 1}×${row.box.bottom - row.box.top + 1} ` +
`at (${row.box.left},${row.box.top})\n`,
);
}
for (const row of skipped) process.stdout.write(`! ${row.file} — ${row.status}\n`);
process.stdout.write(
`${changed.length} changed, ${identical.length} identical, ${skipped.length} skipped.\n` +
(changed.length > 0 ? `Diffs: ${diffDir}\n` : ''),
);
if (changed.length > 0 || skipped.length > 0) process.exitCode = 1;
}
function listOption(value) {
if (value === undefined) return null;
return String(value)
.split(',')
.map((item) => item.trim())
.filter(Boolean);
}
function requiredOption(options, name) {
const value = options[name];
if (typeof value !== 'string' || value === '') {
throw new Error(`Missing --${name}. See --help.`);
}
return value;
}
export function parseCliArgs(argv) {
const positionals = [];
const options = {};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (!arg.startsWith('--')) {
positionals.push(arg);
continue;
}
const eq = arg.indexOf('=');
if (eq !== -1) {
options[arg.slice(2, eq)] = arg.slice(eq + 1);
continue;
}
const name = arg.slice(2);
const next = argv[i + 1];
if (next !== undefined && !next.startsWith('--')) {
options[name] = next;
i += 1;
} else {
options[name] = true;
}
}
return { command: positionals[0], positionals: positionals.slice(1), options };
}
const USAGE = `Usage:
node scripts/storybook-visual-diff.mjs capture --out DIR [options]
node scripts/storybook-visual-diff.mjs compare BEFORE_DIR AFTER_DIR [--diff-dir DIR]
capture options:
--out DIR where PNG shots and manifest.json go (required)
--storybook DIR built catalog to serve (default: ${DEFAULT_STORYBOOK_DIR})
--stories a,b substring filter on story ids (default: the #5794 set)
--schemes a,b color schemes (default: light,dark)
compare options:
--diff-dir DIR where *.diff.png images go (default: <AFTER_DIR>-diff)
Example:
npm --workspace @maka/desktop run build-storybook
node scripts/storybook-visual-diff.mjs capture --out /tmp/maka-shots/before
node scripts/storybook-visual-diff.mjs compare /tmp/maka-shots/before /tmp/maka-shots/after
`;
async function runCli(argv) {
const { command, positionals, options } = parseCliArgs(argv);
if (options.help || command === 'help' || command === undefined) {
process.stdout.write(USAGE);
return;
}
if (command === 'capture') {
await runCapture(options);
return;
}
if (command === 'compare') {
const [before, after] = positionals;
if (!before || !after) throw new Error('compare needs BEFORE_DIR and AFTER_DIR. See --help.');
await runCompare(before, after, options);
return;
}
throw new Error(`Unknown command: ${command}. See --help.`);
}
if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) {
runCli(process.argv.slice(2)).catch((error) => {
process.stderr.write(
`${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`,
);
process.exitCode = 1;
});
}