Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
3cf7b50
feat(storybook): add visual regression testing for stories
pauldambra Jul 13, 2026
d07ce1f
fix: address qa-swarm findings on storybook visual regression workflow
pauldambra Jul 13, 2026
07611ea
refactor: apply simplify pass
pauldambra Jul 13, 2026
547cc1b
Merge remote-tracking branch 'origin/main' into posthog-code/storyboo…
pauldambra Jul 13, 2026
1720dd0
chore(storybook): update visual regression snapshots (1/4)
releaser-array[bot] Jul 13, 2026
ae44495
chore(storybook): update visual regression snapshots (2/4)
releaser-array[bot] Jul 13, 2026
a5a7c6f
chore(storybook): update visual regression snapshots (3/4)
releaser-array[bot] Jul 13, 2026
001fc69
chore(storybook): update visual regression snapshots (4/4)
releaser-array[bot] Jul 13, 2026
3364369
fix(storybook): verify snapshots on auto-commit heads instead of skip…
pauldambra Jul 13, 2026
9c707b8
fix(storybook): stop inert service stubs breaking useService effect deps
pauldambra Jul 13, 2026
5e41b12
chore(storybook): update visual regression snapshots
releaser-array[bot] Jul 13, 2026
dc22dc0
refactor(storybook): submit snapshots to Visual Review instead of com…
pauldambra Jul 13, 2026
5a79f75
chore(storybook): seed Visual Review baseline for PostHog/code
pauldambra Jul 13, 2026
4b2d44d
fix(storybook): skip Visual Review submit while VR_API_TOKEN is unset
pauldambra Jul 13, 2026
9526fdd
chore(visual): update storybook baselines
posthog[bot] Jul 13, 2026
fd15798
chore(storybook): move approved baseline to apps/code/snapshots.yml
pauldambra Jul 13, 2026
4f369b8
fix(storybook): freeze the clock per render and skip baseline-only pu…
pauldambra Jul 13, 2026
0dd04ec
chore(visual): update storybook baselines
posthog[bot] Jul 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
246 changes: 246 additions & 0 deletions .github/workflows/code-storybook.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
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 }}
baseline_only_push: ${{ steps.baseline_push.outputs.result }}
steps:
- name: Detect relevant changes
id: filter
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
with:
predicate-quantifier: every
Comment thread
pauldambra marked this conversation as resolved.
Comment thread
pauldambra marked this conversation as resolved.
filters: |
# Anything that can change a rendered story. The baseline manifest
# is excluded, but note paths-filter diffs the whole PR against
# its base, so once a PR has code changes this filter stays true
# for every later push — the baseline_push step below is what
# skips the VR backend's own approval commits.
code:
- "{apps/code/**,packages/**,pnpm-lock.yaml,.github/workflows/code-storybook.yml}"
- "!apps/code/snapshots.yml"
- "!**/*.md"

# Visual Review commits the approved baseline back to the PR branch as
# a bot; re-running the suite for that push is pure waste (only the
# manifest changed). Compare the push delta (before...after), not the
# PR diff, so it only matches the bot's own baseline commit.
- name: Detect Visual Review baseline-only push
id: baseline_push
if: github.event_name == 'pull_request' && github.event.action == 'synchronize'
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
ACTOR: ${{ github.actor }}
BEFORE: ${{ github.event.before }}
AFTER: ${{ github.event.after }}
run: |
set -euo pipefail
result=false
case "$ACTOR" in
*'[bot]' | posthog-bot) is_bot=true ;;
*) is_bot=false ;;
esac
zero=0000000000000000000000000000000000000000
if [ "$is_bot" = true ] && [ -n "$BEFORE" ] && [ "$BEFORE" != "$zero" ] && [ -n "$AFTER" ]; then
# compare uses merge-base(before, after), which for an appended
# commit is exactly the push delta; a force-push/rebase widens it
# and won't match.
files=$(gh api "repos/${REPO}/compare/${BEFORE}...${AFTER}" --jq '.files[].filename' || true)
if [ -n "$files" ] && ! grep -qv '^apps/code/snapshots\.yml$' <<<"$files"; then
result=true
fi
fi
echo "result=$result" >>"$GITHUB_OUTPUT"
echo "Visual Review baseline-only push: $result"

