From cb619aefd1b7aa9569f4b1e28e2d4d30d5783b42 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Fri, 20 Sep 2024 15:54:27 +0200 Subject: [PATCH] Addon-Test: Implement Addon Test TestProvider Backend --- code/.storybook/vitest.config.ts | 5 +- code/addons/test/package.json | 14 +- code/addons/test/src/logger.ts | 7 + code/addons/test/src/node/boot-test-runner.ts | 74 +++++ code/addons/test/src/node/reporter.ts | 192 +++++++++++++ code/addons/test/src/node/test-manager.ts | 84 ++++++ code/addons/test/src/node/vitest-manager.ts | 104 +++++++ code/addons/test/src/node/vitest.ts | 35 +++ code/addons/test/src/preset.ts | 3 + .../src/core-events/data/testing-module.ts | 87 ++++++ code/core/src/core-events/index.ts | 14 + .../manager/components/sidebar/Sidebar.tsx | 35 ++- .../src/manager/components/sidebar/Tree.tsx | 85 +++++- code/core/src/manager/globals/exports.ts | 18 ++ code/package.json | 9 +- code/yarn.lock | 264 +++++++++++------- 16 files changed, 906 insertions(+), 124 deletions(-) create mode 100644 code/addons/test/src/logger.ts create mode 100644 code/addons/test/src/node/boot-test-runner.ts create mode 100644 code/addons/test/src/node/reporter.ts create mode 100644 code/addons/test/src/node/test-manager.ts create mode 100644 code/addons/test/src/node/vitest-manager.ts create mode 100644 code/addons/test/src/node/vitest.ts create mode 100644 code/core/src/core-events/data/testing-module.ts diff --git a/code/.storybook/vitest.config.ts b/code/.storybook/vitest.config.ts index 25a83b4396ea..9745a67949e6 100644 --- a/code/.storybook/vitest.config.ts +++ b/code/.storybook/vitest.config.ts @@ -31,9 +31,8 @@ export default mergeConfig( test: { name: 'storybook-ui', include: [ - // TODO: test all core and addon stories later - // './core/**/components/**/*.{story,stories}.?(c|m)[jt]s?(x)', - '../addons/**/src/**/*.{story,stories}.?(c|m)[jt]s?(x)', + // TODO: Can be reverted. Temporarily I am adding all stories so that I can trigger tests for all stories in the UI. + '../{core,addons,lib}/**/{src,components,template}/**/*.{story,stories}.?(c|m)[jt]s?(x)', ], exclude: [ ...defaultExclude, diff --git a/code/addons/test/package.json b/code/addons/test/package.json index 4a158133de8b..c8ca27c7f2eb 100644 --- a/code/addons/test/package.json +++ b/code/addons/test/package.json @@ -71,20 +71,23 @@ }, "dependencies": { "@storybook/csf": "^0.1.11", - "@storybook/icons": "^1.2.10" + "@storybook/icons": "^1.2.10", + "chalk": "^5.3.0" }, "devDependencies": { "@types/semver": "^7", - "@vitest/browser": "^2.0.0", + "@vitest/browser": "^2.1.1", + "@vitest/runner": "^2.1.1", "boxen": "^8.0.1", "find-up": "^7.0.0", "semver": "^7.6.3", "tinyrainbow": "^1.2.0", "ts-dedent": "^2.2.0", - "vitest": "^2.0.0" + "vitest": "^2.1.1" }, "peerDependencies": { - "@vitest/browser": "^2.0.0", + "@vitest/browser": "^2.1.1", + "@vitest/runner": "^2.1.1", "storybook": "workspace:^", "vitest": "^2.0.0" }, @@ -104,7 +107,8 @@ "./src/preset.ts", "./src/vitest-plugin/index.ts", "./src/vitest-plugin/global-setup.ts", - "./src/postinstall.ts" + "./src/postinstall.ts", + "./src/node/vitest.ts" ] } } diff --git a/code/addons/test/src/logger.ts b/code/addons/test/src/logger.ts new file mode 100644 index 000000000000..24289135495c --- /dev/null +++ b/code/addons/test/src/logger.ts @@ -0,0 +1,7 @@ +import chalk from 'chalk'; + +import { ADDON_ID } from './constants'; + +export const log = (message: any) => { + console.log(`${chalk.magenta(ADDON_ID)}: ${message.toString().trim()}`); +}; diff --git a/code/addons/test/src/node/boot-test-runner.ts b/code/addons/test/src/node/boot-test-runner.ts new file mode 100644 index 000000000000..b4b86b8a6518 --- /dev/null +++ b/code/addons/test/src/node/boot-test-runner.ts @@ -0,0 +1,74 @@ +import { type ChildProcess, fork } from 'node:child_process'; +import { join } from 'node:path'; + +import type { Channel } from 'storybook/internal/channels'; +import { + TESTING_MODULE_CANCEL_TEST_RUN_REQUEST, + TESTING_MODULE_RUN_ALL_REQUEST, + TESTING_MODULE_RUN_REQUEST, + TESTING_MODULE_WATCH_MODE_REQUEST, +} from 'storybook/internal/core-events'; + +import { log } from '../logger'; + +export function bootTestRunner(channel: Channel) { + // This path is a bit confusing, but essentiall `boot-test-runner` gets bundled into the preset bundle + // which is at the root. Then, from the root, we want to load `node/vitest.js` + const sub = join(__dirname, 'node', 'vitest.js'); + + let child: ChildProcess; + + function restartChildProcess() { + child?.kill(); + log('Restarting Child Process...'); + child = startChildProcess(); + } + + function startChildProcess() { + child = fork(sub, [], { + // We want to pipe output and error + // so that we can prefix the logs in the terminal + // with a clear identifier + stdio: ['inherit', 'pipe', 'pipe', 'ipc'], + silent: true, + }); + + child.stdout?.on('data', (data) => { + log(data); + }); + + child.stderr?.on('data', (data) => { + log(data); + }); + + child.on('message', (result: any) => { + if (result.type === 'error') { + log(result.message); + log(result.error); + restartChildProcess(); + } else { + channel.emit(result.type, ...(result.args || [])); + } + }); + + return child; + } + + child = startChildProcess(); + + channel.on(TESTING_MODULE_RUN_REQUEST, (...args) => { + child.send({ type: TESTING_MODULE_RUN_REQUEST, args, from: 'server' }); + }); + + channel.on(TESTING_MODULE_RUN_ALL_REQUEST, (...args) => { + child.send({ type: TESTING_MODULE_RUN_ALL_REQUEST, args, from: 'server' }); + }); + + channel.on(TESTING_MODULE_WATCH_MODE_REQUEST, (...args) => { + child.send({ type: TESTING_MODULE_WATCH_MODE_REQUEST, args, from: 'server' }); + }); + + channel.on(TESTING_MODULE_CANCEL_TEST_RUN_REQUEST, (...args) => { + child.send({ type: TESTING_MODULE_CANCEL_TEST_RUN_REQUEST, args, from: 'server' }); + }); +} diff --git a/code/addons/test/src/node/reporter.ts b/code/addons/test/src/node/reporter.ts new file mode 100644 index 000000000000..6a735a8fe8a6 --- /dev/null +++ b/code/addons/test/src/node/reporter.ts @@ -0,0 +1,192 @@ +import type { TaskState } from 'vitest'; +import type { Vitest } from 'vitest/node'; +import { type Reporter } from 'vitest/reporters'; + +import type { + TestingModuleRunAssertionResultPayload, + TestingModuleRunResponsePayload, + TestingModuleRunTestResultPayload, +} from 'storybook/internal/core-events'; + +import type { API_StatusUpdate } from '@storybook/types'; + +import type { Suite } from '@vitest/runner'; +// TODO +// We can theoretically avoid the `@vitest/runner` dependency by copying over the necessary +// functions from the `@vitest/runner` package. It is not complex and does not have +// any significant dependencies. +import { getTests } from '@vitest/runner/utils'; + +import { TEST_PROVIDER_ID } from '../constants'; +import type { TestManager } from './test-manager'; + +type Status = 'passed' | 'failed' | 'skipped' | 'pending' | 'todo' | 'disabled'; + +function isDefined(value: any): value is NonNullable { + return value !== undefined && value !== null; +} + +const StatusMap: Record = { + fail: 'failed', + only: 'pending', + pass: 'passed', + run: 'pending', + skip: 'skipped', + todo: 'todo', +}; + +export default class StorybookReporter implements Reporter { + testStatusData: API_StatusUpdate = {}; + + start = 0; + + ctx!: Vitest; + + constructor(private testManager: TestManager) {} + + onInit(ctx: Vitest) { + this.ctx = ctx; + this.start = Date.now(); + } + + getProgressReport(): TestingModuleRunResponsePayload { + const files = this.ctx.state.getFiles(); + const fileTests = getTests(files); + // The number of total tests is dynamic and can change during the run + const numTotalTests = fileTests.length; + + const numFailedTests = fileTests.filter((t) => t.result?.state === 'fail').length; + const numPassedTests = fileTests.filter((t) => t.result?.state === 'pass').length; + const numPendingTests = fileTests.filter( + (t) => t.result?.state === 'run' || t.mode === 'skip' || t.result?.state === 'skip' + ).length; + const testResults: Array = []; + + for (const file of files) { + const tests = getTests([file]); + let startTime = tests.reduce( + (prev, next) => Math.min(prev, next.result?.startTime ?? Number.POSITIVE_INFINITY), + Number.POSITIVE_INFINITY + ); + if (startTime === Number.POSITIVE_INFINITY) { + startTime = this.start; + } + + const endTime = tests.reduce( + (prev, next) => + Math.max(prev, (next.result?.startTime ?? 0) + (next.result?.duration ?? 0)), + startTime + ); + + const assertionResults: TestingModuleRunAssertionResultPayload[] = tests + .map((t) => { + const ancestorTitles: string[] = []; + let iter: Suite | undefined = t.suite; + while (iter) { + ancestorTitles.push(iter.name); + iter = iter.suite; + } + ancestorTitles.reverse(); + + const status = StatusMap[t.result?.state || t.mode] || 'skipped'; + + if (status === 'passed' || status === 'pending') { + return { + status, + duration: t.result?.duration || 0, + storyId: (t.meta as any).storyId, + }; + } + + if (status === 'failed') { + return { + status, + duration: t.result?.duration || 0, + failureMessages: t.result?.errors?.map((e) => e.stack || e.message) || [], + storyId: (t.meta as any).storyId, + }; + } + + return null; + }) + .filter(isDefined); + + const hasFailedTests = tests.some((t) => t.result?.state === 'fail'); + + testResults.push({ + results: assertionResults, + startTime, + endTime, + status: file.result?.state === 'fail' || hasFailedTests ? 'failed' : 'passed', + message: file.result?.errors?.[0]?.message, + }); + } + + return { + numFailedTests, + numPassedTests, + numPendingTests, + numTotalTests, + testResults, + success: true, + // TODO + // It is not simply (numPassedTests + numFailedTests) / numTotalTests + // because numTotalTests is dyanmic and can change during the run + // We need to calculate the progress based on the number of tests that have been run + progress: 0, + startTime: this.start, + }; + } + + async onTaskUpdate() { + try { + const progress = this.getProgressReport(); + + this.testManager.sendProgressReport({ + status: 'success', + payload: progress, + providerId: TEST_PROVIDER_ID, + }); + } catch (e) { + if (e instanceof Error) { + this.testManager.sendProgressReport({ + status: 'failed', + providerId: TEST_PROVIDER_ID, + error: { + name: 'Failed to gather test results', + message: e.message, + stack: e.stack, + }, + }); + } else { + this.testManager.sendProgressReport({ + status: 'failed', + providerId: TEST_PROVIDER_ID, + error: { + name: 'Failed to gather test results', + message: String(e), + stack: undefined, + }, + }); + } + } + } + + // TODO + // Clearing the whole internal state of Vitest might be too aggressive + // Essentially, we want to reset the calculated total number of tests and the + // test results when a new test run starts, so that the getProgressReport + // method can calculate the correct values + async clearVitestState() { + this.ctx.state.filesMap.clear(); + this.ctx.state.pathsSet.clear(); + this.ctx.state.idMap.clear(); + this.ctx.state.errorsSet.clear(); + this.ctx.state.processTimeoutCauses.clear(); + } + + async onFinished() { + this.clearVitestState(); + } +} +export { StorybookReporter }; diff --git a/code/addons/test/src/node/test-manager.ts b/code/addons/test/src/node/test-manager.ts new file mode 100644 index 000000000000..f0542b930aa0 --- /dev/null +++ b/code/addons/test/src/node/test-manager.ts @@ -0,0 +1,84 @@ +import type { Channel } from 'storybook/internal/channels'; +import { + TESTING_MODULE_RUN_ALL_REQUEST, + TESTING_MODULE_RUN_PROGRESS_RESPONSE, + TESTING_MODULE_RUN_REQUEST, + TESTING_MODULE_WATCH_MODE_REQUEST, + type TestingModuleRunAllRequestPayload, + type TestingModuleRunProgressPayload, + type TestingModuleRunRequestPayload, + type TestingModuleWatchModeRequestPayload, +} from 'storybook/internal/core-events'; + +import { TEST_PROVIDER_ID } from '../constants'; +import { VitestManager } from './vitest-manager'; + +export class TestManager { + private vitestManager: VitestManager; + + watchMode = false; + + constructor(private channel: Channel) { + process.env.TEST = 'true'; + process.env.VITEST = 'true'; + process.env.NODE_ENV ??= 'test'; + + this.vitestManager = new VitestManager(channel, this); + + this.channel.on(TESTING_MODULE_RUN_REQUEST, this.handleRunRequest.bind(this)); + this.channel.on(TESTING_MODULE_RUN_ALL_REQUEST, this.handleRunAllRequest.bind(this)); + this.channel.on(TESTING_MODULE_WATCH_MODE_REQUEST, this.handleWatchModeRequest.bind(this)); + } + + async restartVitest(watchMode = false) { + await this.vitestManager.closeVitest(); + await this.vitestManager.startVitest(watchMode); + } + + async handleWatchModeRequest(request: TestingModuleWatchModeRequestPayload) { + try { + if (request.providerId !== TEST_PROVIDER_ID) { + return; + } + + if (this.watchMode !== request.watchMode) { + this.watchMode = request.watchMode; + await this.restartVitest(this.watchMode); + } + } catch (e) { + this.reportFatalError('Failed to change watch mode', e); + } + } + + async handleRunRequest(request: TestingModuleRunRequestPayload) { + try { + if (request.providerId !== TEST_PROVIDER_ID) { + return; + } + + await this.vitestManager.runTests(request.payload); + } catch (e) { + this.reportFatalError('Failed to run tests', e); + } + } + + async handleRunAllRequest(request: TestingModuleRunAllRequestPayload) { + try { + if (request.providerId !== TEST_PROVIDER_ID) { + return; + } + + await this.vitestManager.runAllTests(); + } catch (e) { + this.reportFatalError('Failed to run all tests', e); + } + } + + async sendProgressReport(payload: TestingModuleRunProgressPayload) { + this.channel.emit(TESTING_MODULE_RUN_PROGRESS_RESPONSE, payload); + } + + async reportFatalError(message: string, error: Error | any) { + process.send?.({ type: 'error', message, error: error.stack ?? error }); + } +} diff --git a/code/addons/test/src/node/vitest-manager.ts b/code/addons/test/src/node/vitest-manager.ts new file mode 100644 index 000000000000..98460f9e2926 --- /dev/null +++ b/code/addons/test/src/node/vitest-manager.ts @@ -0,0 +1,104 @@ +import path from 'node:path'; + +import type { TestProject, TestSpecification, Vitest, WorkspaceProject } from 'vitest/node'; + +import type { Channel } from 'storybook/internal/channels'; +import type { TestingModuleRunRequestPayload } from 'storybook/internal/core-events'; + +import StorybookReporter from './reporter'; +import type { TestManager } from './test-manager'; + +export class VitestManager { + vitest: Vitest | null = null; + + vitestStartupCounter = 0; + + constructor( + private channel: Channel, + private testManager: TestManager + ) {} + + async startVitest(watchMode = false) { + const { createVitest } = await import('vitest/node'); + + this.vitest = await createVitest('test', { + watch: watchMode, + passWithNoTests: true, + // standalone: true, + changed: watchMode, + // 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: { + enabled: false, + }, + }); + + if (!this.vitest || this.vitest.projects.length < 1) { + return; + } + + await this.vitest.init(); + } + + async runAllTests() { + if (!this.vitest) { + await this.startVitest(); + } + + const tests = await this.getStorybookTestSpecs(); + await this.cancelCurrentRun(); + await this.vitest!.runFiles(tests, true); + } + + async runTests(testPayload: TestingModuleRunRequestPayload['payload']) { + if (!this.vitest) { + await this.startVitest(); + } + + // This list contains all the test files (story files) that need to be run + // based on the test files that are passed in the tests array + // This list does NOT contain any filtering of specific + // test cases (story) within the test files + const testList: TestSpecification[] = []; + + const storybookTests = await this.getStorybookTestSpecs(); + + for (const storybookTest of storybookTests) { + const match = testPayload.find((test) => { + const absoluteImportPath = path.join(process.cwd(), test.importPath); + return absoluteImportPath === storybookTest.moduleId; + }); + if (match) { + testList.push(storybookTest); + } + } + + await this.cancelCurrentRun(); + await this.vitest!.runFiles(testList, true); + } + + async cancelCurrentRun() { + await this.vitest?.cancelCurrentRun('keyboard-input'); + await this.vitest?.runningPromise; + } + + async closeVitest() { + await this.vitest?.close(); + } + + async getStorybookTestSpecs() { + const globTestSpecs = (await this.vitest?.globTestSpecs()) ?? []; + return ( + globTestSpecs.filter((workspaceSpec) => this.isStorybookProject(workspaceSpec.project)) ?? [] + ); + } + + isStorybookProject(project: TestProject | WorkspaceProject) { + // eslint-disable-next-line no-underscore-dangle + return !!project.config.env?.__STORYBOOK_URL__; + } +} diff --git a/code/addons/test/src/node/vitest.ts b/code/addons/test/src/node/vitest.ts new file mode 100644 index 000000000000..b9053c2caaf7 --- /dev/null +++ b/code/addons/test/src/node/vitest.ts @@ -0,0 +1,35 @@ +import process from 'node:process'; + +import { Channel } from 'storybook/internal/channels'; + +import { TestManager } from './test-manager'; + +process.env.TEST = 'true'; +process.env.VITEST = 'true'; +process.env.NODE_ENV ??= 'test'; + +const channel: Channel = new Channel({ + async: true, + transport: { + send: (event) => { + if (process.send) { + process.send(event); + } + }, + setHandler: (handler) => { + process.on('message', handler); + }, + }, +}); + +const testManager = new TestManager(channel); +testManager.restartVitest(); + +process.on('uncaughtException', (err) => { + process.send?.({ type: 'error', message: 'Uncaught Exception', error: err.stack }); + process.exit(1); +}); + +process.on('unhandledRejection', (reason) => { + throw new Error(`Unhandled Rejection: ${reason}`); +}); diff --git a/code/addons/test/src/preset.ts b/code/addons/test/src/preset.ts index 8b4bc31a9013..be1665356f66 100644 --- a/code/addons/test/src/preset.ts +++ b/code/addons/test/src/preset.ts @@ -1,7 +1,10 @@ import type { Channel } from 'storybook/internal/channels'; import type { Options } from 'storybook/internal/types'; +import { bootTestRunner } from './node/boot-test-runner'; + // eslint-disable-next-line @typescript-eslint/naming-convention export const experimental_serverChannel = async (channel: Channel, options: Options) => { + bootTestRunner(channel); return channel; }; diff --git a/code/core/src/core-events/data/testing-module.ts b/code/core/src/core-events/data/testing-module.ts new file mode 100644 index 000000000000..fa83bc6dd6d9 --- /dev/null +++ b/code/core/src/core-events/data/testing-module.ts @@ -0,0 +1,87 @@ +export type ProviderId = string; + +export type TestingModuleRunRequestStories = { + id: string; + name: string; +}; + +export type TestingModuleRunRequestPayload = { + providerId: ProviderId; + payload: { + stories: TestingModuleRunRequestStories[]; + importPath: string; + componentPath: string; + }[]; +}; + +export type TestingModuleRunAllRequestPayload = { + providerId: ProviderId; +}; + +export type TestingModuleRunProgressPayload = + | { + providerId: ProviderId; + payload: TestingModuleRunResponsePayload; + status: 'success' | 'pending'; + } + | { + providerId: ProviderId; + error: { + name: string; + message: string; + stack?: string; + }; + status: 'failed'; + }; + +export type TestingModuleRunResponsePayload = { + numTotalTests: number; + numPassedTests: number; + numFailedTests: number; + numPendingTests: number; + progress: number; + startTime: number; + success: boolean; + testResults: TestingModuleRunTestResultPayload[]; +}; + +export type TestingModuleRunTestResultPayload = { + results: TestingModuleRunAssertionResultPayload[]; + startTime: number; + endTime: number; + status: 'passed' | 'failed'; + message?: string; +}; + +export type TestingModuleRunAssertionResultPayload = + | { + status: 'success' | 'pending'; + duration: number; + storyId: string; + } + | { + status: 'failed'; + duration: number; + failureMessages: string[]; + storyId: string; + }; + +export type Status = 'success' | 'failed' | 'pending'; + +export type TestingModuleCancelTestRunRequestPayload = { + providerId: ProviderId; +}; + +export type TestingModuleCancelTestRunResponsePayload = + | { + status: 'success'; + } + | { + status: 'failed'; + message: string; + }; + +export type TestingModuleWatchModeRequestPayload = { + providerId: ProviderId; + watchMode: boolean; +}; diff --git a/code/core/src/core-events/index.ts b/code/core/src/core-events/index.ts index 90eed7b0d5b6..3ebf839d43c6 100644 --- a/code/core/src/core-events/index.ts +++ b/code/core/src/core-events/index.ts @@ -84,6 +84,13 @@ enum events { ARGTYPES_INFO_RESPONSE = 'argtypesInfoResponse', CREATE_NEW_STORYFILE_REQUEST = 'createNewStoryfileRequest', CREATE_NEW_STORYFILE_RESPONSE = 'createNewStoryfileResponse', + + TESTING_MODULE_RUN_REQUEST = 'testingModuleRunRequest', + TESTING_MODULE_RUN_PROGRESS_RESPONSE = 'testingModuleRunProgressResponse', + TESTING_MODULE_RUN_ALL_REQUEST = 'testingModuleRunAllRequest', + TESTING_MODULE_CANCEL_TEST_RUN_REQUEST = 'testingModuleCancelTestRunRequest', + TESTING_MODULE_CANCEL_TEST_RUN_RESPONSE = 'testingModuleCancelTestRunResponse', + TESTING_MODULE_WATCH_MODE_REQUEST = 'testingModuleWatchModeRequest', } // Enables: `import Events from ...` @@ -147,6 +154,12 @@ export const { SAVE_STORY_RESPONSE, ARGTYPES_INFO_REQUEST, ARGTYPES_INFO_RESPONSE, + TESTING_MODULE_RUN_REQUEST, + TESTING_MODULE_RUN_PROGRESS_RESPONSE, + TESTING_MODULE_RUN_ALL_REQUEST, + TESTING_MODULE_CANCEL_TEST_RUN_REQUEST, + TESTING_MODULE_CANCEL_TEST_RUN_RESPONSE, + TESTING_MODULE_WATCH_MODE_REQUEST, } = events; export * from './data/create-new-story'; @@ -155,3 +168,4 @@ export * from './data/argtypes-info'; export * from './data/request-response'; export * from './data/save-story'; export * from './data/whats-new'; +export * from './data/testing-module'; diff --git a/code/core/src/manager/components/sidebar/Sidebar.tsx b/code/core/src/manager/components/sidebar/Sidebar.tsx index e937012c43e6..f658f00e4bf8 100644 --- a/code/core/src/manager/components/sidebar/Sidebar.tsx +++ b/code/core/src/manager/components/sidebar/Sidebar.tsx @@ -1,10 +1,16 @@ -import React, { useMemo } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; -import { ScrollArea, Spaced } from '@storybook/core/components'; +import { Button, ScrollArea, Spaced } from '@storybook/core/components'; import { styled } from '@storybook/core/theming'; import type { API_LoadedRefData, Addon_SidebarTopType } from '@storybook/core/types'; -import type { State } from '@storybook/core/manager-api'; +import { + TESTING_MODULE_RUN_ALL_REQUEST, + TESTING_MODULE_WATCH_MODE_REQUEST, + type TestingModuleRunAllRequestPayload, + type TestingModuleWatchModeRequestPayload, +} from '@storybook/core/core-events'; +import { type State, useStorybookApi } from '@storybook/core/manager-api'; import { MEDIA_DESKTOP_BREAKPOINT } from '../../constants'; import { Explorer } from './Explorer'; @@ -13,6 +19,7 @@ import { Heading } from './Heading'; import { Search } from './Search'; import { SearchResults } from './SearchResults'; import { SidebarBottom } from './SidebarBottom'; +import { TEST_PROVIDER_ID } from './Tree'; import type { CombinedDataset, Selection } from './types'; import { useLastViewed } from './useLastViewed'; @@ -133,6 +140,15 @@ export const Sidebar = React.memo(function Sidebar({ const dataset = useCombination(index, indexError, previewInitialized, status, refs); const isLoading = !index && !indexError; const lastViewedProps = useLastViewed(selected); + const api = useStorybookApi(); + const [watchMode, setWatchMode] = useState(false); + + useEffect(() => { + api.emit(TESTING_MODULE_WATCH_MODE_REQUEST, { + providerId: TEST_PROVIDER_ID, + watchMode, + } as TestingModuleWatchModeRequestPayload); + }, [api, watchMode]); return ( @@ -188,6 +204,19 @@ export const Sidebar = React.memo(function Sidebar({ {isLoading ? null : ( + + )} diff --git a/code/core/src/manager/components/sidebar/Tree.tsx b/code/core/src/manager/components/sidebar/Tree.tsx index 4f542d3140a7..0d5c990cb595 100644 --- a/code/core/src/manager/components/sidebar/Tree.tsx +++ b/code/core/src/manager/components/sidebar/Tree.tsx @@ -6,14 +6,21 @@ import { styled, useTheme } from '@storybook/core/theming'; import { CollapseIcon as CollapseIconSvg, ExpandAltIcon, + PlayIcon, StatusFailIcon, StatusPassIcon, StatusWarnIcon, SyncIcon, } from '@storybook/icons'; -import type { API_StatusValue, StoryId } from '@storybook/types'; +import type { API_HashEntry, API_StatusValue, StoryId } from '@storybook/types'; -import { PRELOAD_ENTRIES } from '@storybook/core/core-events'; +import { + PRELOAD_ENTRIES, + TESTING_MODULE_RUN_REQUEST, + type TestingModuleRunRequestPayload, + type TestingModuleRunRequestStories, +} from '@storybook/core/core-events'; +import { useStorybookApi } from '@storybook/core/manager-api'; import type { API, ComponentEntry, @@ -22,7 +29,6 @@ import type { StoriesHash, StoryEntry, } from '@storybook/core/manager-api'; -import { useStorybookApi } from '@storybook/core/manager-api'; import { transparentize } from 'polished'; @@ -44,6 +50,9 @@ import type { Highlight, Item } from './types'; import type { ExpandAction, ExpandedState } from './useExpanded'; import { useExpanded } from './useExpanded'; +export const TEST_ADDON_ID = 'storybook/test'; +export const TEST_PROVIDER_ID = `${TEST_ADDON_ID}/test-provider`; + const Container = styled.div<{ hasOrphans: boolean }>((props) => ({ marginTop: props.hasOrphans ? 20 : 0, marginBottom: 20, @@ -133,6 +142,7 @@ interface NodeProps { status: State['status'][keyof State['status']]; groupStatus: Record; api: API; + collapsedData: Record; } const Node = React.memo(function Node({ @@ -149,6 +159,7 @@ const Node = React.memo(function Node({ isExpanded, setExpanded, onSelectStoryId, + collapsedData, api, }) { const { isDesktop, isMobile, setMobileMenuOpen } = useLayout(); @@ -264,21 +275,64 @@ const Node = React.memo(function Node({ {item.renderLabel?.(item, api) || item.name} - {isExpanded && ( +
+ {isExpanded && ( + { + event.preventDefault(); + // @ts-expect-error (non strict) + setFullyExpanded(); + }} + > + {isFullyExpanded ? : } + + )} { - event.preventDefault(); - // @ts-expect-error (non strict) - setFullyExpanded(); + onClick={() => { + type ImportPath = string; + type ComponentPath = string; + + const importPathMap = new Map(); + const storyFilesMap = new Map(); + + const traverseChildren = (children: string[]) => { + children.forEach((childId) => { + const child = collapsedData[childId]; + if (child.type === 'story') { + const componentPath = (child as any).componentPath; + const importPath = child.importPath; + const storyFile = storyFilesMap.get(importPath) || []; + + storyFile.push({ id: child.id, name: child.name }); + storyFilesMap.set(importPath, storyFile); + importPathMap.set(importPath, componentPath); + } else if (child.type !== 'docs') { + traverseChildren(child.children); + } + }); + }; + + traverseChildren(item.children); + + const testingModuleRunRequestPayload: TestingModuleRunRequestPayload = { + providerId: TEST_PROVIDER_ID, + payload: Array.from(importPathMap.entries()).map(([importPath, componentPath]) => ({ + importPath, + componentPath, + stories: storyFilesMap.get(importPath) ?? [], + })), + }; + + api.emit(TESTING_MODULE_RUN_REQUEST, testingModuleRunRequestPayload); }} > - {isFullyExpanded ? : } + - )} +
); } @@ -560,9 +614,11 @@ export const Tree = React.memo<{ return ( // @ts-expect-error (TODO) = 4.7.x" + typescript: ">= 4.8.x" peerDependenciesMeta: typescript: optional: true bin: msw: cli/index.js - checksum: 10c0/f944d8eb67ccdcf74aba0ce433395a9616420b8b36d43f033635817ef7535553c7bf6854392a95b093546e3481ca99e9017525b6c0bd02f51528b669e672214d + checksum: 10c0/33a8c5697f7cb003a2af33ff6b259eaf7babf180fadf0697d107d0856ab0d2ff1a80d319e788d9127f289ff091334bee589f348180a1fdd0914bf8c4725830dc languageName: node linkType: hard @@ -22077,7 +22147,7 @@ __metadata: languageName: node linkType: hard -"outvariant@npm:^1.2.1, outvariant@npm:^1.4.0, outvariant@npm:^1.4.2": +"outvariant@npm:^1.4.0, outvariant@npm:^1.4.2, outvariant@npm:^1.4.3": version: 1.4.3 resolution: "outvariant@npm:1.4.3" checksum: 10c0/5976ca7740349cb8c71bd3382e2a762b1aeca6f33dc984d9d896acdf3c61f78c3afcf1bfe9cc633a7b3c4b295ec94d292048f83ea2b2594fae4496656eba992c @@ -22579,10 +22649,10 @@ __metadata: languageName: node linkType: hard -"path-to-regexp@npm:^6.2.0": - version: 6.2.2 - resolution: "path-to-regexp@npm:6.2.2" - checksum: 10c0/4b60852d3501fd05ca9dd08c70033d73844e5eca14e41f499f069afa8364f780f15c5098002f93bd42af8b3514de62ac6e82a53b5662de881d2b08c9ef21ea6b +"path-to-regexp@npm:^6.3.0": + version: 6.3.0 + resolution: "path-to-regexp@npm:6.3.0" + checksum: 10c0/73b67f4638b41cde56254e6354e46ae3a2ebc08279583f6af3d96fe4664fc75788f74ed0d18ca44fa4a98491b69434f9eee73b97bb5314bd1b5adb700f5c18d6 languageName: node linkType: hard @@ -27094,10 +27164,17 @@ __metadata: languageName: node linkType: hard -"tinybench@npm:^2.8.0": - version: 2.8.0 - resolution: "tinybench@npm:2.8.0" - checksum: 10c0/5a9a642351fa3e4955e0cbf38f5674be5f3ba6730fd872fd23a5c953ad6c914234d5aba6ea41ef88820180a81829ceece5bd8d3967c490c5171bca1141c2f24d +"tinybench@npm:^2.9.0": + version: 2.9.0 + resolution: "tinybench@npm:2.9.0" + checksum: 10c0/c3500b0f60d2eb8db65250afe750b66d51623057ee88720b7f064894a6cb7eb93360ca824a60a31ab16dab30c7b1f06efe0795b352e37914a9d4bad86386a20c + languageName: node + linkType: hard + +"tinyexec@npm:^0.3.0": + version: 0.3.0 + resolution: "tinyexec@npm:0.3.0" + checksum: 10c0/138a4f4241aea6b6312559508468ab275a31955e66e2f57ed206e0aaabecee622624f208c5740345f0a66e33478fd065e359ed1eb1269eb6fd4fa25d44d0ba3b languageName: node linkType: hard @@ -28515,18 +28592,17 @@ __metadata: languageName: node linkType: hard -"vite-node@npm:2.0.5": - version: 2.0.5 - resolution: "vite-node@npm:2.0.5" +"vite-node@npm:2.1.1": + version: 2.1.1 + resolution: "vite-node@npm:2.1.1" dependencies: cac: "npm:^6.7.14" - debug: "npm:^4.3.5" + debug: "npm:^4.3.6" pathe: "npm:^1.1.2" - tinyrainbow: "npm:^1.2.0" vite: "npm:^5.0.0" bin: vite-node: vite-node.mjs - checksum: 10c0/affcc58ae8d45bce3e8bc3b5767acd57c24441634e2cd967cf97f4e5ed2bcead1714b60150cdf7ee153ebad47659c5cd419883207e1a95b69790331e3243749f + checksum: 10c0/8a8b958df3d48af915e07e7efb042ee4c036ca0b73d2c411dc29254fd3533ada0807ce5096d8339894d3e786418b7d1a9c4ae02718c6aca11b5098de2b14c336 languageName: node linkType: hard @@ -28666,34 +28742,34 @@ __metadata: languageName: node linkType: hard -"vitest@npm:^2.0.0, vitest@npm:^2.0.5": - version: 2.0.5 - resolution: "vitest@npm:2.0.5" - dependencies: - "@ampproject/remapping": "npm:^2.3.0" - "@vitest/expect": "npm:2.0.5" - "@vitest/pretty-format": "npm:^2.0.5" - "@vitest/runner": "npm:2.0.5" - "@vitest/snapshot": "npm:2.0.5" - "@vitest/spy": "npm:2.0.5" - "@vitest/utils": "npm:2.0.5" +"vitest@npm:^2.1.1": + version: 2.1.1 + resolution: "vitest@npm:2.1.1" + dependencies: + "@vitest/expect": "npm:2.1.1" + "@vitest/mocker": "npm:2.1.1" + "@vitest/pretty-format": "npm:^2.1.1" + "@vitest/runner": "npm:2.1.1" + "@vitest/snapshot": "npm:2.1.1" + "@vitest/spy": "npm:2.1.1" + "@vitest/utils": "npm:2.1.1" chai: "npm:^5.1.1" - debug: "npm:^4.3.5" - execa: "npm:^8.0.1" - magic-string: "npm:^0.30.10" + debug: "npm:^4.3.6" + magic-string: "npm:^0.30.11" pathe: "npm:^1.1.2" std-env: "npm:^3.7.0" - tinybench: "npm:^2.8.0" + tinybench: "npm:^2.9.0" + tinyexec: "npm:^0.3.0" tinypool: "npm:^1.0.0" tinyrainbow: "npm:^1.2.0" vite: "npm:^5.0.0" - vite-node: "npm:2.0.5" + vite-node: "npm:2.1.1" why-is-node-running: "npm:^2.3.0" peerDependencies: "@edge-runtime/vm": "*" "@types/node": ^18.0.0 || >=20.0.0 - "@vitest/browser": 2.0.5 - "@vitest/ui": 2.0.5 + "@vitest/browser": 2.1.1 + "@vitest/ui": 2.1.1 happy-dom: "*" jsdom: "*" peerDependenciesMeta: @@ -28711,7 +28787,7 @@ __metadata: optional: true bin: vitest: vitest.mjs - checksum: 10c0/b4e6cca00816bf967a8589111ded72faa12f92f94ccdd0dcd0698ffcfdfc52ec662753f66b387549c600ac699b993fd952efbd99dc57fcf4d1c69a2f1022b259 + checksum: 10c0/77a67092338613376dadd8f6f6872383db8409402ce400ac1de48efd87a7214183e798484a3eb2310221c03554e37a00f9fdbc91e49194e7c68e009a5589f494 languageName: node linkType: hard