Skip to content

Commit 31bd747

Browse files
Merge branch 'main' into posthog-code/fix-codex-subagent-thread-isolation
2 parents 2a5c346 + b647382 commit 31bd747

16 files changed

Lines changed: 3929 additions & 78 deletions
Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
name: Storybook visual regression
2+
3+
# Captures a screenshot of every story (dark + light) and submits them to the
4+
# PostHog Visual Review product, which diffs against the signed baseline
5+
# manifest committed at apps/code/snapshots.yml. Visual changes are reviewed
6+
# and approved at https://us.posthog.com/project/2/visual_review; on approval
7+
# the VR backend commits the updated manifest back to the PR branch. PNGs are
8+
# never committed to this repo.
9+
#
10+
# Until the one-time setup is done (VR repo registration + VR_API_TOKEN secret
11+
# + committed snapshots.yml), the vr step silently no-ops: the job still runs
12+
# the capture, which catches stories that crash or never settle.
13+
14+
on:
15+
pull_request:
16+
push:
17+
branches: [main]
18+
19+
concurrency:
20+
group: code-storybook-${{ github.head_ref || github.ref }}
21+
cancel-in-progress: true
22+
23+
jobs:
24+
changes:
25+
if: github.event_name == 'pull_request'
26+
runs-on: ubuntu-latest
27+
permissions:
28+
pull-requests: read
29+
outputs:
30+
code: ${{ steps.filter.outputs.code }}
31+
baseline_only_push: ${{ steps.baseline_push.outputs.result }}
32+
steps:
33+
- name: Detect relevant changes
34+
id: filter
35+
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
36+
with:
37+
predicate-quantifier: every
38+
filters: |
39+
# Anything that can change a rendered story. The baseline manifest
40+
# is excluded, but note paths-filter diffs the whole PR against
41+
# its base, so once a PR has code changes this filter stays true
42+
# for every later push — the baseline_push step below is what
43+
# skips the VR backend's own approval commits.
44+
code:
45+
- "{apps/code/**,packages/**,pnpm-lock.yaml,.github/workflows/code-storybook.yml}"
46+
- "!apps/code/snapshots.yml"
47+
- "!**/*.md"
48+
49+
# Visual Review commits the approved baseline back to the PR branch as
50+
# a bot; re-running the suite for that push is pure waste (only the
51+
# manifest changed). Compare the push delta (before...after), not the
52+
# PR diff, so it only matches the bot's own baseline commit.
53+
- name: Detect Visual Review baseline-only push
54+
id: baseline_push
55+
if: github.event_name == 'pull_request' && github.event.action == 'synchronize'
56+
env:
57+
GH_TOKEN: ${{ github.token }}
58+
REPO: ${{ github.repository }}
59+
ACTOR: ${{ github.actor }}
60+
BEFORE: ${{ github.event.before }}
61+
AFTER: ${{ github.event.after }}
62+
run: |
63+
set -euo pipefail
64+
result=false
65+
case "$ACTOR" in
66+
*'[bot]' | posthog-bot) is_bot=true ;;
67+
*) is_bot=false ;;
68+
esac
69+
zero=0000000000000000000000000000000000000000
70+
if [ "$is_bot" = true ] && [ -n "$BEFORE" ] && [ "$BEFORE" != "$zero" ] && [ -n "$AFTER" ]; then
71+
# compare uses merge-base(before, after), which for an appended
72+
# commit is exactly the push delta; a force-push/rebase widens it
73+
# and won't match.
74+
files=$(gh api "repos/${REPO}/compare/${BEFORE}...${AFTER}" --jq '.files[].filename' || true)
75+
if [ -n "$files" ] && ! grep -qv '^apps/code/snapshots\.yml$' <<<"$files"; then
76+
result=true
77+
fi
78+
fi
79+
echo "result=$result" >>"$GITHUB_OUTPUT"
80+
echo "Visual Review baseline-only push: $result"
81+
82+
visual-regression:
83+
needs: changes
84+
# Fail closed: if change detection failed (or this is a main push, where
85+
# `changes` is skipped), run instead of skipping — unless the push was
86+
# just the VR bot committing an approved baseline.
87+
if: >-
88+
${{ !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.code == 'true') &&
89+
needs.changes.outputs.baseline_only_push != 'true' }}
90+
runs-on: ubuntu-latest
91+
timeout-minutes: 30
92+
permissions:
93+
contents: read
94+
steps:
95+
- name: Checkout
96+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
97+
with:
98+
persist-credentials: false
99+
100+
- name: Setup pnpm
101+
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
102+
103+
- name: Setup Node.js
104+
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
105+
with:
106+
node-version: 22
107+
cache: "pnpm"
108+
109+
- name: Cache Playwright browsers
110+
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
111+
id: playwright-cache
112+
with:
113+
path: ~/.cache/ms-playwright
114+
key: playwright-chromium-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
115+
restore-keys: |
116+
playwright-chromium-${{ runner.os }}-
117+
118+
- name: Install dependencies
119+
run: pnpm install --frozen-lockfile
120+
121+
- name: Install Playwright Chromium
122+
if: steps.playwright-cache.outputs.cache-hit != 'true'
123+
working-directory: apps/code
124+
run: pnpm exec playwright install --with-deps chromium
125+
126+
- name: Install Playwright system dependencies
127+
# The cache restores browser binaries but not the OS packages they need.
128+
if: steps.playwright-cache.outputs.cache-hit == 'true'
129+
working-directory: apps/code
130+
run: pnpm exec playwright install-deps chromium
131+
132+
- name: Build workspace packages
133+
# Bare `wait` returns the exit status of only the last job passed to
134+
# it, silently swallowing failures from the others. `wait_all` waits
135+
# on each PID individually and checks its own status so any failed
136+
# build fails the step.
137+
run: |
138+
wait_all() {
139+
status=0
140+
for pid in "$@"; do
141+
wait "$pid" || status=$?
142+
done
143+
[ "$status" -eq 0 ]
144+
}
145+
146+
pnpm --filter @posthog/electron-trpc build &
147+
pid1=$!
148+
(pnpm --filter @posthog/shared build && pnpm --filter @posthog/platform build) &
149+
pid2=$!
150+
wait_all "$pid1" "$pid2"
151+
152+
# @posthog/agent imports @posthog/git's dist, so git must finish
153+
# before the last group starts.
154+
pnpm --filter @posthog/git build
155+
156+
pnpm --filter @posthog/enricher build &
157+
pid3=$!
158+
pnpm --filter @posthog/agent build &
159+
pid4=$!
160+
wait_all "$pid3" "$pid4"
161+
162+
- name: Build Storybook
163+
working-directory: apps/code
164+
run: pnpm build-storybook
165+
166+
- name: Serve Storybook
167+
working-directory: apps/code
168+
run: |
169+
pnpm exec http-server storybook-static --port 6006 --silent &
170+
pnpm exec wait-on http://127.0.0.1:6006/iframe.html --timeout 30000
171+
172+
- name: Capture story screenshots
173+
working-directory: apps/code
174+
run: |
175+
rm -rf .storybook/__snapshots__
176+
pnpm test:visual:ci:update
177+
178+
# The vr CLI isn't published anywhere; build it from posthog/posthog's
179+
# master, the same way that repo's own CI does.
180+
- name: Checkout Visual Review CLI
181+
if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'push'
182+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
183+
with:
184+
repository: PostHog/posthog
185+
path: vr-cli
186+
persist-credentials: false
187+
sparse-checkout: |
188+
products/visual_review/cli
189+
products/visual_review/frontend/generated/api.schemas.ts
190+
sparse-checkout-cone-mode: false
191+
192+
- name: Install Visual Review CLI
193+
if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'push'
194+
run: cd vr-cli/products/visual_review/cli && npm ci && npm run build && npm link
195+
196+
- name: Submit snapshots to Visual Review
197+
# Fork PRs can't read the token; their captures still ran above, so a
198+
# broken story fails the job either way.
199+
if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'push'
200+
# On main pushes the run is tracking-only ("observe"), but vr submit
201+
# still exits 1 when it detects changes; keep main green regardless.
202+
continue-on-error: ${{ github.event_name == 'push' }}
203+
env:
204+
VR_TOKEN: ${{ secrets.VR_API_TOKEN }}
205+
VR_BRANCH: ${{ github.event.pull_request.head.ref || github.ref_name }}
206+
VR_COMMIT: ${{ github.event.pull_request.head.sha || github.sha }}
207+
VR_PR: ${{ github.event.pull_request.number }}
208+
VR_PURPOSE: ${{ github.event_name == 'push' && 'observe' || 'review' }}
209+
# Lets the VR web UI re-trigger this job via the Actions API.
210+
JOB_CHECK_RUN_ID: ${{ job.check_run_id }}
211+
run: |
212+
if [ -z "$VR_TOKEN" ]; then
213+
echo "::notice::VR_API_TOKEN secret not set - skipping Visual Review submission"
214+
exit 0
215+
fi
216+
vr submit \
217+
--type storybook \
218+
--dir apps/code/.storybook/__snapshots__/ \
219+
--baseline apps/code/snapshots.yml \
220+
--branch "$VR_BRANCH" \
221+
--commit "$VR_COMMIT" \
222+
${VR_PR:+--pr "$VR_PR"} \
223+
--purpose "$VR_PURPOSE" \
224+
--token "$VR_TOKEN"
225+
226+
- name: Upload failure screenshots
227+
if: failure()
228+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v6.0.0
229+
with:
230+
name: storybook-visual-failures
231+
path: |
232+
apps/code/.storybook/__snapshots__/__diff_output__/
233+
apps/code/.storybook/__snapshots__/__failures__/
234+
if-no-files-found: ignore
235+
retention-days: 7
236+
237+
- name: Upload test results to Trunk
238+
# Run even when tests fail so flaky/failed results are still reported,
239+
# but never let an upload problem fail the job.
240+
if: ${{ !cancelled() }}
241+
continue-on-error: true
242+
uses: trunk-io/analytics-uploader@385f1ccdf345b4532dc4b6c665dd432b702b8e28 # v2.1.2
243+
with:
244+
junit-paths: "apps/code/junit.xml"
245+
org-slug: posthog-inc
246+
token: ${{ secrets.TRUNK_API_TOKEN }}

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,8 @@ apps/mobile/ROADMAP.md
8383