visual-regression:
needs: changes
# Fail closed: if change detection failed (or this is a main push, where
# `changes` is skipped), run instead of skipping — unless the push was
# just the VR bot committing an approved baseline.
if: >-
${{ !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.code == 'true') &&
needs.changes.outputs.baseline_only_push != 'true' }}
runs-on: ubuntu-latest
Comment thread
pauldambra marked this conversation as resolved.
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 }}
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,8 @@ apps/mobile/ROADMAP.md

# pi project-local config (per-developer MCP servers, prompts, etc.)
.pi/

# Storybook visual regression: PNGs are never committed. CI uploads captures
# to the PostHog Visual Review product; the committed baseline is the signed
# hash manifest apps/code/snapshots.yml.
apps/code/.storybook/__snapshots__/
13 changes: 13 additions & 0 deletions apps/code/.storybook/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,19 @@ const config: StorybookConfig = {
find: "@posthog/electron-trpc/renderer",
replacement: path.resolve(__dirname, "./mocks/electron-trpc.ts"),
},
// The agent dist bundles are Node-targeted: tsup opens every file
// with a createRequire(import.meta.url) shim, and tool-use-to-acp
// imports fs/path. Shim those builtins so the bundles load in the
// browser (see mocks/node-module.ts).
{
find: /^(node:)?module$/,
replacement: path.resolve(__dirname, "./mocks/node-module.ts"),
},
{
find: /^(node:)?fs$/,
replacement: path.resolve(__dirname, "./mocks/node-fs.ts"),
},
{ find: /^(node:)?path$/, replacement: "pathe" },
// Resolve the remaining @posthog/* workspace packages to source, exactly
// like the renderer (vite.shared.mts). Without this, Storybook resolves
// them through each package's "./*" exports map, which only falls
Expand Down
12 changes: 12 additions & 0 deletions apps/code/.storybook/mocks/node-fs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Benign fs stub for @posthog/agent dist bundles loaded in Storybook. The
// helpers stories use never touch the filesystem at render time; if one does,
// it gets empty results rather than a crash mid-render.
export function existsSync(): boolean {
return false;
}

export function readFileSync(): string {
return "";
}

export default { existsSync, readFileSync };
12 changes: 12 additions & 0 deletions apps/code/.storybook/mocks/node-module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// The @posthog/agent dist bundles open with tsup's Node shim:
// import { createRequire } from "node:module"; const require = createRequire(import.meta.url);
// In the browser that import resolves to an empty module and every story in
// the chunk dies at load. Stories only use pure helpers from those bundles, so
// a require that throws on use is safe.
export function createRequire(): (id: string) => never {
return (id: string) => {
throw new Error(`require("${id}") is not available in Storybook`);
};
}

export default { createRequire };
31 changes: 31 additions & 0 deletions apps/code/.storybook/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,23 @@ import "./mocks/electron-trpc";
import { Theme } from "@radix-ui/themes";
import "@radix-ui/themes/styles.css";
import type { Preview } from "@storybook/react-vite";
import MockDate from "mockdate";
import "../../../packages/ui/src/styles/globals.css";
import { withAppProviders } from "./withAppProviders";

function inStorybookTestRunner(): boolean {
return navigator.userAgent.includes("StorybookTestRunner");
}

function seededMathRandom(): void {
let state = 0x9e3779b9;
Math.random = () => {
state = Math.imul(state ^ (state >>> 15), state | 1);
state ^= state + Math.imul(state ^ (state >>> 7), state | 61);
return ((state ^ (state >>> 14)) >>> 0) / 4294967296;
};
}

const preview: Preview = {
parameters: {
controls: {
Expand Down Expand Up @@ -40,6 +54,23 @@ const preview: Preview = {
</Theme>
);
},
// Last in the array = outermost, so it runs before every story render.
// Visual regression snapshots need identical pixels on every render:
// freeze the clock (elapsed-time counters, rendered timestamps) and
// re-seed Math.random (random status verbs), including on the re-renders
// the test runner triggers for theme flips and retries. This must live in
// a decorator, not module scope: the test runner appends its UA marker
// via an injected script AFTER the page (and this module) loads, so a
// module-scope inStorybookTestRunner() check reads false. Story-module
// fixtures evaluated at import time still see the real clock — use fixed
// dates there.
(Story) => {
if (inStorybookTestRunner()) {
MockDate.set("2026-07-01T10:30:00Z");
seededMathRandom();
}
return <Story />;
},
],

globalTypes: {
Expand Down
Loading
Loading