Skip to content
Merged
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
11 changes: 4 additions & 7 deletions code/addons/vitest/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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": {
Expand All @@ -120,9 +120,6 @@
"@vitest/browser-playwright": {
"optional": true
},
"@vitest/runner": {
"optional": true
},
"vitest": {
"optional": true
}
Expand Down
88 changes: 88 additions & 0 deletions code/addons/vitest/src/node/coverage-reporter.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
17 changes: 4 additions & 13 deletions code/addons/vitest/src/node/coverage-reporter.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import type { ResolvedCoverageOptions } from 'vitest/node';

import type { ReportNode, Visitor } from 'istanbul-lib-report';
import { ReportBase } from 'istanbul-lib-report';

Expand All @@ -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<Visitor> {
#testManager: StorybookCoverageReporterOptions['testManager'];

#coverageOptions: StorybookCoverageReporterOptions['coverageOptions'];

constructor(opts: StorybookCoverageReporterOptions) {
super();
this.#testManager = opts.testManager;
this.#coverageOptions = opts.coverageOptions;
}

onSummary(node: ReportNode) {
Expand All @@ -32,7 +26,7 @@ class StorybookCoverageReporter extends ReportBase implements Partial<Visitor> {

// 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,
Expand All @@ -47,10 +41,7 @@ class StorybookCoverageReporter extends ReportBase implements Partial<Visitor> {
}
}

/**
* 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' };
6 changes: 2 additions & 4 deletions code/addons/vitest/src/node/reporter.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
36 changes: 34 additions & 2 deletions code/addons/vitest/src/node/test-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -52,17 +53,22 @@ 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
const createVitest = mockCreateVitest;

beforeEach(() => {
vi.clearAllMocks();
mockVitestVersion.value = '4.1.6';
mockStore.setState(() => storeOptions.initialState);
vitest.projects = [{}];
vitest.config.coverage.enabled = false;
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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();

Expand All @@ -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$`)
);
});

Expand Down
22 changes: 11 additions & 11 deletions code/addons/vitest/src/node/vitest-manager.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -44,6 +38,8 @@ const getTestName = (name: string) => `${name}${DOUBLE_SPACES}`;
export class VitestManager {
vitest: Vitest | null = null;

private testNameSeparator = ' ';

vitestStartupCounter = 0;

vitestRestartPromise: Promise<void> | null = null;
Expand All @@ -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 = (
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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)}$`;
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
Loading
Loading