8484
# pi project-local config (per-developer MCP servers, prompts, etc.)
8585
.pi/
86+
87+
# Storybook visual regression: PNGs are never committed. CI uploads captures
88+
# to the PostHog Visual Review product; the committed baseline is the signed
89+
# hash manifest apps/code/snapshots.yml.
90+
apps/code/.storybook/__snapshots__/

apps/code/.storybook/main.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,19 @@ const config: StorybookConfig = {
100100
find: "@posthog/electron-trpc/renderer",
101101
replacement: path.resolve(__dirname, "./mocks/electron-trpc.ts"),
102102
},
103+
// The agent dist bundles are Node-targeted: tsup opens every file
104+
// with a createRequire(import.meta.url) shim, and tool-use-to-acp
105+
// imports fs/path. Shim those builtins so the bundles load in the
106+
// browser (see mocks/node-module.ts).
107+
{
108+
find: /^(node:)?module$/,
109+
replacement: path.resolve(__dirname, "./mocks/node-module.ts"),
110+
},
111+
{
112+
find: /^(node:)?fs$/,
113+
replacement: path.resolve(__dirname, "./mocks/node-fs.ts"),
114+
},
115+
{ find: /^(node:)?path$/, replacement: "pathe" },
103116
// Resolve the remaining @posthog/* workspace packages to source, exactly
104117
// like the renderer (vite.shared.mts). Without this, Storybook resolves
105118
// them through each package's "./*" exports map, which only falls
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
// Benign fs stub for @posthog/agent dist bundles loaded in Storybook. The
2+
// helpers stories use never touch the filesystem at render time; if one does,
3+
// it gets empty results rather than a crash mid-render.
4+
export function existsSync(): boolean {
5+
return false;
6+
}
7+
8+
export function readFileSync(): string {
9+
return "";
10+
}
11+
12+
export default { existsSync, readFileSync };
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
// The @posthog/agent dist bundles open with tsup's Node shim:
2+
// import { createRequire } from "node:module"; const require = createRequire(import.meta.url);
3+
// In the browser that import resolves to an empty module and every story in
4+
// the chunk dies at load. Stories only use pure helpers from those bundles, so
5+
// a require that throws on use is safe.
6+
export function createRequire(): (id: string) => never {
7+
return (id: string) => {
8+
throw new Error(`require("${id}") is not available in Storybook`);
9+
};
10+
}
11+
12+
export default { createRequire };

