diff --git a/code/addons/test/package.json b/code/addons/test/package.json index cd7a737260b7..9bcf07d466d2 100644 --- a/code/addons/test/package.json +++ b/code/addons/test/package.json @@ -109,6 +109,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "semver": "^7.6.3", + "sirv": "^2.0.4", "slash": "^5.0.0", "strip-ansi": "^7.1.0", "tinyglobby": "^0.2.10", diff --git a/code/addons/test/src/node/boot-test-runner.ts b/code/addons/test/src/node/boot-test-runner.ts index 9cf4880ec35e..3f0329807e98 100644 --- a/code/addons/test/src/node/boot-test-runner.ts +++ b/code/addons/test/src/node/boot-test-runner.ts @@ -23,10 +23,13 @@ const MAX_START_TIME = 30000; // which is at the root. Then, from the root, we want to load `node/vitest.mjs` const vitestModulePath = join(__dirname, 'node', 'vitest.mjs'); +// Events that were triggered before Vitest was ready are queued up and resent once it's ready +const eventQueue: { type: string; args: any[] }[] = []; + let child: null | ChildProcess; let ready = false; -const bootTestRunner = async (channel: Channel, initEvent?: string, initArgs?: any[]) => { +const bootTestRunner = async (channel: Channel) => { let stderr: string[] = []; function reportFatalError(e: any) { @@ -58,6 +61,7 @@ const bootTestRunner = async (channel: Channel, initEvent?: string, initArgs?: a const exit = (code = 0) => { killChild(); + eventQueue.length = 0; process.exit(code); }; @@ -81,9 +85,10 @@ const bootTestRunner = async (channel: Channel, initEvent?: string, initArgs?: a child.on('message', (result: any) => { if (result.type === 'ready') { - // Resend the event that triggered the boot sequence, now that the child is ready to handle it - if (initEvent && initArgs) { - child?.send({ type: initEvent, args: initArgs, from: 'server' }); + // Resend events that triggered (during) the boot sequence, now that Vitest is ready + while (eventQueue.length) { + const { type, args } = eventQueue.shift(); + child?.send({ type, args, from: 'server' }); } // Forward all events from the channel to the child process @@ -124,14 +129,18 @@ const bootTestRunner = async (channel: Channel, initEvent?: string, initArgs?: a await Promise.race([startChildProcess(), timeout]).catch((e) => { reportFatalError(e); + eventQueue.length = 0; throw e; }); }; export const runTestRunner = async (channel: Channel, initEvent?: string, initArgs?: any[]) => { + if (!ready && initEvent) { + eventQueue.push({ type: initEvent, args: initArgs }); + } if (!child) { ready = false; - await bootTestRunner(channel, initEvent, initArgs); + await bootTestRunner(channel); ready = true; } }; @@ -141,4 +150,6 @@ export const killTestRunner = () => { child.kill(); child = null; } + ready = false; + eventQueue.length = 0; }; diff --git a/code/addons/test/src/node/test-manager.ts b/code/addons/test/src/node/test-manager.ts index 139fffa3ef6b..544a23f5da49 100644 --- a/code/addons/test/src/node/test-manager.ts +++ b/code/addons/test/src/node/test-manager.ts @@ -39,12 +39,6 @@ export class TestManager { this.vitestManager.startVitest().then(() => options.onReady?.()); } - async restartVitest({ watchMode, coverage }: { watchMode: boolean; coverage: boolean }) { - await this.vitestManager.vitest?.runningPromise; - await this.vitestManager.closeVitest(); - await this.vitestManager.startVitest({ watchMode, coverage }); - } - async handleConfigChange( payload: TestingModuleConfigChangePayload<{ coverage: boolean; a11y: boolean }> ) { @@ -54,7 +48,10 @@ export class TestManager { if (this.coverage !== payload.config.coverage) { try { this.coverage = payload.config.coverage; - await this.restartVitest({ watchMode: this.watchMode, coverage: this.coverage }); + await this.vitestManager.restartVitest({ + watchMode: this.watchMode, + coverage: this.coverage, + }); } catch (e) { const isV8 = e.message?.includes('@vitest/coverage-v8'); const isIstanbul = e.message?.includes('@vitest/coverage-istanbul'); @@ -76,7 +73,7 @@ export class TestManager { if (this.watchMode !== payload.watchMode) { this.watchMode = payload.watchMode; - await this.restartVitest({ watchMode: this.watchMode, coverage: false }); + await this.vitestManager.restartVitest({ watchMode: this.watchMode, coverage: false }); } } catch (e) { this.reportFatalError('Failed to change watch mode', e); @@ -88,32 +85,39 @@ export class TestManager { if (payload.providerId !== TEST_PROVIDER_ID) { return; } + + const allTestsRun = (payload.storyIds ?? []).length === 0; + if (payload.config && this.coverage !== payload.config.coverage) { this.coverage = payload.config.coverage; } - const allTestsRun = (payload.storyIds ?? []).length === 0; if (this.coverage) { /* If we have coverage enabled and we're running all stories, we have to restart Vitest AND disable watch mode otherwise the coverage report will be incorrect, Vitest behaves wonky when re-using the same Vitest instance but with watch mode disabled, among other things it causes the coverage report to be incorrect and stale. - + If we're only running a subset of stories, we have to temporarily disable coverage, as a coverage report for a subset of stories is not useful. */ - await this.restartVitest({ + await this.vitestManager.restartVitest({ watchMode: allTestsRun ? false : this.watchMode, coverage: allTestsRun, }); + } else { + await this.vitestManager.vitestRestartPromise; } await this.vitestManager.runTests(payload); if (this.coverage && !allTestsRun) { // Re-enable coverage if it was temporarily disabled because of a subset of stories was run - await this.restartVitest({ watchMode: this.watchMode, coverage: this.coverage }); + await this.vitestManager.restartVitest({ + watchMode: this.watchMode, + coverage: this.coverage, + }); } } catch (e) { this.reportFatalError('Failed to run tests', e); diff --git a/code/addons/test/src/node/vitest-manager.ts b/code/addons/test/src/node/vitest-manager.ts index 0296945b43c9..9ce9139821bb 100644 --- a/code/addons/test/src/node/vitest-manager.ts +++ b/code/addons/test/src/node/vitest-manager.ts @@ -34,6 +34,8 @@ export class VitestManager { vitestStartupCounter = 0; + vitestRestartPromise: Promise | null = null; + storyCountForCurrentRun: number = 0; constructor(private testManager: TestManager) {} @@ -99,12 +101,30 @@ export class VitestManager { } } + async restartVitest({ watchMode, coverage }: { watchMode: boolean; coverage: boolean }) { + await this.vitestRestartPromise; + this.vitestRestartPromise = new Promise(async (resolve, reject) => { + try { + await this.vitest?.runningPromise; + await this.closeVitest(); + await this.startVitest({ watchMode, coverage }); + resolve(); + } catch (e) { + reject(e); + } finally { + this.vitestRestartPromise = null; + } + }); + return this.vitestRestartPromise; + } + private updateLastChanged(filepath: string) { const projects = this.vitest!.getModuleProjects(filepath); projects.forEach(({ server, browser }) => { - const serverMods = server.moduleGraph.getModulesByFile(filepath); - serverMods?.forEach((mod) => server.moduleGraph.invalidateModule(mod)); - + 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)); @@ -148,6 +168,8 @@ export class VitestManager { async runTests(requestPayload: TestingModuleRunRequestPayload) { if (!this.vitest) { await this.startVitest(); + } else { + await this.vitestRestartPromise; } this.resetTestNamePattern(); diff --git a/code/addons/test/src/vitest-plugin/index.ts b/code/addons/test/src/vitest-plugin/index.ts index 111e53fd2a8f..18749f75b9b9 100644 --- a/code/addons/test/src/vitest-plugin/index.ts +++ b/code/addons/test/src/vitest-plugin/index.ts @@ -7,12 +7,13 @@ import { normalizeStories, validateConfigurationFiles, } from 'storybook/internal/common'; -import { StoryIndexGenerator } from 'storybook/internal/core-server'; +import { StoryIndexGenerator, mapStaticDir } from 'storybook/internal/core-server'; import { readConfig, vitestTransform } from 'storybook/internal/csf-tools'; import { MainFileMissingError } from 'storybook/internal/server-errors'; import type { DocsOptions, StoriesEntry } from 'storybook/internal/types'; import { join, resolve } from 'pathe'; +import sirv from 'sirv'; import { convertPathToPattern } from 'tinyglobby'; import type { InternalOptions, UserOptions } from './types'; @@ -63,6 +64,7 @@ export const storybookTest = (options?: UserOptions): Plugin => { let previewLevelTags: string[]; let storiesGlobs: StoriesEntry[]; let storiesFiles: string[]; + const statics: ReturnType[] = []; return { name: 'vite-plugin-storybook-test', @@ -111,6 +113,15 @@ export const storybookTest = (options?: UserOptions): Plugin => { const framework = await presets.apply('framework', undefined); const frameworkName = typeof framework === 'string' ? framework : framework.name; const storybookEnv = await presets.apply('env', {}); + const staticDirs = await presets.apply('staticDirs', []); + + for (const staticDir of staticDirs) { + try { + statics.push(mapStaticDir(staticDir, configDir)); + } catch (e) { + console.warn(e); + } + } // If we end up needing to know if we are running in browser mode later // const isRunningInBrowserMode = config.plugins.find((plugin: Plugin) => @@ -192,6 +203,18 @@ export const storybookTest = (options?: UserOptions): Plugin => { config.define.__VUE_PROD_HYDRATION_MISMATCH_DETAILS__ = 'false'; } }, + configureServer(server) { + statics.map(({ staticPath, targetEndpoint }) => { + server.middlewares.use( + targetEndpoint, + sirv(staticPath, { + dev: true, + etag: true, + extensions: [], + }) + ); + }); + }, async transform(code, id) { if (process.env.VITEST !== 'true') { return code; diff --git a/code/core/src/core-server/index.ts b/code/core/src/core-server/index.ts index 3163e70875ea..e8898fb5767b 100644 --- a/code/core/src/core-server/index.ts +++ b/code/core/src/core-server/index.ts @@ -6,4 +6,5 @@ export * from './build-static'; export * from './build-dev'; export * from './withTelemetry'; export { default as build } from './standalone'; +export { mapStaticDir } from './utils/server-statics'; export { StoryIndexGenerator } from './utils/StoryIndexGenerator'; diff --git a/code/core/src/core-server/presets/common-preset.ts b/code/core/src/core-server/presets/common-preset.ts index fe5f7db4f53a..34b852cbb7bc 100644 --- a/code/core/src/core-server/presets/common-preset.ts +++ b/code/core/src/core-server/presets/common-preset.ts @@ -67,39 +67,37 @@ export const favicon = async ( ? staticDirsValue.map((dir) => (typeof dir === 'string' ? dir : `${dir.from}:${dir.to}`)) : []; - if (statics && statics.length > 0) { - const lists = await Promise.all( - statics.map(async (dir) => { - const results = []; - const normalizedDir = - staticDirsValue && !isAbsolute(dir) - ? getDirectoryFromWorkingDir({ - configDir: options.configDir, - workingDir: process.cwd(), - directory: dir, - }) - : dir; - - const { staticPath, targetEndpoint } = await parseStaticDir(normalizedDir); - - if (targetEndpoint === '/') { - const url = 'favicon.svg'; - const path = join(staticPath, url); - if (existsSync(path)) { - results.push(path); - } + if (statics.length > 0) { + const lists = statics.map((dir) => { + const results = []; + const normalizedDir = + staticDirsValue && !isAbsolute(dir) + ? getDirectoryFromWorkingDir({ + configDir: options.configDir, + workingDir: process.cwd(), + directory: dir, + }) + : dir; + + const { staticPath, targetEndpoint } = parseStaticDir(normalizedDir); + + if (targetEndpoint === '/') { + const url = 'favicon.svg'; + const path = join(staticPath, url); + if (existsSync(path)) { + results.push(path); } - if (targetEndpoint === '/') { - const url = 'favicon.ico'; - const path = join(staticPath, url); - if (existsSync(path)) { - results.push(path); - } + } + if (targetEndpoint === '/') { + const url = 'favicon.ico'; + const path = join(staticPath, url); + if (existsSync(path)) { + results.push(path); } + } - return results; - }) - ); + return results; + }); const flatlist = lists.reduce((l1, l2) => l1.concat(l2), []); if (flatlist.length > 1) { diff --git a/code/core/src/core-server/utils/__tests__/server-statics.test.ts b/code/core/src/core-server/utils/__tests__/server-statics.test.ts index 0dfaea67f5df..145f855ff136 100644 --- a/code/core/src/core-server/utils/__tests__/server-statics.test.ts +++ b/code/core/src/core-server/utils/__tests__/server-statics.test.ts @@ -15,14 +15,14 @@ describe('parseStaticDir', () => { }); it('returns the static dir/path and default target', async () => { - await expect(parseStaticDir('public')).resolves.toEqual({ + expect(parseStaticDir('public')).toEqual({ staticDir: './public', staticPath: resolve('public'), targetDir: './', targetEndpoint: '/', }); - await expect(parseStaticDir('foo/bar')).resolves.toEqual({ + expect(parseStaticDir('foo/bar')).toEqual({ staticDir: './foo/bar', staticPath: resolve('foo/bar'), targetDir: './', @@ -31,14 +31,14 @@ describe('parseStaticDir', () => { }); it('returns the static dir/path and custom target', async () => { - await expect(parseStaticDir('public:/custom-endpoint')).resolves.toEqual({ + expect(parseStaticDir('public:/custom-endpoint')).toEqual({ staticDir: './public', staticPath: resolve('public'), targetDir: './custom-endpoint', targetEndpoint: '/custom-endpoint', }); - await expect(parseStaticDir('foo/bar:/custom-endpoint')).resolves.toEqual({ + expect(parseStaticDir('foo/bar:/custom-endpoint')).toEqual({ staticDir: './foo/bar', staticPath: resolve('foo/bar'), targetDir: './custom-endpoint', @@ -47,21 +47,21 @@ describe('parseStaticDir', () => { }); it('pins relative endpoint at root', async () => { - const normal = await parseStaticDir('public:relative-endpoint'); + const normal = parseStaticDir('public:relative-endpoint'); expect(normal.targetEndpoint).toBe('/relative-endpoint'); - const windows = await parseStaticDir('C:\\public:relative-endpoint'); + const windows = parseStaticDir('C:\\public:relative-endpoint'); expect(windows.targetEndpoint).toBe('/relative-endpoint'); }); it('checks that the path exists', async () => { existsSyncMock.mockReturnValueOnce(false); - await expect(parseStaticDir('nonexistent')).rejects.toThrow(resolve('nonexistent')); + expect(() => parseStaticDir('nonexistent')).toThrow(resolve('nonexistent')); }); skipWindows(() => { it('supports absolute file paths - posix', async () => { - await expect(parseStaticDir('/foo/bar')).resolves.toEqual({ + expect(parseStaticDir('/foo/bar')).toEqual({ staticDir: '/foo/bar', staticPath: '/foo/bar', targetDir: './', @@ -70,7 +70,7 @@ describe('parseStaticDir', () => { }); it('supports absolute file paths with custom endpoint - posix', async () => { - await expect(parseStaticDir('/foo/bar:/custom-endpoint')).resolves.toEqual({ + expect(parseStaticDir('/foo/bar:/custom-endpoint')).toEqual({ staticDir: '/foo/bar', staticPath: '/foo/bar', targetDir: './custom-endpoint', @@ -81,7 +81,7 @@ describe('parseStaticDir', () => { onlyWindows(() => { it('supports absolute file paths - windows', async () => { - await expect(parseStaticDir('C:\\foo\\bar')).resolves.toEqual({ + expect(parseStaticDir('C:\\foo\\bar')).toEqual({ staticDir: resolve('C:\\foo\\bar'), staticPath: resolve('C:\\foo\\bar'), targetDir: './', @@ -90,14 +90,14 @@ describe('parseStaticDir', () => { }); it('supports absolute file paths with custom endpoint - windows', async () => { - await expect(parseStaticDir('C:\\foo\\bar:/custom-endpoint')).resolves.toEqual({ + expect(parseStaticDir('C:\\foo\\bar:/custom-endpoint')).toEqual({ staticDir: expect.any(String), // can't test this properly on unix staticPath: resolve('C:\\foo\\bar'), targetDir: './custom-endpoint', targetEndpoint: '/custom-endpoint', }); - await expect(parseStaticDir('C:\\foo\\bar:\\custom-endpoint')).resolves.toEqual({ + expect(parseStaticDir('C:\\foo\\bar:\\custom-endpoint')).toEqual({ staticDir: expect.any(String), // can't test this properly on unix staticPath: resolve('C:\\foo\\bar'), targetDir: './custom-endpoint', diff --git a/code/core/src/core-server/utils/copy-all-static-files.ts b/code/core/src/core-server/utils/copy-all-static-files.ts index ba5ccac883c8..2518fa82338c 100644 --- a/code/core/src/core-server/utils/copy-all-static-files.ts +++ b/code/core/src/core-server/utils/copy-all-static-files.ts @@ -14,7 +14,7 @@ export async function copyAllStaticFiles(staticDirs: any[] | undefined, outputDi await Promise.all( staticDirs.map(async (dir) => { try { - const { staticDir, staticPath, targetDir } = await parseStaticDir(dir); + const { staticDir, staticPath, targetDir } = parseStaticDir(dir); const targetPath = join(outputDir, targetDir); // we copy prebuild static files from node_modules/@storybook/manager & preview @@ -54,7 +54,7 @@ export async function copyAllStaticFilesRelativeToMain( await acc; const staticDirAndTarget = typeof dir === 'string' ? dir : `${dir.from}:${dir.to}`; - const { staticPath: from, targetEndpoint: to } = await parseStaticDir( + const { staticPath: from, targetEndpoint: to } = parseStaticDir( getDirectoryFromWorkingDir({ configDir, workingDir, diff --git a/code/core/src/core-server/utils/server-statics.ts b/code/core/src/core-server/utils/server-statics.ts index 3e21b4a3ea58..470d14ceb153 100644 --- a/code/core/src/core-server/utils/server-statics.ts +++ b/code/core/src/core-server/utils/server-statics.ts @@ -2,7 +2,7 @@ import { existsSync } from 'node:fs'; import { basename, isAbsolute, posix, resolve, sep, win32 } from 'node:path'; import { getDirectoryFromWorkingDir } from '@storybook/core/common'; -import type { Options } from '@storybook/core/types'; +import type { Options, StorybookConfigRaw } from '@storybook/core/types'; import { logger } from '@storybook/core/node-logger'; @@ -15,43 +15,31 @@ export async function useStatics(app: Polka.Polka, options: Options): Promise('favicon'); - await Promise.all( - staticDirs - .map((dir) => (typeof dir === 'string' ? dir : `${dir.from}:${dir.to}`)) - .map(async (dir) => { - try { - const normalizedDir = - staticDirs && !isAbsolute(dir) - ? getDirectoryFromWorkingDir({ - configDir: options.configDir, - workingDir: process.cwd(), - directory: dir, - }) - : dir; - const { staticDir, staticPath, targetEndpoint } = await parseStaticDir(normalizedDir); + staticDirs.map((dir) => { + try { + const { staticDir, staticPath, targetEndpoint } = mapStaticDir(dir, options.configDir); - // Don't log for the internal static dir - if (!targetEndpoint.startsWith('/sb-')) { - logger.info( - `=> Serving static files from ${picocolors.cyan(staticDir)} at ${picocolors.cyan(targetEndpoint)}` - ); - } + // Don't log for the internal static dir + if (!targetEndpoint.startsWith('/sb-')) { + logger.info( + `=> Serving static files from ${picocolors.cyan(staticDir)} at ${picocolors.cyan(targetEndpoint)}` + ); + } - app.use( - targetEndpoint, - sirv(staticPath, { - dev: true, - etag: true, - extensions: [], - }) - ); - } catch (e) { - if (e instanceof Error) { - logger.warn(e.message); - } - } - }) - ); + app.use( + targetEndpoint, + sirv(staticPath, { + dev: true, + etag: true, + extensions: [], + }) + ); + } catch (e) { + if (e instanceof Error) { + logger.warn(e.message); + } + } + }); app.get( `/${basename(faviconPath)}`, @@ -63,7 +51,7 @@ export async function useStatics(app: Polka.Polka, options: Options): Promise { +export const parseStaticDir = (arg: string) => { // Split on last index of ':', for Windows compatibility (e.g. 'C:\some\dir:\foo') const lastColonIndex = arg.lastIndexOf(':'); const isWindowsAbsolute = win32.isAbsolute(arg); @@ -90,3 +78,15 @@ export const parseStaticDir = async (arg: string) => { return { staticDir, staticPath, targetDir, targetEndpoint }; }; + +export const mapStaticDir = ( + staticDir: NonNullable[number], + configDir: string +) => { + const specifier = typeof staticDir === 'string' ? staticDir : `${staticDir.from}:${staticDir.to}`; + const normalizedDir = isAbsolute(specifier) + ? specifier + : getDirectoryFromWorkingDir({ configDir, workingDir: process.cwd(), directory: specifier }); + + return parseStaticDir(normalizedDir); +}; diff --git a/code/core/src/manager-api/modules/experimental_testmodule.ts b/code/core/src/manager-api/modules/experimental_testmodule.ts index 294355c73bc6..4058333a3b10 100644 --- a/code/core/src/manager-api/modules/experimental_testmodule.ts +++ b/code/core/src/manager-api/modules/experimental_testmodule.ts @@ -70,7 +70,7 @@ export const init: ModuleFn = ({ store, fullAPI }) => { clearTestProviderState(id) { const update = { cancelling: false, - running: true, + running: false, failed: false, crashed: false, progress: undefined, @@ -85,7 +85,13 @@ export const init: ModuleFn = ({ store, fullAPI }) => { runTestProvider(id, options) { const index = store.getState().index; invariant(index, 'The index is currently unavailable'); - api.updateTestProviderState(id, { running: true, failed: false, crashed: false }); + + api.updateTestProviderState(id, { + running: true, + failed: false, + crashed: false, + progress: undefined, + }); const provider = store.getState().testProviders[id]; diff --git a/code/yarn.lock b/code/yarn.lock index 7edd38620ac6..9a497aefa831 100644 --- a/code/yarn.lock +++ b/code/yarn.lock @@ -6225,6 +6225,7 @@ __metadata: react: "npm:^18.2.0" react-dom: "npm:^18.2.0" semver: "npm:^7.6.3" + sirv: "npm:^2.0.4" slash: "npm:^5.0.0" strip-ansi: "npm:^7.1.0" tinyglobby: "npm:^0.2.10"