Skip to content

Commit 4f369b8

Browse files
committed
fix(storybook): freeze the clock per render and skip baseline-only pushes
MockDate.set was gated on inStorybookTestRunner() at preview module scope, but the test runner appends its UA marker via an injected script after the page loads, so the check read false and the clock was never frozen - rendered wall-clock times drifted between runs (caught by Visual Review on the autoresearch dashboard stories; the drift sat just under jest-image-snapshot's 1% threshold, which is why local verify passes missed it). Freeze the clock in the outermost decorator, where the marker is visible, and pin the autoresearch fixture epoch since module-scope fixtures evaluate before any decorator runs. Also skip the visual-regression job when a push is the VR bot committing an approved baseline: paths-filter diffs the whole PR, so the existing filter exclusion can never catch it; compare the push delta instead, as posthog/posthog does. Generated-By: PostHog Code Task-Id: c8fa7392-2afe-4919-b8be-e6e3b56e1419
1 parent fd15798 commit 4f369b8

3 files changed

Lines changed: 57 additions & 13 deletions

File tree

.github/workflows/code-storybook.yml

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ jobs:
2828
pull-requests: read
2929
outputs:
3030
code: ${{ steps.filter.outputs.code }}
31+
baseline_only_push: ${{ steps.baseline_push.outputs.result }}
3132
steps:
3233
- name: Detect relevant changes
3334
id: filter
@@ -36,18 +37,56 @@ jobs:
3637
predicate-quantifier: every
3738
filters: |
3839
# Anything that can change a rendered story. The baseline manifest
39-
# is excluded so the VR backend's approval commit (which touches
40-
# only snapshots.yml) doesn't re-run the suite for nothing.
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.
4144
code:
4245
- "{apps/code/**,packages/**,pnpm-lock.yaml,.github/workflows/code-storybook.yml}"
4346
- "!apps/code/snapshots.yml"
4447
- "!**/*.md"
4548
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+
4682
visual-regression:
4783
needs: changes
4884
# Fail closed: if change detection failed (or this is a main push, where
49-
# `changes` is skipped), run instead of skipping.
50-
if: ${{ !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.code == 'true') }}
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' }}
5190
runs-on: ubuntu-latest
5291
timeout-minutes: 30
5392
permissions:

apps/code/.storybook/preview.tsx

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,6 @@ function inStorybookTestRunner(): boolean {
1010
return navigator.userAgent.includes("StorybookTestRunner");
1111
}
1212

13-
// Visual regression snapshots need identical pixels on every render: freeze
14-
// the clock (elapsed-time counters, relative timestamps) and re-seed
15-
// Math.random before each story (random status verbs), including on the
16-
// re-renders the test runner triggers for theme flips and retries.
17-
if (inStorybookTestRunner()) {
18-
MockDate.set("2026-07-01T10:30:00Z");
19-
}
20-
2113
function seededMathRandom(): void {
2214
let state = 0x9e3779b9;
2315
Math.random = () => {
@@ -63,8 +55,18 @@ const preview: Preview = {
6355
);
6456
},
6557
// 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.
6667
(Story) => {
6768
if (inStorybookTestRunner()) {
69+
MockDate.set("2026-07-01T10:30:00Z");
6870
seededMathRandom();
6971
}
7072
return <Story />;

packages/ui/src/features/autoresearch/AutoresearchFullDashboard.stories.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,10 @@ interface FullDashboardStoryProps {
3535
approach: string;
3636
}
3737

38-
const STORY_NOW = Date.now();
38+
// Fixed date: module-scope fixtures evaluate at import time, before the
39+
// visual-regression clock freeze, so wall-clock times here would make the
40+
// rendered Time column drift between snapshot runs.
41+
const STORY_NOW = new Date("2026-07-01T10:30:00Z").getTime();
3942
const STARTED_AT = STORY_NOW - 26 * 60_000;
4043

4144
function FullDashboardStory(props: FullDashboardStoryProps) {

0 commit comments

Comments
 (0)