Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions code/addons/vitest/src/preset.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { beforeEach, describe, expect, it } from 'vitest';

import { clearToolsetRegistry, getToolset } from 'storybook/open-service';
import type { Options } from 'storybook/internal/types';

import { services } from './preset.ts';

const options = {
channel: { on: () => {}, off: () => {}, emit: () => {} },
presets: {
apply: async (key: string, fallback?: unknown) =>
key === 'storyIndexGenerator' ? { getIndex: async () => ({ v: 5, entries: {} }) } : fallback,
},
} as unknown as Options;

describe('services preset hook', () => {
beforeEach(() => {
clearToolsetRegistry();
});

// Consumers resolve the `test` toolset for its descriptions and schemas alone, without a dev
// server: `storybook ai` metadata generation applies `services` but never starts one, and a
// non-Vite dev server returns from `experimental_serverChannel` before it could register. Both
// used to hit an unregistered toolset and fail hard.
it('registers the test toolset without a server channel', async () => {
await services(undefined, options);

expect(getToolset('test').methods.run.description).toBeDefined();
});
});
34 changes: 34 additions & 0 deletions code/addons/vitest/src/preset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ import {
} from 'storybook/internal/common';
import {
type StoryIndexGenerator,
createTestToolset,
experimental_UniversalStore,
experimental_getTestProviderStore,
registerToolset,
} from 'storybook/internal/core-server';
import { logger } from 'storybook/internal/node-logger';
import { cleanPaths, oneWayHash, sanitizeError, telemetry } from 'storybook/internal/telemetry';
Expand Down Expand Up @@ -43,6 +45,12 @@ import { runTestRunner } from './node/boot-test-runner.ts';
import type { CachedState, ErrorLike, StoreState } from './types.ts';
import type { StoreEvent } from './types.ts';

/**
* Preset marker: true exactly when this addon is enabled, since only enabled addons' presets load.
* addon-mcp's availability gate reads it through `presets.apply('isAddonVitestEnabled', false)`.
*/
export const isAddonVitestEnabled = true;

type Event =
| {
type: 'test-discrepancy';
Expand All @@ -58,6 +66,32 @@ type Event =
payload: StoreState['currentRun'];
};

/**
* Registers the public `test` toolset.
*
* This addon owns the toolset because running stories needs its channel protocol, but it must
* register from the `services` hook rather than `experimental_serverChannel`: consumers resolve
* the toolset for its descriptions and schemas alone, and the two places that do so — `storybook
* ai` metadata generation (which never starts a dev server) and a non-Vite dev server (where the
* channel hook returns early) — would otherwise ask for a toolset that was never registered and
* fail hard. Registering here matches the availability gate that decides whether the tool is
* offered at all: that gate reads the `isAddonVitestEnabled` marker this preset exports, which is
* true exactly when the addon is enabled — the same condition under which this hook runs. The
* channel is used only when a run is actually triggered.
*/
export const services = async (_value: void, options: Options): Promise<void> => {
const storyIndexGenerator =
await options.presets.apply<Promise<StoryIndexGenerator>>('storyIndexGenerator');

registerToolset(
createTestToolset({
channel: options.channel as Channel,
storyIndex: { getIndex: () => storyIndexGenerator.getIndex() },
a11yEnabled: await options.presets.apply('isAddonA11yEnabled', false),
})
);
};

