Files
webstudio/scripts/visual-regression/run.ts
Oleg Isonen 87968f40ff fix: Improve MCP authoring and preview reliability (#6105)
## Summary

Fix MCP reliability failures reported in ten issues against CLI 0.288.0
and 0.289.0:

- keep every advertised array input schema client-valid, including
compact and tuple schemas
- report a text asset update as successful when its persisted revision
and bytes confirm that the server committed despite a failed response
- preserve standard package import resolution in generated React Router
previews
- upload variable WOFF2 fonts when callers provide the documented static
font metadata shape
- explain unmatched JSX as a stateless fragment syntax error
- distinguish the `animation` component namespace from value helpers and
reject unsupported CSS at-rules with an actionable validation error
- forward optional session tools such as `download-asset` through
top-level CLI shortcuts
- isolate published generated-preview dependencies from parent
workspaces
- return structured screenshot failures when React Router production
builds fail

Sensitive visual regression checks also run automatically for
organization-authored fork PRs. External fork PRs remain gated by
`safe-to-deploy`.

## Root causes and implementation

The MCP handshake compactor retained an array's type but discarded its
`items` schema. Unconstrained and tuple arrays could also be advertised
without `items`. Schema normalization now adds `items: {}` whenever an
array omits it, while focused discovery retains complete constraints.
Catalog coverage rejects every missing array item, including tuple
schemas, and the handshake remains below its 200 KB budget.

Asset content updates previously treated any failed response as a failed
mutation, even when the server had already swapped the revision.
Recovery now reads the current revision, compares the persisted bytes,
and confirms the revision remains stable across the read. It returns the
updated asset only when all evidence agrees. Otherwise it returns
`ASSET_UPDATE_COMMIT_UNCERTAIN` with guidance not to retry blindly.
Definitive 4xx responses retain their original errors.

The generated React Router Vite config replaced the client and SSR
resolver condition lists without explicitly retaining the standard
`import` condition. Both lists now preserve `import`. The regression
test generates a dynamic route, stages the built React Router SDK as a
published dependency without the local `webstudio` source condition, and
runs the generated build.

Variable fonts are detected with `variationAxes`. Merging the documented
static `style` and `weight` upload metadata into that detected variable
shape made validation fail after the binary was read. Font override
validation and shape-aware merging now live in `@webstudio-is/fonts`.
The SDK delegates to that domain function, which validates the complete
override before applying compatible fields, retains the detected
static-or-variable shape, and keeps the detected format.

Fragment parsing is stateless. Unmatched opening and closing tags now
explain that every opening element must use the same closing tag or be
self-closing, and that refreshing the session cannot repair malformed
JSX.

The `animation` binding is a component namespace, not a callable helper.
Tool schema text, evaluation errors, and the MCP manual now separate
component namespaces from value helpers. The MCP JSX `css` binding
parses the final interpolated CSS with CSS-tree and recursively rejects
selectors and unsupported at-rules such as `@keyframes`, while
preserving supported declarations and case-insensitive `@media` rules.
Keeping this validation at the external binding avoids changing internal
component templates.

CLI shortcut discovery previously omitted optional session tools even
though the runtime registered their handlers. Shortcut discovery now
includes the same optional tool categories as the CLI MCP host, while
real top-level CLI commands keep precedence.

Published generated previews could reuse a stale `node_modules` symlink
into a parent source workspace. Only development manifests containing
the local placeholder version may now reuse the CLI dependency tree.
Published manifests replace symlinks with a local install and disable
npm workspace discovery.

React Router could report a bundler failure while its build command
exited successfully outside CI, allowing screenshot startup to terminate
without a structured result. Managed production builds now set `CI=1`,
so the existing MCP error boundary receives the nonzero build failure
and prints structured JSON.

Visual regression authorization now distinguishes repository members
from external contributors. Organization members' fork PRs run on open,
synchronize, and reopen events; external fork code runs only from a
`safe-to-deploy` label event, and a later commit does not reuse that
approval.

## Implementation checklist

- [x] Preserve valid array item schemas in compact MCP discovery
- [x] Normalize tuple input schemas with an `items` fallback
- [x] Keep the MCP handshake below its 200 KB size budget
- [x] Cover the real `update-props` schema and oversized array
compaction
- [x] Reconcile failed asset-update responses against stable persisted
content
- [x] Reject recovery when a concurrent asset revision is detected
- [x] Preserve `import` resolution in generated React Router client and
SSR builds
- [x] Build a generated dynamic route against a staged published SDK
package
- [x] Regenerate and verify affected React Router fixtures
- [x] Preserve detected variable-font axes with documented upload
metadata
- [x] Add actionable unmatched-tag and unsupported-CSS validation
- [x] Correct animation namespace guidance in tool discovery and errors
- [x] Forward optional session tools through top-level CLI shortcuts
- [x] Replace parent-workspace dependency links for published previews
- [x] Disable npm workspace discovery for isolated preview installs
- [x] Make React Router build failures return nonzero in managed
previews
- [x] Run visual regression automatically for organization-authored PRs
- [x] Verify each regression test fails without its corresponding fix
- [x] Review documentation impact and update the authoritative MCP
manual plus generated outputs
- [x] Confirm no database migration or compatibility migration is
required
- [ ] Run `pnpm evaluations` after explicit approval; not run because
repository policy requires approval before agent evaluations

## Verification

- `packages/cli/src/prebuild.test.ts`: 57 tests passed
- `packages/cli/src/commands/schema.test.ts` and `mcp.test.ts`: 51 tests
passed
- `packages/project-build/src/runtime/components.test.ts` and
`mcp.test.ts`: 194 tests passed
- `packages/asset-uploader`: 196 tests passed
- focused font schema, SDK asset schema, CSS parser, and template CSS
tests passed
- `packages/http-client/src/index.test.ts`: 38 tests passed
- relevant CLI, project-build, asset-uploader, fonts, SDK, CSS data,
template, HTTP client, and React Router SDK typechecks passed
- MCP documentation tests, typecheck, generation, and Markdown source
tests passed
- React Router SDK package build passed
- full fixture link, sync, and generation pipeline passed
- affected Docker and Vercel fixture typechecks and locally bootstrapped
production builds passed
- complete CLI suite: 61 test files and 966 tests passed
- CLI typecheck passed
- preserved nested-workspace dynamic preview installed local `0.289.0`
dependencies and completed its production build
- visual-regression workflow YAML parsing and authorization truth table
passed
- repository lint passed with warnings denied
- formatting and `git diff --check` passed

## Tradeoffs and limitations

Deferred array schemas advertise unconstrained `items` in the compact
handshake to remain client-valid without duplicating detailed
constraints. Agents can retrieve the complete item schema through
focused `meta.get-more-tools` discovery.

Asset reconciliation performs three read requests only after an
uncertain 5xx, malformed response, or transport failure. This keeps the
normal successful path unchanged and avoids claiming success if the
revision changes during recovery.

For variable fonts, detected axes define the metadata shape. Static
`style` and `weight` hints are ignored because they cannot be
represented alongside `variationAxes`; compatible fields such as
`family` still override detection.

Native CSS keyframe authoring remains unsupported in fragment `css`
templates. The tool now rejects it before rendering and directs callers
to editable Webstudio Animation components instead of advertising a
nonexistent callable helper.

Published previews perform one isolated npm install when no valid local
dependency tree is cached. Local source development keeps the fast
workspace symlink path for placeholder-version manifests.

Closes #6104
Closes #6099
Closes #6098
Closes #6106
Closes #6107
Closes #6108
Closes #6114
Closes #6115
Closes #6116
Closes #6117
2026-08-12 21:23:35 +02:00

675 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";
import {
formatVisualRegressionDiagnostics,
getIsolatedVerificationServers,
getStoryPairs,
} from "./run-utils";
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,
currentBundleDirectory,
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;
currentBundleDirectory: 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 = getStoryPairs({
ids: remainingChangedIds,
baselineEntries,
currentEntries,
});
if (cachedBaseline) {
await installBaselineDependencies(checkout);
await Promise.all(servers.map((server) => server.close()));
servers.length = 0;
servers.push(
...(await startVisualStoryServers(
getIsolatedVerificationServers({
repositoryRoot,
checkout,
baselineBundleDirectory,
currentBundleDirectory,
baselinePort: visualRegressionConfig.servers.baselinePort,
currentPort: visualRegressionConfig.servers.currentPort,
pairs: changedPairs,
})
))
);
}
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,
currentBundleDirectory,
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 });
for (const line of formatVisualRegressionDiagnostics(report)) {
console.error(line);
}
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();