diff --git a/.github/workflows/nx.yml b/.github/workflows/nx.yml index 226ee2b8ad91..ff5c96043417 100644 --- a/.github/workflows/nx.yml +++ b/.github/workflows/nx.yml @@ -21,13 +21,14 @@ env: jobs: nx: if: > - (github.event_name == 'pull_request' && - github.event.pull_request.head.repo.full_name == github.repository && - (contains(github.event.pull_request.labels.*.name, 'ci:normal') || - contains(github.event.pull_request.labels.*.name, 'ci:merged') || - contains(github.event.pull_request.labels.*.name, 'ci:daily')) - ) || (github.event_name == 'push' && github.ref == 'refs/heads/next') || - (github.event_name == 'schedule') + github.repository == 'storybookjs/storybook' && + ((github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.repository && + (contains(github.event.pull_request.labels.*.name, 'ci:normal') || + contains(github.event.pull_request.labels.*.name, 'ci:merged') || + contains(github.event.pull_request.labels.*.name, 'ci:daily')) + ) || (github.event_name == 'push' && github.ref == 'refs/heads/next') || + (github.event_name == 'schedule')) runs-on: ubuntu-latest env: diff --git a/CHANGELOG.prerelease.md b/CHANGELOG.prerelease.md index 505582cfd353..cc7aa61942e9 100644 --- a/CHANGELOG.prerelease.md +++ b/CHANGELOG.prerelease.md @@ -1,3 +1,13 @@ +## 10.3.0-alpha.8 + +- A11y: Ensure popover dialogs have an ARIA label - [#33500](https://github.com/storybookjs/storybook/pull/33500), thanks @gayanMatch! +- Addon-Vitest: Add channel API to programmatically trigger test runs - [#33206](https://github.com/storybookjs/storybook/pull/33206), thanks @JReinhold! +- Builder-Vite: Centralize Vite plugins for builder-vite and addon-vitest - [#33819](https://github.com/storybookjs/storybook/pull/33819), thanks @valentinpalkovic! +- Core: Revert Pull Request #33420 from Maelryn/fix/copy-button-overlap - [#33877](https://github.com/storybookjs/storybook/pull/33877), thanks @Sidnioulz! +- Next.js-Vite: Fix failing postcss mutation - [#33879](https://github.com/storybookjs/storybook/pull/33879), thanks @valentinpalkovic! +- React: Fix manifest stories empty when meta has no explicit title - [#33878](https://github.com/storybookjs/storybook/pull/33878), thanks @kasperpeulen! +- UI: Fix Copy button overlapping code in portrait mode - [#33420](https://github.com/storybookjs/storybook/pull/33420), thanks @Maelryn! + ## 10.3.0-alpha.7 - Core: Require token for websocket connections - [#33820](https://github.com/storybookjs/storybook/pull/33820), thanks @ghengeveld! diff --git a/MIGRATION.md b/MIGRATION.md index 0805c3682b8b..2b6ba2137b9c 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -627,6 +627,16 @@ The PopoverProvider component acts as a counterpoint to WithTooltip. When you wa PopoverProvider is based on react-aria. It must have a single child that acts as a trigger. This child must have a pressable role (can be clicked or pressed) and must be able to receive React refs. Wrap your trigger component in `forwardRef` if you notice placement issues for your popover. +##### Added: ariaLabel + +The `ariaLabel` prop was added in Storybook 10.3 to provide an accessible label for the popover dialog. This label is announced by screen readers when the popover opens. `ariaLabel` will become mandatory in Storybook 11. + +```tsx +}> + + +``` + #### WithTooltip Component API Changes The WithTooltip component has been reimplemented from the ground up, under the new name `TooltipProvider`. The new implementation will replace `WithTooltip` entirely in Storybook 11. Below is a summary of the changes between both APIs, which will take full effect in Storybook 11. diff --git a/code/addons/a11y/build-config.ts b/code/addons/a11y/build-config.ts index 318e527e3469..6313a6e261f6 100644 --- a/code/addons/a11y/build-config.ts +++ b/code/addons/a11y/build-config.ts @@ -23,6 +23,11 @@ const config: BuildEntries = { entryPoint: './src/postinstall.ts', dts: false, }, + { + exportEntries: ['./preset'], + entryPoint: './src/preset.ts', + dts: false, + }, ], }, }; diff --git a/code/addons/a11y/package.json b/code/addons/a11y/package.json index 4337551f2bc3..ec11c64e135b 100644 --- a/code/addons/a11y/package.json +++ b/code/addons/a11y/package.json @@ -43,6 +43,7 @@ "./manager": "./dist/manager.js", "./package.json": "./package.json", "./postinstall": "./dist/postinstall.js", + "./preset": "./dist/preset.js", "./preview": { "types": "./dist/preview.d.ts", "code": "./src/preview.tsx", diff --git a/code/addons/a11y/preset.js b/code/addons/a11y/preset.js new file mode 100644 index 000000000000..4bd63d324002 --- /dev/null +++ b/code/addons/a11y/preset.js @@ -0,0 +1 @@ +export * from './dist/preset.js'; diff --git a/code/addons/a11y/src/components/A11yContext.tsx b/code/addons/a11y/src/components/A11yContext.tsx index 48d43fa92225..90c6f2f3f328 100644 --- a/code/addons/a11y/src/components/A11yContext.tsx +++ b/code/addons/a11y/src/components/A11yContext.tsx @@ -27,7 +27,7 @@ import { convert, themes } from 'storybook/theming'; import { getFriendlySummaryForAxeResult, getTitleForAxeResult } from '../axeRuleMappingHelper'; import { ADDON_ID, EVENTS, STATUS_TYPE_ID_A11Y, STATUS_TYPE_ID_COMPONENT_TEST } from '../constants'; import type { A11yParameters } from '../params'; -import type { A11YReport, EnhancedResult, EnhancedResults, Status } from '../types'; +import type { A11yReport, EnhancedResult, EnhancedResults, Status } from '../types'; import { RuleType } from '../types'; import type { TestDiscrepancy } from './TestDiscrepancyMessage'; @@ -244,7 +244,7 @@ export const A11yContextProvider: FC = (props) => { const handleReport = useCallback( ({ reporters }: StoryFinishedPayload) => { - const a11yReport = reporters.find((r) => r.type === 'a11y') as Report | undefined; + const a11yReport = reporters.find((r) => r.type === 'a11y') as Report | undefined; if (a11yReport) { if ('error' in a11yReport.result) { diff --git a/code/addons/a11y/src/index.ts b/code/addons/a11y/src/index.ts index 5f447f93eb29..d85e433a1fc0 100644 --- a/code/addons/a11y/src/index.ts +++ b/code/addons/a11y/src/index.ts @@ -5,6 +5,6 @@ import type { A11yTypes } from './types'; export { PARAM_KEY } from './constants'; export * from './params'; -export type { A11yGlobals, A11yTypes } from './types'; +export type { A11yGlobals, A11yTypes, A11yReport } from './types'; export default () => definePreviewAddon(addonAnnotations); diff --git a/code/addons/a11y/src/preset.ts b/code/addons/a11y/src/preset.ts new file mode 100644 index 000000000000..040808d5d523 --- /dev/null +++ b/code/addons/a11y/src/preset.ts @@ -0,0 +1,3 @@ +// enables other addons/presets to detect if a11y is enabled and adjust their behavior accordingly +// using await presets.apply('isAddonA11yEnabled', false); +export const isAddonA11yEnabled = true; diff --git a/code/addons/a11y/src/types.ts b/code/addons/a11y/src/types.ts index c8be7acf7d0e..73a010e48138 100644 --- a/code/addons/a11y/src/types.ts +++ b/code/addons/a11y/src/types.ts @@ -2,7 +2,7 @@ import type { AxeResults, NodeResult, Result } from 'axe-core'; import type { A11yParameters as A11yParams } from './params'; -export type A11YReport = EnhancedResults | { error: Error }; +export type A11yReport = EnhancedResults | { error: Error }; export interface A11yParameters { /** diff --git a/code/addons/docs/src/blocks/components/ArgsTable/ArgValue.tsx b/code/addons/docs/src/blocks/components/ArgsTable/ArgValue.tsx index 1be3d2075744..66a723c592ad 100644 --- a/code/addons/docs/src/blocks/components/ArgsTable/ArgValue.tsx +++ b/code/addons/docs/src/blocks/components/ArgsTable/ArgValue.tsx @@ -203,6 +203,7 @@ const ArgSummary: FC = ({ value, initialExpandedArgs }) => { return ( { diff --git a/code/addons/docs/src/blocks/controls/Color.tsx b/code/addons/docs/src/blocks/controls/Color.tsx index 3c3610c21621..0758f7b8611c 100644 --- a/code/addons/docs/src/blocks/controls/Color.tsx +++ b/code/addons/docs/src/blocks/controls/Color.tsx @@ -393,6 +393,7 @@ export const ColorControl: FC = ({ placeholder="Choose color..." /> color && addPreset(color)} diff --git a/code/addons/vitest/build-config.ts b/code/addons/vitest/build-config.ts index db187f466942..9e5f242e6329 100644 --- a/code/addons/vitest/build-config.ts +++ b/code/addons/vitest/build-config.ts @@ -24,6 +24,10 @@ const config: BuildEntries = { }, ], node: [ + { + exportEntries: ['./constants'], + entryPoint: './src/constants.ts', + }, { exportEntries: ['./preset'], entryPoint: './src/preset.ts', diff --git a/code/addons/vitest/package.json b/code/addons/vitest/package.json index 8358acdc5c0b..be2be1eea930 100644 --- a/code/addons/vitest/package.json +++ b/code/addons/vitest/package.json @@ -43,6 +43,11 @@ "code": "./src/index.ts", "default": "./dist/index.js" }, + "./constants": { + "types": "./dist/constants.d.ts", + "code": "./src/constants.ts", + "default": "./dist/constants.js" + }, "./internal/coverage-reporter": "./dist/node/coverage-reporter.js", "./internal/global-setup": "./dist/vitest-plugin/global-setup.js", "./internal/setup-file": "./dist/vitest-plugin/setup-file.js", @@ -74,6 +79,7 @@ "@storybook/icons": "^2.0.1" }, "devDependencies": { + "@storybook/addon-a11y": "workspace:*", "@types/istanbul-lib-report": "^3.0.3", "@types/micromatch": "^4.0.0", "@types/node": "^22.19.1", diff --git a/code/addons/vitest/src/constants.ts b/code/addons/vitest/src/constants.ts index 0a8fb9d3a9ad..e49ef528030e 100644 --- a/code/addons/vitest/src/constants.ts +++ b/code/addons/vitest/src/constants.ts @@ -1,6 +1,6 @@ import type { StoreOptions } from 'storybook/internal/types'; -import type { RunTrigger, StoreState } from './types'; +import type { CurrentRun, RunTrigger, StoreState } from './types'; export { PANEL_ID as COMPONENT_TESTING_PANEL_ID } from '../../../core/src/component-testing/constants'; export { @@ -28,7 +28,7 @@ export const storeOptions = { watching: false, cancelling: false, fatalError: undefined, - indexUrl: undefined, + index: { entries: {}, v: 5 }, previewAnnotations: [], currentRun: { triggeredBy: undefined, @@ -36,6 +36,9 @@ export const storeOptions = { coverage: false, a11y: false, }, + componentTestStatuses: [], + a11yStatuses: [], + a11yReports: {}, componentTestCount: { success: 0, error: 0, @@ -63,3 +66,26 @@ export const TEST_PROVIDER_STORE_CHANNEL_EVENT_NAME = 'UNIVERSAL_STORE:storybook export const STATUS_TYPE_ID_COMPONENT_TEST = 'storybook/component-test'; export const STATUS_TYPE_ID_A11Y = 'storybook/a11y'; + +// Channel event names for programmatic test triggering +export const TRIGGER_TEST_RUN_REQUEST = `${ADDON_ID}/trigger-test-run-request`; +export const TRIGGER_TEST_RUN_RESPONSE = `${ADDON_ID}/trigger-test-run-response`; + +export type TriggerTestRunRequestPayload = { + requestId: string; + actor: string; + storyIds?: string[]; + config?: Partial; +}; + +export type TestRunResult = CurrentRun; + +export type TriggerTestRunResponsePayload = { + requestId: string; + status: 'completed' | 'error' | 'cancelled'; + result?: TestRunResult; + error?: { + message: string; + error?: import('./types').ErrorLike; + }; +}; diff --git a/code/addons/vitest/src/manager.tsx b/code/addons/vitest/src/manager.tsx index 14e4e0815f2d..1be9319fdc4d 100644 --- a/code/addons/vitest/src/manager.tsx +++ b/code/addons/vitest/src/manager.tsx @@ -42,14 +42,8 @@ addons.register(ADDON_ID, (api) => { }, }); }); - store.untilReady().then(() => { - store.setState((state) => ({ - ...state, - indexUrl: new URL('index.json', window.location.href).toString(), - })); - store.subscribe('TEST_RUN_COMPLETED', ({ payload }) => { - api.emit(STORYBOOK_ADDON_TEST_CHANNEL, { type: 'test-run-completed', payload }); - }); + store.subscribe('TEST_RUN_COMPLETED', ({ payload }) => { + api.emit(STORYBOOK_ADDON_TEST_CHANNEL, { type: 'test-run-completed', payload }); }); addons.add(TEST_PROVIDER_ID, { diff --git a/code/addons/vitest/src/node/test-manager.test.ts b/code/addons/vitest/src/node/test-manager.test.ts index 3a5668e645f3..736e688ab6fc 100644 --- a/code/addons/vitest/src/node/test-manager.test.ts +++ b/code/addons/vitest/src/node/test-manager.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { TestResult } from 'vitest/node'; -import { Channel, type ChannelTransport } from 'storybook/internal/channels'; import { Tag, experimental_MockUniversalStore } from 'storybook/internal/core-server'; import type { Options, @@ -52,16 +52,78 @@ vi.mock('vitest/node', async (importOriginal) => ({ // Use the mock function directly const createVitest = mockCreateVitest; -const transport = { setHandler: vi.fn(), send: vi.fn() } satisfies ChannelTransport; - beforeEach(() => { createVitest.mockResolvedValue(vitest); }); -const mockChannel = new Channel({ transport }); + +const mockIndex = { + v: 5, + entries: { + 'story--one': { + type: 'story', + subtype: 'story', + id: 'story--one', + name: 'One', + title: 'story/one', + importPath: 'path/to/file', + tags: [Tag.TEST], + }, + 'another--one': { + type: 'story', + subtype: 'story', + id: 'another--one', + name: 'One', + title: 'another/one', + importPath: 'path/to/another/file', + tags: [Tag.TEST], + }, + 'story--two': { + type: 'story', + subtype: 'story', + id: 'story--two', + name: 'Two', + title: 'story/two', + importPath: 'path/to/file', + tags: [Tag.TEST], + }, + 'another--two': { + type: 'story', + subtype: 'story', + id: 'another--two', + name: 'Two B', + title: 'another/two', + importPath: 'path/to/another/file', + tags: [Tag.TEST], + }, + 'parent--story': { + type: 'story', + subtype: 'story', + id: 'parent--story', + name: 'Parent story', + title: 'parent/story', + importPath: 'path/to/parent/file', + tags: [Tag.TEST], + }, + 'parent--story:test': { + type: 'story', + subtype: Tag.TEST, + id: 'parent--story:test', + name: 'Test name', + title: 'parent/story', + parent: 'parent--story', + importPath: 'path/to/parent/file', + tags: [Tag.TEST, Tag.TEST_FN], + }, + }, +} as StoryIndex; + const mockStore = new experimental_MockUniversalStore( { ...storeOptions, - initialState: { ...storeOptions.initialState, indexUrl: 'http://localhost:6006/index.json' }, + initialState: { + ...storeOptions.initialState, + index: mockIndex, + }, }, vi ); @@ -102,54 +164,6 @@ const tests = [ }, ]; -global.fetch = vi.fn().mockResolvedValue({ - json: () => - new Promise((resolve) => - resolve({ - v: 5, - entries: { - 'story--one': { - type: 'story', - subtype: 'story', - id: 'story--one', - name: 'One', - title: 'story/one', - importPath: 'path/to/file', - tags: [Tag.TEST], - }, - 'another--one': { - type: 'story', - subtype: 'story', - id: 'another--one', - name: 'One', - title: 'another/one', - importPath: 'path/to/another/file', - tags: [Tag.TEST], - }, - 'parent--story': { - type: 'story', - subtype: 'story', - id: 'parent--story', - name: 'Parent story', - title: 'parent/story', - importPath: 'path/to/parent/file', - tags: [Tag.TEST], - }, - 'parent--story:test': { - type: 'story', - subtype: Tag.TEST, - id: 'parent--story:test', - name: 'Test name', - title: 'parent/story', - parent: 'parent--story', - importPath: 'path/to/parent/file', - tags: [Tag.TEST, Tag.TEST_FN], - }, - }, - } as StoryIndex) - ), -}); - const options: TestManagerOptions = { store: mockStore, componentTestStatusStore: mockComponentTestStatusStore, @@ -262,6 +276,111 @@ describe('TestManager', () => { expect(setTestNamePattern).toHaveBeenCalledWith(new RegExp(`^Parent story${DOUBLE_SPACES}`)); }); + it('should trigger only selected stories in the same file', async () => { + vitest.globTestSpecifications.mockImplementation(() => tests); + const testManager = await TestManager.start(options); + + await testManager.handleTriggerRunEvent({ + type: 'TRIGGER_RUN', + payload: { + storyIds: ['story--one', 'story--two'], + triggeredBy: 'global', + }, + }); + + expect(vitest.runTestSpecifications).toHaveBeenCalledWith(tests.slice(0, 1), true); + + const regex = setTestNamePattern.mock.calls.find(([arg]) => arg instanceof RegExp)?.[0] as + | RegExp + | undefined; + + expect(regex).toBeDefined(); + expect(regex?.test('One')).toBe(true); + expect(regex?.test('Two')).toBe(true); + expect(regex?.test('Parent story Test name')).toBe(false); + }); + + it('should trigger only selected stories across multiple files', async () => { + vitest.globTestSpecifications.mockImplementation(() => tests); + const testManager = await TestManager.start(options); + + await testManager.handleTriggerRunEvent({ + type: 'TRIGGER_RUN', + payload: { + storyIds: ['story--one', 'another--two'], + triggeredBy: 'global', + }, + }); + + expect(vitest.runTestSpecifications).toHaveBeenCalledWith(tests, true); + + const regex = setTestNamePattern.mock.calls.find(([arg]) => arg instanceof RegExp)?.[0] as + | RegExp + | undefined; + + expect(regex).toBeDefined(); + expect(regex?.test('One')).toBe(true); + expect(regex?.test('Two B')).toBe(true); + expect(regex?.test('Two')).toBe(false); + }); + + it('should ignore non-requested same-name story results after run', async () => { + const testManager = await TestManager.start(options); + const passedResult = { state: 'passed', errors: [] } as unknown as TestResult; + + await testManager.runTestsWithState({ + storyIds: ['story--one', 'another--two'], + triggeredBy: 'global', + callback: async () => { + testManager.onTestCaseResult({ + storyId: 'story--one', + testResult: passedResult, + }); + testManager.onTestCaseResult({ + storyId: 'another--one', + testResult: passedResult, + }); + testManager.onTestRunEnd({ + totalTestCount: 2, + unhandledErrors: [], + }); + }, + }); + + expect(mockComponentTestStatusStore.set).toHaveBeenCalledWith( + expect.arrayContaining([expect.objectContaining({ storyId: 'story--one' })]) + ); + expect(mockComponentTestStatusStore.set).not.toHaveBeenCalledWith( + expect.arrayContaining([expect.objectContaining({ storyId: 'another--one' })]) + ); + expect(mockStore.getState().currentRun.totalTestCount).toBe(1); + }); + + it('should keep child test results when parent story is requested', async () => { + const testManager = await TestManager.start(options); + const passedResult = { state: 'passed', errors: [] } as unknown as TestResult; + + await testManager.runTestsWithState({ + storyIds: ['parent--story'], + triggeredBy: 'global', + callback: async () => { + testManager.onTestCaseResult({ + storyId: 'parent--story:test', + testResult: passedResult, + }); + testManager.onTestRunEnd({ + totalTestCount: 1, + unhandledErrors: [], + }); + }, + }); + + expect(mockComponentTestStatusStore.set).toHaveBeenCalledWith( + expect.arrayContaining([expect.objectContaining({ storyId: 'parent--story:test' })]) + ); + expect(mockStore.getState().currentRun.totalTestCount).toBe(1); + }); + it('should restart Vitest before a test run if coverage is enabled', async () => { const testManager = await TestManager.start(options); expect(createVitest).toHaveBeenCalledTimes(1); diff --git a/code/addons/vitest/src/node/test-manager.ts b/code/addons/vitest/src/node/test-manager.ts index f66270dfc091..7f11a90f2599 100644 --- a/code/addons/vitest/src/node/test-manager.ts +++ b/code/addons/vitest/src/node/test-manager.ts @@ -8,11 +8,20 @@ import type { TestProviderStoreById, } from 'storybook/internal/types'; +import type { A11yReport } from '@storybook/addon-a11y'; + import { throttle } from 'es-toolkit/function'; import type { Report } from 'storybook/preview-api'; import { STATUS_TYPE_ID_A11Y, STATUS_TYPE_ID_COMPONENT_TEST, storeOptions } from '../constants'; -import type { RunTrigger, StoreEvent, StoreState, TriggerRunEvent, VitestError } from '../types'; +import type { + CurrentRun, + RunTrigger, + StoreEvent, + StoreState, + TriggerRunEvent, + VitestError, +} from '../types'; import { errorToErrorLike } from '../utils'; import { VitestManager } from './vitest-manager'; @@ -82,6 +91,7 @@ export class TestManager { await this.runTestsWithState({ storyIds: event.payload.storyIds, triggeredBy: event.payload.triggeredBy, + configOverride: event.payload.configOverride, callback: async () => { try { await this.vitestManager.vitestRestartPromise; @@ -114,15 +124,19 @@ export class TestManager { async runTestsWithState({ storyIds, triggeredBy, + configOverride, callback, }: { storyIds?: string[]; triggeredBy: RunTrigger; + configOverride?: StoreState['config']; callback: () => Promise; }) { this.componentTestStatusStore.unset(storyIds); this.a11yStatusStore.unset(storyIds); + const runConfig = configOverride ?? this.store.getState().config; + this.store.setState((s) => ({ ...s, currentRun: { @@ -130,12 +144,12 @@ export class TestManager { triggeredBy, startedAt: Date.now(), storyIds: storyIds, - config: s.config, + config: runConfig, }, })); // set the config at the start of a test run, // so that changing the config during the test run does not affect the currently running test run - process.env.VITEST_STORYBOOK_CONFIG = JSON.stringify(this.store.getState().config); + process.env.VITEST_STORYBOOK_CONFIG = JSON.stringify(runConfig); await this.testProviderStore.runWithState(async () => { await callback(); @@ -165,10 +179,26 @@ export class TestManager { return; } + const requestedStoryIds = this.store.getState().currentRun.storyIds; + if (requestedStoryIds && !this.isRequestedStoryOrChild(storyId, requestedStoryIds)) { + // In focused runs, Vitest name filtering can still pick up same-named tests in other files. + // Drop those results here so status stores and run summaries only reflect requested stories. + return; + } + this.batchedTestCaseResults.push({ storyId, testResult, reports }); this.throttledFlushTestCaseResults(); } + private isRequestedStoryOrChild(storyId: string, requestedStoryIds: string[]) { + if (requestedStoryIds.includes(storyId)) { + return true; + } + + const entry = this.store.getState().index.entries[storyId]; + return entry?.type === 'story' && !!entry.parent && requestedStoryIds.includes(entry.parent); + } + /** * Throttled function to process batched test case results. * @@ -187,6 +217,42 @@ export class TestManager { const testCaseResultsToFlush = this.batchedTestCaseResults; this.batchedTestCaseResults = []; + const componentTestStatuses = testCaseResultsToFlush.map(({ storyId, testResult }) => ({ + storyId, + typeId: STATUS_TYPE_ID_COMPONENT_TEST, + value: testStateToStatusValueMap[testResult.state], + title: 'Component tests', + description: testResult.errors?.map((error) => error.stack || error.message).join('\n') ?? '', + sidebarContextMenu: false, + })); + + this.componentTestStatusStore.set(componentTestStatuses); + + const a11yReportsByStoryId: CurrentRun['a11yReports'] = {}; + const a11yStatuses: typeof componentTestStatuses = []; + + for (const { storyId, reports } of testCaseResultsToFlush) { + const storyA11yReports = reports?.filter((r) => r.type === 'a11y'); + if (!storyA11yReports?.length) { + continue; + } + a11yReportsByStoryId[storyId] = storyA11yReports.map((r) => r.result) as A11yReport[]; + for (const a11yReport of storyA11yReports) { + a11yStatuses.push({ + storyId, + typeId: STATUS_TYPE_ID_A11Y, + value: testStateToStatusValueMap[a11yReport.status], + title: 'Accessibility tests', + description: '', + sidebarContextMenu: false, + }); + } + } + + if (a11yStatuses.length > 0) { + this.a11yStatusStore.set(a11yStatuses); + } + this.store.setState((s) => { let { success: ctSuccess, error: ctError } = s.currentRun.componentTestCount; let { success: a11ySuccess, warning: a11yWarning, error: a11yError } = s.currentRun.a11yCount; @@ -216,6 +282,12 @@ export class TestManager { ...s.currentRun, componentTestCount: { success: ctSuccess, error: ctError }, a11yCount: { success: a11ySuccess, warning: a11yWarning, error: a11yError }, + componentTestStatuses: s.currentRun.componentTestStatuses.concat(componentTestStatuses), + a11yStatuses: s.currentRun.a11yStatuses.concat(a11yStatuses), + a11yReports: { + ...s.currentRun.a11yReports, + ...a11yReportsByStoryId, + }, // in some cases successes and errors can exceed the anticipated totalTestCount // e.g. when testing more tests than the stories we know about upfront // in those cases, we set the totalTestCount to the sum of successes and errors @@ -226,52 +298,26 @@ export class TestManager { }, }; }); - - const componentTestStatuses = testCaseResultsToFlush.map(({ storyId, testResult }) => ({ - storyId, - typeId: STATUS_TYPE_ID_COMPONENT_TEST, - value: testStateToStatusValueMap[testResult.state], - title: 'Component tests', - description: testResult.errors?.map((error) => error.stack || error.message).join('\n') ?? '', - sidebarContextMenu: false, - })); - - this.componentTestStatusStore.set(componentTestStatuses); - - const a11yStatuses = testCaseResultsToFlush - .flatMap(({ storyId, reports }) => - reports - ?.filter((r) => r.type === 'a11y') - .map((a11yReport) => ({ - storyId, - typeId: STATUS_TYPE_ID_A11Y, - value: testStateToStatusValueMap[a11yReport.status], - title: 'Accessibility tests', - description: '', - sidebarContextMenu: false, - })) - ) - .filter((a11yStatus) => a11yStatus !== undefined); - - if (a11yStatuses.length > 0) { - this.a11yStatusStore.set(a11yStatuses); - } }, 500); onTestRunEnd(endResult: { totalTestCount: number; unhandledErrors: VitestError[] }) { this.throttledFlushTestCaseResults.flush(); - this.store.setState((s) => ({ - ...s, - currentRun: { - ...s.currentRun, - // when the test run is finished, we can set the totalTestCount to the actual number of tests run - // this number can be lower than the total number of tests we anticipated upfront - // e.g. when some tests where skipped without us knowing about it upfront - totalTestCount: endResult.totalTestCount, - unhandledErrors: endResult.unhandledErrors, - finishedAt: Date.now(), - }, - })); + this.store.setState((s) => { + const focusedRunTotal = + s.currentRun.componentTestCount.success + s.currentRun.componentTestCount.error; + + return { + ...s, + currentRun: { + ...s.currentRun, + // For focused runs, keep totals aligned with filtered case results. + // For full runs, use Vitest's reported total. + totalTestCount: s.currentRun.storyIds ? focusedRunTotal : endResult.totalTestCount, + unhandledErrors: endResult.unhandledErrors, + finishedAt: Date.now(), + }, + }; + }); } onCoverageCollected(coverageSummary: StoreState['currentRun']['coverageSummary']) { diff --git a/code/addons/vitest/src/node/vitest-manager.ts b/code/addons/vitest/src/node/vitest-manager.ts index f787556ed4b4..018165da983c 100644 --- a/code/addons/vitest/src/node/vitest-manager.ts +++ b/code/addons/vitest/src/node/vitest-manager.ts @@ -10,10 +10,11 @@ import type { import { getProjectRoot, resolvePathInStorybookCache } from 'storybook/internal/common'; import { Tag } from 'storybook/internal/core-server'; -import type { StoryId, StoryIndex, StoryIndexEntry } from 'storybook/internal/types'; +import type { StoryId, StoryIndexEntry } from 'storybook/internal/types'; import * as find from 'empathic/find'; import * as walk from 'empathic/walk'; +import { escapeRegExp } from 'es-toolkit/string'; import path, { dirname, join, normalize, resolve } from 'pathe'; // eslint-disable-next-line depend/ban-dependencies import slash from 'slash'; @@ -103,7 +104,7 @@ export class VitestManager { for (const file of configFiles) { const maybe = find.any([file], { cwd: location, last: getProjectRoot() }); if (maybe && existsSync(maybe)) { - firstVitestConfig ??= maybe; + firstVitestConfig ??= dirname(maybe); const content = readFileSync(maybe, 'utf8'); if (content.includes('storybookTest') || content.includes('@storybook/addon-vitest')) { vitestWorkspaceConfig = dirname(maybe); @@ -218,24 +219,91 @@ export class VitestManager { }); } - private async fetchStories(requestStoryIds?: string[]): Promise { - const indexUrl = this.testManager.store.getState().indexUrl; - if (!indexUrl) { - throw new Error( - 'Tried to fetch stories to test, but the index URL was not set in the store yet.' - ); + private getStories(requestStoryIds?: string[]): StoryIndexEntry[] { + const index = this.testManager.store.getState().index; + if (requestStoryIds) { + const stories: StoryIndexEntry[] = []; + for (const id of requestStoryIds) { + const entry = index.entries[id]; + if (entry?.type === 'story') { + stories.push(entry); + } + } + return stories; } - 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 []; + return Object.values(index.entries).filter((entry) => entry.type === 'story'); + } + + /** + * Builds the exact Vitest name-pattern fragment for one selected Storybook entry. + * + * The pattern differs by entry type: + * + * - Component entry (has child stories/tests): match the whole describe block prefix + * - Story-test entry (has parent): match "parent describe + test name" exactly + * - Regular story entry: match the story name exactly + */ + private buildStoryTestNamePattern( + story: StoryIndexEntry, + allStories: StoryIndexEntry[], + storiesById: Record + ) { + const isParentStory = allStories.some((candidate) => story.id === candidate.parent); + + if (isParentStory) { + return `^${escapeRegExp(getTestName(story.name))}`; } + + if (story.parent) { + const parentStory = storiesById[story.parent]; + if (!parentStory) { + throw new Error(`Parent story not found for story ${story.id}`); + } + + return `^${escapeRegExp(getTestName(parentStory.name))} ${escapeRegExp(story.name)}$`; + } + + return `^${escapeRegExp(story.name)}$`; + } + + /** + * Combines multiple per-story patterns into one global regex so Vitest can run an exact subset of + * tests across one or more files in a single invocation. + */ + private buildTestNamePatternForStories( + selectedStories: StoryIndexEntry[], + allStories: StoryIndexEntry[] + ) { + const storiesById = Object.fromEntries(allStories.map((story) => [story.id, story])) as Record< + StoryId, + StoryIndexEntry + >; + + const storyPatterns = [ + ...new Set( + selectedStories.map((story) => + this.buildStoryTestNamePattern(story, allStories, storiesById) + ) + ), + ]; + + if (!storyPatterns.length) { + return undefined; + } + + if (storyPatterns.length === 1) { + return new RegExp(storyPatterns[0]); + } + + // Build one "OR" expression across all selected stories. + // Example when storyPatterns are "^One$" and "^Parent Child$": + // /(?:(?:^One$)|(?:^Parent Child$))/ + // + // Why wrap each pattern with (?:...)? + // - Keeps each full, already-anchored pattern isolated as one alternative. + // - Prevents precedence issues when joining with `|`. + // - Uses non-capturing groups to avoid unnecessary capture groups. + return new RegExp(`(?:${storyPatterns.map((pattern) => `(?:${pattern})`).join('|')})`); } private filterTestSpecifications( @@ -314,45 +382,17 @@ export class VitestManager { await this.cancelCurrentRun(); const testSpecifications = await this.getStorybookTestSpecifications(); - const allStories = await this.fetchStories(); + const allStories = this.getStories(); const filteredStories = runPayload.storyIds ? allStories.filter((story) => runPayload.storyIds?.includes(story.id)) : allStories; - const isSingleStoryRun = runPayload.storyIds?.length === 1; - if (isSingleStoryRun) { - const selectedStory = filteredStories.find((story) => story.id === runPayload.storyIds?.[0]); - if (!selectedStory) { - throw new Error(`Story ${runPayload.storyIds?.[0]} not found`); - } - - const storyName = selectedStory.name; - let regex: RegExp; - - const isParentStory = allStories.some((story) => selectedStory.id === story.parent); - const hasParentStory = allStories.some((story) => selectedStory.parent === story.id); - - if (isParentStory) { - // Use case 1: "Single" story run on a story with tests - // -> run all tests of that story, as storyName is a describe block - const parentName = getTestName(selectedStory.name); - regex = new RegExp(`^${parentName}`); - } else if (hasParentStory) { - // Use case 2: Single story run on a specific story test - // in this case the regex pattern should be the story parentName + space + story.name - const parentStory = allStories.find((story) => story.id === selectedStory.parent); - if (!parentStory) { - throw new Error(`Parent story not found for story ${selectedStory.id}`); - } - - const parentName = getTestName(parentStory.name); - regex = new RegExp(`^${parentName} ${storyName}$`); - } else { - // Use case 3: Single story run on a story without tests, should be exact match of story name - regex = new RegExp(`^${storyName}$`); + if (runPayload.storyIds?.length) { + const regex = this.buildTestNamePatternForStories(filteredStories, allStories); + if (regex) { + this.vitest!.setGlobalTestNamePattern(regex); } - this.vitest!.setGlobalTestNamePattern(regex); } const { filteredTestSpecifications, filteredStoryIds } = this.filterTestSpecifications( @@ -429,7 +469,7 @@ export class VitestManager { previewAnnotationSpecifications.concat(setupFilesSpecifications); const testSpecifications = await this.getStorybookTestSpecifications(); - const allStories = await this.fetchStories(); + const allStories = this.getStories(); let affectsGlobalFiles = false; diff --git a/code/addons/vitest/src/preset.ts b/code/addons/vitest/src/preset.ts index c95d534cd96b..bb0f035959c7 100644 --- a/code/addons/vitest/src/preset.ts +++ b/code/addons/vitest/src/preset.ts @@ -1,4 +1,3 @@ -import { readFileSync } from 'node:fs'; import { mkdir } from 'node:fs/promises'; import type { Channel } from 'storybook/internal/channels'; @@ -9,9 +8,11 @@ import { resolvePathInStorybookCache, } from 'storybook/internal/common'; import { + type StoryIndexGenerator, experimental_UniversalStore, experimental_getTestProviderStore, } from 'storybook/internal/core-server'; +import { logger } from 'storybook/internal/node-logger'; import { cleanPaths, oneWayHash, sanitizeError, telemetry } from 'storybook/internal/telemetry'; import type { Options, @@ -29,6 +30,10 @@ import { COVERAGE_DIRECTORY, STORE_CHANNEL_EVENT_NAME, STORYBOOK_ADDON_TEST_CHANNEL, + TRIGGER_TEST_RUN_REQUEST, + TRIGGER_TEST_RUN_RESPONSE, + type TriggerTestRunRequestPayload, + type TriggerTestRunResponsePayload, storeOptions, } from './constants'; import { log } from './logger'; @@ -77,6 +82,9 @@ export const experimental_serverChannel = async (channel: Channel, options: Opti return channel; } + const storyIndexGenerator = + await options.presets.apply>('storyIndexGenerator'); + const fsCache = createFileSystemCache({ basePath: resolvePathInStorybookCache(ADDON_ID.replace('/', '-')), ns: 'storybook', @@ -94,6 +102,7 @@ export const experimental_serverChannel = async (channel: Channel, options: Opti initialState: { ...storeOptions.initialState, previewAnnotations: (previewAnnotations ?? []).concat(previewPath ?? []), + index: await storyIndexGenerator.getIndex(), ...selectCachedState(cachedState), }, leader: true, @@ -105,6 +114,16 @@ export const experimental_serverChannel = async (channel: Channel, options: Opti }); const testProviderStore = experimental_getTestProviderStore(ADDON_ID); + storyIndexGenerator.onInvalidated(async () => { + try { + const index = await storyIndexGenerator.getIndex(); + store.setState((s) => ({ ...s, index })); + } catch (error) { + logger.debug('Failed to update story index after invalidation, Error:'); + logger.debug(error); + } + }); + store.subscribe('TRIGGER_RUN', (event, eventInfo) => { testProviderStore.setState('test-provider-state:running'); store.setState((s) => ({ @@ -184,6 +203,60 @@ export const experimental_serverChannel = async (channel: Channel, options: Opti })); }); + // Programmatic test run trigger API + channel.on(TRIGGER_TEST_RUN_REQUEST, async (payload: TriggerTestRunRequestPayload) => { + const { requestId, actor, storyIds, config: configOverride } = payload; + + const sendResponse = (response: Omit) => { + channel.emit(TRIGGER_TEST_RUN_RESPONSE, { requestId, ...response }); + }; + + await store.untilReady(); + + const { + currentRun: { startedAt, finishedAt }, + config, + } = store.getState(); + if (startedAt && !finishedAt) { + sendResponse({ + status: 'error', + error: { message: 'Tests are already running' }, + }); + return; + } + + store.send({ + type: 'TRIGGER_RUN', + payload: { + storyIds, + triggeredBy: `external:${actor}`, + ...(configOverride && { + configOverride: { ...config, ...configOverride }, + }), + }, + }); + + const unsubscribe = store.subscribe((event) => { + switch (event.type) { + case 'TEST_RUN_COMPLETED': { + unsubscribe(); + sendResponse({ status: 'completed', result: event.payload }); + return; + } + case 'FATAL_ERROR': { + unsubscribe(); + sendResponse({ status: 'error', error: event.payload }); + return; + } + case 'CANCEL_RUN': { + unsubscribe(); + sendResponse({ status: 'cancelled' }); + return; + } + } + }); + }); + if (!core.disableTelemetry) { const enableCrashReports = core.enableCrashReports || options.enableCrashReports; diff --git a/code/addons/vitest/src/types.ts b/code/addons/vitest/src/types.ts index b6eca96f92de..e1f4d64ccdd7 100644 --- a/code/addons/vitest/src/types.ts +++ b/code/addons/vitest/src/types.ts @@ -1,7 +1,11 @@ import type { experimental_UniversalStore } from 'storybook/internal/core-server'; -import type { PreviewAnnotation, StoryId } from 'storybook/internal/types'; +import type { PreviewAnnotation, Status, StoryId, StoryIndex } from 'storybook/internal/types'; import type { API_HashEntry } from 'storybook/internal/types'; +// import type { A11yReport } from '@storybook/addon-a11y'; +// TODO: There's a type error in axe-core that makes this error during production builds +type A11yReport = any; + export interface VitestError extends Error { VITEST_TEST_PATH?: string; VITEST_TEST_NAME?: string; @@ -20,7 +24,40 @@ export type ErrorLike = { cause?: ErrorLike; }; -export type RunTrigger = 'run-all' | 'global' | 'watch' | Extract; +export type RunTrigger = + | 'run-all' + | 'global' + | 'watch' + | Extract + | `external:${string}`; + +export type CurrentRun = { + triggeredBy: RunTrigger | undefined; + config: StoreState['config']; + componentTestStatuses: Status[]; + a11yStatuses: Status[]; + componentTestCount: { + success: number; + error: number; + }; + a11yCount: { + success: number; + warning: number; + error: number; + }; + a11yReports: Record; + totalTestCount: number | undefined; + storyIds: StoryId[] | undefined; + startedAt: number | undefined; + finishedAt: number | undefined; + unhandledErrors: VitestError[]; + coverageSummary: + | { + status: 'positive' | 'warning' | 'negative' | 'unknown'; + percentage: number; + } + | undefined; +}; export type StoreState = { config: { @@ -29,9 +66,7 @@ export type StoreState = { }; watching: boolean; cancelling: boolean; - // TODO: Avoid needing to do a fetch request server-side to retrieve the index - // e.g. http://localhost:6006/index.json - indexUrl: string | undefined; + index: StoryIndex; previewAnnotations: PreviewAnnotation[]; fatalError: | { @@ -39,30 +74,7 @@ export type StoreState = { error: ErrorLike; } | undefined; - currentRun: { - triggeredBy: RunTrigger | undefined; - config: StoreState['config']; - componentTestCount: { - success: number; - error: number; - }; - a11yCount: { - success: number; - warning: number; - error: number; - }; - totalTestCount: number | undefined; - storyIds: StoryId[] | undefined; - startedAt: number | undefined; - finishedAt: number | undefined; - unhandledErrors: VitestError[]; - coverageSummary: - | { - status: 'positive' | 'warning' | 'negative' | 'unknown'; - percentage: number; - } - | undefined; - }; + currentRun: CurrentRun; }; export type CachedState = Pick; @@ -72,6 +84,7 @@ export type TriggerRunEvent = { payload: { storyIds?: string[] | undefined; triggeredBy: RunTrigger; + configOverride?: StoreState['config']; }; }; diff --git a/code/addons/vitest/src/vitest-plugin/index.ts b/code/addons/vitest/src/vitest-plugin/index.ts index b07edb2bfd00..f472b46cc97d 100644 --- a/code/addons/vitest/src/vitest-plugin/index.ts +++ b/code/addons/vitest/src/vitest-plugin/index.ts @@ -30,8 +30,9 @@ import path from 'pathe'; import picocolors from 'picocolors'; import sirv from 'sirv'; import { dedent } from 'ts-dedent'; +import type { PluginOption } from 'vite'; -// ! Relative import to prebundle it without needing to depend on the Vite builder +// Shared plugins from builder-vite (relative import to prebundle without adding a package dependency) import { withoutVitePlugins } from '../../../../builders/builder-vite/src/utils/without-vite-plugins'; import type { InternalOptions, UserOptions } from './types'; @@ -194,27 +195,25 @@ export const storybookTest = async (options?: UserOptions): Promise => const stories = await presets.apply('stories', []); - // We can probably add more config here. See code/builders/builder-vite/src/vite-config.ts - // This one is specifically needed for code/builders/builder-vite/src/preset.ts const commonConfig = { root: resolve(finalOptions.configDir, '..') }; const [ + corePlugins, { storiesGlobs }, framework, viteConfigFromStorybook, staticDirs, previewLevelTags, core, - extraOptimizeDeps, features, ] = await Promise.all([ + presets.apply('viteCorePlugins', []), getStoryGlobsAndFiles(presets, directories), presets.apply('framework', undefined), presets.apply<{ plugins?: Plugin[]; root: string }>('viteFinal', commonConfig), presets.apply('staticDirs', []), extractTagsFromPreview(finalOptions.configDir), presets.apply('core'), - presets.apply('optimizeViteDeps', []), presets.apply('features', {}), ]); @@ -230,7 +229,10 @@ export const storybookTest = async (options?: UserOptions): Promise => } // filter out plugins that we know are unnecesary for tests, eg. docgen plugins - const plugins = await withoutVitePlugins(viteConfigFromStorybook.plugins ?? [], pluginsToIgnore); + const plugins: Plugin[] = [ + ...(corePlugins as Plugin[]), + ...(await withoutVitePlugins(viteConfigFromStorybook.plugins ?? [], pluginsToIgnore)), + ]; if (finalOptions.disableAddonDocs) { plugins.push(mdxStubPlugin); @@ -382,26 +384,8 @@ export const storybookTest = async (options?: UserOptions): Promise => }, }, - envPrefix: Array.from( - new Set([...(nonMutableInputConfig.envPrefix || []), 'STORYBOOK_', 'VITE_']) - ), - - resolve: { - conditions: [ - 'storybook', - 'stories', - 'test', - // copying straight from https://github.com/vitejs/vite/blob/main/packages/vite/src/node/constants.ts#L60 - // to avoid having to maintain Vite as a dependency just for this - 'module', - 'browser', - 'development|production', - ], - }, - optimizeDeps: { include: [ - ...extraOptimizeDeps, '@storybook/addon-vitest/internal/setup-file', '@storybook/addon-vitest/internal/global-setup', '@storybook/addon-vitest/internal/test-utils', @@ -419,11 +403,7 @@ export const storybookTest = async (options?: UserOptions): Promise => }, }; - // Merge config from storybook with the plugin config - const config: Omit = mergeConfig( - baseConfig, - viteConfigFromStorybook - ); + const config = mergeConfig(baseConfig, viteConfigFromStorybook); // alert the user of problems if ((nonMutableInputConfig.test?.include?.length ?? 0) > 0) { diff --git a/code/builders/builder-vite/src/build.ts b/code/builders/builder-vite/src/build.ts index f25f14857b61..1f419e038f09 100644 --- a/code/builders/builder-vite/src/build.ts +++ b/code/builders/builder-vite/src/build.ts @@ -4,7 +4,6 @@ import type { Options } from 'storybook/internal/types'; import { dedent } from 'ts-dedent'; import type { InlineConfig } from 'vite'; -import { sanitizeEnvVars } from './envs'; import { createViteLogger } from './logger'; import type { WebpackStatsPlugin } from './plugins'; import { hasVitePlugins } from './utils/has-vite-plugins'; @@ -90,7 +89,7 @@ export async function build(options: Options) { finalConfig.customLogger ??= await createViteLogger(); - await viteBuild(await sanitizeEnvVars(options, finalConfig)); + await viteBuild(finalConfig); const statsPlugin = findPlugin( finalConfig, diff --git a/code/builders/builder-vite/src/codegen-modern-iframe-script.test.ts b/code/builders/builder-vite/src/codegen-modern-iframe-script.test.ts index bac19386fcf9..af2dce770d19 100644 --- a/code/builders/builder-vite/src/codegen-modern-iframe-script.test.ts +++ b/code/builders/builder-vite/src/codegen-modern-iframe-script.test.ts @@ -2,15 +2,10 @@ import { describe, expect, it } from 'vitest'; import { generateModernIframeScriptCodeFromPreviews } from './codegen-modern-iframe-script'; -const projectRoot = 'projectRoot'; - describe('generateModernIframeScriptCodeFromPreviews', () => { it('handle one annotation', async () => { const result = await generateModernIframeScriptCodeFromPreviews({ - previewAnnotations: ['/user/.storybook/preview'], - projectRoot, frameworkName: 'frameworkName', - isCsf4: false, }); expect(result).toMatchInlineSnapshot(` "import { setup } from 'storybook/internal/preview/runtime'; @@ -19,18 +14,10 @@ describe('generateModernIframeScriptCodeFromPreviews', () => { setup(); - import { composeConfigs, PreviewWeb } from 'storybook/preview-api'; - import { isPreview } from 'storybook/internal/csf'; + import { PreviewWeb } from 'storybook/preview-api'; import { importFn } from 'virtual:/@storybook/builder-vite/storybook-stories.js'; - - import * as preview_2408 from "/user/.storybook/preview"; - const getProjectAnnotations = (hmrPreviewAnnotationModules = []) => { - const configs = [ - hmrPreviewAnnotationModules[0] ?? preview_2408 - ] - return composeConfigs(configs); - } - + import { getProjectAnnotations } from 'virtual:/@storybook/builder-vite/project-annotations.js'; + window.__STORYBOOK_PREVIEW__ = window.__STORYBOOK_PREVIEW__ || new PreviewWeb(importFn, getProjectAnnotations); window.__STORYBOOK_STORY_STORE__ = window.__STORYBOOK_STORY_STORE__ || window.__STORYBOOK_PREVIEW__.storyStore; @@ -44,21 +31,13 @@ describe('generateModernIframeScriptCodeFromPreviews', () => { // importFn has changed so we need to patch the new one in window.__STORYBOOK_PREVIEW__.onStoriesChanged({ importFn: newModule.importFn }); }); - - import.meta.hot.accept(["/user/.storybook/preview"], (previewAnnotationModules) => { - // getProjectAnnotations has changed so we need to patch the new one in - window.__STORYBOOK_PREVIEW__.onGetProjectAnnotationsChanged({ getProjectAnnotations: () => getProjectAnnotations(previewAnnotationModules) }); - }); };" `); }); it('handle one annotation CSF4', async () => { const result = await generateModernIframeScriptCodeFromPreviews({ - previewAnnotations: ['/user/.storybook/preview'], - projectRoot, frameworkName: 'frameworkName', - isCsf4: true, }); expect(result).toMatchInlineSnapshot(` "import { setup } from 'storybook/internal/preview/runtime'; @@ -67,16 +46,10 @@ describe('generateModernIframeScriptCodeFromPreviews', () => { setup(); - import { composeConfigs, PreviewWeb } from 'storybook/preview-api'; - import { isPreview } from 'storybook/internal/csf'; + import { PreviewWeb } from 'storybook/preview-api'; import { importFn } from 'virtual:/@storybook/builder-vite/storybook-stories.js'; - - import * as preview_2408 from "/user/.storybook/preview"; - const getProjectAnnotations = (hmrPreviewAnnotationModules = []) => { - const preview = hmrPreviewAnnotationModules[0] ?? preview_2408; - return preview.default.composed; - } - + import { getProjectAnnotations } from 'virtual:/@storybook/builder-vite/project-annotations.js'; + window.__STORYBOOK_PREVIEW__ = window.__STORYBOOK_PREVIEW__ || new PreviewWeb(importFn, getProjectAnnotations); window.__STORYBOOK_STORY_STORE__ = window.__STORYBOOK_STORY_STORE__ || window.__STORYBOOK_PREVIEW__.storyStore; @@ -90,21 +63,13 @@ describe('generateModernIframeScriptCodeFromPreviews', () => { // importFn has changed so we need to patch the new one in window.__STORYBOOK_PREVIEW__.onStoriesChanged({ importFn: newModule.importFn }); }); - - import.meta.hot.accept(["/user/.storybook/preview"], (previewAnnotationModules) => { - // getProjectAnnotations has changed so we need to patch the new one in - window.__STORYBOOK_PREVIEW__.onGetProjectAnnotationsChanged({ getProjectAnnotations: () => getProjectAnnotations(previewAnnotationModules) }); - }); };" `); }); it('handle multiple annotations', async () => { const result = await generateModernIframeScriptCodeFromPreviews({ - previewAnnotations: ['/user/previewAnnotations1', '/user/.storybook/preview'], - projectRoot, frameworkName: 'frameworkName', - isCsf4: false, }); expect(result).toMatchInlineSnapshot(` "import { setup } from 'storybook/internal/preview/runtime'; @@ -113,20 +78,10 @@ describe('generateModernIframeScriptCodeFromPreviews', () => { setup(); - import { composeConfigs, PreviewWeb } from 'storybook/preview-api'; - import { isPreview } from 'storybook/internal/csf'; + import { PreviewWeb } from 'storybook/preview-api'; import { importFn } from 'virtual:/@storybook/builder-vite/storybook-stories.js'; - - import * as previewAnnotations1_2526 from "/user/previewAnnotations1"; - import * as preview_2408 from "/user/.storybook/preview"; - const getProjectAnnotations = (hmrPreviewAnnotationModules = []) => { - const configs = [ - hmrPreviewAnnotationModules[0] ?? previewAnnotations1_2526, - hmrPreviewAnnotationModules[1] ?? preview_2408 - ] - return composeConfigs(configs); - } - + import { getProjectAnnotations } from 'virtual:/@storybook/builder-vite/project-annotations.js'; + window.__STORYBOOK_PREVIEW__ = window.__STORYBOOK_PREVIEW__ || new PreviewWeb(importFn, getProjectAnnotations); window.__STORYBOOK_STORY_STORE__ = window.__STORYBOOK_STORY_STORE__ || window.__STORYBOOK_PREVIEW__.storyStore; @@ -140,21 +95,13 @@ describe('generateModernIframeScriptCodeFromPreviews', () => { // importFn has changed so we need to patch the new one in window.__STORYBOOK_PREVIEW__.onStoriesChanged({ importFn: newModule.importFn }); }); - - import.meta.hot.accept(["/user/previewAnnotations1","/user/.storybook/preview"], (previewAnnotationModules) => { - // getProjectAnnotations has changed so we need to patch the new one in - window.__STORYBOOK_PREVIEW__.onGetProjectAnnotationsChanged({ getProjectAnnotations: () => getProjectAnnotations(previewAnnotationModules) }); - }); };" `); }); it('handle multiple annotations CSF4', async () => { const result = await generateModernIframeScriptCodeFromPreviews({ - previewAnnotations: ['/user/previewAnnotations1', '/user/.storybook/preview'], - projectRoot, frameworkName: 'frameworkName', - isCsf4: true, }); expect(result).toMatchInlineSnapshot(` "import { setup } from 'storybook/internal/preview/runtime'; @@ -163,16 +110,10 @@ describe('generateModernIframeScriptCodeFromPreviews', () => { setup(); - import { composeConfigs, PreviewWeb } from 'storybook/preview-api'; - import { isPreview } from 'storybook/internal/csf'; + import { PreviewWeb } from 'storybook/preview-api'; import { importFn } from 'virtual:/@storybook/builder-vite/storybook-stories.js'; - - import * as preview_2408 from "/user/.storybook/preview"; - const getProjectAnnotations = (hmrPreviewAnnotationModules = []) => { - const preview = hmrPreviewAnnotationModules[0] ?? preview_2408; - return preview.default.composed; - } - + import { getProjectAnnotations } from 'virtual:/@storybook/builder-vite/project-annotations.js'; + window.__STORYBOOK_PREVIEW__ = window.__STORYBOOK_PREVIEW__ || new PreviewWeb(importFn, getProjectAnnotations); window.__STORYBOOK_STORY_STORE__ = window.__STORYBOOK_STORY_STORE__ || window.__STORYBOOK_PREVIEW__.storyStore; @@ -186,11 +127,6 @@ describe('generateModernIframeScriptCodeFromPreviews', () => { // importFn has changed so we need to patch the new one in window.__STORYBOOK_PREVIEW__.onStoriesChanged({ importFn: newModule.importFn }); }); - - import.meta.hot.accept(["/user/.storybook/preview"], (previewAnnotationModules) => { - // getProjectAnnotations has changed so we need to patch the new one in - window.__STORYBOOK_PREVIEW__.onGetProjectAnnotationsChanged({ getProjectAnnotations: () => getProjectAnnotations(previewAnnotationModules) }); - }); };" `); }); diff --git a/code/builders/builder-vite/src/codegen-modern-iframe-script.ts b/code/builders/builder-vite/src/codegen-modern-iframe-script.ts index e063c3504c64..e8a615db5b59 100644 --- a/code/builders/builder-vite/src/codegen-modern-iframe-script.ts +++ b/code/builders/builder-vite/src/codegen-modern-iframe-script.ts @@ -1,84 +1,24 @@ -import { getFrameworkName, loadPreviewOrConfigFile } from 'storybook/internal/common'; +import { getFrameworkName } from 'storybook/internal/common'; import { STORY_HOT_UPDATED } from 'storybook/internal/core-events'; -import { isCsfFactoryPreview, readConfig } from 'storybook/internal/csf-tools'; -import type { Options, PreviewAnnotation } from 'storybook/internal/types'; +import type { Options } from 'storybook/internal/types'; -import { genArrayFromRaw, genImport, genSafeVariableName } from 'knitwork'; -import { filename } from 'pathe/utils'; import { dedent } from 'ts-dedent'; -import { processPreviewAnnotation } from './utils/process-preview-annotation'; +import { VIRTUAL_ID as PROJECT_ANNOTATIONS_VIRTUAL_ID } from './plugins/storybook-project-annotations-plugin'; import { SB_VIRTUAL_FILES } from './virtual-file-names'; -export async function generateModernIframeScriptCode(options: Options, projectRoot: string) { - const { presets, configDir } = options; +export async function generateModernIframeScriptCode(options: Options) { const frameworkName = await getFrameworkName(options); - const previewOrConfigFile = loadPreviewOrConfigFile({ configDir }); - const previewConfig = previewOrConfigFile ? await readConfig(previewOrConfigFile) : undefined; - const isCsf4 = previewConfig ? isCsfFactoryPreview(previewConfig) : false; - - const previewAnnotations = await presets.apply( - 'previewAnnotations', - [], - options - ); return generateModernIframeScriptCodeFromPreviews({ - previewAnnotations: [...previewAnnotations, previewOrConfigFile], - projectRoot, frameworkName, - isCsf4, }); } export async function generateModernIframeScriptCodeFromPreviews(options: { - previewAnnotations: (PreviewAnnotation | undefined)[]; - projectRoot: string; frameworkName: string; - isCsf4: boolean; }) { - const { projectRoot, frameworkName } = options; - const previewAnnotationURLs = options.previewAnnotations - .filter((path) => path !== undefined) - .map((path) => processPreviewAnnotation(path, projectRoot)); - - const variables: string[] = []; - const imports: string[] = []; - for (const previewAnnotation of previewAnnotationURLs) { - const variable = - genSafeVariableName(filename(previewAnnotation)).replace(/_(45|46|47)/g, '_') + - '_' + - hash(previewAnnotation); - variables.push(variable); - imports.push(genImport(previewAnnotation, { name: '*', as: variable })); - } - - const previewFileURL = previewAnnotationURLs[previewAnnotationURLs.length - 1]; - const previewFileVariable = variables[variables.length - 1]; - const previewFileImport = imports[imports.length - 1]; - - // This is pulled out to a variable because it is reused in both the initial page load - // and the HMR handler. - // The `hmrPreviewAnnotationModules` parameter is used to pass the updated modules from HMR. - // However, only the changed modules are provided, the rest are null. - const getPreviewAnnotationsFunction = options.isCsf4 - ? dedent` - const getProjectAnnotations = (hmrPreviewAnnotationModules = []) => { - const preview = hmrPreviewAnnotationModules[0] ?? ${previewFileVariable}; - return preview.default.composed; - }` - : dedent` - const getProjectAnnotations = (hmrPreviewAnnotationModules = []) => { - const configs = ${genArrayFromRaw( - variables.map( - (previewAnnotation, index) => - // Prefer the updated module from an HMR update, otherwise the original module - `hmrPreviewAnnotationModules[${index}] ?? ${previewAnnotation}` - ), - ' ' - )} - return composeConfigs(configs); - }`; + const { frameworkName } = options; const generateHMRHandler = (): string => { // Web components are not compatible with HMR, so disable HMR, reload page instead. @@ -99,11 +39,6 @@ export async function generateModernIframeScriptCodeFromPreviews(options: { // importFn has changed so we need to patch the new one in window.__STORYBOOK_PREVIEW__.onStoriesChanged({ importFn: newModule.importFn }); }); - - import.meta.hot.accept(${JSON.stringify(options.isCsf4 ? [previewFileURL] : previewAnnotationURLs)}, (previewAnnotationModules) => { - // getProjectAnnotations has changed so we need to patch the new one in - window.__STORYBOOK_PREVIEW__.onGetProjectAnnotationsChanged({ getProjectAnnotations: () => getProjectAnnotations(previewAnnotationModules) }); - }); }`.trim(); }; @@ -122,13 +57,10 @@ export async function generateModernIframeScriptCodeFromPreviews(options: { setup(); - import { composeConfigs, PreviewWeb } from 'storybook/preview-api'; - import { isPreview } from 'storybook/internal/csf'; + import { PreviewWeb } from 'storybook/preview-api'; import { importFn } from '${SB_VIRTUAL_FILES.VIRTUAL_STORIES_FILE}'; - - ${options.isCsf4 ? previewFileImport : imports.join('\n')} - ${getPreviewAnnotationsFunction} - + import { getProjectAnnotations } from '${PROJECT_ANNOTATIONS_VIRTUAL_ID}'; + window.__STORYBOOK_PREVIEW__ = window.__STORYBOOK_PREVIEW__ || new PreviewWeb(importFn, getProjectAnnotations); window.__STORYBOOK_STORY_STORE__ = window.__STORYBOOK_STORY_STORE__ || window.__STORYBOOK_PREVIEW__.storyStore; @@ -138,6 +70,3 @@ export async function generateModernIframeScriptCodeFromPreviews(options: { `.trim(); return code; } -function hash(value: string) { - return value.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0); -} diff --git a/code/builders/builder-vite/src/codegen-project-annotations.ts b/code/builders/builder-vite/src/codegen-project-annotations.ts new file mode 100644 index 000000000000..9d13c9390ec9 --- /dev/null +++ b/code/builders/builder-vite/src/codegen-project-annotations.ts @@ -0,0 +1,110 @@ +import { getFrameworkName, loadPreviewOrConfigFile } from 'storybook/internal/common'; +import { isCsfFactoryPreview, readConfig } from 'storybook/internal/csf-tools'; +import type { Options, PreviewAnnotation } from 'storybook/internal/types'; + +import { genArrayFromRaw, genImport, genSafeVariableName } from 'knitwork'; +import { filename } from 'pathe/utils'; +import { dedent } from 'ts-dedent'; + +import { processPreviewAnnotation } from './utils/process-preview-annotation'; + +/** Generates the code for the `PROJECT_ANNOTATIONS_FILE` virtual module. */ +export async function generateProjectAnnotationsCode(options: Options, projectRoot: string) { + const { presets, configDir } = options; + const frameworkName = await getFrameworkName(options); + + const previewOrConfigFile = loadPreviewOrConfigFile({ configDir }); + const previewConfig = previewOrConfigFile ? await readConfig(previewOrConfigFile) : undefined; + const isCsf4 = previewConfig ? isCsfFactoryPreview(previewConfig) : false; + + const previewAnnotations = await presets.apply( + 'previewAnnotations', + [], + options + ); + + return generateProjectAnnotationsCodeFromPreviews({ + previewAnnotations: [...previewAnnotations, previewOrConfigFile], + projectRoot, + frameworkName, + isCsf4, + }); +} + +export function generateProjectAnnotationsCodeFromPreviews(options: { + previewAnnotations: (PreviewAnnotation | undefined)[]; + projectRoot: string; + frameworkName: string; + isCsf4: boolean; +}) { + const { projectRoot } = options; + const previewAnnotationURLs = options.previewAnnotations + .filter((path) => path !== undefined) + .map((path) => processPreviewAnnotation(path, projectRoot)); + + const variables: string[] = []; + const imports: string[] = []; + for (const previewAnnotation of previewAnnotationURLs) { + const variable = + genSafeVariableName(filename(previewAnnotation)).replace(/_(45|46|47)/g, '_') + + '_' + + hash(previewAnnotation); + variables.push(variable); + imports.push(genImport(previewAnnotation, { name: '*', as: variable })); + } + + const previewFileURL = previewAnnotationURLs[previewAnnotationURLs.length - 1]; + const previewFileVariable = variables[variables.length - 1]; + const previewFileImport = imports[imports.length - 1]; + + if (options.isCsf4) { + return dedent` + ${previewFileImport} + + export function getProjectAnnotations(hmrPreviewAnnotationModules = []) { + const preview = hmrPreviewAnnotationModules[0] ?? ${previewFileVariable}; + return preview.default.composed; + } + + if (import.meta.hot) { + import.meta.hot.accept([${JSON.stringify(previewFileURL)}], (previewAnnotationModules) => { + // getProjectAnnotations has changed so we need to patch the new one in + window?.__STORYBOOK_PREVIEW__?.onGetProjectAnnotationsChanged({ + getProjectAnnotations: () => getProjectAnnotations(previewAnnotationModules), + }); + }); + } + `.trim(); + } + + return dedent` + import { composeConfigs } from 'storybook/preview-api'; + + ${imports.join('\n')} + + export function getProjectAnnotations(hmrPreviewAnnotationModules = []) { + const configs = ${genArrayFromRaw( + variables.map( + (previewAnnotation, index) => + // Prefer the updated module from an HMR update, otherwise the original module + `hmrPreviewAnnotationModules[${index}] ?? ${previewAnnotation}` + ), + ' ' + )}; + return composeConfigs(configs); + } + + if (import.meta.hot) { + import.meta.hot.accept(${JSON.stringify(previewAnnotationURLs)}, (previewAnnotationModules) => { + // getProjectAnnotations has changed so we need to patch the new one in + window?.__STORYBOOK_PREVIEW__?.onGetProjectAnnotationsChanged({ + getProjectAnnotations: () => getProjectAnnotations(previewAnnotationModules), + }); + }); + } + `.trim(); +} + +function hash(value: string) { + return value.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0); +} diff --git a/code/builders/builder-vite/src/envs.ts b/code/builders/builder-vite/src/envs.ts index 41716949b81b..bbf0aecef781 100644 --- a/code/builders/builder-vite/src/envs.ts +++ b/code/builders/builder-vite/src/envs.ts @@ -1,5 +1,5 @@ import { stringifyEnvs } from 'storybook/internal/common'; -import type { Builder_EnvsRaw, Options } from 'storybook/internal/types'; +import type { Builder_EnvsRaw } from 'storybook/internal/types'; import type { UserConfig as ViteConfig } from 'vite'; @@ -39,22 +39,3 @@ export function stringifyProcessEnvs(raw: Builder_EnvsRaw, envPrefix: ViteConfig return envs; } - -// Sanitize environment variables if needed -export async function sanitizeEnvVars(options: Options, config: ViteConfig) { - const { presets } = options; - const envsRaw = await presets.apply>('env'); - let { define } = config; - if (Object.keys(envsRaw).length) { - // Stringify env variables after getting `envPrefix` from the config - const envs = stringifyProcessEnvs(envsRaw, config.envPrefix); - define = { - ...define, - ...envs, - }; - } - return { - ...config, - define, - } as ViteConfig; -} diff --git a/code/builders/builder-vite/src/optimizeDeps.ts b/code/builders/builder-vite/src/optimizeDeps.ts index 61e5c1ed5b56..09c8b51b581e 100644 --- a/code/builders/builder-vite/src/optimizeDeps.ts +++ b/code/builders/builder-vite/src/optimizeDeps.ts @@ -1,10 +1,6 @@ -import type { StoryIndexGenerator } from 'storybook/internal/core-server'; -import type { Options, StoryIndex } from 'storybook/internal/types'; - import { type UserConfig, type InlineConfig as ViteInlineConfig, resolveConfig } from 'vite'; import { INCLUDE_CANDIDATES } from './constants'; -import { getUniqueImportPaths } from './utils/unique-import-paths'; /** * Helper function which allows us to `filter` with an async predicate. Uses Promise.all for @@ -15,14 +11,7 @@ const asyncFilter = async (arr: string[], predicate: (val: string) => Promise('storyIndexGenerator'), - ]); - - const index: StoryIndex = await storyIndexGenerator.getIndex(); - +export async function getOptimizeDeps(config: ViteInlineConfig) { // TODO: check if resolveConfig takes a lot of time, possible optimizations here const resolvedConfig = await resolveConfig(config, 'serve', 'development'); @@ -31,13 +20,11 @@ export async function getOptimizeDeps(config: ViteInlineConfig, options: Options const resolve = resolvedConfig.createResolver({ asSrc: false }); const include = await asyncFilter(INCLUDE_CANDIDATES, async (id) => Boolean(await resolve(id))); - const optimizeDeps: UserConfig['optimizeDeps'] = { - ...config.optimizeDeps, - entries: getUniqueImportPaths(index), + const optimizeDeps = { // We need Vite to precompile these dependencies, because they contain non-ESM code that would break // if we served it directly to the browser. - include: [...include, ...extraOptimizeDeps, ...(config.optimizeDeps?.include || [])], - }; + include: [...include, ...(config.optimizeDeps?.include || [])], + } satisfies UserConfig['optimizeDeps']; return optimizeDeps; } diff --git a/code/builders/builder-vite/src/plugins/code-generator-plugin.ts b/code/builders/builder-vite/src/plugins/code-generator-plugin.ts index 2af433d0c90b..50a163e9ff9c 100644 --- a/code/builders/builder-vite/src/plugins/code-generator-plugin.ts +++ b/code/builders/builder-vite/src/plugins/code-generator-plugin.ts @@ -17,10 +17,9 @@ import { getResolvedVirtualModuleId, } from '../virtual-file-names'; -export function codeGeneratorPlugin(options: Options): Plugin { +export function codeGeneratorPlugin(options: Options) { const iframePath = fileURLToPath(importMetaResolve('@storybook/builder-vite/input/iframe.html')); let iframeId: string; - let projectRoot: string; const storyIndexGeneratorPromise: Promise = options.presets.apply('storyIndexGenerator'); @@ -53,7 +52,6 @@ export function codeGeneratorPlugin(options: Options): Plugin { } }, configResolved(config) { - projectRoot = config.root; iframeId = `${config.root}/iframe.html`; }, resolveId(source) { @@ -78,7 +76,7 @@ export function codeGeneratorPlugin(options: Options): Plugin { return generateAddonSetupCode(); } case getResolvedVirtualModuleId(SB_VIRTUAL_FILES.VIRTUAL_APP_FILE): { - return generateModernIframeScriptCode(options, projectRoot); + return generateModernIframeScriptCode(options); } case iframeId: { return readFileSync( @@ -94,5 +92,5 @@ export function codeGeneratorPlugin(options: Options): Plugin { } return transformIframeHtml(html, options); }, - }; + } satisfies Plugin; } diff --git a/code/builders/builder-vite/src/plugins/index.ts b/code/builders/builder-vite/src/plugins/index.ts index bc72dc8755d5..078886a86b4a 100644 --- a/code/builders/builder-vite/src/plugins/index.ts +++ b/code/builders/builder-vite/src/plugins/index.ts @@ -1,6 +1,12 @@ -export * from './inject-export-order-plugin'; -export * from './strip-story-hmr-boundaries'; -export * from './code-generator-plugin'; -export * from './csf-plugin'; -export * from './external-globals-plugin'; -export * from './webpack-stats-plugin'; +// Builder-internal plugins (used by vite-config.ts to assemble the builder's plugin stack) +export { storybookOptimizeDepsPlugin } from './storybook-optimize-deps-plugin'; +export { storybookEntryPlugin } from './storybook-entry-plugin'; +export { pluginWebpackStats } from './webpack-stats-plugin'; +export type { WebpackStatsPlugin } from './webpack-stats-plugin'; + +// Lower-level plugins re-exported for internal use and tests +export { injectExportOrderPlugin } from './inject-export-order-plugin'; +export { stripStoryHMRBoundary } from './strip-story-hmr-boundaries'; +export { codeGeneratorPlugin } from './code-generator-plugin'; +export { csfPlugin } from './csf-plugin'; +export { storybookExternalGlobalsPlugin } from './storybook-external-globals-plugin'; diff --git a/code/builders/builder-vite/src/plugins/inject-export-order-plugin.ts b/code/builders/builder-vite/src/plugins/inject-export-order-plugin.ts index 01b835ff6187..91091a86811b 100644 --- a/code/builders/builder-vite/src/plugins/inject-export-order-plugin.ts +++ b/code/builders/builder-vite/src/plugins/inject-export-order-plugin.ts @@ -1,5 +1,6 @@ import { parse } from 'es-module-lexer'; import MagicString from 'magic-string'; +import type { Plugin } from 'vite'; export async function injectExportOrderPlugin() { const { createFilter } = await import('vite'); @@ -35,5 +36,5 @@ export async function injectExportOrderPlugin() { map: s.generateMap({ hires: true, source: id }), }; }, - }; + } satisfies Plugin; } diff --git a/code/builders/builder-vite/src/plugins/storybook-config-plugin.ts b/code/builders/builder-vite/src/plugins/storybook-config-plugin.ts new file mode 100644 index 000000000000..7152f47f4cf4 --- /dev/null +++ b/code/builders/builder-vite/src/plugins/storybook-config-plugin.ts @@ -0,0 +1,62 @@ +import { isPreservingSymlinks } from 'storybook/internal/common'; + +import { type Plugin } from 'vite'; + +export interface StorybookConfigPluginOptions { + configDir: string; +} + +/** + * A Vite plugin that provides the base Storybook configuration. + * + * This handles: + * + * - Adding Storybook resolve conditions (`storybook`, `stories`, `test`) + * - Setting up environment variable prefixes (`VITE_`, `STORYBOOK_`) + * - Allowing the Storybook config directory in Vite's filesystem restrictions + * - Preserving symlinks when applicable + */ +export function storybookConfigPlugin(options: StorybookConfigPluginOptions): Plugin[] { + return [ + { + name: 'storybook:config-plugin', + enforce: 'pre', + async config(config) { + const { defaultClientConditions = [] } = await import('vite'); + + const existingEnvPrefix = config.envPrefix; + // If an envPrefix is specified in the user's vite config, add STORYBOOK_ to it. + // Otherwise, add both VITE_ and STORYBOOK_ so that Vite doesn't lose its default. + const mergedEnvPrefix = existingEnvPrefix + ? Array.from( + new Set([ + ...(Array.isArray(existingEnvPrefix) ? existingEnvPrefix : [existingEnvPrefix]), + 'STORYBOOK_', + ]) + ) + : ['VITE_', 'STORYBOOK_']; + + return { + resolve: { + conditions: ['storybook', 'stories', 'test', ...defaultClientConditions], + preserveSymlinks: isPreservingSymlinks(), + }, + envPrefix: mergedEnvPrefix, + }; + }, + }, + { + name: 'storybook:allow-storybook-dir', + enforce: 'post', + config(config) { + // If there is NO allow list then Vite allows anything in the root directory. + // If there IS an allow list then Vite only allows the listed directories. + // We add the storybook config directory only if there's already an allow list, + // to avoid disallowing the root unless it's already restricted. + if (config?.server?.fs?.allow) { + config.server.fs.allow.push(options.configDir); + } + }, + }, + ]; +} diff --git a/code/builders/builder-vite/src/plugins/storybook-entry-plugin.ts b/code/builders/builder-vite/src/plugins/storybook-entry-plugin.ts new file mode 100644 index 000000000000..f4c0ba3d38d3 --- /dev/null +++ b/code/builders/builder-vite/src/plugins/storybook-entry-plugin.ts @@ -0,0 +1,22 @@ +import type { Options } from 'storybook/internal/types'; + +import type { Plugin } from 'vite'; + +import { codeGeneratorPlugin } from './code-generator-plugin'; +import { injectExportOrderPlugin } from './inject-export-order-plugin'; +import { stripStoryHMRBoundary } from './strip-story-hmr-boundaries'; + +/** + * A composite Vite plugin that manages the generation and injection of virtual entry points for + * Storybook stories. This is builder-specific and NOT shared with addon-vitest. + */ +export async function storybookEntryPlugin(options: Options): Promise { + return [ + // Pre-enforcement: handles virtual module resolution and loading (must run first) + codeGeneratorPlugin(options), + // Post-enforcement: injects __namedExportsOrder after TypeScript transpilation + await injectExportOrderPlugin(), + // Post-enforcement: removes import.meta.hot.accept() from story files + await stripStoryHMRBoundary(), + ]; +} diff --git a/code/builders/builder-vite/src/plugins/external-globals-plugin.test.ts b/code/builders/builder-vite/src/plugins/storybook-external-globals-plugin.test.ts similarity index 95% rename from code/builders/builder-vite/src/plugins/external-globals-plugin.test.ts rename to code/builders/builder-vite/src/plugins/storybook-external-globals-plugin.test.ts index 67b62690da43..4b5c001f0e3d 100644 --- a/code/builders/builder-vite/src/plugins/external-globals-plugin.test.ts +++ b/code/builders/builder-vite/src/plugins/storybook-external-globals-plugin.test.ts @@ -1,6 +1,6 @@ import { expect, it } from 'vitest'; -import { rewriteImport } from './external-globals-plugin'; +import { rewriteImport } from './storybook-external-globals-plugin'; const packageName = '@storybook/package'; const globals = { [packageName]: '_STORYBOOK_PACKAGE_' }; diff --git a/code/builders/builder-vite/src/plugins/external-globals-plugin.ts b/code/builders/builder-vite/src/plugins/storybook-external-globals-plugin.ts similarity index 89% rename from code/builders/builder-vite/src/plugins/external-globals-plugin.ts rename to code/builders/builder-vite/src/plugins/storybook-external-globals-plugin.ts index 242fb98ff3dc..54bb15a28c96 100644 --- a/code/builders/builder-vite/src/plugins/external-globals-plugin.ts +++ b/code/builders/builder-vite/src/plugins/storybook-external-globals-plugin.ts @@ -2,6 +2,9 @@ import { existsSync } from 'node:fs'; import { mkdir, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; +import { globalsNameReferenceMap } from 'storybook/internal/preview/globals'; +import type { Options } from 'storybook/internal/types'; + import * as pkg from 'empathic/package'; import { init, parse } from 'es-module-lexer'; import MagicString from 'magic-string'; @@ -38,7 +41,17 @@ const replacementMap = new Map([ * https://github.com/eight04/rollup-plugin-external-globals, but simplified to meet our simple * needs. */ -export async function externalGlobalsPlugin(externals: Record): Promise { + +export async function storybookExternalGlobalsPlugin(options: Options): Promise { + const build = await options.presets.apply('build'); + + const externals: typeof globalsNameReferenceMap & Record = + globalsNameReferenceMap; + + if (build?.test?.disableBlocks) { + externals['@storybook/addon-docs/blocks'] = '__STORYBOOK_BLOCKS_EMPTY_MODULE__'; + } + await init; const { mergeAlias } = await import('vite'); diff --git a/code/builders/builder-vite/src/plugins/storybook-optimize-deps-plugin.ts b/code/builders/builder-vite/src/plugins/storybook-optimize-deps-plugin.ts new file mode 100644 index 000000000000..ccbd5e4a9a4e --- /dev/null +++ b/code/builders/builder-vite/src/plugins/storybook-optimize-deps-plugin.ts @@ -0,0 +1,41 @@ +import type { StoryIndexGenerator } from 'storybook/internal/core-server'; +import type { Options, StoryIndex } from 'storybook/internal/types'; + +import { type Plugin } from 'vite'; + +import { getUniqueImportPaths } from '../utils/unique-import-paths'; + +/** A Vite plugin that configures dependency optimization for Storybook's dev server. */ +export function storybookOptimizeDepsPlugin(options: Options): Plugin { + return { + name: 'storybook:optimize-deps-plugin', + async config(config, { command }) { + // optimizeDeps only applies to the dev server, not production builds + if (command !== 'serve') { + return; + } + + const [extraOptimizeDeps, storyIndexGenerator] = await Promise.all([ + options.presets.apply('optimizeViteDeps', []), + options.presets.apply('storyIndexGenerator'), + ]); + + const index: StoryIndex = await storyIndexGenerator.getIndex(); + + return { + optimizeDeps: { + // Story file paths as entry points for the optimizer + entries: [ + ...(typeof config.optimizeDeps?.entries === 'string' + ? [config.optimizeDeps.entries] + : []), + ...getUniqueImportPaths(index), + ], + // Known CJS dependencies that need to be pre-compiled to ESM, + // plus any extra deps from Storybook presets. + include: [...extraOptimizeDeps, ...(config.optimizeDeps?.include || [])], + }, + }; + }, + }; +} diff --git a/code/builders/builder-vite/src/plugins/storybook-project-annotations-plugin.ts b/code/builders/builder-vite/src/plugins/storybook-project-annotations-plugin.ts new file mode 100644 index 000000000000..beb879493f64 --- /dev/null +++ b/code/builders/builder-vite/src/plugins/storybook-project-annotations-plugin.ts @@ -0,0 +1,40 @@ +import type { Options } from 'storybook/internal/types'; + +import type { Plugin } from 'vite'; + +import { generateProjectAnnotationsCode } from '../codegen-project-annotations'; +import { getResolvedVirtualModuleId } from '../virtual-file-names'; + +export const VIRTUAL_ID = 'virtual:/@storybook/builder-vite/project-annotations.js'; +const RESOLVED_VIRTUAL_ID = getResolvedVirtualModuleId(VIRTUAL_ID); + +/** + * A Vite plugin that serves the project annotations virtual module. + * + * The virtual module can be imported as: + * + * ```ts + * import { getProjectAnnotations } from 'virtual:/@storybook/builder-vite/project-annotations.js'; + * ``` + */ +export function storybookProjectAnnotationsPlugin(options: Options): Plugin { + let projectRoot: string; + + return { + name: 'storybook:project-annotations-plugin', + enforce: 'pre', + configResolved(config) { + projectRoot = config.root; + }, + resolveId(source) { + if (source === VIRTUAL_ID) { + return RESOLVED_VIRTUAL_ID; + } + }, + async load(id) { + if (id === RESOLVED_VIRTUAL_ID) { + return generateProjectAnnotationsCode(options, projectRoot); + } + }, + }; +} diff --git a/code/builders/builder-vite/src/plugins/storybook-runtime-plugin.ts b/code/builders/builder-vite/src/plugins/storybook-runtime-plugin.ts new file mode 100644 index 000000000000..a60d66eace7e --- /dev/null +++ b/code/builders/builder-vite/src/plugins/storybook-runtime-plugin.ts @@ -0,0 +1,31 @@ +import type { Builder_EnvsRaw } from 'storybook/internal/types'; +import type { Options } from 'storybook/internal/types'; + +import type { Plugin } from 'vite'; + +import { stringifyProcessEnvs } from '../envs'; + +export interface StorybookRuntimePluginOptions { + externals: Record; + envs?: Builder_EnvsRaw; +} + +/** A composite Vite plugin that injects environment variables for Storybook's runtime. */ +export async function storybookSanitizeEnvs(options: Options): Promise { + const plugins: Plugin[] = []; + const envs = await options.presets.apply('env'); + + if (envs && Object.keys(envs).length > 0) { + plugins.push({ + name: 'storybook:env-plugin', + config(config) { + const envDefines = stringifyProcessEnvs(envs, config.envPrefix); + return { + define: envDefines, + }; + }, + }); + } + + return plugins; +} diff --git a/code/builders/builder-vite/src/plugins/strip-story-hmr-boundaries.ts b/code/builders/builder-vite/src/plugins/strip-story-hmr-boundaries.ts index 509a7d06adbd..baa433d1afed 100644 --- a/code/builders/builder-vite/src/plugins/strip-story-hmr-boundaries.ts +++ b/code/builders/builder-vite/src/plugins/strip-story-hmr-boundaries.ts @@ -6,7 +6,7 @@ import type { Plugin } from 'vite'; * boundaries, but vite has a bug which causes them to be treated as boundaries * (https://github.com/vitejs/vite/issues/9869). */ -export async function stripStoryHMRBoundary(): Promise { +export async function stripStoryHMRBoundary() { const { createFilter } = await import('vite'); const filter = createFilter(/\.stories\.(tsx?|jsx?|svelte|vue)$/); @@ -26,5 +26,5 @@ export async function stripStoryHMRBoundary(): Promise { map: s.generateMap({ hires: true, source: id }), }; }, - }; + } satisfies Plugin; } diff --git a/code/builders/builder-vite/src/preset.ts b/code/builders/builder-vite/src/preset.ts index b309970c3314..0f33305a8c20 100644 --- a/code/builders/builder-vite/src/preset.ts +++ b/code/builders/builder-vite/src/preset.ts @@ -1,34 +1,39 @@ import { findConfigFile } from 'storybook/internal/common'; import type { Options } from 'storybook/internal/types'; -import type { UserConfig } from 'vite'; +import type { PluginOption } from 'vite'; +import { storybookConfigPlugin } from './plugins/storybook-config-plugin'; +import { storybookOptimizeDepsPlugin } from './plugins/storybook-optimize-deps-plugin'; +import { storybookProjectAnnotationsPlugin } from './plugins/storybook-project-annotations-plugin'; +import { storybookSanitizeEnvs } from './plugins/storybook-runtime-plugin'; import { viteInjectMockerRuntime } from './plugins/vite-inject-mocker/plugin'; import { viteMockPlugin } from './plugins/vite-mock/plugin'; -// This preset defines currently mocking plugins for Vite -// It is defined as a viteFinal preset so that @storybook/addon-vitest can use it as well and that it doesn't have to be duplicated in addon-vitest. -// The main vite configuration is defined in `./vite-config.ts`. -export async function viteFinal(existing: UserConfig, options: Options) { +/** + * Preset that provides the core Storybook Vite plugins shared between `@storybook/builder-vite` and + * `@storybook/addon-vitest`. + */ +export async function viteCorePlugins( + _: PluginOption[], + options: Options +): Promise { const previewConfigPath = findConfigFile('preview', options.configDir); - // If there's no preview file, there's nothing to mock. - if (!previewConfigPath) { - return existing; - } - - const coreOptions = await options.presets.apply('core'); - - return { - ...existing, - plugins: [ - ...(existing.plugins ?? []), - ...(previewConfigPath - ? [ - viteInjectMockerRuntime({ previewConfigPath }), - viteMockPlugin({ previewConfigPath, coreOptions, configDir: options.configDir }), - ] - : []), - ], - }; + return [ + storybookProjectAnnotationsPlugin(options), + ...storybookConfigPlugin({ configDir: options.configDir }), + storybookOptimizeDepsPlugin(options), + ...(await storybookSanitizeEnvs(options)), + ...(previewConfigPath + ? [ + viteInjectMockerRuntime({ previewConfigPath }), + viteMockPlugin({ + previewConfigPath, + coreOptions: await options.presets.apply('core'), + configDir: options.configDir, + }), + ] + : []), + ]; } diff --git a/code/builders/builder-vite/src/vite-config.test.ts b/code/builders/builder-vite/src/vite-config.test.ts index 3097c069431a..9bb9a0b6913d 100644 --- a/code/builders/builder-vite/src/vite-config.test.ts +++ b/code/builders/builder-vite/src/vite-config.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it, vi } from 'vitest'; +import { Channel } from 'storybook/internal/channels'; import type { Options, Presets } from 'storybook/internal/types'; import { loadConfigFromFile } from 'vite'; +import { storybookConfigPlugin } from './plugins/storybook-config-plugin'; import { commonConfig } from './vite-config'; vi.mock('vite', async (importOriginal) => ({ @@ -17,6 +19,7 @@ const dummyOptions: Options = { configType: 'DEVELOPMENT', configDir: '', packageJson: {}, + channel: new Channel({}), presets: { apply: async (key: string) => ({ @@ -34,7 +37,7 @@ const dummyOptions: Options = { }; describe('commonConfig', () => { - it('should preserve default envPrefix', async () => { + it('should set configFile to false and include plugins', async () => { loadConfigFromFileMock.mockReturnValueOnce( Promise.resolve({ config: {}, @@ -43,30 +46,45 @@ describe('commonConfig', () => { }) ); const config = await commonConfig(dummyOptions, 'development'); - expect(config.envPrefix).toStrictEqual(['VITE_', 'STORYBOOK_']); + expect(config.configFile).toBe(false); + expect(config.plugins).toBeDefined(); }); +}); - it('should preserve custom envPrefix string', async () => { - loadConfigFromFileMock.mockReturnValueOnce( - Promise.resolve({ - config: { envPrefix: 'SECRET_' }, - path: '', - dependencies: [], - }) - ); - const config = await commonConfig(dummyOptions, 'development'); - expect(config.envPrefix).toStrictEqual(['SECRET_', 'STORYBOOK_']); +describe('storybookConfigPlugin', () => { + it('should set default envPrefix when no user envPrefix is set', async () => { + const plugins = storybookConfigPlugin({ configDir: '/test/.storybook' }); + const configPlugin = plugins.find((p) => p.name === 'storybook:config-plugin')!; + + // The config hook receives the current Vite config and returns partial config to merge + const result = await (configPlugin.config as Function)({}, {}); + expect(result.envPrefix).toStrictEqual(['VITE_', 'STORYBOOK_']); }); - it('should preserve custom envPrefix array', async () => { - loadConfigFromFileMock.mockReturnValueOnce( - Promise.resolve({ - config: { envPrefix: ['SECRET_', 'VUE_'] }, - path: '', - dependencies: [], - }) - ); - const config = await commonConfig(dummyOptions, 'development'); - expect(config.envPrefix).toStrictEqual(['SECRET_', 'VUE_', 'STORYBOOK_']); + it('should include storybook resolve conditions', async () => { + const plugins = storybookConfigPlugin({ configDir: '/test/.storybook' }); + const configPlugin = plugins.find((p) => p.name === 'storybook:config-plugin')!; + + const result = await (configPlugin.config as Function)({}, {}); + expect(result.resolve.conditions).toContain('storybook'); + expect(result.resolve.conditions).toContain('stories'); + expect(result.resolve.conditions).toContain('test'); + }); + + it('should not set base when not provided', async () => { + const plugins = storybookConfigPlugin({ configDir: '/test/.storybook' }); + const configPlugin = plugins.find((p) => p.name === 'storybook:config-plugin')!; + + const result = await (configPlugin.config as Function)({}, {}); + expect(result.base).toBeUndefined(); + }); + + it('should allow storybook dir when server fs allow list exists', () => { + const plugins = storybookConfigPlugin({ configDir: '/test/.storybook' }); + const allowPlugin = plugins.find((p) => p.name === 'storybook:allow-storybook-dir')!; + + const config = { server: { fs: { allow: ['/some/path'] } } }; + (allowPlugin.config as Function)(config); + expect(config.server.fs.allow).toContain('/test/.storybook'); }); }); diff --git a/code/builders/builder-vite/src/vite-config.ts b/code/builders/builder-vite/src/vite-config.ts index 9cb4e86042f2..807f50152219 100644 --- a/code/builders/builder-vite/src/vite-config.ts +++ b/code/builders/builder-vite/src/vite-config.ts @@ -1,11 +1,6 @@ import { resolve } from 'node:path'; -import { - getBuilderOptions, - isPreservingSymlinks, - resolvePathInStorybookCache, -} from 'storybook/internal/common'; -import { globalsNameReferenceMap } from 'storybook/internal/preview/globals'; +import { getBuilderOptions, resolvePathInStorybookCache } from 'storybook/internal/common'; import type { Options } from 'storybook/internal/types'; import type { @@ -17,13 +12,12 @@ import type { } from 'vite'; import { - codeGeneratorPlugin, csfPlugin, - externalGlobalsPlugin, - injectExportOrderPlugin, pluginWebpackStats, - stripStoryHMRBoundary, + storybookEntryPlugin, + storybookExternalGlobalsPlugin, } from './plugins'; +import { viteCorePlugins as corePlugins } from './preset'; import type { BuilderOptions } from './types'; export type PluginConfigType = 'build' | 'development'; @@ -46,7 +40,7 @@ export async function commonConfig( _type: PluginConfigType ): Promise { const configEnv = _type === 'development' ? configEnvServe : configEnvBuild; - const { loadConfigFromFile, mergeConfig, defaultClientConditions = [] } = await import('vite'); + const { loadConfigFromFile, mergeConfig } = await import('vite'); const { viteConfigPath } = await getBuilderOptions(options); @@ -58,22 +52,20 @@ export async function commonConfig( const { config: { build: buildProperty = undefined, ...userConfig } = {} } = (await loadConfigFromFile(configEnv, viteConfigPath, projectRoot)) ?? {}; - // This is the main Vite config that is used by Storybook. - // Some shared vite plugins are defined in the `./preset.ts` file so that it can be shared between the @storybook/builder-vite and @storybook/addon-vitest package. + // Storybook's Vite config is assembled from self-contained plugins. + // The config plugin handles base settings (root, cacheDir, resolve conditions, etc.), + // while other plugins handle entry points, docgen, and runtime globals. + // Shared vite plugins for mocking are defined in `./preset.ts` so that they can be + // shared between @storybook/builder-vite and @storybook/addon-vitest. const sbConfig: InlineConfig = { configFile: false, - cacheDir: resolvePathInStorybookCache('sb-vite', options.cacheKey), + plugins: await pluginConfig(options), root: projectRoot, - // Allow storybook deployed as subfolder. See https://github.com/storybookjs/builder-vite/issues/238 + // Allow storybook deployed as subfolder. See https://github.com/storybookjs/builder-vite/issues/238 base: './', - plugins: await pluginConfig(options), - resolve: { - conditions: ['storybook', 'stories', 'test', ...defaultClientConditions], - preserveSymlinks: isPreservingSymlinks(), - }, - // If an envPrefix is specified in the vite config, add STORYBOOK_ to it, - // otherwise, add VITE_ and STORYBOOK_ so that vite doesn't lose its default. - envPrefix: userConfig.envPrefix ? ['STORYBOOK_'] : ['VITE_', 'STORYBOOK_'], + ...(options.cacheKey + ? { cacheDir: resolvePathInStorybookCache('sb-vite', options.cacheKey) } + : {}), // Pass build.target option from user's vite config build: { target: buildProperty?.target, @@ -86,33 +78,14 @@ export async function commonConfig( } export async function pluginConfig(options: Options) { - const build = await options.presets.apply('build'); - - const externals: Record = globalsNameReferenceMap; - - if (build?.test?.disableBlocks) { - externals['@storybook/addon-docs/blocks'] = '__STORYBOOK_BLOCKS_EMPTY_MODULE__'; - } - const plugins = [ - codeGeneratorPlugin(options), + // Shared core plugins (resolve conditions, envPrefix, fs.allow, externals, env vars, etc.) + ...(await corePlugins([], options)), + await storybookExternalGlobalsPlugin(options), await csfPlugin(options), - await injectExportOrderPlugin(), - await stripStoryHMRBoundary(), - { - name: 'storybook:allow-storybook-dir', - enforce: 'post', - config(config) { - // if there is NO allow list then Vite allows anything in the root directory - // if there is an allow list then Vite only allows anything in the listed directories - // add storybook specific directories only if there's an allow list so that we don't end up - // disallowing the root unless root is already disallowed - if (config?.server?.fs?.allow) { - config.server.fs.allow.push(options.configDir); - } - }, - }, - await externalGlobalsPlugin(externals), + // Entry plugin: virtual modules for stories, addon setup, and main app entry + ...(await storybookEntryPlugin(options)), + // Builder-specific: webpack-compatible stats for turbosnap/chromatic pluginWebpackStats({ workingDir: process.cwd() }), ] as PluginOption[]; diff --git a/code/builders/builder-vite/src/vite-server.ts b/code/builders/builder-vite/src/vite-server.ts index 30e712c5a0cf..9c694e94e410 100644 --- a/code/builders/builder-vite/src/vite-server.ts +++ b/code/builders/builder-vite/src/vite-server.ts @@ -5,7 +5,6 @@ import type { Server } from 'http'; import { dedent } from 'ts-dedent'; import type { InlineConfig, ServerOptions } from 'vite'; -import { sanitizeEnvVars } from './envs'; import { createViteLogger } from './logger'; import { getOptimizeDeps } from './optimizeDeps'; import { commonConfig } from './vite-config'; @@ -15,9 +14,15 @@ export async function createViteServer(options: Options, devServer: Server) { const commonCfg = await commonConfig(options, 'development'); + const optimizeDeps = await getOptimizeDeps(commonCfg); + const config: InlineConfig & { server: ServerOptions } = { ...commonCfg, // Set up dev server + optimizeDeps: { + ...commonCfg.optimizeDeps, + include: [...(commonCfg.optimizeDeps?.include || []), ...optimizeDeps.include], + }, server: { middlewareMode: true, hmr: { @@ -29,7 +34,6 @@ export async function createViteServer(options: Options, devServer: Server) { }, }, appType: 'custom' as const, - optimizeDeps: await getOptimizeDeps(commonCfg, options), }; // '0.0.0.0' binds to all interfaces, which is useful for Docker and other containerized environments. @@ -51,5 +55,5 @@ export async function createViteServer(options: Options, devServer: Server) { const { createServer } = await import('vite'); finalConfig.customLogger ??= await createViteLogger(); - return createServer(await sanitizeEnvVars(options, finalConfig)); + return createServer(finalConfig); } diff --git a/code/core/src/channels/index.ts b/code/core/src/channels/index.ts index c6979045e6e4..f7a36226ee3a 100644 --- a/code/core/src/channels/index.ts +++ b/code/core/src/channels/index.ts @@ -50,4 +50,10 @@ export function createBrowserChannel({ page, extraTransports = [] }: Options): C return channel; } -export type { Listener, ChannelEvent, ChannelTransport, ChannelHandler } from './types'; +export type { + Listener, + ChannelEvent, + ChannelTransport, + ChannelHandler, + ChannelLike, +} from './types'; diff --git a/code/core/src/channels/main.ts b/code/core/src/channels/main.ts index 62bebb4dd54b..ec4119b3c7db 100644 --- a/code/core/src/channels/main.ts +++ b/code/core/src/channels/main.ts @@ -3,6 +3,7 @@ import type { ChannelArgsMulti, ChannelArgsSingle, ChannelEvent, + ChannelLike, ChannelTransport, EventsKeyValue, Listener, @@ -18,7 +19,7 @@ const generateRandomId = () => { return Math.random().toString(16).slice(2); }; -export class Channel { +export class Channel implements ChannelLike { readonly isAsync: boolean; private sender = generateRandomId(); diff --git a/code/core/src/channels/types.ts b/code/core/src/channels/types.ts index f2904700cf1d..49ba640a0665 100644 --- a/code/core/src/channels/types.ts +++ b/code/core/src/channels/types.ts @@ -29,6 +29,25 @@ export interface EventsKeyValue { [key: string]: Listener[]; } +/** + * Structural interface for Channel, used in type declarations to avoid nominal incompatibility + * between source and dist Channel class declarations. + */ +export interface ChannelLike { + readonly isAsync: boolean; + readonly hasTransport: boolean; + addListener(eventName: string, listener: Listener): void; + emit(eventName: string, ...args: any): void; + last(eventName: string): any; + eventNames(): string[]; + listenerCount(eventName: string): number; + listeners(eventName: string): Listener[] | undefined; + once(eventName: string, listener: Listener): void; + removeAllListeners(eventName?: string): void; + removeListener(eventName: string, listener: Listener): void; + on(eventName: string, listener: Listener): void; + off(eventName: string, listener: Listener): void; +} export type ChannelArgs = ChannelArgsSingle | ChannelArgsMulti; export interface ChannelArgsSingle { transport?: ChannelTransport; diff --git a/code/core/src/cli/buildIndex.ts b/code/core/src/cli/buildIndex.ts index 890cf3b56203..7cb6cd5a7ce3 100644 --- a/code/core/src/cli/buildIndex.ts +++ b/code/core/src/cli/buildIndex.ts @@ -1,3 +1,4 @@ +import { Channel } from 'storybook/internal/channels'; import { cache } from 'storybook/internal/common'; import { buildIndexStandalone, withTelemetry } from 'storybook/internal/core-server'; import type { BuilderOptions, CLIBaseOptions } from 'storybook/internal/types'; @@ -23,6 +24,7 @@ export const buildIndex = async ( ...options, corePresets: [], overridePresets: [], - }; + channel: new Channel({}), + } as unknown as Parameters[1]['presetOptions']; await withTelemetry('index', { cliOptions, presetOptions }, () => buildIndexStandalone(options)); }; diff --git a/code/core/src/common/presets.ts b/code/core/src/common/presets.ts index cb340f7cb6b2..38298c4b22a3 100644 --- a/code/core/src/common/presets.ts +++ b/code/core/src/common/presets.ts @@ -15,6 +15,7 @@ import type { import { join, parse, resolve } from 'pathe'; import { dedent } from 'ts-dedent'; +import type { ChannelLike } from '../channels'; import { importModule, safeResolveModule } from '../shared/utils/module'; import { getInterpretedFile } from './utils/interpret-files'; import { stripAbsNodeModulesPath } from './utils/strip-abs-node-modules-path'; @@ -335,6 +336,7 @@ export async function loadAllPresets( /** Whether preset failures should be critical or not */ isCritical?: boolean; build?: StorybookConfigRaw['build']; + channel: ChannelLike; } ) { const { corePresets = [], overridePresets = [], ...restOptions } = options; diff --git a/code/core/src/components/components/Popover/PopoverProvider.stories.tsx b/code/core/src/components/components/Popover/PopoverProvider.stories.tsx index c6a4f4f68cb5..0db176a23c44 100644 --- a/code/core/src/components/components/Popover/PopoverProvider.stories.tsx +++ b/code/core/src/components/components/Popover/PopoverProvider.stories.tsx @@ -28,6 +28,7 @@ const meta = preview.meta({ title: 'Overlay/PopoverProvider', component: PopoverProvider, args: { + ariaLabel: 'Sample popover', hasChrome: true, offset: 8, placement: 'top', diff --git a/code/core/src/components/components/Popover/PopoverProvider.tsx b/code/core/src/components/components/Popover/PopoverProvider.tsx index 7b953613c32a..d597c8d0f494 100644 --- a/code/core/src/components/components/Popover/PopoverProvider.tsx +++ b/code/core/src/components/components/Popover/PopoverProvider.tsx @@ -1,5 +1,7 @@ import type { DOMAttributes, ReactElement, ReactNode } from 'react'; -import React, { useCallback, useState } from 'react'; +import React, { cloneElement, useCallback, useState } from 'react'; + +import { deprecate } from 'storybook/internal/client-logger'; import { Pressable } from '@react-aria/interactions'; import { DialogTrigger } from 'react-aria-components/patched-dist/Dialog'; @@ -9,6 +11,12 @@ import { type PopperPlacement, convertToReactAriaPlacement } from '../shared/ove import { Popover } from './Popover'; export interface PopoverProviderProps { + /** + * An accessible label for the popover dialog, announced by screen readers. This prop will become + * mandatory in Storybook 11. Provide a concise description of the popover's purpose. + */ + ariaLabel?: string; + /** Whether to display the Popover in a prestyled container. True by default. */ hasChrome?: boolean; @@ -53,6 +61,7 @@ export interface PopoverProviderProps { } export const PopoverProvider = ({ + ariaLabel, placement: placementProp = 'bottom-start', hasChrome = true, hasCloseButton = false, @@ -66,6 +75,12 @@ export const PopoverProvider = ({ onVisibleChange, ...props }: PopoverProviderProps) => { + if (!ariaLabel) { + deprecate( + "The 'ariaLabel' prop on 'PopoverProvider' will become mandatory in Storybook 11. Provide a concise, accessible label describing the popover's purpose." + ); + } + // Map Popper.js placement to react-aria placement best we can. const placement = convertToReactAriaPlacement(placementProp); @@ -86,8 +101,22 @@ export const PopoverProvider = ({ onOpenChange={onOpenChange} {...props} > - {children} - + + { + /* React-aria does not inject aria-haspopup='dialog' to support legacy screen readers, so we do it ourselves. */ + cloneElement( + children, + // @ts-expect-error aria-haspopup is a valid ARIA attribute but cloneElement types are too strict + { 'aria-haspopup': 'dialog' } + ) + } + + ( height: '300px', }} > - + diff --git a/code/core/src/core-server/build-dev.ts b/code/core/src/core-server/build-dev.ts index 2f9f02502af1..27fbd87539f0 100644 --- a/code/core/src/core-server/build-dev.ts +++ b/code/core/src/core-server/build-dev.ts @@ -26,11 +26,14 @@ import { dedent } from 'ts-dedent'; import { detectPnp } from '../cli/detect'; import { resolvePackageDir } from '../shared/utils/module'; import { storybookDevServer } from './dev-server'; +import { getWsToken } from './presets/wsToken'; import { buildOrThrow } from './utils/build-or-throw'; import { getManagerBuilder, getPreviewBuilder } from './utils/get-builders'; +import { getServerChannel } from './utils/get-server-channel'; import { outputStartupInformation } from './utils/output-startup-information'; import { outputStats } from './utils/output-stats'; import { getServerChannelUrl, getServerPort } from './utils/server-address'; +import { getServer } from './utils/server-init'; import { stripCommentsAndStrings } from './utils/strip-comments-and-strings'; import { updateCheck } from './utils/update-check'; import { warnOnIncompatibleAddons } from './utils/warnOnIncompatibleAddons'; @@ -142,6 +145,9 @@ export async function buildDevStandalone( await warnWhenUsingArgTypesRegex(previewConfigPath, config); } catch (e) {} + const server = await getServer(options); + const channel = getServerChannel(server, getWsToken()); + // Load first pass: We need to determine the builder // We need to do this because builders might introduce 'overridePresets' which we need to take into account // We hope to remove this in SB8 @@ -152,6 +158,7 @@ export async function buildDevStandalone( ], ...options, isCritical: true, + channel, }); const { renderer, builder, disableTelemetry } = await presets.apply('core', {}); @@ -209,19 +216,22 @@ export async function buildDevStandalone( import.meta.resolve('storybook/internal/core-server/presets/common-override-preset'), ], ...options, + channel, }); const features = await presets.apply('features'); global.FEATURES = features; + await presets.apply('experimental_serverChannel', channel); const fullOptions: Options = { ...options, presets, features, + channel, }; const { address, networkAddress, managerResult, previewResult } = await buildOrThrow(async () => - storybookDevServer(fullOptions) + storybookDevServer(fullOptions, server) ); const previewTotalTime = previewResult?.totalTime; diff --git a/code/core/src/core-server/build-static.ts b/code/core/src/core-server/build-static.ts index 3375f84edcd8..8feef56617e4 100644 --- a/code/core/src/core-server/build-static.ts +++ b/code/core/src/core-server/build-static.ts @@ -1,11 +1,11 @@ import { cp, mkdir } from 'node:fs/promises'; import { rm } from 'node:fs/promises'; +import { Channel } from 'storybook/internal/channels'; import { loadAllPresets, loadMainConfig, logConfig, - normalizeStories, resolveAddonName, } from 'storybook/internal/common'; import { logger } from 'storybook/internal/node-logger'; @@ -68,16 +68,25 @@ export async function buildStaticStandalone(options: BuildStaticStandaloneOption .resolve('storybook/internal/core-server/presets/common-override-preset'); logger.step('Loading presets'); + + // no-op channel, as it's only relevant in dev mode + const channel = new Channel({}); let presets = await loadAllPresets({ corePresets: [commonPreset, ...corePresets], overridePresets: [commonOverridePreset], isCritical: true, + channel, ...options, }); const { renderer } = await presets.apply('core', {}); const build = await presets.apply('build', {}); - const [previewBuilder, managerBuilder] = await getBuilders({ ...options, presets, build }); + const [previewBuilder, managerBuilder] = await getBuilders({ + ...options, + presets, + build, + channel, + }); const resolvedRenderer = renderer ? resolveAddonName(options.configDir, renderer, options) @@ -91,8 +100,9 @@ export async function buildStaticStandalone(options: BuildStaticStandaloneOption ...corePresets, ], overridePresets: [...(previewBuilder.overridePresets || []), commonOverridePreset], - ...options, build, + channel, + ...options, }); const [features, core, staticDirs] = await Promise.all([ @@ -110,6 +120,7 @@ export async function buildStaticStandalone(options: BuildStaticStandaloneOption const fullOptions: Options = { ...options, + channel, presets, features, build, diff --git a/code/core/src/core-server/dev-server.ts b/code/core/src/core-server/dev-server.ts index 1f3d5329fcbd..accc93fdcca6 100644 --- a/code/core/src/core-server/dev-server.ts +++ b/code/core/src/core-server/dev-server.ts @@ -4,7 +4,6 @@ import { MissingBuilderError } from 'storybook/internal/server-errors'; import type { Options } from 'storybook/internal/types'; import compression from '@polka/compression'; -import assert from 'assert'; import polka from 'polka'; import invariant from 'tiny-invariant'; @@ -13,7 +12,6 @@ import { type StoryIndexGenerator } from './utils/StoryIndexGenerator'; import { doTelemetry } from './utils/doTelemetry'; import { getManagerBuilder, getPreviewBuilder } from './utils/get-builders'; import { getCachingMiddleware } from './utils/get-caching-middleware'; -import { getServerChannel } from './utils/get-server-channel'; import { getAccessControlMiddleware } from './utils/getAccessControlMiddleware'; import { registerIndexJsonRoute } from './utils/index-json'; import { registerManifests } from './utils/manifests/manifests'; @@ -21,20 +19,17 @@ import { useStorybookMetadata } from './utils/metadata'; import { getMiddleware } from './utils/middleware'; import { openInBrowser } from './utils/open-browser/open-in-browser'; import { getServerAddresses } from './utils/server-address'; -import { getServer } from './utils/server-init'; +import type { getServer } from './utils/server-init'; import { useStatics } from './utils/server-statics'; import { summarizeIndex } from './utils/summarizeIndex'; -export async function storybookDevServer(options: Options) { - const [server, core] = await Promise.all([getServer(options), options.presets.apply('core')]); - const app = polka({ server }); - - assert(core?.channelOptions?.wsToken, 'wsToken is required for securing the server channel'); +export async function storybookDevServer( + options: Options, + server: Awaited> +) { + const core = await options.presets.apply('core'); - const serverChannel = await options.presets.apply( - 'experimental_serverChannel', - getServerChannel(server, core.channelOptions.wsToken) - ); + const app = polka({ server }); const workingDir = process.cwd(); const configDir = options.configDir; @@ -62,7 +57,7 @@ export async function storybookDevServer(options: Options) { app, storyIndexGeneratorPromise, normalizedStories, - serverChannel, + channel: options.channel, workingDir, configDir, }); @@ -110,7 +105,7 @@ export async function storybookDevServer(options: Options) { options, router: app, server, - channel: serverChannel, + channel: options.channel, }); let previewResult: Awaited> = @@ -124,7 +119,7 @@ export async function storybookDevServer(options: Options) { options, router: app, server, - channel: serverChannel, + channel: options.channel, }) .catch(async (e: any) => { logger.error('Failed to build the preview'); diff --git a/code/core/src/core-server/load.ts b/code/core/src/core-server/load.ts index 8ac8ca7d19f3..0c0d716dce37 100644 --- a/code/core/src/core-server/load.ts +++ b/code/core/src/core-server/load.ts @@ -1,3 +1,4 @@ +import { Channel } from 'storybook/internal/channels'; import { getProjectRoot, loadAllPresets, @@ -48,6 +49,9 @@ export async function loadStorybook( // We need to do this because builders might introduce 'overridePresets' which we need to take into account // We hope to remove this in SB8 + // no-op channel, as it's only relevant in dev mode + const channel = new Channel({}); + let presets = await loadAllPresets({ corePresets, overridePresets: [ @@ -55,6 +59,7 @@ export async function loadStorybook( ], ...options, isCritical: true, + channel, }); const { renderer, builder } = await presets.apply('core', {}); @@ -77,6 +82,7 @@ export async function loadStorybook( overridePresets: [ import.meta.resolve('storybook/internal/core-server/presets/common-override-preset'), ], + channel, ...options, }); diff --git a/code/core/src/core-server/presets/common-preset.ts b/code/core/src/core-server/presets/common-preset.ts index bc1e172fa390..22387945616a 100644 --- a/code/core/src/core-server/presets/common-preset.ts +++ b/code/core/src/core-server/presets/common-preset.ts @@ -1,4 +1,3 @@ -import { randomUUID } from 'node:crypto'; import { existsSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; @@ -41,6 +40,7 @@ import { defaultFavicon, defaultStaticDirs } from '../utils/constants'; import { initializeSaveStory } from '../utils/save-story/save-story'; import { parseStaticDir } from '../utils/server-statics'; import { type OptionsWithRequiredCache, initializeWhatsNew } from '../utils/whats-new'; +import { getWsToken } from './wsToken'; const interpolate = (string: string, data: Record = {}) => Object.entries(data).reduce((acc, [k, v]) => acc.replace(new RegExp(`%${k}%`, 'g'), v), string); @@ -191,12 +191,11 @@ export const experimental_serverAPI = (extension: Record, opti * ...existing, someConfig })`, just overwriting everything and not merging with the existing * values. */ -const wsToken = randomUUID(); export const core = async (existing: CoreConfig, options: Options): Promise => ({ ...existing, channelOptions: { ...(existing?.channelOptions ?? {}), - ...(options.configType === 'DEVELOPMENT' ? { wsToken } : {}), + ...(options.configType === 'DEVELOPMENT' ? { wsToken: getWsToken() } : {}), }, disableTelemetry: options.disableTelemetry === true, enableCrashReports: diff --git a/code/core/src/core-server/presets/wsToken.ts b/code/core/src/core-server/presets/wsToken.ts new file mode 100644 index 000000000000..16491ee498d4 --- /dev/null +++ b/code/core/src/core-server/presets/wsToken.ts @@ -0,0 +1,22 @@ +import { randomUUID } from 'crypto'; + +/** + * This function generates a WebSocket token and stores it in the global scope, ensuring it is a + * singleton. + * + * This is because otherwise there is a cyclical dependency between the server channel and the + * presets: + * + * 1. Token is needed to create the server channel + * 2. Initial loading of all presets needs the server channel + * 3. Core preset needs to have the token in its channel options + * + * By making the token a shared singleton, we can ensure that both the server channel and the + * presets have access to the same token without creating this circular dependency. + */ +export const getWsToken = () => { + if (!globalThis.STORYBOOK_WEBSOCKET_TOKEN) { + globalThis.STORYBOOK_WEBSOCKET_TOKEN = randomUUID(); + } + return globalThis.STORYBOOK_WEBSOCKET_TOKEN; +}; diff --git a/code/core/src/core-server/utils/doTelemetry.ts b/code/core/src/core-server/utils/doTelemetry.ts index 2e6464dcfb96..447a96a6962c 100644 --- a/code/core/src/core-server/utils/doTelemetry.ts +++ b/code/core/src/core-server/utils/doTelemetry.ts @@ -28,7 +28,11 @@ export async function doTelemetry( } sendTelemetryError(err, 'dev', { cliOptions: options, - presetOptions: { ...options, corePresets: [], overridePresets: [] }, + presetOptions: { + ...options, + corePresets: [], + overridePresets: [], + }, }); return; } diff --git a/code/core/src/core-server/utils/index-json.test.ts b/code/core/src/core-server/utils/index-json.test.ts index f9a1ba45ce5c..a12d58bfec3e 100644 --- a/code/core/src/core-server/utils/index-json.test.ts +++ b/code/core/src/core-server/utils/index-json.test.ts @@ -96,7 +96,7 @@ describe('registerIndexJsonRoute', () => { const mockServerChannel = { emit: vi.fn() } as any as ServerChannel; registerIndexJsonRoute({ app, - serverChannel: mockServerChannel, + channel: mockServerChannel, workingDir, normalizedStories, storyIndexGeneratorPromise: getStoryIndexGeneratorPromise(), @@ -496,7 +496,7 @@ describe('registerIndexJsonRoute', () => { registerIndexJsonRoute({ app, - serverChannel: mockServerChannel, + channel: mockServerChannel, workingDir, normalizedStories, storyIndexGeneratorPromise: getStoryIndexGeneratorPromise(), @@ -527,7 +527,7 @@ describe('registerIndexJsonRoute', () => { const mockServerChannel = { emit: vi.fn() } as any as ServerChannel; registerIndexJsonRoute({ app, - serverChannel: mockServerChannel, + channel: mockServerChannel, workingDir, normalizedStories, storyIndexGeneratorPromise: getStoryIndexGeneratorPromise(), @@ -564,7 +564,7 @@ describe('registerIndexJsonRoute', () => { const mockServerChannel = { emit: vi.fn() } as any as ServerChannel; registerIndexJsonRoute({ app, - serverChannel: mockServerChannel, + channel: mockServerChannel, workingDir, normalizedStories, storyIndexGeneratorPromise: getStoryIndexGeneratorPromise(), @@ -610,7 +610,7 @@ describe('registerIndexJsonRoute', () => { const mockServerChannel = { emit: vi.fn() } as any as ServerChannel; registerIndexJsonRoute({ app, - serverChannel: mockServerChannel, + channel: mockServerChannel, workingDir, normalizedStories, storyIndexGeneratorPromise: getStoryIndexGeneratorPromise(), diff --git a/code/core/src/core-server/utils/index-json.ts b/code/core/src/core-server/utils/index-json.ts index 5c098805db14..daf33d195a62 100644 --- a/code/core/src/core-server/utils/index-json.ts +++ b/code/core/src/core-server/utils/index-json.ts @@ -1,6 +1,7 @@ import { writeFile } from 'node:fs/promises'; import { basename } from 'node:path'; +import type { ChannelLike } from 'storybook/internal/channels'; import { STORY_INDEX_INVALIDATED } from 'storybook/internal/core-events'; import type { NormalizedStoriesSpecifier } from 'storybook/internal/types'; @@ -8,7 +9,6 @@ import { debounce } from 'es-toolkit/function'; import type { Polka } from 'polka'; import type { StoryIndexGenerator } from './StoryIndexGenerator'; -import type { ServerChannel } from './get-server-channel'; import { watchStorySpecifiers } from './watch-story-specifiers'; import { watchConfig } from './watchConfig'; @@ -28,17 +28,17 @@ export function registerIndexJsonRoute({ storyIndexGeneratorPromise, workingDir = process.cwd(), configDir, - serverChannel, + channel, normalizedStories, }: { app: Polka; storyIndexGeneratorPromise: Promise; - serverChannel: ServerChannel; + channel: ChannelLike; workingDir?: string; configDir?: string; normalizedStories: NormalizedStoriesSpecifier[]; }) { - const maybeInvalidate = debounce(() => serverChannel.emit(STORY_INDEX_INVALIDATED), DEBOUNCE, { + const maybeInvalidate = debounce(() => channel.emit(STORY_INDEX_INVALIDATED), DEBOUNCE, { edges: ['leading', 'trailing'], }); watchStorySpecifiers(normalizedStories, { workingDir }, async (path, removed) => { diff --git a/code/core/src/core-server/utils/whats-new.ts b/code/core/src/core-server/utils/whats-new.ts index c63bf60086cc..b9ab06edd29e 100644 --- a/code/core/src/core-server/utils/whats-new.ts +++ b/code/core/src/core-server/utils/whats-new.ts @@ -101,7 +101,11 @@ export function initializeWhatsNew( if (isTelemetryEnabled) { await sendTelemetryError(error, 'core-config', { cliOptions: options, - presetOptions: { ...options, corePresets: [], overridePresets: [] }, + presetOptions: { + ...options, + corePresets: [], + overridePresets: [], + }, skipPrompt: true, }); } @@ -115,7 +119,11 @@ export function initializeWhatsNew( if (isTelemetryEnabled) { await sendTelemetryError(error, 'browser', { cliOptions: options, - presetOptions: { ...options, corePresets: [], overridePresets: [] }, + presetOptions: { + ...options, + corePresets: [], + overridePresets: [], + }, skipPrompt: true, }); } diff --git a/code/core/src/manager/components/preview/tools/share.tsx b/code/core/src/manager/components/preview/tools/share.tsx index caa46f7c78d4..63c688b0d4af 100644 --- a/code/core/src/manager/components/preview/tools/share.tsx +++ b/code/core/src/manager/components/preview/tools/share.tsx @@ -146,6 +146,7 @@ export const shareTool: Addon_BaseType = { {({ api, storyId, refId }) => storyId ? ( { { {loaded && ( ( diff --git a/code/core/src/manager/components/sidebar/ContextMenu.tsx b/code/core/src/manager/components/sidebar/ContextMenu.tsx index 55bd6b9f34da..93894cec3dd9 100644 --- a/code/core/src/manager/components/sidebar/ContextMenu.tsx +++ b/code/core/src/manager/components/sidebar/ContextMenu.tsx @@ -196,6 +196,7 @@ export const useContextMenu = (context: API_HashEntry, links: Link[], api: API) onMouseEnter: handlers.onMouseEnter, node: shouldRender ? ( = ({ menu, isHighlighted, onClick return ( } diff --git a/code/core/src/manager/components/sidebar/RefBlocks.tsx b/code/core/src/manager/components/sidebar/RefBlocks.tsx index e908249eb068..9ef2564ab2e4 100644 --- a/code/core/src/manager/components/sidebar/RefBlocks.tsx +++ b/code/core/src/manager/components/sidebar/RefBlocks.tsx @@ -127,6 +127,7 @@ export const ErrorBlock: FC<{ error: Error }> = ({ error }) => { Oh no! Something went wrong loading this Storybook.
( diff --git a/code/core/src/manager/components/sidebar/TagsFilter.tsx b/code/core/src/manager/components/sidebar/TagsFilter.tsx index 31d49b8c0e8f..6c8dd4667ba6 100644 --- a/code/core/src/manager/components/sidebar/TagsFilter.tsx +++ b/code/core/src/manager/components/sidebar/TagsFilter.tsx @@ -212,6 +212,7 @@ export const TagsFilter = ({ api, indexJson, tagPresets }: TagsFilterProps) => { return ( { key="tags" ariaLabel="Tag filters" ariaDescription="Filter the items shown in a sidebar based on the tags applied to them." - aria-haspopup="dialog" variant="ghost" padding="small" isHighlighted={tagsActive} diff --git a/code/core/src/manager/container/Menu.stories.tsx b/code/core/src/manager/container/Menu.stories.tsx index 5c81f339a7c9..0368fbf4d89a 100644 --- a/code/core/src/manager/container/Menu.stories.tsx +++ b/code/core/src/manager/container/Menu.stories.tsx @@ -21,7 +21,13 @@ export default { height: '300px', }} > - + diff --git a/code/core/src/types/modules/core-common.ts b/code/core/src/types/modules/core-common.ts index 2f9330515d27..582966f5bf52 100644 --- a/code/core/src/types/modules/core-common.ts +++ b/code/core/src/types/modules/core-common.ts @@ -1,4 +1,5 @@ // should be node:http, but that caused the ui/manager to fail to build, might be able to switch this back once ui/manager is in the core +import type { ChannelLike } from 'storybook/internal/channels'; import type { FileSystemCache } from 'storybook/internal/common'; import { type StoryIndexGenerator } from 'storybook/internal/core-server'; import { type CsfFile } from 'storybook/internal/csf-tools'; @@ -19,10 +20,6 @@ import type { SupportedRenderer } from './renderers'; export type BuilderName = 'webpack5' | '@storybook/builder-webpack5' | string; export type RendererName = string; -interface ServerChannel { - emit(type: string, args?: any): void; -} - export interface CoreConfig { builder?: | BuilderName @@ -221,6 +218,7 @@ export interface BuilderOptions { export interface StorybookConfigOptions { presets: Presets; presetsList?: LoadedPreset[]; + channel: ChannelLike; } export type Options = LoadOptions & @@ -259,7 +257,7 @@ export interface Builder { startTime: ReturnType; router: ServerApp; server: HttpServer; - channel: ServerChannel; + channel: ChannelLike; }) => Promise; diff --git a/code/core/src/typings.d.ts b/code/core/src/typings.d.ts index b82e449d0c25..512933868d83 100644 --- a/code/core/src/typings.d.ts +++ b/code/core/src/typings.d.ts @@ -6,6 +6,8 @@ declare var LOGLEVEL: 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'silent' | declare var REFS: any; declare var VERSIONCHECK: any; +declare var STORYBOOK_WEBSOCKET_TOKEN: string; + declare var STORYBOOK_ADDON_STATE: Record; declare var STORYBOOK_BUILDER: import('./types/modules/builders').SupportedBuilder | undefined; declare var STORYBOOK_FRAMEWORK: diff --git a/code/frameworks/angular/src/builders/build-storybook/index.ts b/code/frameworks/angular/src/builders/build-storybook/index.ts index 2f34d7bc3aba..3401d2aaea2c 100644 --- a/code/frameworks/angular/src/builders/build-storybook/index.ts +++ b/code/frameworks/angular/src/builders/build-storybook/index.ts @@ -31,6 +31,7 @@ import { errorSummary, printErrorDetails } from '../utils/error-handler'; import { runCompodoc } from '../utils/run-compodoc'; import type { StandaloneOptions } from '../utils/standalone-options'; import { VERSION } from '@angular/core'; +import { Channel } from 'storybook/internal/channels'; addToGlobalContext('cliVersion', versions.storybook); @@ -192,7 +193,12 @@ async function runInstance(options: StandaloneBuildOptions) { 'build', { cliOptions: options, - presetOptions: { ...options, corePresets: [], overridePresets: [] }, + presetOptions: { + ...options, + corePresets: [], + overridePresets: [], + channel: new Channel({}), + }, printError: printErrorDetails, }, async () => { diff --git a/code/frameworks/angular/src/builders/start-storybook/index.ts b/code/frameworks/angular/src/builders/start-storybook/index.ts index 977dd4cc717f..b79002708863 100644 --- a/code/frameworks/angular/src/builders/start-storybook/index.ts +++ b/code/frameworks/angular/src/builders/start-storybook/index.ts @@ -32,6 +32,7 @@ import { errorSummary, printErrorDetails } from '../utils/error-handler'; import { runCompodoc } from '../utils/run-compodoc'; import type { StandaloneOptions } from '../utils/standalone-options'; import { VERSION } from '@angular/core'; +import { Channel } from 'storybook/internal/channels'; addToGlobalContext('cliVersion', versions.storybook); @@ -238,7 +239,12 @@ async function runInstance(options: StandaloneOptions) { 'dev', { cliOptions: options, - presetOptions: { ...options, corePresets: [], overridePresets: [] }, + presetOptions: { + ...options, + corePresets: [], + overridePresets: [], + channel: new Channel({}), + }, printError: printErrorDetails, }, () => { diff --git a/code/frameworks/angular/src/server/framework-preset-angular-cli.test.ts b/code/frameworks/angular/src/server/framework-preset-angular-cli.test.ts index d702777f2b86..c91ad6df7ac6 100644 --- a/code/frameworks/angular/src/server/framework-preset-angular-cli.test.ts +++ b/code/frameworks/angular/src/server/framework-preset-angular-cli.test.ts @@ -7,6 +7,7 @@ import { logging } from '@angular-devkit/core'; import { getBuilderOptions } from './framework-preset-angular-cli'; import type { PresetOptions } from './preset-options'; +import { Channel } from 'storybook/internal/channels'; // Mock all dependencies vi.mock('storybook/internal/node-logger', () => ({ @@ -89,6 +90,7 @@ describe('framework-preset-angular-cli', () => { apply: vi.fn(), } as any, angularBrowserTarget: 'test-project:build:development', + channel: new Channel({}), }; await getBuilderOptions(options, mockBuilderContext); @@ -119,6 +121,7 @@ describe('framework-preset-angular-cli', () => { } as any, angularBrowserTarget: 'test-project:build', angularBuilderOptions: storybookOptions, + channel: new Channel({}), }; const result = await getBuilderOptions(options, mockBuilderContext); @@ -139,6 +142,7 @@ describe('framework-preset-angular-cli', () => { apply: vi.fn(), } as any, tsConfig: '/custom/tsconfig.json', + channel: new Channel({}), }; const result = await getBuilderOptions(options, mockBuilderContext); @@ -156,6 +160,7 @@ describe('framework-preset-angular-cli', () => { presets: { apply: vi.fn(), } as any, + channel: new Channel({}), }; const result = await getBuilderOptions(options, mockBuilderContext); @@ -182,6 +187,7 @@ describe('framework-preset-angular-cli', () => { apply: vi.fn(), } as any, angularBrowserTarget: 'test-project:build', + channel: new Channel({}), }; const result = await getBuilderOptions(options, mockBuilderContext); @@ -196,6 +202,7 @@ describe('framework-preset-angular-cli', () => { presets: { apply: vi.fn(), } as any, + channel: new Channel({}), }; const result = await getBuilderOptions(options, mockBuilderContext); @@ -218,6 +225,7 @@ describe('framework-preset-angular-cli', () => { apply: vi.fn(), } as any, angularBrowserTarget: 'test-project:build', + channel: new Channel({}), }; const result = await getBuilderOptions(options, mockBuilderContext); @@ -238,6 +246,7 @@ describe('framework-preset-angular-cli', () => { apply: vi.fn(), } as any, angularBrowserTarget: 'test-project:build:production', + channel: new Channel({}), }; const result = await getBuilderOptions(options, mockBuilderContext); @@ -262,6 +271,7 @@ describe('framework-preset-angular-cli', () => { } as any, angularBrowserTarget: 'test-project:build', angularBuilderOptions: {}, + channel: new Channel({}), }; const result = await getBuilderOptions(options, mockBuilderContext); @@ -287,6 +297,7 @@ describe('framework-preset-angular-cli', () => { apply: vi.fn(), } as any, angularBrowserTarget: 'test-project:build', + channel: new Channel({}), }; const result = await getBuilderOptions(options, mockBuilderContext); diff --git a/code/package.json b/code/package.json index 6e851fb208c4..b4df7d5be045 100644 --- a/code/package.json +++ b/code/package.json @@ -220,5 +220,6 @@ "Dependency Upgrades" ] ] - } + }, + "deferredNextVersion": "10.3.0-alpha.8" } diff --git a/code/playwright.config.ts b/code/playwright.config.ts index fdd61266912d..af6f071f3c5c 100644 --- a/code/playwright.config.ts +++ b/code/playwright.config.ts @@ -47,7 +47,7 @@ export default defineConfig({ // baseURL: 'http://localhost:3000', /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ - trace: 'on-first-retry', + trace: 'retain-on-failure', }, /* Configure projects for major browsers */ diff --git a/code/renderers/react/src/componentManifest/generator.test.ts b/code/renderers/react/src/componentManifest/generator.test.ts index 1bf32627f73d..f170d0d01887 100644 --- a/code/renderers/react/src/componentManifest/generator.test.ts +++ b/code/renderers/react/src/componentManifest/generator.test.ts @@ -575,6 +575,89 @@ test('should create component manifest when only attached-mdx docs have manifest `); }); +test('stories are populated when meta has no explicit title', async () => { + vol.fromJSON( + { + ['./package.json']: JSON.stringify({ name: 'some-package' }), + ['./src/stories/Card.stories.ts']: dedent` + import type { Meta, StoryObj } from '@storybook/react'; + import { Card } from './Card'; + + const meta: Meta = { + component: Card, + }; + export default meta; + type Story = StoryObj; + + export const Default: Story = { args: { label: 'Click me' } }; + export const Large: Story = { args: { label: 'Big button', size: 'large' } }; + `, + ['./src/stories/Card.tsx']: dedent` + import React from 'react'; + export interface CardProps { + label: string; + size?: 'small' | 'large'; + } + + /** A simple card component */ + export const Card = ({ label, size }: CardProps) => { + return
{label}
; + }; + `, + }, + '/app' + ); + + const manifestEntries = [ + { + type: 'story', + subtype: 'story', + id: 'card--default', + name: 'Default', + title: 'Card', + importPath: './src/stories/Card.stories.ts', + componentPath: './src/stories/Card.tsx', + tags: [Tag.DEV, Tag.TEST, Tag.MANIFEST], + exportName: 'Default', + }, + { + type: 'story', + subtype: 'story', + id: 'card--large', + name: 'Large', + title: 'Card', + importPath: './src/stories/Card.stories.ts', + componentPath: './src/stories/Card.tsx', + tags: [Tag.DEV, Tag.TEST, Tag.MANIFEST], + exportName: 'Large', + }, + ]; + + const result = await manifests(undefined, { manifestEntries } as any); + const component = result?.components?.components?.['card']; + + // When no explicit title is in the meta, stories should still be populated + // because the generator should use the index entry's title as fallback + expect(component?.stories).toMatchInlineSnapshot(` + [ + { + "description": undefined, + "id": "card--default", + "name": "Default", + "snippet": "const Default = () => ;", + "summary": undefined, + }, + { + "description": undefined, + "id": "card--large", + "name": "Large", + "snippet": "const Large = () => ;", + "summary": undefined, + }, + ] + `); +}); + test('should extract story description and summary from JSDoc comments', async () => { const code = withCSF3(dedent` /** diff --git a/code/renderers/react/src/componentManifest/generator.ts b/code/renderers/react/src/componentManifest/generator.ts index 1c2115c3a63b..7c98a2e97050 100644 --- a/code/renderers/react/src/componentManifest/generator.ts +++ b/code/renderers/react/src/componentManifest/generator.ts @@ -130,7 +130,7 @@ export const manifests: PresetPropertyFn< (entry as DocsIndexEntry).storiesImports[0]; const absoluteImportPath = path.join(process.cwd(), storyFilePath); const storyFile = cachedReadFileSync(absoluteImportPath, 'utf-8') as string; - const csf = loadCsf(storyFile, { makeTitle: (title) => title ?? 'No title' }).parse(); + const csf = loadCsf(storyFile, { makeTitle: () => entry.title }).parse(); const componentName = csf._meta?.component; const id = entry.id.split('--')[0]; diff --git a/docs/versions/next.json b/docs/versions/next.json index 575b2ea5f85c..e1ef8669414a 100644 --- a/docs/versions/next.json +++ b/docs/versions/next.json @@ -1 +1 @@ -{"version":"10.3.0-alpha.7","info":{"plain":"- Core: Require token for websocket connections - [#33820](https://github.com/storybookjs/storybook/pull/33820), thanks @ghengeveld!\n- Next.js: Handle legacyBehavior prop in Link mock component - [#33862](https://github.com/storybookjs/storybook/pull/33862), thanks @yatishgoel!\n- Preact: Support inferring props from component types - [#33828](https://github.com/storybookjs/storybook/pull/33828), thanks @JoviDeCroock!"}} \ No newline at end of file +{"version":"10.3.0-alpha.8","info":{"plain":"- A11y: Ensure popover dialogs have an ARIA label - [#33500](https://github.com/storybookjs/storybook/pull/33500), thanks @gayanMatch!\n- Addon-Vitest: Add channel API to programmatically trigger test runs - [#33206](https://github.com/storybookjs/storybook/pull/33206), thanks @JReinhold!\n- Builder-Vite: Centralize Vite plugins for builder-vite and addon-vitest - [#33819](https://github.com/storybookjs/storybook/pull/33819), thanks @valentinpalkovic!\n- Core: Revert Pull Request #33420 from Maelryn/fix/copy-button-overlap - [#33877](https://github.com/storybookjs/storybook/pull/33877), thanks @Sidnioulz!\n- Next.js-Vite: Fix failing postcss mutation - [#33879](https://github.com/storybookjs/storybook/pull/33879), thanks @valentinpalkovic!\n- React: Fix manifest stories empty when meta has no explicit title - [#33878](https://github.com/storybookjs/storybook/pull/33878), thanks @kasperpeulen!\n- UI: Fix Copy button overlapping code in portrait mode - [#33420](https://github.com/storybookjs/storybook/pull/33420), thanks @Maelryn!"}} \ No newline at end of file diff --git a/scripts/ci/sandboxes.ts b/scripts/ci/sandboxes.ts index 760b9299add3..fc1ebec3fbb0 100644 --- a/scripts/ci/sandboxes.ts +++ b/scripts/ci/sandboxes.ts @@ -97,6 +97,10 @@ function defineSandboxJob_dev({ }, }, artifact.persist(join(LINUX_ROOT_DIR, WORKING_DIR, 'test-results'), 'test-results'), + artifact.persist( + join(LINUX_ROOT_DIR, WORKING_DIR, 'code', 'playwright-results'), + 'playwright-results' + ), testResults.persist(join(LINUX_ROOT_DIR, WORKING_DIR, 'test-results')), ] : [ @@ -289,6 +293,10 @@ export function defineSandboxFlow(key: Key) { }, }, artifact.persist(join(LINUX_ROOT_DIR, WORKING_DIR, 'test-results'), 'test-results'), + artifact.persist( + join(LINUX_ROOT_DIR, WORKING_DIR, 'code', 'playwright-results'), + 'playwright-results' + ), testResults.persist(join(LINUX_ROOT_DIR, WORKING_DIR, 'test-results')), ], }), diff --git a/scripts/tasks/sandbox-parts.ts b/scripts/tasks/sandbox-parts.ts index 6e26c07b7bac..87bcc1442af1 100644 --- a/scripts/tasks/sandbox-parts.ts +++ b/scripts/tasks/sandbox-parts.ts @@ -497,8 +497,7 @@ export async function setupVitest(details: TemplateDetails, options: PassedOptio if (shouldUseCsf4) { await writeFile( setupFilePath, - dedent`import { beforeAll } from 'vitest' - import { setProjectAnnotations } from '${storybookPackage}' + dedent`import { setProjectAnnotations } from '${storybookPackage}' import projectAnnotations from './preview' // setProjectAnnotations still kept to support non-CSF4 story tests @@ -508,7 +507,7 @@ export async function setupVitest(details: TemplateDetails, options: PassedOptio } else { await writeFile( setupFilePath, - dedent`import { beforeAll } from 'vitest' + dedent` import { setProjectAnnotations } from '${storybookPackage}' import * as rendererDocsAnnotations from '${template.expected.renderer}/entry-preview-docs' import * as addonA11yAnnotations from '@storybook/addon-a11y/preview' diff --git a/yarn.lock b/yarn.lock index ab9fa385dbad..60717fa42593 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7728,6 +7728,7 @@ __metadata: version: 0.0.0-use.local resolution: "@storybook/addon-vitest@workspace:code/addons/vitest" dependencies: + "@storybook/addon-a11y": "workspace:*" "@storybook/global": "npm:^5.0.0" "@storybook/icons": "npm:^2.0.1" "@types/istanbul-lib-report": "npm:^3.0.3"