apps/code/.storybook/preview.tsx

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,23 @@ import "./mocks/electron-trpc";
22
import { Theme } from "@radix-ui/themes";
33
import "@radix-ui/themes/styles.css";
44
import type { Preview } from "@storybook/react-vite";
5+
import MockDate from "mockdate";
56
import "../../../packages/ui/src/styles/globals.css";
67
import { withAppProviders } from "./withAppProviders";
78

9+
function inStorybookTestRunner(): boolean {
10+
return navigator.userAgent.includes("StorybookTestRunner");
11+
}
12+
13+
function seededMathRandom(): void {
14+
let state = 0x9e3779b9;
15+
Math.random = () => {
16+
state = Math.imul(state ^ (state >>> 15), state | 1);
17+
state ^= state + Math.imul(state ^ (state >>> 7), state | 61);
18+
return ((state ^ (state >>> 14)) >>> 0) / 4294967296;
19+
};
20+
}
21+
822
const preview: Preview = {
923
parameters: {
1024
controls: {
@@ -40,6 +54,23 @@ const preview: Preview = {
4054
</Theme>
4155
);
4256
},
57+
// Last in the array = outermost, so it runs before every story render.
58+
// Visual regression snapshots need identical pixels on every render:
59+
// freeze the clock (elapsed-time counters, rendered timestamps) and
60+
// re-seed Math.random (random status verbs), including on the re-renders
61+
// the test runner triggers for theme flips and retries. This must live in
62+
// a decorator, not module scope: the test runner appends its UA marker
63+
// via an injected script AFTER the page (and this module) loads, so a
64+
// module-scope inStorybookTestRunner() check reads false. Story-module
65+
// fixtures evaluated at import time still see the real clock — use fixed
66+
// dates there.
67+
(Story) => {
68+
if (inStorybookTestRunner()) {
69+
MockDate.set("2026-07-01T10:30:00Z");
70+
seededMathRandom();
71+
}
72+
return <Story />;
73+
},
4374
],
4475

4576
globalTypes: {

0 commit comments

Comments
 (0)