feat(storybook): add visual regression testing for stories #10
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Storybook visual regression | |
| # Captures a screenshot of every story (dark + light) and submits them to the | |
| # PostHog Visual Review product, which diffs against the signed baseline | |
| # manifest committed at apps/code/snapshots.yml. Visual changes are reviewed | |
| # and approved at https://us.posthog.com/project/2/visual_review; on approval | |
| # the VR backend commits the updated manifest back to the PR branch. PNGs are | |
| # never committed to this repo. | |
| # | |
| # Until the one-time setup is done (VR repo registration + VR_API_TOKEN secret | |
| # + committed snapshots.yml), the vr step silently no-ops: the job still runs | |
| # the capture, which catches stories that crash or never settle. | |
| on: | |
| pull_request: | |
| push: | |
| branches: [main] | |
| concurrency: | |
| group: code-storybook-${{ github.head_ref || github.ref }} | |
| cancel-in-progress: true | |
| jobs: | |
| changes: | |
| if: github.event_name == 'pull_request' | |
| runs-on: ubuntu-latest | |
| permissions: | |
| pull-requests: read | |
| outputs: | |
| code: ${{ steps.filter.outputs.code }} | |
| 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. The baseline manifest | |
| # is excluded so the VR backend's approval commit (which touches | |
| # only snapshots.yml) doesn't re-run the suite for nothing. | |
| code: | |
| - "{apps/code/**,packages/**,pnpm-lock.yaml,.github/workflows/code-storybook.yml}" | |
| - "!apps/code/snapshots.yml" | |
| - "!**/*.md" | |
| visual-regression: | |
| needs: changes | |
| # Fail closed: if change detection failed (or this is a main push, where | |
| # `changes` is skipped), run instead of skipping. | |
| if: ${{ !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.code == 'true') }} | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 30 | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| with: | |
| persist-credentials: false | |
| - 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: Capture story screenshots | |
| working-directory: apps/code | |
| run: | | |
| rm -rf .storybook/__snapshots__ | |
| pnpm test:visual:ci:update | |
| # The vr CLI isn't published anywhere; build it from posthog/posthog's | |
| # master, the same way that repo's own CI does. | |
| - name: Checkout Visual Review CLI | |
| if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'push' | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| with: | |
| repository: PostHog/posthog | |
| path: vr-cli | |
| persist-credentials: false | |
| sparse-checkout: | | |
| products/visual_review/cli | |
| products/visual_review/frontend/generated/api.schemas.ts | |
| sparse-checkout-cone-mode: false | |
| - name: Install Visual Review CLI | |
| if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'push' | |
| run: cd vr-cli/products/visual_review/cli && npm ci && npm run build && npm link | |
| - name: Submit snapshots to Visual Review | |
| # Fork PRs can't read the token; their captures still ran above, so a | |
| # broken story fails the job either way. | |
| if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'push' | |
| # On main pushes the run is tracking-only ("observe"), but vr submit | |
| # still exits 1 when it detects changes; keep main green regardless. | |
| continue-on-error: ${{ github.event_name == 'push' }} | |
| env: | |
| VR_TOKEN: ${{ secrets.VR_API_TOKEN }} | |
| VR_BRANCH: ${{ github.event.pull_request.head.ref || github.ref_name }} | |
| VR_COMMIT: ${{ github.event.pull_request.head.sha || github.sha }} | |
| VR_PR: ${{ github.event.pull_request.number }} | |
| VR_PURPOSE: ${{ github.event_name == 'push' && 'observe' || 'review' }} | |
| # Lets the VR web UI re-trigger this job via the Actions API. | |
| JOB_CHECK_RUN_ID: ${{ job.check_run_id }} | |
| run: | | |
| if [ -z "$VR_TOKEN" ]; then | |
| echo "::notice::VR_API_TOKEN secret not set - skipping Visual Review submission" | |
| exit 0 | |
| fi | |
| vr submit \ | |
| --type storybook \ | |
| --dir apps/code/.storybook/__snapshots__/ \ | |
| --baseline apps/code/snapshots.yml \ | |
| --branch "$VR_BRANCH" \ | |
| --commit "$VR_COMMIT" \ | |
| ${VR_PR:+--pr "$VR_PR"} \ | |
| --purpose "$VR_PURPOSE" \ | |
| --token "$VR_TOKEN" | |
| - 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 }} |