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/src/vitest-plugin/setup-file.test.ts b/code/addons/vitest/src/vitest-plugin/setup-file.test.ts index 5f3540e5db05..911fa566a7fe 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,68 @@ describe('modifyErrorMessage', () => { expect(task.result?.errors?.[0].message).toBe('Non story test failure'); }); }); + +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 () => { + 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(); + }); + + 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 5621698ab1bc..662a5051a6e7 100644 --- a/code/addons/vitest/src/vitest-plugin/setup-file.ts +++ b/code/addons/vitest/src/vitest-plugin/setup-file.ts @@ -1,9 +1,10 @@ -import { afterEach, beforeAll, vi } from 'vitest'; +import { beforeEach, afterEach, beforeAll, vi } from 'vitest'; import type { RunnerTask } from 'vitest'; 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 @@ -34,10 +35,62 @@ export const modifyErrorMessage = ({ task }: { task: Task }) => { } }; +export const resetMousePositionBeforeTests = async () => { + try { + const browserCommands = await import('vitest/browser').then((module) => module.commands); + if ('resetMousePosition' in browserCommands && isFunction(browserCommands.resetMousePosition)) { + await browserCommands.resetMousePosition(); + } + } catch (error) { + // Retry with Vitest 3 context module when vitest/browser is not found. + if (error instanceof Error && error.message.includes("Cannot find module 'vitest/browser'")) { + 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('can be imported only inside the Browser Mode') + ) { + return; + } + + // Throw anything else + throw error; + } +}; + beforeAll(() => { if (globalThis.globalProjectAnnotations) { return globalThis.globalProjectAnnotations.beforeAll(); } }); +beforeEach(resetMousePositionBeforeTests); + afterEach(modifyErrorMessage);