Skip to content
Closed
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
10 changes: 10 additions & 0 deletions code/addons/vitest/build-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,16 @@ const config: BuildEntries = {
entryPoint: './src/vitest-plugin/test-utils.ts',
dts: false,
},
{
exportEntries: ['./internal/vitest-context'],
entryPoint: './src/vitest-plugin/vitest-context.ts',
dts: false,
},
{
exportEntries: ['./internal/vitest-context-legacy'],
entryPoint: './src/vitest-plugin/vitest-context-legacy.ts',
dts: false,
},
],
node: [
{
Expand Down
14 changes: 3 additions & 11 deletions code/addons/vitest/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@
"./internal/global-setup": "./dist/vitest-plugin/global-setup.js",
"./internal/setup-file": "./dist/vitest-plugin/setup-file.js",
"./internal/test-utils": "./dist/vitest-plugin/test-utils.js",
"./internal/vitest-context": "./dist/vitest-plugin/vitest-context.js",
"./internal/vitest-context-legacy": "./dist/vitest-plugin/vitest-context-legacy.js",
"./manager": "./dist/manager.js",
"./package.json": "./package.json",
"./postinstall": "./dist/postinstall.js",
Expand Down Expand Up @@ -101,27 +103,17 @@
"tree-kill": "^1.2.2",
"ts-dedent": "^2.2.0",
"typescript": "^5.8.3",
"vitest": "^4.0.1"
"vitest": "^3.2.4"
},
"peerDependencies": {
"@vitest/browser": "^3.0.0 || ^4.0.0",
"@vitest/browser-playwright": "^4.0.0",
"@vitest/runner": "^3.0.0 || ^4.0.0",
"storybook": "workspace:^",
"vitest": "^3.0.0 || ^4.0.0"
},
"peerDependenciesMeta": {
"@vitest/browser": {
"optional": true
},
"@vitest/browser-playwright": {
"optional": true
},
"@vitest/runner": {
"optional": true
},
"vitest": {
"optional": true
}
},
"publishConfig": {
Expand Down
1 change: 0 additions & 1 deletion code/addons/vitest/src/node/reporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,6 @@ export class StorybookReporter implements Reporter {
this.ctx.state.idMap.clear();
this.ctx.state.errorsSet.clear();
// TODO: Remove this once we don't support Vitest < 4
// @ts-expect-error processTimeoutCauses does not exist in Vitest 4
this.ctx.state.processTimeoutCauses?.clear();
}
}
1 change: 0 additions & 1 deletion code/addons/vitest/src/node/vitest-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,6 @@ export class VitestManager {
}

private updateLastChanged(filepath: string) {
// @ts-expect-error `server` only exists in Vitest 3
this.vitest!.projects.forEach(({ browser, vite, server }) => {
if (server) {
const serverMods = server.moduleGraph.getModulesByFile(filepath);
Expand Down
9 changes: 6 additions & 3 deletions code/addons/vitest/src/postinstall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -468,11 +468,14 @@ export default async function postInstall(options: PostinstallOptions) {
logger.plain(` ${rootConfig}`);

const formattedContent = await formatFileContent(rootConfig, generate(target).code);
// Only add triple slash reference to vite.config files, not vitest.config files
// vitest.config files already have the vitest/config types available
const shouldAddReference = !configFileHasTypeReference && !vitestConfigFile;
await writeFile(
rootConfig,
configFileHasTypeReference
? formattedContent
: '/// <reference types="vitest/config" />\n' + formattedContent
shouldAddReference
? '/// <reference types="vitest/config" />\n' + formattedContent
: formattedContent
);
} else {
logErrors(
Expand Down
23 changes: 23 additions & 0 deletions code/addons/vitest/src/vitest-plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import type { Presets } from 'storybook/internal/types';
import { match } from 'micromatch';
import { dirname, join, normalize, relative, resolve, sep } from 'pathe';
import picocolors from 'picocolors';
import semver from 'semver';
import sirv from 'sirv';
import { dedent } from 'ts-dedent';

Expand Down Expand Up @@ -97,6 +98,16 @@ const mdxStubPlugin: Plugin = {
},
};

export const checkIsVitest4orHigher = async () => {
const vitestVersion = await import('vitest/package.json', { with: { type: 'json' } }).then(
(pkg) => {
return pkg.default.version;
}
);

return semver.major(vitestVersion) >= 4;
};

export const storybookTest = async (options?: UserOptions): Promise<Plugin[]> => {
const finalOptions = {
...defaultOptions,
Expand All @@ -123,6 +134,8 @@ export const storybookTest = async (options?: UserOptions): Promise<Plugin[]> =>
// We are overriding the environment variable to 'true' if vitest runs via @storybook/addon-vitest's backend
const isVitestStorybook = optionalEnvToBoolean(process.env.VITEST_STORYBOOK);

const isVitest4OrHigher = await checkIsVitest4orHigher();

const directories = {
configDir: finalOptions.configDir,
workingDir: WORKING_DIR,
Expand Down Expand Up @@ -175,6 +188,10 @@ export const storybookTest = async (options?: UserOptions): Promise<Plugin[]> =>
plugins.push(mdxStubPlugin);
}

const addonVitestPath = dirname(
fileURLToPath(import.meta.resolve('@storybook/addon-vitest/package.json'))
);

const storybookTestPlugin: Plugin = {
name: 'vite-plugin-storybook-test',
async transformIndexHtml(html) {
Expand Down Expand Up @@ -307,6 +324,11 @@ export const storybookTest = async (options?: UserOptions): Promise<Plugin[]> =>
),

resolve: {
alias: {
'@storybook/addon-vitest/internal/vitest-context': isVitest4OrHigher
? join(addonVitestPath, 'dist/vitest-plugin/vitest-context.js')
: join(addonVitestPath, 'dist/vitest-plugin/vitest-context-legacy.js'),
},
conditions: [
'storybook',
'stories',
Expand All @@ -325,6 +347,7 @@ export const storybookTest = async (options?: UserOptions): Promise<Plugin[]> =>
'@storybook/addon-vitest/internal/setup-file',
'@storybook/addon-vitest/internal/global-setup',
'@storybook/addon-vitest/internal/test-utils',
'@storybook/addon-vitest/internal/vitest-context',
...(frameworkName?.includes('react') || frameworkName?.includes('nextjs')
? ['react-dom/test-utils']
: []),
Expand Down
17 changes: 8 additions & 9 deletions code/addons/vitest/src/vitest-plugin/test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,11 @@ import { type RunnerTask, type TaskMeta, type TestContext } from 'vitest';
import { type Meta, type Story, getStoryChildren, isStory } from 'storybook/internal/csf';
import type { ComponentAnnotations, ComposedStoryFn, Renderer } from 'storybook/internal/types';

import { server } from '@vitest/browser/context';
import { type Report, composeStory, getCsfFactoryAnnotations } from 'storybook/preview-api';

import type { VitestBrowserContext } from './types';
import { setViewport } from './viewports';

declare module '@vitest/browser/context' {
interface BrowserCommands {
getInitialGlobals: () => Promise<Record<string, any>>;
}
}

const { getInitialGlobals } = server.commands;

/**
* Converts a file URL to a file path, handling URL encoding
*
Expand Down Expand Up @@ -49,6 +41,13 @@ export const testStory = (

const storyAnnotations = test ? test.input : annotations.story;

const { server } = (await import(
// @ts-expect-error - This is an internal alias that will be resolved by the vitest plugin at runtime
'@storybook/addon-vitest/internal/vitest-context'
)) as unknown as VitestBrowserContext;

const { getInitialGlobals } = server.commands;
Comment on lines +44 to +49

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Verify runtime alias resolution and consider adding error handling.

The dynamic import relies on the vitest plugin to resolve the '@storybook/addon-vitest/internal/vitest-context' alias at runtime. If the alias is not properly configured or the imported module doesn't match the expected VitestBrowserContext shape, the type assertion will fail silently, leading to unclear runtime errors when accessing server.commands.

Run the following script to verify that the vitest plugin properly sets up this alias:

Consider adding runtime error handling to provide clearer error messages if the import fails:

let server: VitestBrowserContext['server'];
try {
  const context = (await import(
    // @ts-expect-error - This is an internal alias that will be resolved by the vitest plugin at runtime
    '@storybook/addon-vitest/internal/vitest-context'
  )) as unknown as VitestBrowserContext;
  server = context.server;
} catch (error) {
  throw new Error(
    'Failed to load vitest context. Ensure the vitest plugin is properly configured.',
    { cause: error }
  );
}

const { getInitialGlobals } = server.commands;

🏁 Script executed:

#!/bin/bash
# Description: Verify the vitest-context alias is configured in the vitest plugin

# Find the vitest plugin configuration
echo "=== Searching for vitest-context alias configuration ==="
rg -n "vitest-context" --type=ts -C 3

echo ""
echo "=== Searching for alias resolution in vitest plugin ==="
rg -n "resolve.*alias" code/addons/vitest/src/vitest-plugin/ --type=ts -C 5

Length of output: 3824


🏁 Script executed:

#!/bin/bash
# Verify vitest-context exports and error handling patterns

echo "=== Checking vitest-context.ts exports ==="
cat code/addons/vitest/src/vitest-plugin/vitest-context.ts

echo ""
echo "=== Checking vitest-context-legacy.ts exports ==="
cat code/addons/vitest/src/vitest-plugin/vitest-context-legacy.ts

echo ""
echo "=== Checking full context of test-utils.ts function ==="
cat code/addons/vitest/src/vitest-plugin/test-utils.ts

Length of output: 3703


🏁 Script executed:

#!/bin/bash
# Verify the actual module exports and usage patterns

echo "=== Searching for other imports from vitest-context alias ==="
rg -n "@storybook/addon-vitest/internal/vitest-context" --type=ts -B2 -A2

echo ""
echo "=== Checking how @vitest/browser is imported elsewhere ==="
rg -n "from ['\"](vitest|@vitest)" code/addons/vitest/src/vitest-plugin/ --type=ts | head -20

echo ""
echo "=== Check if there's module augmentation in types.ts ==="
cat code/addons/vitest/src/vitest-plugin/types.ts | head -50

Length of output: 5022


Add error handling and verify vitest-context.ts exports destructurable members for Vitest 4+ compatibility.

The dynamic import relies on @storybook/addon-vitest/internal/vitest-context exporting a server property, but vitest-context.ts (Vitest 4+ path) only exports a named function getVitestBrowserContext and does not re-export destructurable members like vitest-context-legacy.ts does. This will cause a runtime error when test-utils.ts attempts to destructure { server } on Vitest 4+.

The pattern shown in viewports.ts with error handling demonstrates the appropriate defensive approach:

const { server } = (await import(
  '@storybook/addon-vitest/internal/vitest-context'
).catch(() => ({
  server: { commands: {} }
}))) as unknown as VitestBrowserContext;

const { getInitialGlobals } = server.commands;

Alternatively, ensure vitest-context.ts re-exports the required members like vitest-context-legacy.ts does by adding export * from 'vitest/browser';.

🤖 Prompt for AI Agents
In code/addons/vitest/src/vitest-plugin/test-utils.ts around lines 44-49 the
dynamic import assumes the module exports a destructurable `server` property
which breaks for Vitest 4+ (vitest-context.ts exports a named function only);
either add defensive error handling and a fallback object when importing (mirror
the pattern used in viewports.ts so the import catches failures and returns an
object with server: { commands: {} } to avoid destructure runtime errors) or
modify the Vitest 4+ module (vitest-context.ts) to re-export the expected
members (e.g., `export * from 'vitest/browser'` or explicitly re-export
`server`/commands) so the destructuring succeeds.


const composedStory = composeStory(
storyAnnotations,
annotations.meta!,
Expand Down
19 changes: 19 additions & 0 deletions code/addons/vitest/src/vitest-plugin/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,22 @@
import type { BrowserCommands, BrowserPage } from '@vitest/browser/context';

interface GetInitialGlobalsResult {
a11y?: {
manual?: boolean;
};
}

interface ExtendedBrowserCommands extends BrowserCommands {
getInitialGlobals?: () => Promise<GetInitialGlobalsResult>;
}

export interface VitestBrowserContext {
page: BrowserPage;
server: {
commands: ExtendedBrowserCommands;
};
}

export type UserOptions = {
/**
* The directory where the Storybook configuration is located, relative to the vitest
Expand Down
6 changes: 4 additions & 2 deletions code/addons/vitest/src/vitest-plugin/viewports.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { page } from '@vitest/browser/context';
// @ts-expect-error - This is an internal alias that will be resolved by the vitest plugin at runtime
import { page } from '@storybook/addon-vitest/internal/vitest-context';

import { INITIAL_VIEWPORTS } from 'storybook/viewport';

import {
Expand All @@ -10,7 +12,7 @@ import {
setViewport,
} from './viewports';

vi.mock('@vitest/browser/context', () => ({
vi.mock('@storybook/addon-vitest/internal/vitest-context', () => ({
page: {
viewport: vi.fn(),
},
Expand Down
7 changes: 5 additions & 2 deletions code/addons/vitest/src/vitest-plugin/viewports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { UnsupportedViewportDimensionError } from 'storybook/internal/preview-er
import { MINIMAL_VIEWPORTS } from 'storybook/viewport';
import type { ViewportMap } from 'storybook/viewport';

import type { VitestBrowserContext } from './types';

declare global {
// eslint-disable-next-line no-var
var __vitest_browser__: boolean;
Expand Down Expand Up @@ -66,9 +68,10 @@ export const setViewport = async (parameters: Parameters = {}, globals: Globals
defaultViewport = viewportsParam.defaultViewport;
}

const { page } = await import('@vitest/browser/context').catch(() => ({
// @ts-expect-error - This is an internal alias that will be resolved by the vitest plugin at runtime
const { page } = (await import('@storybook/addon-vitest/internal/vitest-context').catch(() => ({
page: null,
}));
}))) as unknown as VitestBrowserContext;

if (!page || !globalThis.__vitest_browser__) {
return;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from '@vitest/browser/context';
3 changes: 3 additions & 0 deletions code/addons/vitest/src/vitest-plugin/vitest-context-modern.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore (vitest/browser is vitest 4 only, and we're using vitest 3 locally)
export * from 'vitest/browser';
26 changes: 26 additions & 0 deletions code/addons/vitest/src/vitest-plugin/vitest-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import semver from 'semver';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Replace version sniffing with feature detection to improve robustness.

The current approach uses JSON import attributes and semver to detect Vitest version, but this has several drawbacks:

  1. Import attributes brittleness: JSON import attributes (with: { type: 'json' }) are not universally supported across all Node versions and bundlers, and the module shape may vary (direct properties vs default.version).
  2. Runtime overhead: Adding semver as a runtime dependency just for version detection is heavy.
  3. Maintenance burden: Version checks require updates when Vitest changes its versioning scheme.

A previous review comment suggested using feature detection instead, which is more robust and eliminates the semver dependency.

Apply the feature detection approach suggested in the prior review:

-import semver from 'semver';
-
 type VitestBrowserContext = typeof import('@vitest/browser/context');
 
 type VitestServerContext = VitestBrowserContext & {
   server: {
     commands: typeof import('@vitest/browser/context').server.commands & {
       getInitialGlobals: () => Promise<Record<string, any>>;
     };
   };
 };
 
-/** Gets the Vitest browser context based on which version of Vitest is installed. */
+/** Gets the Vitest browser context by feature detection (v4 first, then v3). */
 export const getVitestBrowserContext = async (): Promise<VitestServerContext> => {
-  const vitestVersion = await import('vitest/package.json', { with: { type: 'json' } }).then(
-    (v) => v.version
-  );
-
-  if (semver.major(vitestVersion) >= 4) {
-    return import('vitest/browser') as unknown as Promise<VitestServerContext>;
+  try {
+    return (await import('vitest/browser')) as unknown as VitestServerContext;
+  } catch {
+    return (await import('@vitest/browser/context')) as unknown as VitestServerContext;
   }
-
-  return import('@vitest/browser/context') as Promise<VitestServerContext>;
 };

Optionally, add caching to avoid repeated dynamic imports:

let _ctxPromise: Promise<VitestServerContext> | undefined;

export const getVitestBrowserContext = async (): Promise<VitestServerContext> => {
  if (_ctxPromise) return _ctxPromise;
  _ctxPromise = (async () => {
    try {
      return (await import('vitest/browser')) as unknown as VitestServerContext;
    } catch {
      return (await import('@vitest/browser/context')) as unknown as VitestServerContext;
    }
  })();
  return _ctxPromise;
};

Also applies to: 15-20

🤖 Prompt for AI Agents
In code/addons/vitest/src/vitest-plugin/vitest-context.ts around lines 1 and
15-20, replace the current Vitest version-sniffing logic (including the semver
dependency and JSON import-attributes) with feature-detection via attempting to
import the newer module path first and falling back to the older one; implement
an async cached loader (a top-level Promise variable) so repeated calls reuse
the same dynamic import result; remove the semver import and any JSON-with-type
import logic and update exports to use the resolved module shape directly as the
VitestServerContext.


type VitestBrowserContext = typeof import('@vitest/browser/context');

type VitestServerContext = VitestBrowserContext & {
server: {
commands: typeof import('@vitest/browser/context').server.commands & {
getInitialGlobals: () => Promise<Record<string, any>>;
};
};
};

/** Gets the Vitest browser context based on which version of Vitest is installed. */
export const getVitestBrowserContext = async (): Promise<VitestServerContext> => {
const vitestVersion = await import('vitest/package.json', { with: { type: 'json' } }).then(
(v) => v.version
);

if (semver.major(vitestVersion) >= 4) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore (vitest/browser is vitest 4 only, and we're using vitest 3 locally)
return import('vitest/browser') as unknown as Promise<VitestServerContext>;
}

return import('@vitest/browser/context') as Promise<VitestServerContext>;
};
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,23 @@ export const viteInjectMockerRuntime = (options: {
config() {
return {
optimizeDeps: {
include: ['@vitest/mocker', '@vitest/mocker/browser'],
include: [
//
'@vitest/mocker',
'@vitest/mocker/browser',
'@vitest/mocker/node',
],
exclude: [
//
'fsevents',
],
},
resolve: {
// Aliasing necessary for package managers like pnpm, since resolving modules from a virtual module
// leads to errors, if the imported module is not a dependency of the project.
// By resolving the module to the real path, we can avoid this issue.
alias: {
'@vitest/mocker/node': fileURLToPath(import.meta.resolve('@vitest/mocker/node')),
'@vitest/mocker/browser': fileURLToPath(import.meta.resolve('@vitest/mocker/browser')),
'@vitest/mocker': fileURLToPath(import.meta.resolve('@vitest/mocker')),
},
Expand Down
Loading