Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
92b7ce4
test: add visual regression coverage for Modal variants
TaprootFreak May 12, 2026
fa1b1f8
test: pin Storybook packages to 8.6.18 for clean npm ci
TaprootFreak May 12, 2026
4687b9a
test: align lockfile with strict npm ci and skip app type-check in St…
TaprootFreak May 12, 2026
84a118b
test: respect repo .npmrc legacy-peer-deps=false in chromatic workflow
TaprootFreak May 12, 2026
8728811
test: skip Chromatic job when project token is not yet configured
TaprootFreak May 12, 2026
dd0a312
test: tighten Modal stories and Chromatic workflow
TaprootFreak May 12, 2026
0dbb6e6
test: align style with repo conventions
TaprootFreak May 12, 2026
45a3c73
test: rebuild lockfile by surgical npm install instead of full regen
TaprootFreak May 12, 2026
32c70fe
test: replace Chromatic with self-hosted Playwright visual regression
TaprootFreak May 12, 2026
f13d90f
test: prune dead config and tighten the failure-recovery shell
TaprootFreak May 12, 2026
ef1f176
test: load Inter font in Storybook so baselines match the app
TaprootFreak May 12, 2026
1c74af4
fix: serve Storybook with cleanUrls disabled and flatten snapshot pat…
TaprootFreak May 12, 2026
5193348
test: harden Modal visual regression — realistic content, stable topO…
TaprootFreak May 12, 2026
4128963
test: seed final Modal visual regression baselines (#1099)
TaprootFreak May 17, 2026
c8b2fec
test: widen visual-regression tolerance and refresh drifted mobile ba…
TaprootFreak May 17, 2026
a8f3f16
test: align Modal visual regression setup with repo conventions
TaprootFreak May 17, 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
67 changes: 67 additions & 0 deletions .github/workflows/visual-regression.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
name: Visual Regression

on:
pull_request:
branches:
- main
- develop
push:
branches:
- develop
workflow_dispatch:

permissions:
contents: read

env:
NODE_VERSION: '20.x'

jobs:
visual:
name: Storybook visual regression
if: github.head_ref != 'develop'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Use Node.js ${{ env.NODE_VERSION }}
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'

- name: Install packages
run: npm ci

- name: Install Playwright Chromium
run: npx playwright install --with-deps chromium

- name: Build Storybook
run: npm run build-storybook

- name: Run visual regression tests
run: npm run test:visual

- name: Stage baseline candidates from actuals
# On failure Playwright writes `<name>-actual.png` files for missing or
# diverging snapshots. Re-shape them into the snapshot directory layout
# so the maintainer can drop the artifact straight into the repo.
if: failure()
run: |
set -e
mkdir -p baseline-candidates/modal-visual.spec.ts
find e2e/storybook/test-results -name '*-actual.png' -print0 | while IFS= read -r -d '' src; do
base=$(basename "$src" -actual.png)
cp "$src" "baseline-candidates/modal-visual.spec.ts/${base}.png"
done

- name: Upload diff + baseline-candidates on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: visual-regression-diff
path: |
e2e/storybook/test-results/
baseline-candidates/
retention-days: 14
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ test-results/
/widget
/dist

# storybook
/storybook-static

# misc
.DS_Store
*.pem
Expand Down
35 changes: 35 additions & 0 deletions .storybook/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import path from 'path';
import type { StorybookConfig } from '@storybook/react-webpack5';

const config: StorybookConfig = {
stories: ['../src/**/*.stories.@(ts|tsx)'],
// Ship a serve.json into the build so `npx serve storybook-static` does not
// strip `.html` extensions and redirect `/iframe.html?id=…` to `/iframe`,
// losing the query string and breaking story rendering in the visual tests.
staticDirs: ['./static'],
addons: ['@storybook/addon-essentials', '@storybook/addon-interactions', '@storybook/preset-create-react-app'],
framework: {
name: '@storybook/react-webpack5',
options: {},
},
typescript: {
check: false,
reactDocgen: false,
},
webpackFinal: (webpackConfig) => {
webpackConfig.resolve!.alias = {
...webpackConfig.resolve!.alias,
src: path.resolve(__dirname, '../src'),
};
// App type-checking is handled by `react-app-rewired build`. Storybook only
// needs to compile stories for visual rendering; ForkTsCheckerWebpackPlugin
// (injected by the CRA preset) would otherwise re-evaluate the whole app
// under strict mode and fail on pre-existing type drift unrelated to stories.
webpackConfig.plugins = webpackConfig.plugins!.filter(
(plugin) => plugin?.constructor?.name !== 'ForkTsCheckerWebpackPlugin',
);
return webpackConfig;
},
};

export default config;
10 changes: 10 additions & 0 deletions .storybook/preview-head.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<!-- Mirror the font loading from public/index.html so component stories render
with the same typography as the deployed app. The Playwright spec waits
on `document.fonts.ready` before snapshotting, so this keeps baselines
stable across browser/runner combinations. -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&display=swap"
rel="stylesheet"
/>
43 changes: 43 additions & 0 deletions .storybook/preview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { ReactNode, useRef } from 'react';
import type { Preview } from '@storybook/react';
import { LayoutContextProvider } from '../src/contexts/layout.context';
import '../src/index.css';

// Mirrors the app's layout: a 64 px header surrogate above the modal anchor so
// Modal's `topOffset` resolves to 64 and snapshots include the visible header.
function StorybookLayoutHost({ children }: { children: ReactNode }) {
const rootRef = useRef<HTMLDivElement | null>(null);
const modalRootRef = useRef<HTMLDivElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);

return (
<div ref={rootRef} className="min-h-screen w-full bg-white">
<div
ref={scrollRef}
className="h-16 w-full bg-dfxBlue-800 flex items-center px-4 text-sm font-medium text-white"
>
DFX Services
</div>
<div ref={modalRootRef} className="relative" data-testid="modal-root-anchor" />
<LayoutContextProvider rootRef={rootRef} modalRootRef={modalRootRef} scrollRef={scrollRef}>
{children}
</LayoutContextProvider>
</div>
);
}

