Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
104 changes: 104 additions & 0 deletions .github/workflows/storybook-visual-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# Storybook Visual Regression Tests
#
# Builds Storybook and runs Playwright screenshot tests against the static
# output. Each story is rendered in isolation (no backend, no auth), making
# these tests fast and reliable compared to full E2E screenshot tests.
#
# Snapshot strategy (mirrors end-to-end-test-playwright):
# PW_DOCKER=1 → baselines read/written to __snapshots__/ (committed)
# host runs → __local_snapshots__/ (gitignored, never overwrite refs)
#
# First-time baseline setup:
# When __snapshots__/ is empty (new stories added), PW_UPDATE_SNAPSHOTS
# defaults to 'missing' — tests pass and new baselines are written.
# The "Upload new/updated snapshots" step uploads them as an artifact.
# Download the artifact, copy the PNGs into
# storybook-visual-tests/tests/__snapshots__/ and commit them.
#
# Updating baselines after an intentional visual change:
# Re-run this workflow with the input update_snapshots: all
# (via workflow_dispatch), download the artifact, and commit the new PNGs.

name: Storybook Visual Tests

on:
push:
branches: [master, rc]
pull_request:
workflow_dispatch:
inputs:
update_snapshots:
description: >
Snapshot update mode.
'missing' writes only new baselines (default CI behaviour).
'all' regenerates every baseline — use after intentional UI changes.
type: choice
options: [missing, all, none]
default: missing

env:
NODE_VERSION: '22.18.0'
# Treat the GitHub Actions runner as the canonical Docker environment for
# snapshot purposes (same flag the CircleCI playwright jobs use).
PW_DOCKER: '1'

jobs:
storybook-visual:
name: Visual regression (Storybook + Playwright)
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}

- name: Enable corepack and activate pnpm
run: |
corepack enable
corepack prepare pnpm@10.33.0 --activate

- name: Cache pnpm store
uses: actions/cache@v4
with:
path: ~/.local/share/pnpm/store
key: pnpm-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: pnpm-${{ runner.os }}-

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Install Playwright Chromium browser
run: pnpm exec playwright install chromium --with-deps

- name: Build Storybook
run: pnpm build-storybook

- name: Run visual tests
env:
# workflow_dispatch lets the caller override; push/PR runs use the
# default (undefined → Playwright falls back to 'missing').
PW_UPDATE_SNAPSHOTS: ${{ inputs.update_snapshots }}
run: node scripts/serve-and-test-storybook.js

# Upload the canonical baselines whenever they were written or updated
# so developers can download, review, and commit them.
- name: Upload new/updated snapshots
if: always()
uses: actions/upload-artifact@v4
with:
name: storybook-snapshots
path: storybook-visual-tests/tests/__snapshots__/
if-no-files-found: ignore

# Upload pixel diffs for any failing tests so reviewers can see exactly
# what changed without having to reproduce locally.
- name: Upload diff screenshots on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: storybook-visual-diffs
path: test-results/
if-no-files-found: ignore
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,9 @@ api-e2e/validation.js
.vs/
.nx
.turbo

# Storybook local (host-rendered) visual snapshots — not canonical references
storybook-visual-tests/tests/__local_snapshots__/
storybook-static/
playwright-report/
test-results/
129 changes: 129 additions & 0 deletions .storybook/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import type { StorybookConfig } from '@storybook/react-webpack5';
import path from 'path';

// Shared Sass resources that mirror the rspack.config.js sass-resources-loader setup.
// LoadingIndicator and most other components use $brand-primary and other bootstrap/global
// variables that are only available if these are injected into every SCSS compilation.
const sassResources = [
path.resolve(
__dirname,
'../node_modules/bootstrap-sass/assets/stylesheets/bootstrap/_variables.scss'
),
path.resolve(
__dirname,
'../node_modules/bootstrap-sass/assets/stylesheets/bootstrap/_mixins'
),
path.resolve(__dirname, '../src/globalStyles/variables.scss'),
];

