feat(storybook): add visual regression testing for stories #1
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 | |
| 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 }} |