feat(storybook): add visual regression testing for stories #5
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 }} | |
| verify_only: "${{ startsWith(steps.head-commit.outputs.message, 'chore(storybook): update visual regression snapshots') }}" | |
| 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). | |
| # verify_only is what breaks the loop: the retriggered run | |
| # executes in verify mode, which never commits. | |
| 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 verify_only switch 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. | |
| 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 | |
| # 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 | |
| # When the head commit is itself a snapshot auto-commit, updating again | |
| # would retrigger this workflow forever. Verify instead: the run still | |
| # proves the committed baselines pass, but never commits, which breaks | |
| # the loop while keeping the check green-because-it-ran (not | |
| # green-because-it-skipped). | |
| - name: Verify visual regression snapshots | |
| if: needs.changes.outputs.verify_only == 'true' | |
| working-directory: apps/code | |
| run: pnpm test:visual:ci:verify | |
| - name: Update visual regression snapshots | |
| if: needs.changes.outputs.verify_only != 'true' | |
| 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 | |
| if: needs.changes.outputs.verify_only != 'true' | |
| 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 }} |