From 9529855ecf8365270b5221c89c4349f59b053b8c Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Mon, 11 May 2026 15:19:41 +0200 Subject: [PATCH 1/4] Vitest: Reset playwright cursor position to avoid hover bug --- code/addons/vitest/src/vitest-plugin/setup-file.ts | 10 +++++++++- .../vitest/templates/vitest.config.3.2.template.ts | 11 +++++++++++ .../vitest/templates/vitest.config.4.template.ts | 11 +++++++++++ .../addons/vitest/templates/vitest.config.template.ts | 11 +++++++++++ 4 files changed, 42 insertions(+), 1 deletion(-) diff --git a/code/addons/vitest/src/vitest-plugin/setup-file.ts b/code/addons/vitest/src/vitest-plugin/setup-file.ts index 5621698ab1bc..4016dc653c46 100644 --- a/code/addons/vitest/src/vitest-plugin/setup-file.ts +++ b/code/addons/vitest/src/vitest-plugin/setup-file.ts @@ -1,9 +1,11 @@ -import { afterEach, beforeAll, vi } from 'vitest'; +import { beforeEach, afterEach, beforeAll, vi } from 'vitest'; import type { RunnerTask } from 'vitest'; +import { commands } from 'vitest/browser'; import { Channel } from 'storybook/internal/channels'; import { COMPONENT_TESTING_PANEL_ID } from '../constants.ts'; +import { isFunction } from 'es-toolkit/predicate'; declare global { // eslint-disable-next-line @typescript-eslint/ban-ts-comment @@ -40,4 +42,10 @@ beforeAll(() => { } }); +beforeEach(async () => { + if ('resetMousePosition' in commands && isFunction(commands.resetMousePosition)) { + await commands.resetMousePosition(); + } +}); + afterEach(modifyErrorMessage); diff --git a/code/addons/vitest/templates/vitest.config.3.2.template.ts b/code/addons/vitest/templates/vitest.config.3.2.template.ts index 53a741e1caf4..ed30c1028274 100644 --- a/code/addons/vitest/templates/vitest.config.3.2.template.ts +++ b/code/addons/vitest/templates/vitest.config.3.2.template.ts @@ -2,12 +2,20 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { defineConfig } from 'vitest/config'; +import type { BrowserCommand } from 'vitest/node'; import { storybookTest } from '@storybook/addon-vitest/vitest-plugin'; const dirname = typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url)); +const resetMousePosition: BrowserCommand<[number, number]> = async (ctx) => { + if (ctx.provider.name !== 'playwright') + throw new Error('resetMousePosition requires the Playwright provider'); + const frame = await ctx.frame(); + await frame.page().mouse.move(-1000, -1000); +}; + // More info at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon export default defineConfig({ test: { @@ -26,6 +34,9 @@ export default defineConfig({ headless: true, provider: 'playwright', instances: [{ browser: 'chromium' }], + commands: { + resetMousePosition, + }, }, }, }, diff --git a/code/addons/vitest/templates/vitest.config.4.template.ts b/code/addons/vitest/templates/vitest.config.4.template.ts index 6448c6e98266..f21947ce006f 100644 --- a/code/addons/vitest/templates/vitest.config.4.template.ts +++ b/code/addons/vitest/templates/vitest.config.4.template.ts @@ -2,6 +2,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { defineConfig } from 'vitest/config'; +import type { BrowserCommand } from 'vitest/node'; import { storybookTest } from '@storybook/addon-vitest/vitest-plugin'; @@ -10,6 +11,13 @@ import { playwright } from '@vitest/browser-playwright'; const dirname = typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url)); +const resetMousePosition: BrowserCommand<[number, number]> = async (ctx) => { + if (ctx.provider.name !== 'playwright') + throw new Error('resetMousePosition requires the Playwright provider'); + const frame = await ctx.frame(); + await frame.page().mouse.move(-1000, -1000); +}; + // More info at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon export default defineConfig({ test: { @@ -28,6 +36,9 @@ export default defineConfig({ headless: true, provider: playwright({}), instances: [{ browser: 'chromium' }], + commands: { + resetMousePosition, + }, }, }, }, diff --git a/code/addons/vitest/templates/vitest.config.template.ts b/code/addons/vitest/templates/vitest.config.template.ts index 32bec29f773d..4cf0f88fa6e3 100644 --- a/code/addons/vitest/templates/vitest.config.template.ts +++ b/code/addons/vitest/templates/vitest.config.template.ts @@ -2,12 +2,20 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { defineConfig } from 'vitest/config'; +import type { BrowserCommand } from 'vitest/node'; import { storybookTest } from '@storybook/addon-vitest/vitest-plugin'; const dirname = typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url)); +const resetMousePosition: BrowserCommand<[number, number]> = async (ctx) => { + if (ctx.provider.name !== 'playwright') + throw new Error('resetMousePosition requires the Playwright provider'); + const frame = await ctx.frame(); + await frame.page().mouse.move(-1000, -1000); +}; + // More info at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon export default defineConfig({ test: { @@ -26,6 +34,9 @@ export default defineConfig({ headless: true, provider: 'playwright', instances: [{ browser: 'chromium' }], + commands: { + resetMousePosition, + }, }, }, }, From 82c355ada349f244f7d18649172c4c479470b27b Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Mon, 11 May 2026 15:53:43 +0200 Subject: [PATCH 2/4] Vitest: Inject command without touching user config --- code/addons/vitest/src/vitest-plugin/index.ts | 14 ++++++++++++++ .../vitest/templates/vitest.config.3.2.template.ts | 11 ----------- .../vitest/templates/vitest.config.4.template.ts | 11 ----------- .../vitest/templates/vitest.config.template.ts | 11 ----------- 4 files changed, 14 insertions(+), 33 deletions(-) diff --git a/code/addons/vitest/src/vitest-plugin/index.ts b/code/addons/vitest/src/vitest-plugin/index.ts index fce687c60e50..bf44f4a43ace 100644 --- a/code/addons/vitest/src/vitest-plugin/index.ts +++ b/code/addons/vitest/src/vitest-plugin/index.ts @@ -3,6 +3,7 @@ import { fileURLToPath } from 'node:url'; import type { Plugin } from 'vitest/config'; import { mergeConfig } from 'vitest/config'; import type { ViteUserConfig } from 'vitest/config'; +import type {} from '@vitest/browser-playwright'; import { DEFAULT_FILES_PATTERN, @@ -403,6 +404,19 @@ export const storybookTest = async (options?: UserOptions): Promise => screenshotFailures: false, } : {}), + + // Inject the cursor reset command we use to prevent accidental hover states when running + // Storybook tests in Chromium on Linux. There is a known race condition / special code path + // in Chromium causing it to sometimes apply :hover to the element under the mouse cursor even + // when there was no mouse movement. + commands: { + async resetMousePosition(ctx) { + if (ctx.provider.name === 'playwright') { + const frame = await ctx.frame(); + await frame.page().mouse.move(-1000, -1000); + } + }, + }, }, }, diff --git a/code/addons/vitest/templates/vitest.config.3.2.template.ts b/code/addons/vitest/templates/vitest.config.3.2.template.ts index ed30c1028274..53a741e1caf4 100644 --- a/code/addons/vitest/templates/vitest.config.3.2.template.ts +++ b/code/addons/vitest/templates/vitest.config.3.2.template.ts @@ -2,20 +2,12 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { defineConfig } from 'vitest/config'; -import type { BrowserCommand } from 'vitest/node'; import { storybookTest } from '@storybook/addon-vitest/vitest-plugin'; const dirname = typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url)); -const resetMousePosition: BrowserCommand<[number, number]> = async (ctx) => { - if (ctx.provider.name !== 'playwright') - throw new Error('resetMousePosition requires the Playwright provider'); - const frame = await ctx.frame(); - await frame.page().mouse.move(-1000, -1000); -}; - // More info at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon export default defineConfig({ test: { @@ -34,9 +26,6 @@ export default defineConfig({ headless: true, provider: 'playwright', instances: [{ browser: 'chromium' }], - commands: { - resetMousePosition, - }, }, }, }, diff --git a/code/addons/vitest/templates/vitest.config.4.template.ts b/code/addons/vitest/templates/vitest.config.4.template.ts index f21947ce006f..6448c6e98266 100644 --- a/code/addons/vitest/templates/vitest.config.4.template.ts +++ b/code/addons/vitest/templates/vitest.config.4.template.ts @@ -2,7 +2,6 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { defineConfig } from 'vitest/config'; -import type { BrowserCommand } from 'vitest/node'; import { storybookTest } from '@storybook/addon-vitest/vitest-plugin'; @@ -11,13 +10,6 @@ import { playwright } from '@vitest/browser-playwright'; const dirname = typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url)); -const resetMousePosition: BrowserCommand<[number, number]> = async (ctx) => { - if (ctx.provider.name !== 'playwright') - throw new Error('resetMousePosition requires the Playwright provider'); - const frame = await ctx.frame(); - await frame.page().mouse.move(-1000, -1000); -}; - // More info at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon export default defineConfig({ test: { @@ -36,9 +28,6 @@ export default defineConfig({ headless: true, provider: playwright({}), instances: [{ browser: 'chromium' }], - commands: { - resetMousePosition, - }, }, }, }, diff --git a/code/addons/vitest/templates/vitest.config.template.ts b/code/addons/vitest/templates/vitest.config.template.ts index 4cf0f88fa6e3..32bec29f773d 100644 --- a/code/addons/vitest/templates/vitest.config.template.ts +++ b/code/addons/vitest/templates/vitest.config.template.ts @@ -2,20 +2,12 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { defineConfig } from 'vitest/config'; -import type { BrowserCommand } from 'vitest/node'; import { storybookTest } from '@storybook/addon-vitest/vitest-plugin'; const dirname = typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url)); -const resetMousePosition: BrowserCommand<[number, number]> = async (ctx) => { - if (ctx.provider.name !== 'playwright') - throw new Error('resetMousePosition requires the Playwright provider'); - const frame = await ctx.frame(); - await frame.page().mouse.move(-1000, -1000); -}; - // More info at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon export default defineConfig({ test: { @@ -34,9 +26,6 @@ export default defineConfig({ headless: true, provider: 'playwright', instances: [{ browser: 'chromium' }], - commands: { - resetMousePosition, - }, }, }, }, From 88827c21e5989809658129d6d26982e22291479c Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Mon, 11 May 2026 16:23:16 +0200 Subject: [PATCH 3/4] Add tests --- .../src/vitest-plugin/setup-file.test.ts | 45 ++++++++++++++++++- .../vitest/src/vitest-plugin/setup-file.ts | 39 +++++++++++++--- 2 files changed, 76 insertions(+), 8 deletions(-) diff --git a/code/addons/vitest/src/vitest-plugin/setup-file.test.ts b/code/addons/vitest/src/vitest-plugin/setup-file.test.ts index 5f3540e5db05..67174075eb14 100644 --- a/code/addons/vitest/src/vitest-plugin/setup-file.test.ts +++ b/code/addons/vitest/src/vitest-plugin/setup-file.test.ts @@ -1,6 +1,6 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { type Task, modifyErrorMessage } from './setup-file.ts'; +import { type Task, modifyErrorMessage, resetMousePositionBeforeTests } from './setup-file.ts'; describe('modifyErrorMessage', () => { const originalUrl = import.meta.env.__STORYBOOK_URL__; @@ -77,3 +77,44 @@ describe('modifyErrorMessage', () => { expect(task.result?.errors?.[0].message).toBe('Non story test failure'); }); }); + +describe('resetMousePositionBeforeTests', () => { + afterEach(() => { + vi.clearAllMocks(); + vi.doUnmock('vitest/browser'); + }); + + it('should reset the mouse position when the browser command exists', async () => { + const resetMousePosition = vi.fn().mockResolvedValue(undefined); + + vi.doMock('vitest/browser', () => ({ + commands: { + resetMousePosition, + }, + })); + + await resetMousePositionBeforeTests(); + + expect(resetMousePosition).toHaveBeenCalledTimes(1); + }); + + it('should do nothing when resetMousePosition is not callable', async () => { + vi.doMock('vitest/browser', () => ({ + commands: { + resetMousePosition: 'not-a-function', + }, + })); + + await expect(resetMousePositionBeforeTests()).resolves.toBeUndefined(); + }); + + it('should rethrow unexpected errors', async () => { + const error = new Error('boom'); + + vi.doMock('vitest/browser', () => { + throw error; + }); + + await expect(resetMousePositionBeforeTests()).rejects.toThrow(); + }); +}); diff --git a/code/addons/vitest/src/vitest-plugin/setup-file.ts b/code/addons/vitest/src/vitest-plugin/setup-file.ts index 4016dc653c46..a1cf62e4c9cf 100644 --- a/code/addons/vitest/src/vitest-plugin/setup-file.ts +++ b/code/addons/vitest/src/vitest-plugin/setup-file.ts @@ -1,6 +1,5 @@ import { beforeEach, afterEach, beforeAll, vi } from 'vitest'; import type { RunnerTask } from 'vitest'; -import { commands } from 'vitest/browser'; import { Channel } from 'storybook/internal/channels'; @@ -36,16 +35,44 @@ export const modifyErrorMessage = ({ task }: { task: Task }) => { } }; +export const resetMousePositionBeforeTests = async () => { + try { + const { commands } = await import('vitest/browser'); + if ('resetMousePosition' in commands && isFunction(commands.resetMousePosition)) { + await commands.resetMousePosition(); + } + } catch (error) { + // Ignore when vitest/browser is not found not found (Vitest 3) + if (error instanceof Error && error.message.includes("Cannot find module 'vitest/browser'")) { + return; + } + // Ignore when commands is not exported by vitest/browser (Vitest 3) + if ( + error instanceof Error && + error.message.includes("Cannot destructure property 'commands'") + ) { + return; + } + + // Ignore "Error: vitest/browser can be imported only inside the Browser Mode." + if ( + error instanceof Error && + error.message.includes('vitest/browser can be imported only inside the Browser Mode') + ) { + return; + } + + // Throw anything else + throw error; + } +}; + beforeAll(() => { if (globalThis.globalProjectAnnotations) { return globalThis.globalProjectAnnotations.beforeAll(); } }); -beforeEach(async () => { - if ('resetMousePosition' in commands && isFunction(commands.resetMousePosition)) { - await commands.resetMousePosition(); - } -}); +beforeEach(resetMousePositionBeforeTests); afterEach(modifyErrorMessage); From 0d45949d4f6470e25dea570f064a0c1776883a53 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 12 May 2026 07:26:21 +0000 Subject: [PATCH 4/4] Add Vitest v3 browser context fallback for resetMousePosition Agent-Logs-Url: https://github.com/storybookjs/storybook/sessions/5f0cc737-4c5b-458c-9325-9a8e76a15c8e --- .../src/vitest-plugin/setup-file.test.ts | 24 ++++++++++ .../vitest/src/vitest-plugin/setup-file.ts | 44 +++++++++++++------ 2 files changed, 55 insertions(+), 13 deletions(-) diff --git a/code/addons/vitest/src/vitest-plugin/setup-file.test.ts b/code/addons/vitest/src/vitest-plugin/setup-file.test.ts index 67174075eb14..911fa566a7fe 100644 --- a/code/addons/vitest/src/vitest-plugin/setup-file.test.ts +++ b/code/addons/vitest/src/vitest-plugin/setup-file.test.ts @@ -82,6 +82,7 @@ describe('resetMousePositionBeforeTests', () => { afterEach(() => { vi.clearAllMocks(); vi.doUnmock('vitest/browser'); + vi.doUnmock('@vitest/browser/context'); }); it('should reset the mouse position when the browser command exists', async () => { @@ -117,4 +118,27 @@ describe('resetMousePositionBeforeTests', () => { await expect(resetMousePositionBeforeTests()).rejects.toThrow(); }); + + it('should fallback to vitest v3 browser context when vitest/browser is not found', async () => { + const resetMousePosition = vi.fn().mockResolvedValue(undefined); + + vi.doMock('vitest/browser', () => { + const browser = {}; + Object.defineProperty(browser, 'commands', { + get: () => { + throw new Error("Cannot find module 'vitest/browser'"); + }, + }); + return browser; + }); + vi.doMock('@vitest/browser/context', () => ({ + commands: { + resetMousePosition, + }, + })); + + await resetMousePositionBeforeTests(); + + expect(resetMousePosition).toHaveBeenCalledTimes(1); + }); }); diff --git a/code/addons/vitest/src/vitest-plugin/setup-file.ts b/code/addons/vitest/src/vitest-plugin/setup-file.ts index a1cf62e4c9cf..662a5051a6e7 100644 --- a/code/addons/vitest/src/vitest-plugin/setup-file.ts +++ b/code/addons/vitest/src/vitest-plugin/setup-file.ts @@ -37,27 +37,45 @@ export const modifyErrorMessage = ({ task }: { task: Task }) => { export const resetMousePositionBeforeTests = async () => { try { - const { commands } = await import('vitest/browser'); - if ('resetMousePosition' in commands && isFunction(commands.resetMousePosition)) { - await commands.resetMousePosition(); + const browserCommands = await import('vitest/browser').then((module) => module.commands); + if ('resetMousePosition' in browserCommands && isFunction(browserCommands.resetMousePosition)) { + await browserCommands.resetMousePosition(); } } catch (error) { - // Ignore when vitest/browser is not found not found (Vitest 3) + // Retry with Vitest 3 context module when vitest/browser is not found. if (error instanceof Error && error.message.includes("Cannot find module 'vitest/browser'")) { - return; - } - // Ignore when commands is not exported by vitest/browser (Vitest 3) - if ( - error instanceof Error && - error.message.includes("Cannot destructure property 'commands'") - ) { - return; + try { + const browserCommands = await import('@vitest/browser/context').then( + (module) => module.commands + ); + if ( + 'resetMousePosition' in browserCommands && + isFunction(browserCommands.resetMousePosition) + ) { + await browserCommands.resetMousePosition(); + } + return; + } catch (vitest3Error) { + if ( + vitest3Error instanceof Error && + vitest3Error.message.includes("Cannot find module '@vitest/browser/context'") + ) { + return; + } + if ( + vitest3Error instanceof Error && + vitest3Error.message.includes('can be imported only inside the Browser Mode') + ) { + return; + } + throw vitest3Error; + } } // Ignore "Error: vitest/browser can be imported only inside the Browser Mode." if ( error instanceof Error && - error.message.includes('vitest/browser can be imported only inside the Browser Mode') + error.message.includes('can be imported only inside the Browser Mode') ) { return; }