const preview: Preview = {
parameters: {
layout: 'fullscreen',
controls: { expanded: true },
},
decorators: [
(Story) => (
<StorybookLayoutHost>
<Story />
</StorybookLayoutHost>
),
],
};

export default preview;
4 changes: 4 additions & 0 deletions .storybook/static/serve.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"cleanUrls": false,
"directoryListing": false
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
60 changes: 60 additions & 0 deletions e2e/storybook/modal-visual.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { test, expect } from '@playwright/test';

// Visual regression guard for the shared <Modal> component.
//
// Background: PR #1048 silently changed the Modal default variant from
// fullscreen to dialog, breaking every fullscreen modal in production
// (KYC, Safe Deposit, Buy/Sell, recall, limit request, chargeback)
// until PR #1090 reintroduced an explicit variant prop.
//
// Each story is rendered headless against the built Storybook bundle and
// pixel-diffed against a committed baseline. The Default story is the
// crucial regression guard: it renders <Modal> without passing a variant,
// so any future default flip diverges its snapshot from the explicit
// Fullscreen baseline and fails CI before merge.

const STORYBOOK_VIEWPORTS = {
mobile: { width: 375, height: 812 },
desktop: { width: 1280, height: 800 },
} as const;

const STORIES = [
{ id: 'components-modal--default', name: 'modal-default' },
{ id: 'components-modal--fullscreen', name: 'modal-fullscreen' },
{ id: 'components-modal--dialog', name: 'modal-dialog' },
] as const;

for (const story of STORIES) {
for (const [viewportName, viewport] of Object.entries(STORYBOOK_VIEWPORTS)) {
test(`${story.name} @ ${viewportName}`, async ({ page }) => {
await page.setViewportSize(viewport);
await page.goto(`/iframe.html?id=${story.id}&viewMode=story`);

// Story root must be mounted before we look for the modal portal.
await page.locator('#storybook-root').waitFor({ state: 'visible' });

// The Modal portal lands on document.body; wait for its outer wrapper
// (`z-50` is on both variants) to ensure the component has reached
// its second render after `mounted` and refs have been set.
await page.locator('div.z-50').waitFor({ state: 'visible' });

// Fonts must be ready or text rendering shifts subpixels between runs.
await page.evaluate(() => document.fonts.ready);

// Modal's fullscreen variant computes `topOffset` from a ResizeObserver
// on documentElement which fires asynchronously after the initial paint.
// Wait two animation frames so the re-render with the final `top` style
// has been committed before the snapshot is taken.
await page.evaluate(
() =>
new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
),
);

await expect(page).toHaveScreenshot(`${story.name}-${viewportName}.png`, {
fullPage: true,
});
});
}
}
Loading
Loading