export const experimental_serverChannel = async (channel: Channel, options: Options) => {
const core = await options.presets.apply('core');

Expand Down
9 changes: 9 additions & 0 deletions code/core/build-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,15 @@ const config: BuildEntries = {
exportEntries: ['./open-service'],
entryPoint: './src/shared/open-service/index.ts',
},
{
// Dependency-light docs toolset surface bundled into `@storybook/mcp`'s dist (core is a
// dev dependency there), replacing that package's own manifest-formatter copies. Portable:
// its d.ts is one flat self-contained file so that consumer can inline it through standard
// resolution; guarded by `portable-dist.test.ts` next to the entry source.
exportEntries: ['./internal/toolsets-docs'],
entryPoint: './src/shared/open-service/toolsets/docs/public.ts',
portable: { external: ['valibot'] },
},
],
runtime: [
{
Expand Down
6 changes: 6 additions & 0 deletions code/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,11 @@
"code": "./src/telemetry/index.ts",
"default": "./dist/telemetry/index.js"
},
"./internal/toolsets-docs": {
"types": "./dist/shared/open-service/toolsets/docs/public.d.ts",
"code": "./src/shared/open-service/toolsets/docs/public.ts",
"default": "./dist/shared/open-service/toolsets/docs/public.js"
},
"./internal/types": {
"types": "./dist/types/index.d.ts",
"code": "./src/types/index.ts",
Expand Down Expand Up @@ -371,6 +376,7 @@
"npmlog": "^7.0.0",
"p-limit": "^7.2.0",
"package-manager-detector": "^1.1.0",
"pathe": "^1.1.2",
"picocolors": "^1.1.0",
"picomatch": "^2.3.0",
"picoquery": "^2.5.0",
Expand Down
23 changes: 23 additions & 0 deletions code/core/src/core-server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,29 @@ export {
registerService as experimental_registerService,
} from '../shared/open-service/server.ts';

/**
* Toolset registration for addons that host a public toolset.
*
* Exposed from the Node entry rather than `storybook/open-service`: toolset factories reach
* server-only code, and that entry is built for the browser.
*/
export { registerToolset } from '../shared/open-service/toolset-registry.ts';
export { createTestToolset } from '../shared/open-service/toolsets/test/definition.ts';
export type {
CreateTestToolsetOptions,
TestToolset,
} from '../shared/open-service/toolsets/test/definition.ts';
export { createDocsToolset } from '../shared/open-service/toolsets/docs/definition.ts';
export { emptyManifests } from '../shared/open-service/toolsets/docs/access.ts';
export type { DocsAccess, ResolvedDocsEntry } from '../shared/open-service/toolsets/docs/access.ts';
export { createServiceDocsAccess } from '../shared/open-service/toolsets/docs/access-service.ts';
export { createManifestDocsAccess } from '../shared/open-service/toolsets/docs/access-manifest.ts';
export { createLocalDocsAccess } from '../shared/open-service/toolsets/docs/access-local.ts';
export { loadManifests } from './utils/manifests/manifests.ts';
export { createStoriesToolset } from '../shared/open-service/toolsets/stories/definition.ts';
export type { PreviewStoriesOutput } from '../shared/open-service/toolsets/stories/definition.ts';
export { reviewToolset } from '../shared/open-service/toolsets/review/definition.ts';

export { UniversalStore as experimental_UniversalStore } from '../shared/universal-store/index.ts';
export { MockUniversalStore as experimental_MockUniversalStore } from '../shared/universal-store/mock.ts';
export {
Expand Down
71 changes: 55 additions & 16 deletions code/core/src/core-server/presets/common-preset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,16 @@ import { StoryIndexGenerator } from 'storybook/internal/core-server';
import { loadCsf } from 'storybook/internal/csf-tools';
import { logger } from 'storybook/internal/node-logger';
import { telemetry } from 'storybook/internal/telemetry';
import type {
CoreConfig,
DocgenProviderDescriptor,
Indexer,
Options,
PresetProperty,
PresetPropertyFn,
StoryDocsProvider,
StorybookConfigRaw,
import {
CHANGE_DETECTION_STATUS_TYPE_ID,
type CoreConfig,
type DocgenProviderDescriptor,
type Indexer,
type Options,
type PresetProperty,
type PresetPropertyFn,
type StoryDocsProvider,
type StorybookConfigRaw,
} from 'storybook/internal/types';

import { OpenServiceServicesAppliedTwiceError } from '../../server-errors.ts';
Expand All @@ -36,9 +37,14 @@ import { createDocgenWorkerClient } from '../../shared/open-service/services/doc
import { registerModuleGraphService } from '../../shared/open-service/services/module-graph/server.ts';
import { registerReviewService } from '../../shared/open-service/services/review/server.ts';
import { registerStoryDocsService } from '../../shared/open-service/services/story-docs/server.ts';
import { createLocalDocsAccess } from '../../shared/open-service/toolsets/docs/access-local.ts';
import { registerToolset } from '../../shared/open-service/toolset-registry.ts';
import { docsToolset } from '../../shared/open-service/toolsets/docs/definition.ts';
import { createDocsToolset } from '../../shared/open-service/toolsets/docs/definition.ts';
import { reviewToolset } from '../../shared/open-service/toolsets/review/definition.ts';
import { createStoriesToolset } from '../../shared/open-service/toolsets/stories/definition.ts';
import { GitDiffProvider } from '../change-detection/GitDiffProvider.ts';
import { getStatusStoreByTypeId } from '../stores/status.ts';
import { loadManifests } from '../utils/manifests/manifests.ts';

import * as pathe from 'pathe';
import { isAbsolute, join } from 'pathe';
Expand All @@ -50,7 +56,7 @@ import { initCreateNewStoryChannel } from '../server-channel/create-new-story-ch
import { initFileSearchChannel } from '../server-channel/file-search-channel.ts';
import { initGhostStoriesChannel } from '../server-channel/ghost-stories-channel.ts';
import { initOpenInEditorChannel } from '../server-channel/open-in-editor-channel.ts';
import { isReviewFeatureEnabled } from '../../shared/review/features.ts';
import { isReviewExplicitlyEnabled, isReviewFeatureEnabled } from '../../shared/review/features.ts';
import { initReviewChannel } from '../server-channel/review-channel.ts';
import { initTelemetryChannel } from '../server-channel/telemetry-channel.ts';
import { initializeChecklist } from '../utils/checklist.ts';
Expand Down Expand Up @@ -367,13 +373,31 @@ export const services = async (_value: void, options: Options): Promise<void> =>
presets: options.presets,
});

// Toolsets register imperatively alongside their services: addons contribute both from their own
// `services` hook. The stories and test toolsets join once their boot-time dependencies are wired
// (stories factory; test moves to addon-vitest).
registerToolset(docsToolset);

const features = await options.presets.apply('features');

// Toolsets register imperatively alongside their services: addons contribute both from their own
// `services` hook. The test toolset registers from addon-vitest, which owns the channel it needs.
const storyIndex = { getIndex: () => storyIndexGenerator.getIndex() };
const gitDiffProvider = new GitDiffProvider(process.cwd());

registerToolset(
createStoriesToolset({
storyIndex,
git: {
getRepoRoot: () => gitDiffProvider.getRepoRoot(),
getChangedFiles: () => gitDiffProvider.getChangedFiles(),
},
changeStatuses: {
getAll: () => getStatusStoreByTypeId(CHANGE_DETECTION_STATUS_TYPE_ID).getAll(),
},
// The explicit opt-in gate, not `isReviewFeatureEnabled`: with the flag unset the review
// infrastructure below still registers (the `storybook ai` CLI channel enables the tool per
// request), but direct MCP clients never see `display-review`, so the stories prose must
// not point at it.
reviewEnabled: isReviewExplicitlyEnabled(features),
})
);

if (isReviewFeatureEnabled(features)) {
registerReviewService({
getIndex: () => storyIndexGenerator.getIndex(),
Expand Down Expand Up @@ -410,12 +434,27 @@ export const services = async (_value: void, options: Options): Promise<void> =>
});
}

// Story-docs registers whenever this block runs, docgen only when a worker is available, so
// docgen registered ⇒ story-docs registered. `createLocalDocsAccess` requires both before
// reading from the services and degrades to the manifests otherwise, so reordering or gating
// these registrations cannot make the docs toolset throw — only fall back.
registerStoryDocsService({
getIndex: () => storyIndexGenerator.getIndex(),
storyDocsProvider,
workingDir: process.cwd(),
});
}

registerToolset(
createDocsToolset({
// Registration-based selection between the docgen services and the inline manifests, shared
// with addon-mcp's composed local source so both read this Storybook the same way.
docsAccess: createLocalDocsAccess({
storyIndex,
getManifests: () => loadManifests(options.presets),
}),
})
);
};

