Files
webstudio/scripts/visual-regression/run.ts
T
Oleg Isonen db2d092268 ci: replace Lost Pixel with self-hosted visual regression (#6086)
## 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.
2026-08-12 10:55:52 +02:00

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();