mirror of
https://github.com/storybookjs/storybook.git
synced 2025-04-07 07:21:17 +08:00
424 lines
14 KiB
TypeScript
424 lines
14 KiB
TypeScript
import { existsSync } from 'node:fs';
|
|
|
|
import type {
|
|
CoverageOptions,
|
|
ResolvedCoverageOptions,
|
|
TestProject,
|
|
TestRunResult,
|
|
TestSpecification,
|
|
Vitest,
|
|
WorkspaceProject,
|
|
} from 'vitest/node';
|
|
import * as vitestNode from 'vitest/node';
|
|
|
|
import { resolvePathInStorybookCache } from 'storybook/internal/common';
|
|
import type { TestingModuleRunRequestPayload } from 'storybook/internal/core-events';
|
|
import type { DocsIndexEntry, StoryIndex, StoryIndexEntry } from 'storybook/internal/types';
|
|
|
|
import { findUp } from 'find-up';
|
|
import path, { dirname, join, normalize } from 'pathe';
|
|
import { satisfies } from 'semver';
|
|
import slash from 'slash';
|
|
|
|
import { COVERAGE_DIRECTORY } from '../constants';
|
|
import { log } from '../logger';
|
|
import type { StorybookCoverageReporterOptions } from './coverage-reporter';
|
|
import { StorybookReporter } from './reporter';
|
|
import type { TestManager } from './test-manager';
|
|
|
|
const VITEST_CONFIG_FILE_EXTENSIONS = ['mts', 'mjs', 'cts', 'cjs', 'ts', 'tsx', 'js', 'jsx'];
|
|
const VITEST_WORKSPACE_FILE_EXTENSION = ['ts', 'js', 'json'];
|
|
|
|
type TagsFilter = {
|
|
include: string[];
|
|
exclude: string[];
|
|
skip: string[];
|
|
};
|
|
|
|
const packageDir = dirname(require.resolve('@storybook/addon-test/package.json'));
|
|
|
|
const vitestVersion = vitestNode.version;
|
|
const isVitest3OrLater = vitestVersion
|
|
? satisfies(vitestVersion, '>=3.0.0-beta.3', { includePrerelease: true })
|
|
: false;
|
|
|
|
// We have to tell Vitest that it runs as part of Storybook
|
|
process.env.VITEST_STORYBOOK = 'true';
|
|
|
|
export class VitestManager {
|
|
vitest: Vitest | null = null;
|
|
|
|
vitestStartupCounter = 0;
|
|
|
|
vitestRestartPromise: Promise<void> | null = null;
|
|
|
|
storyCountForCurrentRun: number = 0;
|
|
|
|
runningPromise: Promise<any> | null = null;
|
|
|
|
isCancelling = false;
|
|
|
|
constructor(private testManager: TestManager) {}
|
|
|
|
async startVitest({ coverage = false } = {}) {
|
|
const { createVitest } = await import('vitest/node');
|
|
|
|
const storybookCoverageReporter: [string, StorybookCoverageReporterOptions] = [
|
|
join(packageDir, 'dist/node/coverage-reporter.js'),
|
|
{
|
|
testManager: this.testManager,
|
|
coverageOptions: this.vitest?.config?.coverage as ResolvedCoverageOptions<'v8'> | undefined,
|
|
},
|
|
];
|
|
const coverageOptions = (
|
|
coverage
|
|
? {
|
|
enabled: true,
|
|
clean: false,
|
|
cleanOnRerun: false,
|
|
reportOnFailure: true,
|
|
reporter: [['html', {}], storybookCoverageReporter],
|
|
reportsDirectory: resolvePathInStorybookCache(COVERAGE_DIRECTORY),
|
|
}
|
|
: { enabled: false }
|
|
) as CoverageOptions;
|
|
|
|
const vitestWorkspaceConfig = await findUp([
|
|
...VITEST_WORKSPACE_FILE_EXTENSION.map((ext) => `vitest.workspace.${ext}`),
|
|
...VITEST_CONFIG_FILE_EXTENSIONS.map((ext) => `vitest.config.${ext}`),
|
|
]);
|
|
|
|
this.vitest = await createVitest('test', {
|
|
root: vitestWorkspaceConfig ? dirname(vitestWorkspaceConfig) : process.cwd(),
|
|
watch: true,
|
|
passWithNoTests: false,
|
|
// TODO:
|
|
// Do we want to enable Vite's default reporter?
|
|
// The output in the terminal might be too spamy and it might be better to
|
|
// find a way to just show errors and warnings for example
|
|
// Otherwise it might be hard for the user to discover Storybook related logs
|
|
reporters: ['default', new StorybookReporter(this.testManager)],
|
|
coverage: coverageOptions,
|
|
});
|
|
|
|
if (this.vitest) {
|
|
this.vitest.onCancel(() => {
|
|
// TODO: handle cancellation
|
|
});
|
|
}
|
|
|
|
try {
|
|
await this.vitest.init();
|
|
} catch (e: any) {
|
|
let message = 'Failed to initialize Vitest';
|
|
const isV8 = e.message?.includes('@vitest/coverage-v8');
|
|
const isIstanbul = e.message?.includes('@vitest/coverage-istanbul');
|
|
|
|
if (
|
|
(e.message?.includes('Failed to load url') && (isIstanbul || isV8)) ||
|
|
// Vitest will sometimes not throw the correct missing-package-detection error, so we have to check for this as well
|
|
(e instanceof TypeError &&
|
|
e?.message === "Cannot read properties of undefined (reading 'name')")
|
|
) {
|
|
const coveragePackage = isIstanbul ? 'coverage-istanbul' : 'coverage-v8';
|
|
message += `\n\nPlease install the @vitest/${coveragePackage} package to collect coverage\n`;
|
|
}
|
|
this.testManager.reportFatalError(message, e);
|
|
return;
|
|
}
|
|
|
|
await this.setupWatchers();
|
|
}
|
|
|
|
async restartVitest({ coverage }: { coverage: boolean }) {
|
|
await this.vitestRestartPromise;
|
|
this.vitestRestartPromise = new Promise(async (resolve, reject) => {
|
|
try {
|
|
await this.runningPromise;
|
|
await this.closeVitest();
|
|
await this.startVitest({ coverage });
|
|
resolve();
|
|
} catch (e) {
|
|
reject(e);
|
|
} finally {
|
|
this.vitestRestartPromise = null;
|
|
}
|
|
});
|
|
return this.vitestRestartPromise;
|
|
}
|
|
|
|
private setGlobalTestNamePattern(pattern: string | RegExp) {
|
|
if (isVitest3OrLater) {
|
|
this.vitest!.setGlobalTestNamePattern(pattern);
|
|
} else {
|
|
// @ts-expect-error vitest.configOverride is a Vitest < 3 API.
|
|
this.vitest!.configOverride.testNamePattern = pattern;
|
|
}
|
|
}
|
|
|
|
private resetGlobalTestNamePattern() {
|
|
if (this.vitest) {
|
|
this.setGlobalTestNamePattern('');
|
|
}
|
|
}
|
|
|
|
private updateLastChanged(filepath: string) {
|
|
const projects = this.vitest!.getModuleProjects(filepath);
|
|
projects.forEach(({ server, browser }) => {
|
|
if (server) {
|
|
const serverMods = server.moduleGraph.getModulesByFile(filepath);
|
|
serverMods?.forEach((mod) => server.moduleGraph.invalidateModule(mod));
|
|
}
|
|
if (browser) {
|
|
const browserMods = browser.vite.moduleGraph.getModulesByFile(filepath);
|
|
browserMods?.forEach((mod) => browser.vite.moduleGraph.invalidateModule(mod));
|
|
}
|
|
});
|
|
}
|
|
|
|
private async fetchStories(indexUrl: string, requestStoryIds?: string[]) {
|
|
try {
|
|
const index = (await Promise.race([
|
|
fetch(indexUrl).then((res) => res.json()),
|
|
new Promise((_, reject) => setTimeout(reject, 3000, new Error('Request took too long'))),
|
|
])) as StoryIndex;
|
|
const storyIds = requestStoryIds || Object.keys(index.entries);
|
|
return storyIds.map((id) => index.entries[id]).filter((story) => story.type === 'story');
|
|
} catch (e: any) {
|
|
log('Failed to fetch story index: ' + e.message);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
private filterStories(
|
|
story: StoryIndexEntry | DocsIndexEntry,
|
|
moduleId: string,
|
|
tagsFilter: TagsFilter
|
|
) {
|
|
const absoluteImportPath = path.join(process.cwd(), story.importPath);
|
|
if (absoluteImportPath !== moduleId) {
|
|
return false;
|
|
}
|
|
if (tagsFilter.include.length && !tagsFilter.include.some((tag) => story.tags?.includes(tag))) {
|
|
return false;
|
|
}
|
|
if (tagsFilter.exclude.some((tag) => story.tags?.includes(tag))) {
|
|
return false;
|
|
}
|
|
// Skipped tests are intentionally included here
|
|
return true;
|
|
}
|
|
|
|
private get vite() {
|
|
// TODO: vitest.server is a Vitest < 3.0.0 API. Remove as soon as we don't support < 3.0.0 anymore.
|
|
return isVitest3OrLater ? this.vitest?.vite : this.vitest?.server;
|
|
}
|
|
|
|
async runFiles(specifications: TestSpecification[], allTestsRun?: boolean) {
|
|
this.isCancelling = false;
|
|
const runTest: (
|
|
specifications: TestSpecification[],
|
|
allTestsRun?: boolean | undefined
|
|
// @ts-expect-error vitest.runFiles is a Vitest < 3.0.0 API. Remove as soon as we don't support < 3.0.0 anymore.
|
|
) => Promise<TestRunResult> = this.vitest!.runFiles ?? this.vitest!.runTestSpecifications;
|
|
this.runningPromise = runTest.call(this.vitest, specifications, allTestsRun);
|
|
await this.runningPromise;
|
|
this.runningPromise = null;
|
|
}
|
|
|
|
async runTests(requestPayload: TestingModuleRunRequestPayload) {
|
|
if (!this.vitest) {
|
|
await this.startVitest();
|
|
} else {
|
|
await this.vitestRestartPromise;
|
|
}
|
|
|
|
this.resetGlobalTestNamePattern();
|
|
|
|
const stories = await this.fetchStories(requestPayload.indexUrl, requestPayload.storyIds);
|
|
const vitestTestSpecs = await this.getStorybookTestSpecs();
|
|
const isSingleStoryRun = requestPayload.storyIds?.length === 1;
|
|
|
|
const { filteredTestFiles, totalTestCount } = vitestTestSpecs.reduce(
|
|
(acc, spec) => {
|
|
/* eslint-disable no-underscore-dangle */
|
|
const { env = {} } = spec.project.config;
|
|
const include = env.__VITEST_INCLUDE_TAGS__?.split(',').filter(Boolean) ?? ['test'];
|
|
const exclude = env.__VITEST_EXCLUDE_TAGS__?.split(',').filter(Boolean) ?? [];
|
|
const skip = env.__VITEST_SKIP_TAGS__?.split(',').filter(Boolean) ?? [];
|
|
/* eslint-enable no-underscore-dangle */
|
|
|
|
const matches = stories.filter((story) =>
|
|
this.filterStories(story, spec.moduleId, { include, exclude, skip })
|
|
);
|
|
if (matches.length) {
|
|
if (!this.testManager.store.getState().watching) {
|
|
// Clear the file cache if watch mode is not enabled
|
|
this.updateLastChanged(spec.moduleId);
|
|
}
|
|
acc.filteredTestFiles.push(spec);
|
|
acc.totalTestCount += matches.filter(
|
|
// Don't count skipped stories, because StorybookReporter doesn't include them either
|
|
(story) => !skip.some((tag) => story.tags?.includes(tag))
|
|
).length;
|
|
}
|
|
return acc;
|
|
},
|
|
{ filteredTestFiles: [] as TestSpecification[], totalTestCount: 0 }
|
|
);
|
|
|
|
await this.cancelCurrentRun();
|
|
this.storyCountForCurrentRun = totalTestCount;
|
|
|
|
if (isSingleStoryRun) {
|
|
const storyName = stories[0].name;
|
|
const regex = new RegExp(`^${storyName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`);
|
|
this.setGlobalTestNamePattern(regex);
|
|
}
|
|
|
|
await this.runFiles(filteredTestFiles, true);
|
|
this.resetGlobalTestNamePattern();
|
|
}
|
|
|
|
async cancelCurrentRun() {
|
|
this.isCancelling = true;
|
|
await this.vitest?.cancelCurrentRun('keyboard-input');
|
|
await this.runningPromise;
|
|
this.isCancelling = false;
|
|
}
|
|
|
|
async closeVitest() {
|
|
await this.vitest?.close();
|
|
}
|
|
|
|
async getStorybookTestSpecs() {
|
|
const globTestSpecs = (await this.vitest?.globTestSpecs()) ?? [];
|
|
return (
|
|
globTestSpecs.filter((workspaceSpec) => this.isStorybookProject(workspaceSpec.project)) ?? []
|
|
);
|
|
}
|
|
|
|
private async getTestDependencies(spec: TestSpecification, deps = new Set<string>()) {
|
|
const addImports = async (project: TestProject, filepath: string) => {
|
|
if (deps.has(filepath)) {
|
|
return;
|
|
}
|
|
deps.add(filepath);
|
|
|
|
// TODO: Remove project.server once we don't support Vitest < 3.0.0 anymore
|
|
const server = isVitest3OrLater ? project.vite : project.server;
|
|
|
|
const mod = server.moduleGraph.getModuleById(filepath);
|
|
// @ts-expect-error project.vitenode is a Vitest < 3 API.
|
|
const viteNode = isVitest3OrLater ? project.vite : project.vitenode;
|
|
const transformed = mod?.ssrTransformResult || (await viteNode.transformRequest(filepath));
|
|
if (!transformed) {
|
|
return;
|
|
}
|
|
const dependencies = [...(transformed.deps || []), ...(transformed.dynamicDeps || [])];
|
|
await Promise.all(
|
|
dependencies.map(async (dep) => {
|
|
const idPath = await server.pluginContainer.resolveId(dep, filepath, {
|
|
ssr: true,
|
|
});
|
|
const fsPath = idPath && !idPath.external && idPath.id.split('?')[0];
|
|
if (
|
|
fsPath &&
|
|
!fsPath.includes('node_modules') &&
|
|
!deps.has(fsPath) &&
|
|
existsSync(fsPath)
|
|
) {
|
|
await addImports(project, fsPath);
|
|
}
|
|
})
|
|
);
|
|
};
|
|
|
|
await addImports(
|
|
// @ts-expect-error spec.project.workspaceProject is a Vitest < 3 API.
|
|
isVitest3OrLater ? spec.project : spec.project.workspaceProject,
|
|
spec.moduleId
|
|
);
|
|
deps.delete(spec.moduleId);
|
|
|
|
return deps;
|
|
}
|
|
|
|
async runAffectedTests(trigger: string) {
|
|
if (!this.vitest) {
|
|
return;
|
|
}
|
|
this.resetGlobalTestNamePattern();
|
|
|
|
const globTestSpecs: (filters?: string[] | undefined) => Promise<TestSpecification[]> =
|
|
// TODO: vitest.globTestSpecs is a Vitest < 3.0.0 API.
|
|
isVitest3OrLater ? this.vitest.globTestSpecifications : this.vitest.globTestSpecs;
|
|
|
|
const globTestFiles = await globTestSpecs.call(this.vitest);
|
|
|
|
const testGraphs = await Promise.all(
|
|
globTestFiles
|
|
.filter((workspace) => this.isStorybookProject(workspace.project))
|
|
.map(async (spec) => {
|
|
const deps = await this.getTestDependencies(spec);
|
|
return [spec, deps] as const;
|
|
})
|
|
);
|
|
const triggerAffectedTests: TestSpecification[] = [];
|
|
|
|
for (const [workspaceSpec, deps] of testGraphs) {
|
|
if (trigger && (trigger === workspaceSpec.moduleId || deps.has(trigger))) {
|
|
triggerAffectedTests.push(workspaceSpec);
|
|
}
|
|
}
|
|
|
|
if (triggerAffectedTests.length) {
|
|
await this.vitest.cancelCurrentRun('keyboard-input');
|
|
await this.runningPromise;
|
|
await this.runFiles(triggerAffectedTests, false);
|
|
}
|
|
}
|
|
|
|
async runAffectedTestsAfterChange(file: string) {
|
|
const id = slash(file);
|
|
this.vitest?.logger.clearHighlightCache(id);
|
|
this.updateLastChanged(id);
|
|
|
|
// when watch mode is disabled, don't trigger any tests (below)
|
|
// but still invalidate the cache for the changed file, which is handled above
|
|
if (!this.testManager.store.getState().watching) {
|
|
return;
|
|
}
|
|
|
|
this.storyCountForCurrentRun = 0;
|
|
await this.runAffectedTests(file);
|
|
}
|
|
|
|
async registerVitestConfigListener() {
|
|
this.vite?.watcher.on('change', async (file) => {
|
|
file = normalize(file);
|
|
const isConfig = file === this.vite?.config.configFile;
|
|
if (isConfig) {
|
|
log('Restarting Vitest due to config change');
|
|
await this.closeVitest();
|
|
await this.startVitest();
|
|
}
|
|
});
|
|
}
|
|
|
|
async setupWatchers() {
|
|
this.resetGlobalTestNamePattern();
|
|
const server = this.vite;
|
|
server?.watcher.removeAllListeners('change');
|
|
server?.watcher.removeAllListeners('add');
|
|
server?.watcher.on('change', this.runAffectedTestsAfterChange.bind(this));
|
|
server?.watcher.on('add', this.runAffectedTestsAfterChange.bind(this));
|
|
this.registerVitestConfigListener();
|
|
}
|
|
|
|
isStorybookProject(project: TestProject | WorkspaceProject) {
|
|
// eslint-disable-next-line no-underscore-dangle
|
|
return !!project.config.env?.__STORYBOOK_URL__;
|
|
}
|
|
}
|