Testland
Browse all skills & agents

visual-baseline-gate

Consumes pre-classified visual-diff JSON and a reviewer-signed acceptance log to produce a single go/no-go CI verdict for visual regression. Blocks when intentional baseline changes lack a non-author reviewer sign-off or when regressions are present, and emits a markdown + JSON artifact for the CI step. Use this skill when the gate's input is pre-classified diff data and the enforcement concern is reviewer approval, not when the goal is fanning out to multiple engines (use a multi-engine CI orchestrator for that).

Install with skills.sh (any agent)

npx skills add testland/qa --skill visual-baseline-gate
View source

visual-baseline-gate

Overview

A typical visual-regression CI run produces an engine-specific verdict (Chromatic exit code, Percy build status, Playwright snapshot pass/fail). That's not enough for a strict gate, because:

  1. Each engine treats "visual changes" differently - Chromatic exits 1 on changes (review needed), Percy returns success but flags the build pending, Playwright fails the test outright.
  2. "The author updated baselines and committed them" is not the same as "a reviewer approved the baseline change." A --update-snapshots commit is a self-approval; for safety-relevant components it should require a sign-off.

This skill defines a build-an-X workflow that consumes both the diff classifier output and an explicit acceptance log to emit a single go/no-go verdict.

When to use

  • The team uses two or more visual engines and wants one CI gate (similar in motivation to data-quality-gate in the qa-data-quality plugin, for data quality).
  • The team wants to enforce a "reviewer approval" rule on baseline updates - i.e. a --update-snapshots commit by the PR author cannot self-approve a baseline change to a critical component.
  • The team wants the classified visual-diff output to become CI-blocking rather than advisory.

If the project uses one engine only and trusts engine-native review (e.g. all changes go through Chromatic UI approval), prefer the engine's native CI integration - see the matching engine's "CI integration" section in its SKILL.md.

How to use

  1. Collect the two inputs - the pre-classified diff JSON (one record per snapshot, category in intentional | incidental | regression) and the reviewer-committed .visual-acceptance.yml (see Inputs).
  2. Run the gate decision rule - regression blocks always; intentional blocks until listed in the acceptance log; incidental warns only (see The gate decision rule).
  3. Enforce author-cannot-self-approve - verify the commit that added .visual-acceptance.yml was authored by someone other than the PR author (see Author cannot self-approve).
  4. Emit the artifact and exit - write the markdown + JSON summary and exit non-zero on a no-go verdict so CI halts. The full artifact template, the runnable CI entrypoint, and the GitHub Actions wiring are in references/artifact-and-ci-wiring.md.

Inputs

The gate consumes two inputs:

  1. Classification artifact - the pre-classified diff JSON, one record per snapshot:

    {
      "snapshot": "dashboard-mobile-375",
      "engine":   "playwright",
      "category": "intentional|incidental|regression",
      "pattern":  "text-truncation|...|null",
      "paired_change": true,
      "diff_url": "playwright-report/data/dashboard-mobile-375-diff.png"
    }
    
  2. Acceptance log - a YAML file at .visual-acceptance.yml committed by reviewers (NOT the PR author) that explicitly accepts each intentional baseline change for the current PR:

    # .visual-acceptance.yml - committed by reviewers in the PR's review pass
    pr: 1234
    accepted_by: reviewer-handle
    accepted_at: 2026-05-04T12:00:00Z
    snapshots:
      - snapshot: dashboard-mobile-375
        reason: "Intentional CTA color change per design spec DS-456"
      - snapshot: pricing-tablet-768
        reason: "Intentional pricing tier rename"
    

The acceptance file lives in the PR branch; merging the PR records the acceptance in git history.

The gate decision rule

def visual_gate(classifications, acceptance_log, *,
                require_reviewer_acceptance=True):
    accepted = {s["snapshot"] for s in acceptance_log.get("snapshots", [])}
    blockers = []
    for c in classifications:
        if c["category"] == "regression":
            blockers.append((c, "regression - blocks unconditionally"))
        elif c["category"] == "intentional" and require_reviewer_acceptance:
            if c["snapshot"] not in accepted:
                blockers.append((c, "intentional - missing reviewer acceptance"))
        elif c["category"] == "incidental":
            # incidental requires investigation but does NOT block by default
            pass

    return {
        "verdict": "no-go" if blockers else "go",
        "blockers": blockers,
        "incidentals": [c for c in classifications if c["category"] == "incidental"],
        "intentional_accepted": [c for c in classifications
                                 if c["category"] == "intentional"
                                 and c["snapshot"] in accepted],
    }

