diff --git a/code/addons/vitest/package.json b/code/addons/vitest/package.json index 36fa494c0944..f00228506889 100644 --- a/code/addons/vitest/package.json +++ b/code/addons/vitest/package.json @@ -88,10 +88,11 @@ "@types/node": "^22.19.1", "@types/semver": "^7.7.1", "@vitest/browser-playwright": "^4.1.6", - "@vitest/runner": "^4.1.6", + "@vitest/istanbul-lib-report": "^1.0.1", "empathic": "^2.0.0", "es-toolkit": "^1.43.0", "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", "micromatch": "^4.0.8", "pathe": "^1.1.2", "picocolors": "^1.1.0", @@ -107,11 +108,10 @@ "vitest": "^4.1.6" }, "peerDependencies": { - "@vitest/browser": "^3.0.0 || ^4.0.0 || ^5.0.0", + "@vitest/browser": "^4.0.0 || ^5.0.0", "@vitest/browser-playwright": "^4.0.0 || ^5.0.0", - "@vitest/runner": "^3.0.0 || ^4.0.0 || ^5.0.0", "storybook": "workspace:^", - "vitest": "^3.0.0 || ^4.0.0 || ^5.0.0" + "vitest": "^4.0.0 || ^5.0.0" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -120,9 +120,6 @@ "@vitest/browser-playwright": { "optional": true }, - "@vitest/runner": { - "optional": true - }, "vitest": { "optional": true } diff --git a/code/addons/vitest/src/node/coverage-reporter.test.ts b/code/addons/vitest/src/node/coverage-reporter.test.ts new file mode 100644 index 000000000000..22ec3e817466 --- /dev/null +++ b/code/addons/vitest/src/node/coverage-reporter.test.ts @@ -0,0 +1,88 @@ +import { execFile } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; + +import { expect, it, vi } from 'vitest'; + +import type { ReportNode } from 'istanbul-lib-report'; + +import StorybookCoverageReporter from './coverage-reporter.ts'; +import type { TestManager } from './test-manager.ts'; + +const BUILT = existsSync(new URL('../../dist/node/coverage-reporter.js', import.meta.url)); + +it.runIf(process.env.CI)('is built before the loader tests run', () => { + expect(BUILT).toBe(true); +}); + +it.runIf(BUILT).each([ + ['Vitest 4', "require('istanbul-reports').create"], + ['Vitest 5', "(await import('@vitest/istanbul-lib-report')).createAsync"], +])('loads the built coverage reporter through the %s loader', async (_version, loader) => { + await expect( + promisify(execFile)( + process.execPath, + [ + '--input-type=module', + '--eval', + ` + import { strict as assert } from 'node:assert'; + import { createRequire } from 'node:module'; + const require = createRequire(import.meta.url); + const summaries = []; + const reporter = await (${loader})( + '@storybook/addon-vitest/internal/coverage-reporter', + { + testManager: { + onCoverageCollected: (summary) => summaries.push(summary), + vitestManager: { vitest: { config: { coverage: { watermarks: { statements: [50, 90] } } } } }, + }, + } + ); + reporter.onSummary({ + isRoot: () => true, + getCoverageSummary: () => ({ data: { statements: { pct: 87.4 } } }), + }); + assert.deepEqual(summaries, [{ percentage: 87, status: 'warning' }]); + `, + ], + { cwd: fileURLToPath(new URL('../../', import.meta.url)) } + ) + ).resolves.toMatchObject({ stdout: '' }); +}); + +it.each([ + { percentage: 49, watermarks: [50, 90], expected: { percentage: 49, status: 'negative' } }, + { percentage: 50, watermarks: [50, 90], expected: { percentage: 50, status: 'warning' } }, + { percentage: 89.5, watermarks: [50, 90], expected: { percentage: 90, status: 'positive' } }, + { percentage: 79.4, watermarks: undefined, expected: { percentage: 79, status: 'warning' } }, + { percentage: 80, watermarks: [], expected: { percentage: 80, status: 'positive' } }, + { percentage: 60, watermarks: [60], expected: { percentage: 60, status: 'warning' } }, +])( + 'reports $expected for $percentage with watermarks $watermarks', + ({ percentage, watermarks, expected }) => { + const onCoverageCollected = vi.fn(); + const testManager = { + onCoverageCollected, + vitestManager: { + vitest: { config: { coverage: { watermarks: { statements: watermarks } } } }, + }, + } as unknown as TestManager; + const reporter = new StorybookCoverageReporter({ testManager }); + reporter.onSummary({ + isRoot: () => true, + getCoverageSummary: () => ({ data: { statements: { pct: percentage } } }), + } as unknown as ReportNode); + expect(onCoverageCollected).toHaveBeenCalledExactlyOnceWith(expected); + } +); + +it('ignores nested coverage summaries', () => { + const onCoverageCollected = vi.fn(); + const reporter = new StorybookCoverageReporter({ + testManager: { onCoverageCollected } as unknown as TestManager, + }); + reporter.onSummary({ isRoot: () => false } as ReportNode); + expect(onCoverageCollected).not.toHaveBeenCalled(); +}); diff --git a/code/addons/vitest/src/node/coverage-reporter.ts b/code/addons/vitest/src/node/coverage-reporter.ts index 118d1730c59f..d7e649a2b29a 100644 --- a/code/addons/vitest/src/node/coverage-reporter.ts +++ b/code/addons/vitest/src/node/coverage-reporter.ts @@ -1,5 +1,3 @@ -import type { ResolvedCoverageOptions } from 'vitest/node'; - import type { ReportNode, Visitor } from 'istanbul-lib-report'; import { ReportBase } from 'istanbul-lib-report'; @@ -8,18 +6,14 @@ import type { TestManager } from './test-manager.ts'; export type StorybookCoverageReporterOptions = { testManager: TestManager; - coverageOptions: ResolvedCoverageOptions | undefined; }; class StorybookCoverageReporter extends ReportBase implements Partial { #testManager: StorybookCoverageReporterOptions['testManager']; - #coverageOptions: StorybookCoverageReporterOptions['coverageOptions']; - constructor(opts: StorybookCoverageReporterOptions) { super(); this.#testManager = opts.testManager; - this.#coverageOptions = opts.coverageOptions; } onSummary(node: ReportNode) { @@ -32,7 +26,7 @@ class StorybookCoverageReporter extends ReportBase implements Partial { // Fallback to Vitest's default watermarks https://vitest.dev/config/#coverage-watermarks const [lowWatermark = 50, highWatermark = 80] = - this.#coverageOptions?.watermarks?.statements ?? []; + this.#testManager.vitestManager.vitest?.config.coverage.watermarks?.statements ?? []; const coverageSummary: StoreState['currentRun']['coverageSummary'] = { percentage, @@ -47,10 +41,7 @@ class StorybookCoverageReporter extends ReportBase implements Partial { } } -/** - * This is exported weirdly because the coverage tool loading this uses `Cont = require(name)`. So - * it doesn't support a default export, it has to be the "root" export as CJS - * - * @see https://nodejs.org/docs/latest-v20.x/api/modules.html#loading-ecmascript-modules-using-require - */ +export default StorybookCoverageReporter; + +// Vitest 4 load custom coverage reporters with require(name). export { StorybookCoverageReporter as 'module.exports' }; diff --git a/code/addons/vitest/src/node/reporter.ts b/code/addons/vitest/src/node/reporter.ts index 493541170323..d729d987e289 100644 --- a/code/addons/vitest/src/node/reporter.ts +++ b/code/addons/vitest/src/node/reporter.ts @@ -1,8 +1,6 @@ -import type { SerializedError } from 'vitest'; -import type { TestCase, TestModule, Vitest } from 'vitest/node'; -import { type Reporter } from 'vitest/reporters'; +import type { SerializedError, TaskMeta } from 'vitest'; +import type { Reporter, TestCase, TestModule, Vitest } from 'vitest/node'; -import type { TaskMeta } from '@vitest/runner'; import type { Report } from 'storybook/preview-api'; import type { VitestError } from '../types.ts'; diff --git a/code/addons/vitest/src/node/test-manager.test.ts b/code/addons/vitest/src/node/test-manager.test.ts index 7c2a61fcd4a7..8915d7d3dcfd 100644 --- a/code/addons/vitest/src/node/test-manager.test.ts +++ b/code/addons/vitest/src/node/test-manager.test.ts @@ -25,6 +25,7 @@ import { DOUBLE_SPACES } from './vitest-manager.ts'; const setTestNamePattern = vi.hoisted(() => vi.fn()); const vitest = vi.hoisted(() => ({ projects: [{}], + standalone: vi.fn(), init: vi.fn(), close: vi.fn(), onCancel: vi.fn(), @@ -52,10 +53,14 @@ const vitest = vi.hoisted(() => ({ }, })); +const mockVitestVersion = vi.hoisted(() => ({ value: '4.1.6' })); const mockCreateVitest = vi.fn(); vi.mock('vitest/node', () => ({ createVitest: mockCreateVitest, + get version() { + return mockVitestVersion.value; + }, })); // Use the mock function directly @@ -63,6 +68,7 @@ const createVitest = mockCreateVitest; beforeEach(() => { vi.clearAllMocks(); + mockVitestVersion.value = '4.1.6'; mockStore.setState(() => storeOptions.initialState); vitest.projects = [{}]; vitest.config.coverage.enabled = false; @@ -194,6 +200,28 @@ const options: TestManagerOptions = { }; describe('TestManager', () => { + it('initializes Vitest without running tests', async () => { + await TestManager.start(options); + expect(vitest.standalone).toHaveBeenCalledOnce(); + expect(vitest.init).not.toHaveBeenCalled(); + expect(vitest.runTestSpecifications).not.toHaveBeenCalled(); + }); + + it.each(['3.2.4', '4.0.0'])( + 'initializes Vitest %s without standalone or running tests', + async (version) => { + mockVitestVersion.value = version; + const { standalone, ...legacyVitest } = vitest; + createVitest.mockResolvedValue(legacyVitest); + + await TestManager.start(options); + + expect(legacyVitest.init).toHaveBeenCalledOnce(); + expect(standalone).not.toHaveBeenCalled(); + expect(legacyVitest.runTestSpecifications).not.toHaveBeenCalled(); + } + ); + it('should create a vitest instance', async () => { new TestManager(options); await vi.waitFor(() => { @@ -490,7 +518,11 @@ describe('TestManager', () => { expect(setTestNamePattern).toHaveBeenCalledWith(new RegExp(`^One$`)); }); - it('should trigger a single story test', async () => { + it.each([ + ['4.1.6', ' '], + ['5.0.0', ' > '], + ])('should trigger a single story test with Vitest %s', async (version, separator) => { + mockVitestVersion.value = version; vitest.globTestSpecifications.mockImplementation(() => tests); const testManager = await startWithIndex(); @@ -503,7 +535,7 @@ describe('TestManager', () => { }); // regex should be Parent Story Name + Test Name expect(setTestNamePattern).toHaveBeenCalledWith( - new RegExp(`^Parent story${DOUBLE_SPACES} Test name$`) + new RegExp(`^Parent story${DOUBLE_SPACES}${separator}Test name$`) ); }); diff --git a/code/addons/vitest/src/node/vitest-manager.ts b/code/addons/vitest/src/node/vitest-manager.ts index c58b0ce2023d..95411ddbe37b 100644 --- a/code/addons/vitest/src/node/vitest-manager.ts +++ b/code/addons/vitest/src/node/vitest-manager.ts @@ -1,12 +1,6 @@ import { existsSync, readFileSync } from 'node:fs'; -import type { - CoverageOptions, - ResolvedCoverageOptions, - TestProject, - TestSpecification, - Vitest, -} from 'vitest/node'; +import type { CoverageOptions, TestProject, TestSpecification, Vitest } from 'vitest/node'; import { getProjectRoot, resolvePathInStorybookCache } from 'storybook/internal/common'; import { Tag } from 'storybook/internal/core-server'; @@ -44,6 +38,8 @@ const getTestName = (name: string) => `${name}${DOUBLE_SPACES}`; export class VitestManager { vitest: Vitest | null = null; + private testNameSeparator = ' '; + vitestStartupCounter = 0; vitestRestartPromise: Promise | null = null; @@ -53,13 +49,13 @@ export class VitestManager { constructor(private testManager: TestManager) {} async startVitest({ coverage }: { coverage: boolean }) { - const { createVitest } = await import('vitest/node'); + const { createVitest, version } = await import('vitest/node'); + this.testNameSeparator = Number.parseInt(version, 10) >= 5 ? ' > ' : ' '; const storybookCoverageReporter: [string, StorybookCoverageReporterOptions] = [ '@storybook/addon-vitest/internal/coverage-reporter', { testManager: this.testManager, - coverageOptions: this.vitest?.config?.coverage as ResolvedCoverageOptions | undefined, }, ]; const coverageOptions = ( @@ -159,7 +155,11 @@ export class VitestManager { } try { - await this.vitest.init(); + if (typeof this.vitest.standalone === 'function') { + await this.vitest.standalone(); + } else { + await this.vitest.init(); + } } catch (e: any) { let message = 'Failed to initialize Vitest'; const isV8 = e.message?.includes('@vitest/coverage-v8'); @@ -261,7 +261,7 @@ export class VitestManager { throw new Error(`Parent story not found for story ${story.id}`); } - return `^${escapeRegExp(getTestName(parentStory.name))} ${escapeRegExp(story.name)}$`; + return `^${escapeRegExp(getTestName(parentStory.name))}${this.testNameSeparator}${escapeRegExp(story.name)}$`; } return `^${escapeRegExp(story.name)}$`; diff --git a/code/addons/vitest/src/vitest-plugin/agent-telemetry-reporter.ts b/code/addons/vitest/src/vitest-plugin/agent-telemetry-reporter.ts index b8092e16ff91..c0196d95d8b9 100644 --- a/code/addons/vitest/src/vitest-plugin/agent-telemetry-reporter.ts +++ b/code/addons/vitest/src/vitest-plugin/agent-telemetry-reporter.ts @@ -1,8 +1,6 @@ -import type { SerializedError } from 'vitest'; -import type { TestCase, TestModule, Vitest } from 'vitest/node'; -import type { Reporter } from 'vitest/reporters'; +import type { SerializedError, TaskMeta } from 'vitest'; +import type { Reporter, TestCase, TestModule, Vitest } from 'vitest/node'; -import type { TaskMeta } from '@vitest/runner'; import type { Report } from 'storybook/preview-api'; import { analyzeTestResults, toStoryTestResult } from 'storybook/internal/core-server'; import type { StoryTestResult } from 'storybook/internal/core-server'; diff --git a/yarn.lock b/yarn.lock index 6dc970211b36..bdfe50b6970d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9991,10 +9991,11 @@ __metadata: "@types/node": "npm:^22.19.1" "@types/semver": "npm:^7.7.1" "@vitest/browser-playwright": "npm:^4.1.6" - "@vitest/runner": "npm:^4.1.6" + "@vitest/istanbul-lib-report": "npm:^1.0.1" empathic: "npm:^2.0.0" es-toolkit: "npm:^1.43.0" istanbul-lib-report: "npm:^3.0.1" + istanbul-reports: "npm:^3.2.0" micromatch: "npm:^4.0.8" pathe: "npm:^1.1.2" picocolors: "npm:^1.1.0" @@ -10009,18 +10010,15 @@ __metadata: typescript: "npm:^6.0.3" vitest: "npm:^4.1.6" peerDependencies: - "@vitest/browser": ^3.0.0 || ^4.0.0 || ^5.0.0 + "@vitest/browser": ^4.0.0 || ^5.0.0 "@vitest/browser-playwright": ^4.0.0 || ^5.0.0 - "@vitest/runner": ^3.0.0 || ^4.0.0 || ^5.0.0 storybook: "workspace:^" - vitest: ^3.0.0 || ^4.0.0 || ^5.0.0 + vitest: ^4.0.0 || ^5.0.0 peerDependenciesMeta: "@vitest/browser": optional: true "@vitest/browser-playwright": optional: true - "@vitest/runner": - optional: true vitest: optional: true languageName: unknown @@ -14381,6 +14379,22 @@ __metadata: languageName: node linkType: hard +"@vitest/istanbul-lib-coverage@npm:1.0.2": + version: 1.0.2 + resolution: "@vitest/istanbul-lib-coverage@npm:1.0.2" + checksum: 10c0/c839315f8c54fb1eb7d51c61cd908f78c2909bbd17ee0d3d8824764f7eed2785471c40ab29f065eee18b577b8275ece8d4100f0ca6c7a05a39a4128683c57292 + languageName: node + linkType: hard + +"@vitest/istanbul-lib-report@npm:^1.0.1": + version: 1.0.2 + resolution: "@vitest/istanbul-lib-report@npm:1.0.2" + dependencies: + "@vitest/istanbul-lib-coverage": "npm:1.0.2" + checksum: 10c0/9b0b3c898448dfe99a51a51238fd564efb1efdfd3dc46570ad6b51a592c9f26811de7be6d07756782d4d0ee71d2431b0592c63be0921f7776bf213a22d87f4d3 + languageName: node + linkType: hard + "@vitest/mocker@npm:3.2.4": version: 3.2.4 resolution: "@vitest/mocker@npm:3.2.4" @@ -14475,16 +14489,6 @@ __metadata: languageName: node linkType: hard -"@vitest/runner@npm:^4.1.6": - version: 4.1.11 - resolution: "@vitest/runner@npm:4.1.11" - dependencies: - "@vitest/utils": "npm:4.1.11" - pathe: "npm:^2.0.3" - checksum: 10c0/3c782b055e9e688e1785f7c8937bd1669bad1b0e5758cb8b844f918f30a324b1d21e174d46c941ce80ebf37f2b89b55b6d619a675025914a1ece6377b95909e2 - languageName: node - linkType: hard - "@vitest/snapshot@npm:4.1.10": version: 4.1.10 resolution: "@vitest/snapshot@npm:4.1.10"