Skip to main content

Stable Baselines

Flaky visual tests waste everyone's time. A screenshot that fails because a third-party ad loaded a different image, or because a font hadn't finished rendering, is noise — not a regression. This guide covers the built-in tools that keep your baselines clean and your diffs meaningful, all configurable from .testivai/config.json.


Animation & transition freezing

The SDK automatically freezes animations, transitions, and the blinking caret before every capture. No setup required.

Under the hood, the adapter injects a stabilizing <style> block that pauses CSS animations/transitions and hides the caret, then waits for in-flight web fonts to finish loading before the shutter fires.

This is controlled by the stabilize option — on by default:

{
"stabilize": true
}

Set it to false only when you explicitly want to capture mid-animation states (e.g. a loading spinner snapshot).


ignoreSelectors — hide dynamic elements

Some page regions change on every run and are irrelevant to your visual baseline: timestamps, live-chat widgets, rotating ad banners, "last updated" badges. ignoreSelectors lets you blank those elements before the capture so they never contribute to the diff.

Two modes

Every entry in ignoreSelectors supports one of two modes, controlling how the element is neutralized:

ModeCSSEffect
mask (default)visibility: hidden !importantBlanks the element but keeps its layout — the box still occupies space
collapsedisplay: none !importantRemoves layout influence — everything below shifts up

When to use each

  • mask — for image/avatar/ad content inside a stable-width container. The box stays put so the rest of the layout doesn't shift. Ideal for <img> tags, <svg> icons, and inline badges whose container size is predictable.
  • collapse — for variable-height dynamic content like a footer whose height changes between runs, or an injected alert bar that appears sometimes but not always. If you don't remove its layout influence, everything below it shifts and the diff lights up.

Config shapes

All three forms are valid and can be mixed in the same array:

Bare strings (default to mask):

{
"ignoreSelectors": ["#live-chat", ".timestamp", "[data-testid='ad-banner']"]
}

Object with explicit mode:

{
"ignoreSelectors": [
{ "selector": "#footer", "mode": "collapse" },
{ "selector": ".ad-container", "mode": "mask" }
]
}

Mixed array:

{
"ignoreSelectors": [
"#live-chat",
{ "selector": "#footer", "mode": "collapse" },
".version-badge",
{ "selector": ".alert-bar", "mode": "collapse" }
]
}

Selectors are deduplicated across sources (global config, project config, per-snapshot calls) — the first occurrence wins.

Where to set them

ignoreSelectors can be defined in three places, merged first-write-wins:

  1. .testivai/config.json — global for the repo (recommended for shared dynamic chrome)
  2. testivai.config.ts — per-project overrides
  3. Per-snapshot call — one-off exclusions for a specific testivai.witness()

Quick-reference recipe table

ProblemFixWhy
Dynamic text (timestamps, emails, counters, "ago" strings)ignoreSelectors with maskBlanks the text while preserving the surrounding layout
Data-dependent table/list heights that vary across runsignoreSelectors with collapseRemoves the element from flow so the rest of the page stays aligned
Image/avatar/ad content changing inside a stable containerignoreSelectors with maskHides the variable content, keeps the container box
Cross-machine anti-aliasing or font-rendering differencesTune threshold or enable noiseAutoPassThe DOM noise hint already flags these ("pixels differ but DOM unchanged"); let tolerance auto-pass them
DOM noise hint

When the pixel diff is non-zero but the DOM is structurally identical, the report labels the snapshot with a noise hint. This usually means font rendering, sub-pixel anti-aliasing, or GPU-level differences — not a real visual regression. Use noiseAutoPass to let these auto-pass up to a noiseMaxDiffPercent ceiling.


Global config (.testivai/config.json)

The .testivai/config.json file at your project root is the single source of truth for local-mode settings. Every field is optional and falls back to a safe default — and so is the file itself: with no config.json at all, the defaults below apply unchanged.

General

FieldTypeDefaultDescription
mode"local""local"Operating mode. Optional — omit it and the defaults apply unchanged

Diff tolerance

FieldTypeDefaultDescription
thresholdnumber (0–1)0.1Per-pixel tolerance. Pixels differing by less than this fraction (0.1 = 10%) are considered identical
maxDiffPercentnumber (0–100)0Snapshots whose total diff percentage is at or below this value report as passed. Default 0 = only pixel-perfect passes
maxDiffPixelsnumberunsetAbsolute variant: pass when changed-pixel count is at or below this. When both are set, satisfying either passes
noiseAutoPassbooleanfalseAuto-pass diffs whose DOM is structurally identical (the noise hint), up to noiseMaxDiffPercent
noiseMaxDiffPercentnumber (0–100)1Upper diff-% bound for noiseAutoPass. DOM-identical diffs larger than this still show as changed
shiftTolerancenumber (px)offAuto-pass when every diff region is a pure element shift within this many px per axis — kills sub-pixel layout jitter (labeled autoPassed: "shift", auditable in the report)
diffRegionsobject{ minSize: 10, mergeDistance: 12 }Diff clustering: minSize = noise floor in pixels, mergeDistance = px gap for merging nearby diff regions

Capture behavior

FieldTypeDefaultDescription
stabilizebooleantrueFreeze CSS animations/transitions, hide the caret, and wait for web fonts before every capture
ignoreSelectors(string | { selector, mode })[][]Elements to hide during capture. Bare strings default to mask mode
mask(string | object)[][]Page areas excluded from the pixel diff and hatched in the report. Supports CSS selectors and geometric regions

Output & workflow

