mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-11-09 05:26:24 +09:00
## Summary Replace the defunct Lost Pixel service and the interim Chromatic workflow with a fast, self-hosted visual comparison pipeline. The workflow indexes the existing CSF stories without building Storybook, builds and serves a lightweight browser app with Vite, renders the merge base and current revision through Webstudio's shared browser tooling, and compares screenshots with Webstudio's pixel-diff and OCR engine. It uses GitHub Actions cache and artifacts only—no R2 bucket or external visual-testing service. Closes #6064 ## Implementation - [x] Remove the Lost Pixel workflow, configuration, acknowledgement, and global two-second wait. - [x] Remove the interim Chromatic workflow and settings. - [x] Extract source-agnostic capture, diff, OCR, audit, cache, and report tooling into `@webstudio-is/vision`; rename the Webstudio-specific preview boundary to `@webstudio-is/project-build/preview`. - [x] Expose the reusable browser capture session through the shared vision boundary. - [x] Make OCR and diff-artifact generation conditional on detected pixel changes. - [x] Render existing CSF stories through a typed, lightweight browser harness built with Vite. - [x] Replace the embedded HTML and handwritten HTTP/MIME server with a real HTML entry, Vite production build, and Vite preview server; isolate declarative build configuration in `story-build.ts`. - [x] Apply Storybook preview annotations, decorators, global CSS, backgrounds, and story metadata. - [x] Store story scopes as shared data and load each revision's own configuration. - [x] Discover changed, added, and removed stories from both revisions. - [x] Populate private story sources symmetrically in the baseline worktree. - [x] Isolate every story with a fresh React root and deterministic time, randomness, intervals, focus, animation, and layout readiness. - [x] Preserve story-owned visual options for masking, settling, and interval control. - [x] Reuse Vite preview servers, one Chromium process, grouped browser pages, production bundles, and a bounded CPU worker pool for image comparison. - [x] Recheck initial pixel differences with serial captures, then recapture persistent baseline/current pairs on the same browser page so renderer variance cannot create false failures. - [x] Report individual story render failures without discarding successful captures. - [x] Enforce a 0.1 per-pixel threshold and 0.001% total mismatch tolerance. - [x] Run OCR only for changed comparisons and include text-change evidence. - [x] Move the generic report model, writer, static viewer, and opener into the shared Vision package for reuse by CI and future CLI comparisons. - [x] Bundle report HTML, styling, and browser behavior as static Vision assets without runtime source-file lookups. - [x] Copy external screenshot inputs into self-contained report artifacts for CLI reuse. - [x] Handle cached removed-only comparisons without rendering the removed story on the current server. - [x] Support focused local reproduction and opening the report with one command. - [x] Pin CI to the lockfile's Playwright Chromium, cache complete revision screenshots with matching restore/save paths and automatically derived runtime invalidation, and render both revisions on a miss. - [x] Keep cache archives bounded to one revision instead of accumulating the cache history. - [x] Run the complete visual suite in one CI job and upload one report as a 14-day artifact. - [x] Fail PR checks on visual differences, empty runs, missing screenshots, missing or empty report artifacts, and infrastructure failures. - [x] Rerun fork checks when `safe-to-deploy` is added. - [x] Allow reviewed visual-only differences through `visual-change-approved` and invalidate approval after new commits. - [x] Keep capture, pixel diff, OCR, and report generation source-agnostic for future draft-versus-published project comparisons. - [x] Use Vite for the complete story HTML, module, asset, production-build, and preview-server lifecycle; inline the selected stories and skip ineffective tree-shaking to keep the cached run under one minute. - [x] Review Builder product documentation impact: no user-facing Builder documentation is affected; contributor usage is documented in `scripts/visual-regression/README.md`. - [x] Review fixture impact: no fixture inputs or generated fixture outputs are affected. ## Verification - [x] Project-build Vision tests, including the reusable report test, and entrypoint contract tests pass. - [x] CLI screenshot, preview, and browser-session tests pass. - [x] Project-build, CLI, Builder, design-system, and visual-runner typechecks pass. - [x] Visual runner tests pass (10), and the complete Vision package passes (79), including demonstrated fail-before/pass-after coverage. - [x] Full Storybook production build passes. - [x] Full local Vite run completes all 238 stories in 69.9s cold and 50.3s with a cached baseline plus two same-page serial verifications, with zero differences; identical-image comparison remains sub-second. - [x] The report writer passes a bundled-output smoke test and materializes external screenshot assets. - [x] The generated report validates all 238 entries and referenced images. - [x] Hosted cached-baseline comparison completes all 238 stories in 52.9s with zero differences; the cache-miss fallback completes in 101.3s. - [x] Cache-miss fallback renders both Git revisions on demand. - [x] Visual differences produce baseline, current, diff, OCR, JSON, and HTML evidence and fail without approval. - [x] Hosted single-job visual workflow restores both the pinned browser and baseline cache, validates all 238 public and private stories with zero differences, and uploads the report. - [x] The final Vite production build renders all 238 stories without dependency invalidation or duplicate React runtimes, and restores `NODE_ENV` so cached serial fallback installs the baseline's dev dependencies. - [x] Root lint, package-boundary validation, and relevant formatting checks pass. - [x] Workflow YAML parses successfully. - [x] `git diff --check`, staged-diff review, and final worktree review pass. ## Usage Run the complete local comparison and open the visual report: ```sh pnpm visual-regression:ui ``` Focus one component while iterating: ```sh pnpm visual-regression:ui -- --grep "Design system/Button" ``` Reopen the latest report without rerunning: ```sh pnpm visual-regression:report ``` The CI report is available as the `visual-regression-report` artifact. ## Tradeoffs - Baseline screenshots are a disposable acceleration cache, not source of truth. A missing or invalid cache renders the merge base directly from Git. - GitHub artifact storage provides downloadable one-click reports; permanently hosted browser links would require another hosting surface. - OCR runs after pixel triage because invoking Tesseract for every unchanged story would make the common path unnecessarily slow. - Vite production builds add Rollup's module graph and HTML/asset pipeline. The runner emits one bundle because every selected story is rendered, and skips tree-shaking because it removed little while increasing build time. - The verified local path is 50.3s cached and 69.9s cold. The hosted path is 52.9s cached and 101.3s cold. A new browser/runtime cache must render its baseline once; the pinned browser keeps later runners on the reusable path. - The complete suite stays in one Actions job; browser-page concurrency is internal and bounded.
659 lines
20 KiB
TypeScript
659 lines
20 KiB
TypeScript
import { execFile, spawn } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import { access, mkdir, rm } from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { parseArgs, promisify } from "node:util";
|
|
import {
|
|
createBrowserScreenshotSession,
|
|
type BrowserScreenshotSession,
|
|
} from "@webstudio-is/vision/browser";
|
|
import {
|
|
classifyScreenshotComparisonReport,
|
|
openScreenshotComparisonReport,
|
|
writeScreenshotComparisonReport,
|
|
type ScreenshotComparisonReport,
|
|
} from "@webstudio-is/vision/report";
|
|
import { compareVisualEntries } from "@webstudio-is/vision/comparison";
|
|
import {
|
|
restoreScreenshotCache,
|
|
writeScreenshotCache,
|
|
} from "@webstudio-is/vision/screenshot-cache";
|
|
import {
|
|
defaultScreenshotDependencies,
|
|
resolveScreenshotBrowser,
|
|
} from "webstudio/vision";
|
|
import {
|
|
readStoryManifest,
|
|
readStorySources,
|
|
type VisualStoryEntry,
|
|
} from "./manifest";
|
|
import {
|
|
captureStoryPairs,
|
|
captureStories,
|
|
getStoryCaptureOptions,
|
|
getInitialCaptureTarget,
|
|
} from "./capture";
|
|
import { visualRegressionConfig } from "./config";
|
|
import { getScreenshotRuntimeHash } from "./runtime-hash";
|
|
import {
|
|
startVisualStoryServer,
|
|
startVisualStoryServers,
|
|
} from "./story-server";
|
|
|
|
const repositoryRoot = process.cwd();
|
|
const outputRoot = path.join(repositoryRoot, ".visual-regression");
|
|
const reportDirectory = path.join(outputRoot, "report");
|
|
const assetDirectory = path.join(reportDirectory, "assets");
|
|
const screenshotCacheRoot = path.join(
|
|
repositoryRoot,
|
|
".visual-regression-cache"
|
|
);
|
|
const defaultCaptureConcurrency = Math.min(
|
|
visualRegressionConfig.capture.concurrency.maximum,
|
|
Math.max(
|
|
visualRegressionConfig.capture.concurrency.minimum,
|
|
os.availableParallelism()
|
|
)
|
|
);
|
|
const captureConcurrency = Number(
|
|
process.env.VISUAL_CAPTURE_CONCURRENCY ?? defaultCaptureConcurrency
|
|
);
|
|
const execFileAsync = promisify(execFile);
|
|
if (Number.isInteger(captureConcurrency) === false || captureConcurrency < 1) {
|
|
throw new Error("VISUAL_CAPTURE_CONCURRENCY must be a positive integer.");
|
|
}
|
|
|
|
const { values: arguments_ } = parseArgs({
|
|
args: process.argv.slice(2).filter((argument) => argument !== "--"),
|
|
options: {
|
|
base: { type: "string", default: "origin/main" },
|
|
grep: { type: "string" },
|
|
"open-report": { type: "boolean", default: false },
|
|
"approve-visual-changes": { type: "boolean", default: false },
|
|
},
|
|
strict: true,
|
|
});
|
|
const baseRef = arguments_.base;
|
|
const grep = arguments_.grep;
|
|
const openReport = arguments_["open-report"];
|
|
const approved = arguments_["approve-visual-changes"];
|
|
|
|
const run = async ({
|
|
command,
|
|
commandArgs,
|
|
cwd = repositoryRoot,
|
|
}: {
|
|
command: string;
|
|
commandArgs: string[];
|
|
cwd?: string;
|
|
}) => {
|
|
const child = spawn(command, commandArgs, {
|
|
cwd,
|
|
stdio: ["ignore", "inherit", "inherit"],
|
|
});
|
|
const exitCode = await new Promise<number>((resolve, reject) => {
|
|
child.on("error", reject);
|
|
child.on("exit", (code, signal) => {
|
|
if (signal !== null) {
|
|
reject(new Error(`${command} exited with signal ${signal}`));
|
|
return;
|
|
}
|
|
resolve(code ?? 1);
|
|
});
|
|
});
|
|
if (exitCode !== 0) {
|
|
throw new Error(`${command} exited with code ${exitCode}`);
|
|
}
|
|
return exitCode;
|
|
};
|
|
|
|
const installBaselineDependencies = (checkout: string) =>
|
|
run({
|
|
command: "pnpm",
|
|
commandArgs: [
|
|
"install",
|
|
"--frozen-lockfile",
|
|
"--ignore-scripts",
|
|
"--prod=false",
|
|
],
|
|
cwd: checkout,
|
|
});
|
|
|
|
const getCommandOutput = async (command: string, commandArgs: string[]) => {
|
|
const { stdout } = await execFileAsync(command, commandArgs, {
|
|
cwd: repositoryRoot,
|
|
encoding: "utf8",
|
|
});
|
|
return stdout.trim();
|
|
};
|
|
|
|
const getScreenshotCacheDirectory = (revision: string, runtimeHash: string) =>
|
|
path.join(screenshotCacheRoot, `${revision}-${runtimeHash}`);
|
|
|
|
type StoryCapture = Awaited<ReturnType<typeof captureStories>>;
|
|
|
|
const replaceStoryCaptures = ({
|
|
ids,
|
|
capture,
|
|
replacement,
|
|
}: {
|
|
ids: readonly string[];
|
|
capture: StoryCapture;
|
|
replacement: StoryCapture;
|
|
}) => {
|
|
for (const id of ids) {
|
|
capture.paths.delete(id);
|
|
capture.errors.delete(id);
|
|
}
|
|
for (const [id, screenshotPath] of replacement.paths) {
|
|
capture.paths.set(id, screenshotPath);
|
|
}
|
|
for (const [id, error] of replacement.errors) {
|
|
capture.errors.set(id, error);
|
|
}
|
|
};
|
|
|
|
const compareStories = async ({
|
|
baselineEntries,
|
|
currentEntries,
|
|
baselinePaths,
|
|
currentPaths,
|
|
baselineErrors,
|
|
currentErrors,
|
|
analyzeDifferences = true,
|
|
}: {
|
|
baselineEntries: Record<string, VisualStoryEntry>;
|
|
currentEntries: Record<string, VisualStoryEntry>;
|
|
baselinePaths: Map<string, string>;
|
|
currentPaths: Map<string, string>;
|
|
baselineErrors: Map<string, string>;
|
|
currentErrors: Map<string, string>;
|
|
analyzeDifferences?: boolean;
|
|
}) => {
|
|
return await compareVisualEntries({
|
|
baselineEntries: Object.values(baselineEntries),
|
|
currentEntries: Object.values(currentEntries),
|
|
baselinePaths,
|
|
currentPaths,
|
|
baselineErrors,
|
|
currentErrors,
|
|
artifactDirectory: assetDirectory,
|
|
pixelThreshold: visualRegressionConfig.comparison.pixelThreshold,
|
|
maxMismatchPercentage:
|
|
visualRegressionConfig.comparison.maxMismatchPercentage,
|
|
concurrency: Math.min(
|
|
visualRegressionConfig.comparison.concurrency,
|
|
os.availableParallelism()
|
|
),
|
|
analyzeText: analyzeDifferences ? "when-different" : false,
|
|
writeArtifacts: analyzeDifferences ? "when-different" : false,
|
|
});
|
|
};
|
|
|
|
const prepareBaselineCheckout = async (checkout: string) => {
|
|
const [baselineCommit, currentCommit] = await Promise.all([
|
|
getCommandOutput("git", ["merge-base", "HEAD", baseRef]),
|
|
getCommandOutput("git", ["rev-parse", "HEAD"]),
|
|
]);
|
|
const currentRevisionIsClean =
|
|
(await getCommandOutput("git", ["status", "--porcelain"])) === "";
|
|
console.info(`Comparing ${baselineCommit} with ${currentCommit}`);
|
|
|
|
const checkoutExists = await access(path.join(checkout, ".git"))
|
|
.then(() => true)
|
|
.catch(() => false);
|
|
await run(
|
|
checkoutExists
|
|
? {
|
|
command: "git",
|
|
commandArgs: ["switch", "--detach", baselineCommit],
|
|
cwd: checkout,
|
|
}
|
|
: {
|
|
command: "git",
|
|
commandArgs: [
|
|
"worktree",
|
|
"add",
|
|
"--detach",
|
|
checkout,
|
|
baselineCommit,
|
|
],
|
|
}
|
|
);
|
|
|
|
const currentSubmodules = await getCommandOutput("git", [
|
|
"submodule",
|
|
"status",
|
|
"--recursive",
|
|
]);
|
|
if (
|
|
currentSubmodules
|
|
.split("\n")
|
|
.some((status) => status !== "" && status.startsWith("-") === false)
|
|
) {
|
|
await run({
|
|
command: "git",
|
|
commandArgs: ["submodule", "update", "--init", "--recursive"],
|
|
cwd: checkout,
|
|
});
|
|
}
|
|
return { baselineCommit, currentCommit, currentRevisionIsClean };
|
|
};
|
|
|
|
const readComparisonEntries = async (checkout: string) => {
|
|
const currentStorySources = await readStorySources(repositoryRoot);
|
|
if (currentStorySources === undefined) {
|
|
throw new Error("Current story source configuration is missing.");
|
|
}
|
|
const baselineStorySources =
|
|
(await readStorySources(checkout)) ?? currentStorySources;
|
|
const [baselineEntries, currentEntries] = await Promise.all([
|
|
readStoryManifest({ root: checkout, storySources: baselineStorySources }),
|
|
readStoryManifest({
|
|
root: repositoryRoot,
|
|
storySources: currentStorySources,
|
|
}),
|
|
]);
|
|
const matches = grep === undefined ? undefined : new RegExp(grep, "i");
|
|
const filterEntries = (entries: Record<string, VisualStoryEntry>) =>
|
|
Object.fromEntries(
|
|
Object.entries(entries).filter(
|
|
([id, entry]) =>
|
|
matches === undefined ||
|
|
matches.test(`${id} ${entry.title} ${entry.name}`)
|
|
)
|
|
);
|
|
return {
|
|
baselineEntries: filterEntries(baselineEntries),
|
|
currentEntries: filterEntries(currentEntries),
|
|
};
|
|
};
|
|
|
|
const verifyChangedCaptures = async ({
|
|
comparisons,
|
|
baselineEntries,
|
|
currentEntries,
|
|
baselineCapture,
|
|
currentCapture,
|
|
cachedBaseline,
|
|
browserPath,
|
|
browserSession,
|
|
checkout,
|
|
baselineBundleDirectory,
|
|
servers,
|
|
}: {
|
|
comparisons: Awaited<ReturnType<typeof compareStories>>;
|
|
baselineEntries: Record<string, VisualStoryEntry>;
|
|
currentEntries: Record<string, VisualStoryEntry>;
|
|
baselineCapture: StoryCapture;
|
|
currentCapture: StoryCapture;
|
|
cachedBaseline: boolean;
|
|
browserPath: string;
|
|
browserSession: BrowserScreenshotSession | undefined;
|
|
checkout: string;
|
|
baselineBundleDirectory: string;
|
|
servers: Array<Awaited<ReturnType<typeof startVisualStoryServer>>>;
|
|
}) => {
|
|
const changedIds = comparisons
|
|
.filter(({ status }) => status === "changed")
|
|
.map(({ id }) => id);
|
|
if (changedIds.length === 0) {
|
|
return { comparisons, performed: false };
|
|
}
|
|
|
|
console.info(
|
|
`Verifying ${changedIds.length} visual differences with serial captures.`
|
|
);
|
|
const currentVerification = await captureStories({
|
|
assetDirectory,
|
|
browserPath,
|
|
concurrency: 1,
|
|
entries: changedIds.flatMap((id) => {
|
|
const entry = currentEntries[id];
|
|
return entry === undefined ? [] : [entry];
|
|
}),
|
|
port: visualRegressionConfig.servers.currentPort,
|
|
target: "current",
|
|
session: browserSession,
|
|
});
|
|
replaceStoryCaptures({
|
|
ids: changedIds,
|
|
capture: currentCapture,
|
|
replacement: currentVerification,
|
|
});
|
|
comparisons = await compareStories({
|
|
baselineEntries,
|
|
currentEntries,
|
|
baselinePaths: baselineCapture.paths,
|
|
currentPaths: currentCapture.paths,
|
|
baselineErrors: baselineCapture.errors,
|
|
currentErrors: currentCapture.errors,
|
|
analyzeDifferences: false,
|
|
});
|
|
const remainingChangedIds = comparisons
|
|
.filter(({ status }) => status === "changed")
|
|
.map(({ id }) => id);
|
|
if (remainingChangedIds.length === 0) {
|
|
return { comparisons, performed: true };
|
|
}
|
|
|
|
const changedPairs = remainingChangedIds.flatMap((id) => {
|
|
const baseline = baselineEntries[id];
|
|
const current = currentEntries[id];
|
|
return baseline === undefined || current === undefined
|
|
? []
|
|
: [{ baseline, current }];
|
|
});
|
|
if (cachedBaseline) {
|
|
await installBaselineDependencies(checkout);
|
|
servers.push(
|
|
await startVisualStoryServer({
|
|
root: checkout,
|
|
port: visualRegressionConfig.servers.baselinePort,
|
|
outputDirectory: baselineBundleDirectory,
|
|
storyFiles: changedPairs.map(({ baseline }) => baseline.file),
|
|
})
|
|
);
|
|
}
|
|
const pairedVerification = await captureStoryPairs({
|
|
assetDirectory,
|
|
browserPath,
|
|
pairs: changedPairs,
|
|
baselinePort: visualRegressionConfig.servers.baselinePort,
|
|
currentPort: visualRegressionConfig.servers.currentPort,
|
|
session: browserSession,
|
|
});
|
|
replaceStoryCaptures({
|
|
ids: remainingChangedIds,
|
|
capture: baselineCapture,
|
|
replacement: pairedVerification.baseline,
|
|
});
|
|
replaceStoryCaptures({
|
|
ids: remainingChangedIds,
|
|
capture: currentCapture,
|
|
replacement: pairedVerification.current,
|
|
});
|
|
return {
|
|
comparisons: await compareStories({
|
|
baselineEntries,
|
|
currentEntries,
|
|
baselinePaths: baselineCapture.paths,
|
|
currentPaths: currentCapture.paths,
|
|
baselineErrors: baselineCapture.errors,
|
|
currentErrors: currentCapture.errors,
|
|
}),
|
|
performed: true,
|
|
};
|
|
};
|
|
|
|
const main = async () => {
|
|
const startedAt = Date.now();
|
|
let phaseStartedAt = startedAt;
|
|
const logPhase = (name: string) => {
|
|
const now = Date.now();
|
|
console.info(`${name}: ${((now - phaseStartedAt) / 1000).toFixed(1)}s`);
|
|
phaseStartedAt = now;
|
|
};
|
|
const cacheKey = createHash("sha256")
|
|
.update(repositoryRoot)
|
|
.digest("hex")
|
|
.slice(0, 12);
|
|
const temporaryRoot = path.join(
|
|
os.tmpdir(),
|
|
`webstudio-visual-regression-${cacheKey}`
|
|
);
|
|
const checkout = path.join(temporaryRoot, "checkout");
|
|
const baselineBundleDirectory = path.join(temporaryRoot, "baseline-bundle");
|
|
const currentBundleDirectory = path.join(temporaryRoot, "current-bundle");
|
|
const servers: Array<Awaited<ReturnType<typeof startVisualStoryServer>>> = [];
|
|
let browserSession: BrowserScreenshotSession | undefined;
|
|
let report: ScreenshotComparisonReport | undefined;
|
|
|
|
await rm(outputRoot, { recursive: true, force: true });
|
|
await mkdir(assetDirectory, { recursive: true });
|
|
await mkdir(temporaryRoot, { recursive: true });
|
|
|
|
try {
|
|
const { baselineCommit, currentCommit, currentRevisionIsClean } =
|
|
await prepareBaselineCheckout(checkout);
|
|
const browser = await resolveScreenshotBrowser(
|
|
{ browser: "auto" },
|
|
defaultScreenshotDependencies
|
|
);
|
|
const {
|
|
baselineEntries: filteredBaselineEntries,
|
|
currentEntries: filteredCurrentEntries,
|
|
} = await readComparisonEntries(checkout);
|
|
const filteredIds = new Set([
|
|
...Object.keys(filteredBaselineEntries),
|
|
...Object.keys(filteredCurrentEntries),
|
|
]);
|
|
console.info(`Running ${filteredIds.size} visual story ids.`);
|
|
const screenshotRuntimeHash = await getScreenshotRuntimeHash({
|
|
root: repositoryRoot,
|
|
browserVersion: await getCommandOutput(browser.path, ["--version"]),
|
|
});
|
|
const screenshotCacheDirectory = getScreenshotCacheDirectory(
|
|
baselineCommit,
|
|
screenshotRuntimeHash
|
|
);
|
|
const cachedBaselinePaths = await restoreScreenshotCache({
|
|
directory: screenshotCacheDirectory,
|
|
ids: Object.keys(filteredBaselineEntries),
|
|
getOutputPath: (id) => path.join(assetDirectory, id, "baseline.png"),
|
|
});
|
|
if (cachedBaselinePaths !== undefined) {
|
|
console.info(`Reusing baseline screenshots for ${baselineCommit}.`);
|
|
} else {
|
|
console.info(
|
|
`Baseline screenshot cache is unavailable; rendering ${
|
|
Object.keys(filteredBaselineEntries).length
|
|
} baseline stories.`
|
|
);
|
|
await installBaselineDependencies(checkout);
|
|
}
|
|
servers.push(
|
|
...(await startVisualStoryServers([
|
|
...(cachedBaselinePaths === undefined
|
|
? [
|
|
{
|
|
root: checkout,
|
|
port: visualRegressionConfig.servers.baselinePort,
|
|
outputDirectory: baselineBundleDirectory,
|
|
storyFiles: Object.values(filteredBaselineEntries).map(
|
|
(entry) => entry.file
|
|
),
|
|
},
|
|
]
|
|
: []),
|
|
{
|
|
root: repositoryRoot,
|
|
port: visualRegressionConfig.servers.currentPort,
|
|
outputDirectory: currentBundleDirectory,
|
|
storyFiles: Object.values(filteredCurrentEntries).map(
|
|
(entry) => entry.file
|
|
),
|
|
},
|
|
]))
|
|
);
|
|
logPhase("Setup and bundles");
|
|
|
|
const baselineEntriesToCapture = Object.values(filteredBaselineEntries);
|
|
const currentEntriesToCapture = Object.values(filteredCurrentEntries);
|
|
if (
|
|
baselineEntriesToCapture.length === 0 &&
|
|
currentEntriesToCapture.length === 0
|
|
) {
|
|
throw new Error("No stories matched the visual comparison.");
|
|
}
|
|
const initialCapture = getInitialCaptureTarget({
|
|
baselineEntries: baselineEntriesToCapture,
|
|
currentEntries: currentEntriesToCapture,
|
|
hasCachedBaseline: cachedBaselinePaths !== undefined,
|
|
});
|
|
if (initialCapture !== undefined) {
|
|
const firstOutput = path.join(
|
|
assetDirectory,
|
|
initialCapture.entry.id,
|
|
"browser-session.png"
|
|
);
|
|
await mkdir(path.dirname(firstOutput), { recursive: true });
|
|
browserSession = await createBrowserScreenshotSession(
|
|
getStoryCaptureOptions({
|
|
browserPath: browser.path,
|
|
entry: initialCapture.entry,
|
|
output: firstOutput,
|
|
port:
|
|
initialCapture.target === "baseline"
|
|
? visualRegressionConfig.servers.baselinePort
|
|
: visualRegressionConfig.servers.currentPort,
|
|
})
|
|
);
|
|
}
|
|
const baselineCapturePromise =
|
|
cachedBaselinePaths === undefined
|
|
? captureStories({
|
|
assetDirectory,
|
|
browserPath: browser.path,
|
|
concurrency: captureConcurrency,
|
|
entries: baselineEntriesToCapture,
|
|
port: visualRegressionConfig.servers.baselinePort,
|
|
target: "baseline",
|
|
session: browserSession,
|
|
})
|
|
: Promise.resolve({
|
|
paths: cachedBaselinePaths,
|
|
errors: new Map<string, string>(),
|
|
});
|
|
const currentCapturePromise = captureStories({
|
|
assetDirectory,
|
|
browserPath: browser.path,
|
|
concurrency: captureConcurrency,
|
|
entries: currentEntriesToCapture,
|
|
port: visualRegressionConfig.servers.currentPort,
|
|
target: "current",
|
|
session: browserSession,
|
|
});
|
|
const [baselineCapture, currentCapture] = await Promise.all([
|
|
baselineCapturePromise,
|
|
currentCapturePromise,
|
|
]);
|
|
logPhase("Screenshots");
|
|
let comparisons = await compareStories({
|
|
baselineEntries: filteredBaselineEntries,
|
|
currentEntries: filteredCurrentEntries,
|
|
baselinePaths: baselineCapture.paths,
|
|
currentPaths: currentCapture.paths,
|
|
baselineErrors: baselineCapture.errors,
|
|
currentErrors: currentCapture.errors,
|
|
analyzeDifferences: false,
|
|
});
|
|
logPhase("Image comparison");
|
|
const verification = await verifyChangedCaptures({
|
|
comparisons,
|
|
baselineEntries: filteredBaselineEntries,
|
|
currentEntries: filteredCurrentEntries,
|
|
baselineCapture,
|
|
currentCapture,
|
|
cachedBaseline: cachedBaselinePaths !== undefined,
|
|
browserPath: browser.path,
|
|
browserSession,
|
|
checkout,
|
|
baselineBundleDirectory,
|
|
servers,
|
|
});
|
|
comparisons = verification.comparisons;
|
|
if (verification.performed) {
|
|
logPhase("Serial verification");
|
|
}
|
|
if (
|
|
cachedBaselinePaths === undefined &&
|
|
baselineCapture.errors.size === 0
|
|
) {
|
|
await writeScreenshotCache({
|
|
directory: screenshotCacheDirectory,
|
|
paths: baselineCapture.paths,
|
|
});
|
|
}
|
|
if (currentRevisionIsClean && currentCapture.errors.size === 0) {
|
|
await writeScreenshotCache({
|
|
directory: getScreenshotCacheDirectory(
|
|
currentCommit,
|
|
screenshotRuntimeHash
|
|
),
|
|
paths: currentCapture.paths,
|
|
});
|
|
}
|
|
report = {
|
|
baselineLabel: baselineCommit,
|
|
currentLabel: currentCommit,
|
|
durationMs: Date.now() - startedAt,
|
|
comparisons,
|
|
errors: [],
|
|
};
|
|
} catch (error) {
|
|
const message =
|
|
error instanceof Error ? (error.stack ?? error.message) : String(error);
|
|
report = {
|
|
baselineLabel: baseRef,
|
|
currentLabel: "HEAD",
|
|
durationMs: Date.now() - startedAt,
|
|
comparisons: [],
|
|
errors: [message],
|
|
};
|
|
} finally {
|
|
await browserSession?.close().catch(() => undefined);
|
|
await Promise.all(
|
|
servers.map((server) => server.close().catch(() => undefined))
|
|
);
|
|
await Promise.all([
|
|
rm(baselineBundleDirectory, { recursive: true, force: true }),
|
|
rm(currentBundleDirectory, { recursive: true, force: true }),
|
|
]);
|
|
}
|
|
|
|
if (report === undefined) {
|
|
throw new Error("Visual comparison did not produce a report.");
|
|
}
|
|
await writeScreenshotComparisonReport({ report, reportDirectory });
|
|
const comparisonResult = classifyScreenshotComparisonReport(report);
|
|
const result =
|
|
comparisonResult === "failure"
|
|
? "test-failure"
|
|
: comparisonResult === "passed"
|
|
? "passed"
|
|
: approved
|
|
? "approved"
|
|
: "visual-differences";
|
|
const changed = report.comparisons.filter(
|
|
({ status }) => status !== "unchanged"
|
|
).length;
|
|
console.info(
|
|
`${report.comparisons.length} stories compared in ${(
|
|
report.durationMs / 1000
|
|
).toFixed(1)}s; ${changed} differences.`
|
|
);
|
|
if (result === "approved") {
|
|
console.info(
|
|
"Visual differences were explicitly approved for this revision."
|
|
);
|
|
} else if (result === "visual-differences") {
|
|
console.error(
|
|
"Visual differences detected. Open .visual-regression/report/index.html."
|
|
);
|
|
} else if (result === "test-failure") {
|
|
console.error(
|
|
"Visual regression infrastructure or story rendering failed."
|
|
);
|
|
}
|
|
if (openReport) {
|
|
await openScreenshotComparisonReport(
|
|
path.join(reportDirectory, "index.html")
|
|
);
|
|
}
|
|
process.exitCode = result === "passed" || result === "approved" ? 0 : 1;
|
|
};
|
|
|
|
await main();
|