Skip to content

feat(storybook): add visual regression testing for stories #1

feat(storybook): add visual regression testing for stories

feat(storybook): add visual regression testing for stories #1

Workflow file for this run

name: Storybook visual regression
on:
pull_request:
concurrency:
group: code-storybook-${{ github.head_ref || github.ref }}
cancel-in-progress: true
jobs:
changes:
runs-on: ubuntu-latest
permissions:
pull-requests: read
contents: read
outputs:
code: ${{ steps.filter.outputs.code }}
head_commit_message: ${{ steps.head-commit.outputs.message }}
steps:
- name: Detect relevant changes
id: filter
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
with:
predicate-quantifier: every
filters: |
# Anything that can change a rendered story. Snapshot baselines are
# excluded so a PR that only touches them (e.g. a manual refresh)
# skips the run. Note: dorny/paths-filter diffs the whole PR
# against its base, not just the latest commit, so this filter
# alone does NOT stop the auto-commit from re-triggering itself
# (the diff still includes the code changes from earlier commits).
# The circuit breaker on the visual-regression job's `if` below is
# what actually breaks the loop.
code:
- "{apps/code/**,packages/**,pnpm-lock.yaml,.github/workflows/code-storybook.yml}"
- "!apps/code/.storybook/__snapshots__/**"
- "!**/*.md"
# The pull_request event payload has no head.commit.message, so fetch it
# via the API to power the circuit breaker below.
- name: Get head commit message
id: head-commit
env:
GH_TOKEN: ${{ github.token }}
run: |
message=$(gh api repos/${{ github.repository }}/commits/${{ github.event.pull_request.head.sha }} --jq '.commit.message')
echo "message<<EOF" >> "$GITHUB_OUTPUT"
echo "$message" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
visual-regression:
needs: changes
# Fail closed: if change detection itself failed, run instead of skipping.
# Circuit breaker: skip when the head commit is itself a snapshot
# auto-commit, so the commit step's own retrigger doesn't loop forever.
if: >-
${{ !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.code == 'true') &&
!startsWith(needs.changes.outputs.head_commit_message, 'chore(storybook): update visual regression snapshots') }}
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
# Auto-commit targets the PR head branch, so test against it rather
# than the synthetic merge commit.
ref: ${{ github.event.pull_request.head.sha }}
- name: Setup pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
cache: "pnpm"
- name: Cache Playwright browsers
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
id: playwright-cache
with:
path: ~/.cache/ms-playwright
key: playwright-chromium-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
playwright-chromium-${{ runner.os }}-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Install Playwright Chromium
if: steps.playwright-cache.outputs.cache-hit != 'true'
working-directory: apps/code
run: pnpm exec playwright install --with-deps chromium
- name: Install Playwright system dependencies
# The cache restores browser binaries but not the OS packages they need.
if: steps.playwright-cache.outputs.cache-hit == 'true'
working-directory: apps/code
run: pnpm exec playwright install-deps chromium
- name: Build workspace packages
# Bare `wait` returns the exit status of only the last job passed to
# it, silently swallowing failures from the others. `wait_all` waits
# on each PID individually and checks its own status so any failed
# build fails the step.
run: |
wait_all() {
status=0
for pid in "$@"; do
wait "$pid" || status=$?
done
[ "$status" -eq 0 ]
}
pnpm --filter @posthog/electron-trpc build &
pid1=$!
(pnpm --filter @posthog/shared build && pnpm --filter @posthog/platform build) &
pid2=$!
wait_all "$pid1" "$pid2"
# @posthog/agent imports @posthog/git's dist, so git must finish
# before the last group starts.
pnpm --filter @posthog/git build
pnpm --filter @posthog/enricher build &
pid3=$!
pnpm --filter @posthog/agent build &
pid4=$!
wait_all "$pid3" "$pid4"
- name: Build Storybook
working-directory: apps/code
run: pnpm build-storybook
- name: Serve Storybook
working-directory: apps/code
run: |
pnpm exec http-server storybook-static --port 6006 --silent &
pnpm exec wait-on http://127.0.0.1:6006/iframe.html --timeout 30000
- name: Update visual regression snapshots
working-directory: apps/code
env:
# Tells jest-image-snapshot's outdated-snapshot-reporter to delete
# baseline PNGs that no story wrote this run, so renamed/removed
# stories don't leave orphaned snapshots behind.
JEST_IMAGE_SNAPSHOT_TRACK_OBSOLETE: "1"
run: pnpm test:visual:ci:update
- name: Detect snapshot changes
id: diff
env:
IS_FORK: ${{ github.event.pull_request.head.repo.full_name != github.repository }}
run: |
git add -A apps/code/.storybook/__snapshots__
if git diff --staged --quiet -- apps/code/.storybook/__snapshots__; then
echo "changed=false" >> "$GITHUB_OUTPUT"
echo "commit=false" >> "$GITHUB_OUTPUT"
echo "fail-fork=false" >> "$GITHUB_OUTPUT"
else
echo "changed=true" >> "$GITHUB_OUTPUT"
echo "commit=$([ "$IS_FORK" = "true" ] && echo false || echo true)" >> "$GITHUB_OUTPUT"
echo "fail-fork=$IS_FORK" >> "$GITHUB_OUTPUT"
git diff --staged --stat -- apps/code/.storybook/__snapshots__
fi
- name: Get app token
id: app-token
if: steps.diff.outputs.commit == 'true'
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
app-id: ${{ secrets.GH_APP_ARRAY_RELEASER_APP_ID }}
private-key: ${{ secrets.GH_APP_ARRAY_RELEASER_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: ${{ github.event.repository.name }}
permission-contents: write
- name: Commit updated snapshots to the PR branch
if: steps.diff.outputs.commit == 'true'
env:
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
REPO: ${{ github.repository }}
BRANCH: ${{ github.event.pull_request.head.ref }}
EXPECTED_HEAD_OID: ${{ github.event.pull_request.head.sha }}
run: node scripts/commit-storybook-snapshots.mjs
- name: Fail on snapshot changes from a fork
if: steps.diff.outputs.fail-fork == 'true'
run: |
echo "Visual regression snapshots changed, but fork PRs cannot receive auto-commits."
echo "Run 'pnpm --filter code test:visual:update' against a Linux build and commit the results."
exit 1
- name: Upload failure screenshots
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v6.0.0
with:
name: storybook-visual-failures
path: |
apps/code/.storybook/__snapshots__/__diff_output__/
apps/code/.storybook/__snapshots__/__failures__/
if-no-files-found: ignore
retention-days: 7
- name: Upload test results to Trunk
# Run even when tests fail so flaky/failed results are still reported,
# but never let an upload problem fail the job.
if: ${{ !cancelled() }}
continue-on-error: true
uses: trunk-io/analytics-uploader@385f1ccdf345b4532dc4b6c665dd432b702b8e28 # v2.1.2
with:
junit-paths: "apps/code/junit.xml"
org-slug: posthog-inc
token: ${{ secrets.TRUNK_API_TOKEN }}