FieldTypeDefaultDescription
reportDirstring"visual-report"Directory for the self-contained HTML diff report
autoOpenbooleantrueAutomatically open the HTML report in your browser after a run
failOnDiffbooleanfalseWhen true, exit with a non-zero code if any diffs are detected (useful in CI)
failOnMissingbooleantrueExit 3 from testivai report when a baseline received no capture this run — silent coverage loss. Set false (or pass --allow-missing) for filtered runs
shareUploadCommandstringStorage-agnostic upload hook for report --share: shell command with {file} placeholder; last stdout line is the shared URL (aws s3 cp …, gsutil, rclone, curl)
volatileAttributesstring[][]Attribute names whose values the DOM diff ignores (presence still counts) — for per-run URLs in src/srcset that otherwise poison the noise hint. blob: URLs are always normalized
baselinesDirstring.testivai/baselinesWhere baselines live. Supports a {platform} token (darwin/linux/win32) for per-OS baselines

Standalone mode (testivai witness <url>)

These apply only to the no-test-suite crawler; framework adapters ignore them.

FieldTypeDefaultDescription
pagesstring[]unsetExplicit page paths to capture, resolved against the start URL (e.g. ["/", "/pricing"]). When set, link crawling is skipped
maxPagesnumber10Cap on pages discovered when crawling same-origin links from the start page
viewport{ width, height }1280x800Capture viewport

Complete example

{
"mode": "local",
"threshold": 0.1,
"reportDir": "visual-report",
"autoOpen": true,
"failOnDiff": false,
"stabilize": true,
"maxDiffPercent": 0,
"noiseAutoPass": false,
"noiseMaxDiffPercent": 1,
"ignoreSelectors": [
"#live-chat",
".timestamp",
{ "selector": "#dynamic-footer", "mode": "collapse" }
],
"mask": [
"[data-testid='user-avatar']"
],
"diffRegions": {
"minSize": 10,
"mergeDistance": 12
}
}

npx testivai init scaffolds a smaller version of this file — only the defaults (mode, threshold, autoOpen, failOnDiff, failOnMissing, maxDiffPercent, noiseAutoPass, noiseMaxDiffPercent, stabilize). The example above adds the optional keys (reportDir, ignoreSelectors, mask, diffRegions) that you opt into by hand. Delete any field you don't need — every key falls back to its default.

Repository size — what baselines actually cost

Baselines are PNGs committed to your repository, so it's fair to ask what that does to clone times. Measured numbers rather than reassurance:

  • A full-page screenshot of a straightforward page is around 175 KB.
  • Re-approving one stores a second full copy. Two different versions of the same screenshot measured 355,259 bytes raw and 342,845 bytes packed — git's delta compression saved 3.5%. PNG is already deflate-compressed, so there is almost nothing left for git to squeeze.
  • Identical images cost nothing extra. Git is content-addressed, so two snapshot names capturing the same pixels share one object.

The arithmetic that follows: a 50-snapshot suite is roughly 8.5 MB on the first commit, and every round of approvals adds about another 8.5 MB to history permanently. That's fine for years on a normal project. It becomes a problem when approvals are frequent and the suite is large.

Keeping it small (do these before reaching for LFS)

  1. Approve deliberately. Most growth comes from re-approving everything when only a few snapshots genuinely changed. npx testivai approve <name> beats --all as a habit.
  2. Cut the churn at the source. ignoreSelectors and mask on timestamps, carousels and live widgets stop snapshots changing for reasons nobody cares about — every avoided approval is a copy you never store.
  3. Capture what you're actually testing. A component or above-the-fold capture is a fraction of a full-page shot of a long marketing page.
  4. Shallow-clone in CI. fetch-depth: 1 (the default for actions/checkout) means CI never pays for history at all.

Should you use Git LFS?

Usually no, and this is a genuine recommendation rather than a hedge:

  • On GitHub, LFS has storage and bandwidth quotas that cost money past the free tier, and visual baselines are exactly the kind of asset that burns bandwidth on every CI run.
  • It breaks the fork workflow. Forks don't inherit LFS budget, so contributors to a public repository may be unable to fetch your baselines — which defeats the point of committing them.
  • Everyone, including CI, needs git-lfs installed and lfs: true on checkout. It's one more thing to go wrong in someone's first five minutes.

LFS earns its keep when the repository is private, the suite is large (hundreds of snapshots), and approvals are frequent — that combination is where plain git genuinely strains. Below it, the four steps above are cheaper and have no failure modes.

If history has already grown past comfort, the fix is a one-off history rewrite (git filter-repo) rather than migrating to LFS, which doesn't shrink anything already committed.

Cross-platform baselines

Font rasterization differs between macOS, Linux, and Windows — a baseline captured on a Mac laptop will flag on a Linux CI runner forever. Two good setups:

1. One gating platform (simplest). Let CI (Linux) own the baselines: capture and approve them from CI via the GitHub Action's /testivai approve flow, and treat local runs as advisory.

2. Per-platform baselines. Give each OS its own baseline set with the {platform} token:

{
"baselinesDir": ".testivai/baselines-{platform}"
}

Mac devs gate against baselines-darwin/, CI gates against baselines-linux/ — both committed, no cross-OS noise. Remember each platform's baselines must be approved from a run on that platform.

Volatile attributes

If your app rewrites src/srcset/href per run (CDN hashes, signed URLs), the DOM diff sees attributeChanges: 1 and withholds the render-noise hint even for pure anti-aliasing diffs. Declare those attributes volatile:

{
"volatileAttributes": ["src", "srcset"]
}

The attribute's presence still counts (removing an image is a real change); only its value churn is ignored. blob: object URLs are always normalized — they are per-session by construction — while data: URIs stay significant because they encode content.