Describe the bug
We're using test-runner to take Visual Regression snapshots of our components. Sometimes the test runner compares an the incorrect story to the snapshot. So our "default story" would sometimes be compared against a snapshot of the next story, "warning story" in this case.
This usually doesn't happen when running the test runner a second time which makes me think it might be related to Vite taking a while to bundle the story on the first view?
To Reproduce
test-runner.ts
```tsx
import {
TestRunnerConfig,
getStoryContext,
waitForPageReady,
} from '@storybook/test-runner'
import { injectAxe, checkA11y, configureAxe } from 'axe-playwright'
import { toMatchImageSnapshot } from 'jest-image-snapshot'
import path from 'path'
import fs from 'fs'
import { contract, gel2Themes, gel3Themes } from '@snsw-gel/theming'
import { Page } from 'playwright'
const themes = [...gel2Themes, ...gel3Themes]
async function waitForReady(page: Page) {
await page.waitForTimeout(30)
await page.waitForLoadState('networkidle')
await waitForPageReady(page)
await page.evaluate(async () => {
while (true) {
if (document.readyState === 'complete') {
await new Promise(resolve => setTimeout(resolve, 100))
if (document.readyState === 'complete') {
break
}
}
await new Promise(resolve => {
document.addEventListener('DOMContentLoaded', resolve, {
once: true,
})
})
}
})
await page.evaluate(async () => {
await document.fonts.ready
})
}
async function removeThemes(page: Page) {
await page.evaluate(
themes => {
document.body.classList.remove(...themes)
},
themes.map(t => t.className),
)
}
async function enableTheme(page: Page, idx: number) {
await page.evaluate(
async ([idx, themes]) => {
themes.forEach((cls, i) => {
document.body.classList.toggle(cls, i === idx)
})
},
[idx, themes.map(t => t.className)] as const,
)
}
async function runAxeTest(page: Page, storyContext) {
await removeThemes(page)
// Apply story-level a11y rules
await configureAxe(page, {
rules: storyContext.parameters?.a11y?.config?.rules,
})
await checkA11y(page, '#storybook-root', {
detailedReport: true,
verbose: false,
// pass axe options defined in @storybook/addon-a11y
axeOptions: storyContext.parameters?.a11y?.options,
})
}
async function runVisualRegressionTesting(page: Page, storyContext) {
const browserName = page.context()?.browser()?.browserType().name()
const breakpointsToTest = new Set(['smMobile', 'lgMobile', 'tablet'])
let entries = Object.entries(contract.config.breakpoints).filter(([key]) =>
breakpointsToTest.has(key),
)
let rootDir = path.resolve(storyContext.parameters.fileName)
while (rootDir !== '/') {
const packageJsonPath = path.resolve(rootDir, 'package.json')
if (fs.existsSync(packageJsonPath)) {
break
}
rootDir = path.resolve(rootDir, '..')
}
if (browserName !== 'webkit') {
if (!storyContext.kind.includes('default')) return
entries = [entries[entries.length - 1]]
}
for (let [breakpointKey, breakpoint] of entries) {
let maxWidth = 'max' in breakpoint ? breakpoint.max : breakpoint.min + 1
let pageHeight = 1080
await page.setViewportSize({
width: maxWidth - 1,
height: pageHeight,
})
const height = await page.evaluate(() => {
return document
.querySelector('#storybook-root')
?.getBoundingClientRect().height
})
while (height && pageHeight < height) {
pageHeight += 1080
}
await page.setViewportSize({
width: maxWidth - 1,
height: pageHeight,
})
for (let i = 0; i < themes.length; i++) {
const theme = themes[i]
await enableTheme(page, i)
await waitForReady(page)
const customSnapshotsDir = `${rootDir}/snapshots/${
storyContext.kind
}/${theme.className.replace('.', '')}/${breakpointKey}`
const image = await page.screenshot()
expect(image).toMatchImageSnapshot({
customSnapshotsDir,
customSnapshotIdentifier: storyContext.id,
})
}
}
}
const config: TestRunnerConfig = {
logLevel: 'none',
setup() {
// @ts-ignore
expect.extend({ toMatchImageSnapshot })
},
async preVisit(page, context) {
// Inject Axe utilities in the page before the story renders
await injectAxe(page)
},
async postVisit(page, context) {
// Get entire context of a story, including parameters, args, argTypes, etc.
const storyContext = await getStoryContext(page, context)
if (storyContext.parameters?.e2e?.enabled === false) {
return
}
const browserName = page.context()?.browser()?.browserType().name()
if (browserName !== 'webkit') {
if (!storyContext.kind.includes('default')) return
}
await page.addStyleTag({
content: `
* {
transition: none !important;
-webkit-transition: none !important;
-moz-transition: none !important;
-o-transition: none !important;
-ms-transition: none !important;
animation: none !important;
-webkit-animation: none !important;
-moz-animation: none !important;
-o-animation: none !important;
-ms-animation: none !important;
transition-duration: 0s !important;
animation-duration: 0s !important;
}
svg animate {
display: none !important;
}
`,
})
await waitForPageReady(page)
// Do not test a11y for stories that disable a11y
if (storyContext.parameters?.a11y?.enabled !== false) {
await runAxeTest(page, storyContext)
}
if (
storyContext.parameters?.visual?.enabled !== false &&
!process.env.CI
) {
await runVisualRegressionTesting(page, storyContext)
}
},
}
export default config
```
main.tsx
```tsx
import type { StorybookConfig } from '@storybook/react-vite'
import { InlineConfig, mergeConfig } from 'vite'
import fs from 'fs'
import os from 'os'
import { join, dirname, resolve } from 'path'
/**
* This function is used to resolve the absolute path of a package.
* It is needed in projects that use Yarn PnP or are set up within a monorepo.
*/
function getAbsolutePath(value: string): any {
return dirname(require.resolve(join(value, 'package.json')))
}
const resolveCache = new Map()
const requestersCache = new Map>()
const config: StorybookConfig = {
stories: [
'../../../packages/*/src/**/*.stories.@(js|ts|tsx|jsx)',
'../../../packages/*/stories/**/*.stories.@(js|ts|tsx|jsx)',
'../../../packages/*/stories/**/*.mdx',
],
staticDirs: ['../public'],
typescript: {
check: true,
reactDocgen: 'react-docgen-typescript',
reactDocgenTypescriptOptions: {
shouldExtractLiteralValuesFromEnum: true,
shouldRemoveUndefinedFromOptional: true,
include: ['../../**/src/**/*.{ts,tsx}'],
},
},
addons: [
getAbsolutePath('@storybook/addon-links'),
getAbsolutePath('@storybook/addon-essentials'),
getAbsolutePath('@storybook/addon-interactions'),
'@storybook/addon-docs',
getAbsolutePath('storybook-addon-jsx'),
getAbsolutePath('@storybook/addon-a11y'),
getAbsolutePath('@storybook/addon-mdx-gfm'),
],
core: {},
docs: {
autodocs: true,
},
async viteFinal(config, { configType }) {
if (configType === 'DEVELOPMENT') {
// Your development configuration goes here
}
if (configType === 'PRODUCTION') {
// Your production configuration goes here.
}
return mergeConfig(config, {
assetsInclude: ['**/*.md'],
resolve: {
alias: [],
},
optimizeDeps: {
include: [
'@babel/parser',
'react-element-to-jsx-string',
'@babel/runtime/helpers/interopRequireWildcard',
'@mdx-js/react',
'@storybook/addon-docs',
'@storybook/react',
'@duetds/date-picker',
'@duetds/date-picker/dist/loader',
'@stencil/core',
'@base2/pretty-print-object',
'@storybook/client-api',
'@storybook/blocks',
'@storybook/client-logger',
'fast-deep-equal',
'lodash',
'styled-components',
'lodash-es',
'lodash/isPlainObject',
'lodash/mapValues',
'lodash/pickBy',
'lodash/pick',
'lodash/startCase',
'lodash/isFunction',
'lodash/isString',
'util-deprecate',
'@storybook/csf',
'react-router',
'react-router-dom',
'global',
'synchronous-promise',
'memoizerific',
'stable',
'doctrine',
'html-tags',
'escodegen',
'acorn',
'prettier',
'@prettier/sync',
'acorn-jsx',
'@base2/pretty-print-object',
'prop-types',
'react-dom',
'qs',
'uuid-browser',
'uuid-browser/v4',
'jest-mock',
// '@snsw-gel/react',
],
},
define: {
'process.env.PATHNAME': JSON.stringify(process.env.PATHNAME || ""),
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
'process.env.STORYBOOK': JSON.stringify(true),
'PKG_NAME': JSON.stringify(''),
'PKG_VERSION': JSON.stringify(''),
'GEL_NAME': JSON.stringify(
require('../../react/package.json').name,
),
'GEL_VERSION': JSON.stringify(
require('../../react/package.json').version,
),
'SNAPSHOT_RELEASE': JSON.stringify(
/\d+\.\d+.\d+-.*/.test(
require('../../react/package.json').version,
),
),
},
// Your environment configuration here
plugins: [
{
enforce: 'post',
name: 'vite-plugin-resolve',
resolveId(id, requester, ...rest) {
if (id === 'package.json' && requester) {
let target = dirname(requester)
let resolved = ''
while (!resolved && target !== os.homedir()) {
let foundPackage = resolve(
target,
'package.json',
)
if (fs.existsSync(foundPackage)) {
resolved = foundPackage
} else {
target = dirname(target)
}
}
if (resolved) {
return resolved
}
}
if (id === '@snsw-gel/storybook') {
return require.resolve('../dist/esm/index.mjs')
}
let result
try {
result = require.resolve(id)
} catch (e) {
return null
}
const cachedResult = resolveCache.get(id)
let requesters = requestersCache.get(id)
if (!requesters) {
requesters = new Set()
requesters.add(requester!)
requestersCache.set(id, requesters)
}
if (cachedResult && cachedResult !== result) {
console.warn(
`Multiple requests resolving to different locations recieved for ${id} ${[
...requesters,
].join(', ')}`,
)
}
return result
},
},
],
})
},
framework: {
name: getAbsolutePath('@storybook/react-vite'),
options: {},
},
}
export default config
```
Expected behaviour
Ideally the test runner would wait for the previous tests to finish before moving to the next story
Screenshots
In the above screenshot the test runner has rendered the next story "warning" before the the previous tests have finished
Describe the bug We're using test-runner to take Visual Regression snapshots of our components. Sometimes the test runner compares an the incorrect story to the snapshot. So our "default story" would sometimes be compared against a snapshot of the next story, "warning story" in this case.
This usually doesn't happen when running the test runner a second time which makes me think it might be related to Vite taking a while to bundle the story on the first view?
To Reproduce
test-runner.ts
```tsx import { TestRunnerConfig, getStoryContext, waitForPageReady, } from '@storybook/test-runner' import { injectAxe, checkA11y, configureAxe } from 'axe-playwright' import { toMatchImageSnapshot } from 'jest-image-snapshot' import path from 'path' import fs from 'fs' import { contract, gel2Themes, gel3Themes } from '@snsw-gel/theming' import { Page } from 'playwright' const themes = [...gel2Themes, ...gel3Themes] async function waitForReady(page: Page) { await page.waitForTimeout(30) await page.waitForLoadState('networkidle') await waitForPageReady(page) await page.evaluate(async () => { while (true) { if (document.readyState === 'complete') { await new Promise(resolve => setTimeout(resolve, 100)) if (document.readyState === 'complete') { break } } await new Promise(resolve => { document.addEventListener('DOMContentLoaded', resolve, { once: true, }) }) } }) await page.evaluate(async () => { await document.fonts.ready }) } async function removeThemes(page: Page) { await page.evaluate( themes => { document.body.classList.remove(...themes) }, themes.map(t => t.className), ) } async function enableTheme(page: Page, idx: number) { await page.evaluate( async ([idx, themes]) => { themes.forEach((cls, i) => { document.body.classList.toggle(cls, i === idx) }) }, [idx, themes.map(t => t.className)] as const, ) } async function runAxeTest(page: Page, storyContext) { await removeThemes(page) // Apply story-level a11y rules await configureAxe(page, { rules: storyContext.parameters?.a11y?.config?.rules, }) await checkA11y(page, '#storybook-root', { detailedReport: true, verbose: false, // pass axe options defined in @storybook/addon-a11y axeOptions: storyContext.parameters?.a11y?.options, }) } async function runVisualRegressionTesting(page: Page, storyContext) { const browserName = page.context()?.browser()?.browserType().name() const breakpointsToTest = new Set(['smMobile', 'lgMobile', 'tablet']) let entries = Object.entries(contract.config.breakpoints).filter(([key]) => breakpointsToTest.has(key), ) let rootDir = path.resolve(storyContext.parameters.fileName) while (rootDir !== '/') { const packageJsonPath = path.resolve(rootDir, 'package.json') if (fs.existsSync(packageJsonPath)) { break } rootDir = path.resolve(rootDir, '..') } if (browserName !== 'webkit') { if (!storyContext.kind.includes('default')) return entries = [entries[entries.length - 1]] } for (let [breakpointKey, breakpoint] of entries) { let maxWidth = 'max' in breakpoint ? breakpoint.max : breakpoint.min + 1 let pageHeight = 1080 await page.setViewportSize({ width: maxWidth - 1, height: pageHeight, }) const height = await page.evaluate(() => { return document .querySelector('#storybook-root') ?.getBoundingClientRect().height }) while (height && pageHeight < height) { pageHeight += 1080 } await page.setViewportSize({ width: maxWidth - 1, height: pageHeight, }) for (let i = 0; i < themes.length; i++) { const theme = themes[i] await enableTheme(page, i) await waitForReady(page) const customSnapshotsDir = `${rootDir}/snapshots/${ storyContext.kind }/${theme.className.replace('.', '')}/${breakpointKey}` const image = await page.screenshot() expect(image).toMatchImageSnapshot({ customSnapshotsDir, customSnapshotIdentifier: storyContext.id, }) } } } const config: TestRunnerConfig = { logLevel: 'none', setup() { // @ts-ignore expect.extend({ toMatchImageSnapshot }) }, async preVisit(page, context) { // Inject Axe utilities in the page before the story renders await injectAxe(page) }, async postVisit(page, context) { // Get entire context of a story, including parameters, args, argTypes, etc. const storyContext = await getStoryContext(page, context) if (storyContext.parameters?.e2e?.enabled === false) { return } const browserName = page.context()?.browser()?.browserType().name() if (browserName !== 'webkit') { if (!storyContext.kind.includes('default')) return } await page.addStyleTag({ content: ` * { transition: none !important; -webkit-transition: none !important; -moz-transition: none !important; -o-transition: none !important; -ms-transition: none !important; animation: none !important; -webkit-animation: none !important; -moz-animation: none !important; -o-animation: none !important; -ms-animation: none !important; transition-duration: 0s !important; animation-duration: 0s !important; } svg animate { display: none !important; } `, }) await waitForPageReady(page) // Do not test a11y for stories that disable a11y if (storyContext.parameters?.a11y?.enabled !== false) { await runAxeTest(page, storyContext) } if ( storyContext.parameters?.visual?.enabled !== false && !process.env.CI ) { await runVisualRegressionTesting(page, storyContext) } }, } export default config ```
main.tsx
```tsx import type { StorybookConfig } from '@storybook/react-vite' import { InlineConfig, mergeConfig } from 'vite' import fs from 'fs' import os from 'os' import { join, dirname, resolve } from 'path' /** * This function is used to resolve the absolute path of a package. * It is needed in projects that use Yarn PnP or are set up within a monorepo. */ function getAbsolutePath(value: string): any { return dirname(require.resolve(join(value, 'package.json'))) } const resolveCache = new Map()
const requestersCache = new Map>()
const config: StorybookConfig = {
stories: [
'../../../packages/*/src/**/*.stories.@(js|ts|tsx|jsx)',
'../../../packages/*/stories/**/*.stories.@(js|ts|tsx|jsx)',
'../../../packages/*/stories/**/*.mdx',
],
staticDirs: ['../public'],
typescript: {
check: true,
reactDocgen: 'react-docgen-typescript',
reactDocgenTypescriptOptions: {
shouldExtractLiteralValuesFromEnum: true,
shouldRemoveUndefinedFromOptional: true,
include: ['../../**/src/**/*.{ts,tsx}'],
},
},
addons: [
getAbsolutePath('@storybook/addon-links'),
getAbsolutePath('@storybook/addon-essentials'),
getAbsolutePath('@storybook/addon-interactions'),
'@storybook/addon-docs',
getAbsolutePath('storybook-addon-jsx'),
getAbsolutePath('@storybook/addon-a11y'),
getAbsolutePath('@storybook/addon-mdx-gfm'),
],
core: {},
docs: {
autodocs: true,
},
async viteFinal(config, { configType }) {
if (configType === 'DEVELOPMENT') {
// Your development configuration goes here
}
if (configType === 'PRODUCTION') {
// Your production configuration goes here.
}
return mergeConfig(config, {
assetsInclude: ['**/*.md'],
resolve: {
alias: [],
},
optimizeDeps: {
include: [
'@babel/parser',
'react-element-to-jsx-string',
'@babel/runtime/helpers/interopRequireWildcard',
'@mdx-js/react',
'@storybook/addon-docs',
'@storybook/react',
'@duetds/date-picker',
'@duetds/date-picker/dist/loader',
'@stencil/core',
'@base2/pretty-print-object',
'@storybook/client-api',
'@storybook/blocks',
'@storybook/client-logger',
'fast-deep-equal',
'lodash',
'styled-components',
'lodash-es',
'lodash/isPlainObject',
'lodash/mapValues',
'lodash/pickBy',
'lodash/pick',
'lodash/startCase',
'lodash/isFunction',
'lodash/isString',
'util-deprecate',
'@storybook/csf',
'react-router',
'react-router-dom',
'global',
'synchronous-promise',
'memoizerific',
'stable',
'doctrine',
'html-tags',
'escodegen',
'acorn',
'prettier',
'@prettier/sync',
'acorn-jsx',
'@base2/pretty-print-object',
'prop-types',
'react-dom',
'qs',
'uuid-browser',
'uuid-browser/v4',
'jest-mock',
// '@snsw-gel/react',
],
},
define: {
'process.env.PATHNAME': JSON.stringify(process.env.PATHNAME || ""),
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
'process.env.STORYBOOK': JSON.stringify(true),
'PKG_NAME': JSON.stringify(''),
'PKG_VERSION': JSON.stringify(''),
'GEL_NAME': JSON.stringify(
require('../../react/package.json').name,
),
'GEL_VERSION': JSON.stringify(
require('../../react/package.json').version,
),
'SNAPSHOT_RELEASE': JSON.stringify(
/\d+\.\d+.\d+-.*/.test(
require('../../react/package.json').version,
),
),
},
// Your environment configuration here
plugins: [
{
enforce: 'post',
name: 'vite-plugin-resolve',
resolveId(id, requester, ...rest) {
if (id === 'package.json' && requester) {
let target = dirname(requester)
let resolved = ''
while (!resolved && target !== os.homedir()) {
let foundPackage = resolve(
target,
'package.json',
)
if (fs.existsSync(foundPackage)) {
resolved = foundPackage
} else {
target = dirname(target)
}
}
if (resolved) {
return resolved
}
}
if (id === '@snsw-gel/storybook') {
return require.resolve('../dist/esm/index.mjs')
}
let result
try {
result = require.resolve(id)
} catch (e) {
return null
}
const cachedResult = resolveCache.get(id)
let requesters = requestersCache.get(id)
if (!requesters) {
requesters = new Set()
requesters.add(requester!)
requestersCache.set(id, requesters)
}
if (cachedResult && cachedResult !== result) {
console.warn(
`Multiple requests resolving to different locations recieved for ${id} ${[
...requesters,
].join(', ')}`,
)
}
return result
},
},
],
})
},
framework: {
name: getAbsolutePath('@storybook/react-vite'),
options: {},
},
}
export default config
```
Expected behaviour Ideally the test runner would wait for the previous tests to finish before moving to the next story
Screenshots
In the above screenshot the test runner has rendered the next story "warning" before the the previous tests have finished
System
System: OS: macOS 13.6.2 CPU: (10) arm64 Apple M1 Pro Shell: 5.9 - /bin/zsh Binaries: Node: 18.19.0 - /private/var/folders/fn/6s5sc1b56pv0618wzc4k16v00000gq/T/xfs-0781bbb6/node Yarn: 4.0.2 - /private/var/folders/fn/6s5sc1b56pv0618wzc4k16v00000gq/T/xfs-0781bbb6/yarn <----- active npm: 10.2.3 - /usr/local/bin/npm Browsers: Chrome: 122.0.6261.129 Safari: 16.6
Additional context –