Skip to content
Open
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
14 changes: 14 additions & 0 deletions code/addons/vitest/src/vitest-plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -403,6 +404,19 @@ export const storybookTest = async (options?: UserOptions): Promise<Plugin[]> =>
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);
}
},
},
},
},

Expand Down
69 changes: 67 additions & 2 deletions code/addons/vitest/src/vitest-plugin/setup-file.test.ts
Original file line number Diff line number Diff line change
@@ -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__;
Expand Down Expand Up @@ -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);
});
});
55 changes: 54 additions & 1 deletion code/addons/vitest/src/vitest-plugin/setup-file.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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);
Loading