const config: StorybookConfig = {
stories: ['../src/**/*.stories.@(ts|tsx)'],

addons: [
'@storybook/addon-essentials',
// Use SWC instead of Babel for faster builds. Decorator options are
// applied directly in webpackFinal below because the addon's
// swcLoaderOptions merging is unreliable across patch versions.
'@storybook/addon-webpack5-compiler-swc',
],

framework: {
name: '@storybook/react-webpack5',
options: {},
},

webpackFinal: async config => {
// Patch the SWC loader rule installed by addon-webpack5-compiler-swc to
// enable legacy TypeScript decorators. MobX @observer/@observable/@computed
// require `legacyDecorator: true`; without it SWC throws "Expression expected"
// on every decorated class. We also set react.runtime to 'classic' because
// the project is on React 16 which doesn't have the automatic JSX runtime.
if (config.module?.rules) {
config.module.rules = config.module.rules.map(rule => {
if (!rule || typeof rule !== 'object') return rule;
const r = rule as any;
const uses: any[] = Array.isArray(r.use)
? r.use
: r.use
? [r.use]
: [];
const swcIndex = uses.findIndex(
(u: any) =>
typeof u === 'object' &&
typeof u?.loader === 'string' &&
u.loader.includes('swc-loader')
);
if (swcIndex === -1) return rule;
const patchedUses = [...uses];
patchedUses[swcIndex] = {
loader: uses[swcIndex].loader,
options: {
jsc: {
parser: {
syntax: 'typescript',
tsx: true,
decorators: true,
},
transform: {
legacyDecorator: true,
decoratorMetadata: false,
react: { runtime: 'classic' },
},
},
},
};
return { ...r, use: patchedUses };
});
}

// Add src/ as a module root so that repo-style imports like
// 'shared/components/Foo' resolve to 'src/shared/components/Foo',
// matching the tsconfig `"*": ["src/*"]` path mapping.
config.resolve!.modules = [
path.resolve(__dirname, '../src'),
'node_modules',
];

// SCSS modules: mirror the rspack chain so $brand-primary and other
// global variables are available in every .module.scss file.
config.module!.rules!.push({
test: /\.module\.scss$/,
use: [
'style-loader',
{
loader: 'css-loader',
options: {
modules: {
localIdentName: '[name]__[local]--[hash:base64:5]',
},
importLoaders: 2,
},
},
'sass-loader',
{
loader: 'sass-resources-loader',
options: { resources: sassResources },
},
],
});

// Plain (non-module) SCSS
config.module!.rules!.push({
test: /\.scss$/,
exclude: /\.module\.scss$/,
use: [
'style-loader',
'css-loader',
'sass-loader',
{
loader: 'sass-resources-loader',
options: { resources: sassResources },
},
],
});

return config;
},
};

export default config;
33 changes: 33 additions & 0 deletions .storybook/preview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { Preview } from '@storybook/react';
import * as React from 'react';

// React 16 does not ship useInsertionEffect; Storybook 8 internals reference
// it via a re-export. Stub it so the preview bundle resolves without warnings.
if (!(React as any).useInsertionEffect) {
(React as any).useInsertionEffect = (React as any).useLayoutEffect;
}

const preview: Preview = {
parameters: {
// Sensible viewport default matching the app's typical minimum width
viewport: {
defaultViewport: 'desktop',
viewports: {
desktop: {
name: 'Desktop (1280px)',
styles: { width: '1280px', height: '800px' },
type: 'desktop',
},
tablet: {
name: 'Tablet (768px)',
styles: { width: '768px', height: '1024px' },
type: 'tablet',
},
},
},
// Disable animations in snapshot/screenshot mode to prevent flakiness
chromatic: { disableSnapshot: false },
},
};

export default preview;
13 changes: 12 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,12 @@
"e2e:local": "export RETRIES=0 && export CBIOPORTAL_URL=http://localhost:8080 && export && export SCREENSHOT_DIRECTORY=/local/screenshots/ && cd end-to-end-test && pnpm run test-webdriver-manager-local",
"e2e:remote": "export RETRIES=0 && export CBIOPORTAL_URL=https://www.cbioportal.org && export SCREENSHOT_DIRECTORY=/remote/screenshots/ && rm -r -f end-to-end-test/shared/results && cd end-to-end-test && pnpm run test-webdriver-manager-remote",
"e2e:report": "npx http-server end-to-end-test -o /shared/imageCompare.html -p 8089",
"localChromeDriver": "chromedriver"
"localChromeDriver": "chromedriver",
"storybook": "storybook dev -p 6006 --no-open",
"build-storybook": "storybook build",
"test:storybook": "node scripts/serve-and-test-storybook.js",
"test:storybook:ci": "storybook build && node scripts/serve-and-test-storybook.js",
"test:storybook:update": "node scripts/serve-and-test-storybook.js --update-snapshots"
},
"engines": {
"node": "22.18.0"
Expand Down Expand Up @@ -301,8 +306,14 @@
"xml2js": "^0.5.0"
},
"devDependencies": {
"@playwright/test": "^1.59.1",
"@rspack/cli": "1.7.11",
"@rspack/core": "1.7.11",
"@storybook/addon-essentials": "^8.5.0",
"@storybook/addon-webpack5-compiler-swc": "^1.0.5",
"@storybook/react-webpack5": "^8.5.0",
"@storybook/test": "^8.5.0",
"storybook": "^8.5.0",
"@types/cheerio": "^0.22.23",
"@types/react-test-renderer": "^17.0.1",
"@types/yargs": "17.0.35",
Expand Down
Loading