Default behavior:

  • regression -> block (always).
  • intentional -> block UNTIL listed in .visual-acceptance.yml.
  • incidental -> surface as a warning, do not block.

For low-risk projects, set require_reviewer_acceptance=False so intentional changes pass without explicit acceptance - this collapses the gate to "block on regressions only."

Author cannot self-approve

For a stricter gate, validate that the commit adding .visual-acceptance.yml was authored by someone other than the PR author:

ACCEPTANCE_AUTHOR=$(git log --format='%ae' -1 .visual-acceptance.yml)
PR_AUTHOR=$(gh pr view --json author --jq '.author.login + "@..."')

if [[ "$ACCEPTANCE_AUTHOR" == "$PR_AUTHOR" ]]; then
  echo "ERROR: PR author cannot self-approve baseline changes"
  exit 1
fi

This is the visual-regression analog of GitHub's "require approval from someone other than the last committer" branch protection.

Worked example

Run the gate on one build. The classification step emitted three records, and one reviewer committed an acceptance log:

classifications = [
    {"snapshot": "dashboard-mobile-375", "engine": "playwright", "category": "regression"},
    {"snapshot": "pricing-tablet-768",   "engine": "percy",      "category": "intentional"},
    {"snapshot": "hero-desktop-1280",    "engine": "chromatic",  "category": "intentional"},
]
acceptance_log = {"snapshots": [{"snapshot": "hero-desktop-1280",
                                 "reason": "Intentional hero copy update per DS-789"}]}

result = visual_gate(classifications, acceptance_log)

Walking the rule per record:

SnapshotCategoryIn acceptance log?Outcome
dashboard-mobile-375regressionn/ablock - regressions always block
pricing-tablet-768intentionalnoblock - missing reviewer acceptance
hero-desktop-1280intentionalyespass - reviewer signed off

result["verdict"] is "no-go" (two blockers), so the CI step exits non-zero and the merge is held. The gate prints:

# Visual Baseline Gate - verdict: NO-GO
- playwright :: dashboard-mobile-375 :: regression - blocks unconditionally
- percy :: pricing-tablet-768 :: intentional - missing reviewer acceptance

To turn this build green, a non-author reviewer either fixes the dashboard-mobile-375 regression or, if the diff is actually intended, adds both snapshots to .visual-acceptance.yml and re-runs the gate. The full markdown + JSON artifact and the CI wiring that surfaces this output are in references/artifact-and-ci-wiring.md.

References

  • percy-visual-regression-testing
  • chromatic-visual-regression-testing
  • playwright-snapshots
  • storybook-visual-regression-testing
  • visual-baseline-conventions - the conventions this gate enforces.
  • data-quality-gate - sibling gate skill for data-quality results, same artifact shape.

Visual baseline gate - artifact format and CI wiring

View source (opens in new window)

Visual baseline gate - artifact format and CI wiring

Deep reference for the visual-baseline-gate SKILL.md. Consult when emitting the gate's markdown + JSON artifact or wiring the gate into a CI pipeline.

Markdown artifact

The gate writes visual-gate.md. Its shape matches data-quality-gate for cross-domain consistency:

# Visual Baseline Gate - verdict: NO-GO

**Blockers: 2**