// Store the promise (not the result) to prevent race conditions.
Expand Down
22 changes: 22 additions & 0 deletions code/core/src/core-server/utils/manifests/manifests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,18 @@ function mergeServicePayloads(
);
}

/**
* Whether the dev server should 404 `manifests/components.json` and `manifests/docs.json` because
* docgen-server mode owns that data.
*
* Deliberately flag-based, while the docs toolset's engine selection (`createLocalDocsAccess`) is
* registration-based. The two can disagree: with the flag on but the docgen services unregistered
* (manager-only build, no docgen worker), the toolset falls back to reading the inline manifests
* while this route keeps 404ing them — so a composing parent Storybook fetching manifests over
* HTTP sees nothing even though the local MCP tools still serve docs. Accepted for now: switching
* this gate to registration would change composition behavior that only the live-fixture suites
* cover, so it is deferred to its own change.
*/
function isDocgenServerManifestMode(features: {
experimentalDocgenServer?: boolean;
componentsManifest?: boolean;
Expand Down Expand Up @@ -123,6 +135,16 @@ async function getManifests(
);
}

/**
* Loads the live manifests, the same way the dev-server manifest routes do.
*
* Exposed for the docs toolset, which reads manifest data in-process instead of fetching its own
* server over loopback HTTP.
*/
export async function loadManifests(presets: Presets) {
return getManifests(presets, await getManifestEntries(presets), { watch: true });
}
Comment thread
kasperpeulen marked this conversation as resolved.

/**
* Resolves the docgen `meta` for the components HTML debugger.
*
Expand Down
Loading
Loading