| Snapshot                  | Engine     | Category    | Reason                         | Diff |
|---------------------------|------------|-------------|--------------------------------|------|
| dashboard-mobile-375      | playwright | regression  | text-truncation                | [diff](playwright-report/data/dashboard-mobile-375-diff.png) |
| pricing-desktop-1280      | chromatic  | intentional | missing reviewer acceptance    | [build](https://chromatic.com/build/...) |

**Incidentals (advisory): 1**

| Snapshot                | Engine | Category   | Pattern         |
|-------------------------|--------|------------|-----------------|
| onboarding-tablet-768   | percy  | incidental | anti-aliasing   |

**Intentional + accepted: 5**

(see .visual-acceptance.yml for rationale)

JSON sibling

A machine-readable visual-gate.json for downstream tooling:

{
  "verdict": "no-go",
  "blockers": [...],
  "incidentals": [...],
  "intentional_accepted": [...]
}

A no-go verdict exits non-zero so CI halts.

CI entrypoint script

A minimal runnable gate that reads both inputs, fails closed when no classifications were produced, and exits non-zero on no-go:

# scripts/run_visual_gate.py
import json, os, sys, yaml
from pathlib import Path

CLASS_PATH = Path("visual-classifications.json")  # output of the visual-diff classification step
ACCEPT_PATH = Path(".visual-acceptance.yml")

if not CLASS_PATH.exists():
    print("No visual classifications produced - fail closed.")
    sys.exit(1)

classifications = json.loads(CLASS_PATH.read_text())
acceptance = yaml.safe_load(ACCEPT_PATH.read_text()) if ACCEPT_PATH.exists() else {"snapshots": []}
accepted = {s["snapshot"] for s in acceptance.get("snapshots", [])}

blockers = []
for c in classifications:
    if c["category"] == "regression":
        blockers.append((c, "regression"))
    elif c["category"] == "intentional" and c["snapshot"] not in accepted:
        blockers.append((c, "missing reviewer acceptance"))

verdict = "no-go" if blockers else "go"
print(f"# Visual Baseline Gate - verdict: {verdict.upper()}")
for c, reason in blockers:
    print(f"- {c['engine']} :: {c['snapshot']} :: {reason}")

sys.exit(0 if verdict == "go" else 1)

GitHub Actions wiring

Run the gate after each engine has produced its diff manifest and after the visual-diff classification step has produced visual-classifications.json:

- name: Run visual-diff classification (advisory)
  run: |
    # produces visual-classifications.json
    ...

- name: Visual baseline gate
  run: python scripts/run_visual_gate.py

- name: Upload gate artifact
  if: always()
  uses: actions/upload-artifact@v4
  with:
    name: visual-baseline-gate
    path: |
      visual-classifications.json
      visual-gate.json
      visual-gate.md
    retention-days: 14

The if: always() upload keeps the artifact even on a no-go exit, so reviewers can audit which snapshots blocked the merge.

Related skills

chromatic-visual-regression-testing

Authors and runs Chromatic visual tests on Storybook, Playwright, or Cypress projects via the `chromatic` CLI; configures baselines, TurboSnap, UI Review, and CI gating; reads exit codes for change-vs-error classification. Use when the project ships visual regression coverage to Chromatic Cloud.

percy-visual-regression-testing

Authors Percy visual snapshot tests via the @percy/cli + framework SDK (Playwright, Cypress, Selenium, Storybook), runs them with `percy exec -- {test command}`, configures viewports / masking / ignored regions, and reviews diffs in the Percy build UI. Use when the project ships visual regression coverage to BrowserStack Percy.

playwright-snapshots

Authors Playwright `expect(page).toHaveScreenshot()` assertions, configures masks / clips / threshold / maxDiffPixels per test, manages the per-OS / per-browser snapshot directory, and runs the update flow with `--update-snapshots`. Use when the project ships self-hosted visual regression coverage in Playwright (no external snapshot service).

responsive-breakpoint-runner

Produces a single breakpoint-matrix report (rows = pages/stories, columns = viewports) across Percy, Chromatic, Playwright snapshots, or Storybook test-runner. Routes per-engine viewport syntax, runs each, and aggregates the results into one cross-breakpoint view. Use when the team needs one unified pass/fail view across three or more viewport widths instead of separate per-engine or per-breakpoint reports. The matrix-view output is the distinguishing trait: it dispatches to playwright-snapshots (and the other engines) rather than replacing any single-engine skill.

storybook-visual-regression-testing

Sets up visual regression coverage for a Storybook project - either via the official @chromatic-com/storybook addon (hosted) or via @storybook/test-runner with a postVisit hook that calls Playwright's toHaveScreenshot (self-hosted). Covers test-runner install, lifecycle hooks (setup / preVisit / postVisit), and CI integration. Use when a repo already has a working `.storybook/` config and the team wants per-story visual coverage rather than page-level snapshots.

visual-baseline-conventions

Reference catalog for visual regression coverage decisions - which Storybook stories or pages get baselines, how to choose breakpoints, when to mask vs adjust threshold, when to add or remove a baseline, and a decision matrix for picking among Percy / Chromatic / Playwright / Storybook test-runner. Use when designing visual coverage for a new project or auditing an existing baseline set.

visual-diff-summarizer

Triages a PR's visual regression diffs when there are too many changed screenshots to review one by one. Clusters snapshots (from Percy, Chromatic, Playwright `toHaveScreenshot`, Storybook, Loki) by component / route, separates changes that match PR intent from cascade / regression suspects, recommends which baselines to update, and emits one PR comment pointing the reviewer at the screenshots that need actual eyes. Use when a PR has 20+ visual diffs / changed screenshots and the reviewer needs help deciding which to open.