diff --git a/code/addons/mcp/src/mcp-handler.ts b/code/addons/mcp/src/mcp-handler.ts index 6aac8c4d6167..2917cfc70499 100644 --- a/code/addons/mcp/src/mcp-handler.ts +++ b/code/addons/mcp/src/mcp-handler.ts @@ -152,6 +152,7 @@ export const mcpServerHandler = async ({ endpoint, toolsets: getToolsets(webRequest, addonOptions), reviewEnabled: isReviewEnabledForRequest(webRequest, reviewGates!), + cliClient: webRequest.headers.get(STORYBOOK_MCP_PROXY_HEADER) === 'true', origin: origin!, disableTelemetry: disableTelemetry!, a11yEnabled, diff --git a/code/addons/mcp/src/preset.test.ts b/code/addons/mcp/src/preset.test.ts index e73087fd78ed..de2ddd04e099 100644 --- a/code/addons/mcp/src/preset.test.ts +++ b/code/addons/mcp/src/preset.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import type { Options } from 'storybook/internal/types'; import { experimental_devServer } from './preset.ts'; import * as mcpHandlerModule from './mcp-handler.ts'; -import * as runStoryTests from './tools/run-story-tests.ts'; +import * as runStoryTests from './utils/addon-vitest.ts'; import * as moduleGraph from './utils/module-graph.ts'; import { registerCoreToolsetsForTest } from './test-support/register-core-toolsets.ts'; @@ -419,7 +419,7 @@ describe('experimental_devServer', () => { }); it('should show Storybook version requirement for addon-vitest and a manual manifest link', async () => { - vi.spyOn(runStoryTests, 'getAddonVitestConstants').mockResolvedValue(undefined); + vi.spyOn(runStoryTests, 'isAddonVitestEnabled').mockResolvedValue(false); const manifestEnabledOptions = { presets: { apply: vi.fn((key: string) => { diff --git a/code/addons/mcp/src/storybook-ai-metadata.test.ts b/code/addons/mcp/src/storybook-ai-metadata.test.ts index 5fe831157e34..a124441216ed 100644 --- a/code/addons/mcp/src/storybook-ai-metadata.test.ts +++ b/code/addons/mcp/src/storybook-ai-metadata.test.ts @@ -2,15 +2,15 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { registerCoreToolsetsForTest } from './test-support/register-core-toolsets.ts'; import { McpServer } from 'tmcp'; import { ValibotJsonSchemaAdapter } from '@tmcp/adapter-valibot'; -import { GET_TOOL_NAME } from '@storybook/mcp'; import { logger } from 'storybook/internal/node-logger'; import { buildStorybookAiMetadata } from './storybook-ai-metadata.ts'; -import { getAddonVitestConstants } from './tools/run-story-tests.ts'; +import { isAddonVitestEnabled } from './utils/addon-vitest.ts'; import type { AddonContext } from './types.ts'; import { getManifestStatus } from './tools/is-manifest-available.ts'; import { isAddonA11yEnabled } from './utils/is-addon-a11y-enabled.ts'; import { getReviewStatus } from './utils/is-review-available.ts'; import { isModuleGraphSupported, isModuleGraphSupportedByBuilder } from './utils/module-graph.ts'; +import { MCP_TOOL_NAMES } from 'storybook/open-service'; import { DISPLAY_REVIEW_TOOL_NAME, GET_STORIES_BY_COMPONENT_TOOL_NAME, @@ -41,10 +41,7 @@ vi.mock('./utils/is-addon-a11y-enabled.ts', () => ({ isAddonA11yEnabled: vi.fn(), })); -vi.mock('./tools/run-story-tests.ts', async (importActual) => ({ - ...(await importActual()), - getAddonVitestConstants: vi.fn(), -})); +vi.mock('./utils/addon-vitest.ts', { spy: true }); describe('buildStorybookAiMetadata', () => { beforeEach(() => { @@ -65,10 +62,7 @@ describe('buildStorybookAiMetadata', () => { hasFeatureFlag: true, docgenServer: false, }); - vi.mocked(getAddonVitestConstants).mockResolvedValue({ - TRIGGER_TEST_RUN_REQUEST: 'TRIGGER_TEST_RUN_REQUEST', - TRIGGER_TEST_RUN_RESPONSE: 'TRIGGER_TEST_RUN_RESPONSE', - }); + vi.mocked(isAddonVitestEnabled).mockResolvedValue(true); vi.mocked(isAddonA11yEnabled).mockResolvedValue(true); }); @@ -158,7 +152,7 @@ describe('buildStorybookAiMetadata', () => { }); it('keeps addon-vitest availability aligned between metadata and live tools/list', async () => { - vi.mocked(getAddonVitestConstants).mockResolvedValue(undefined); + vi.mocked(isAddonVitestEnabled).mockResolvedValue(false); const options = createOptions(); const metadata = await buildStorybookAiMetadata(options); @@ -493,6 +487,50 @@ function createOptions({ } as any; } +/** + * The published tool surface for each availability combination. + * + * Availability decides which tools an agent is offered at all, so it is part of the wire contract + * and not just internal wiring. These pin the surface per combination; the descriptions and schemas + * behind each name are snapshotted by the e2e suite against a live server. + */ +const LIST_TOOL_NAME = MCP_TOOL_NAMES['docs.list']; +const GET_TOOL_NAME = MCP_TOOL_NAMES['docs.show']; + +describe('tool availability variants', () => { + const names = async (overrides: Partial) => + (await listRegisteredTools(createOptions(), { availability: createAvailability(overrides) })) + .map((tool: { name: string }) => tool.name) + .sort(); + + it('offers no review tool when review is off', async () => { + const withReview = await names({}); + const withoutReview = await names({ reviewEnabled: false, changeDetectionEnabled: false }); + + expect(withReview).toContain(DISPLAY_REVIEW_TOOL_NAME); + expect(withoutReview).not.toContain(DISPLAY_REVIEW_TOOL_NAME); + }); + + it('offers no test tool when addon-vitest is absent', async () => { + expect(await names({ testSupported: false })).not.toContain(RUN_STORY_TESTS_TOOL_NAME); + }); + + it('keeps the docs tools when a11y is off, which only varies their prose', async () => { + expect(await names({ a11yEnabled: false })).toEqual(await names({ a11yEnabled: true })); + }); + + it('offers no docs tools when docs are unavailable', async () => { + const withoutDocs = await names({ + docsEnabled: false, + docsHasManifests: false, + docsFeatureEnabled: false, + }); + + expect(withoutDocs).not.toContain(LIST_TOOL_NAME); + expect(withoutDocs).not.toContain(GET_TOOL_NAME); + }); +}); + function createAvailability(overrides: Partial = {}): ToolAvailability { return { moduleGraphSupported: true, diff --git a/code/addons/mcp/src/telemetry.test.ts b/code/addons/mcp/src/telemetry.test.ts index f5e1ed4c636c..6e2ba5131a82 100644 --- a/code/addons/mcp/src/telemetry.test.ts +++ b/code/addons/mcp/src/telemetry.test.ts @@ -47,9 +47,25 @@ describe('collectTelemetry', () => { roots: { listChanged: true }, }, customField: 'custom-value', + consumer: 'mcp', }); }); + it('reports the storybook ai CLI channel as the cli consumer', async () => { + vi.mocked(telemetry).mockResolvedValue(undefined); + + const cliServer = Object.assign(Object.create(mockServer), { + ctx: { ...mockServer.ctx, custom: { cliClient: true } }, + }) as any; + + await collectTelemetry({ event: 'test-event', server: cliServer }); + + expect(telemetry).toHaveBeenCalledWith( + 'addon-mcp', + expect.objectContaining({ consumer: 'cli' }) + ); + }); + it('should pass through additional payload fields', async () => { vi.mocked(telemetry).mockResolvedValue(undefined); @@ -69,6 +85,7 @@ describe('collectTelemetry', () => { toolName: 'list-all-documentation', duration: 123, success: true, + consumer: 'mcp', }); }); @@ -103,6 +120,7 @@ describe('collectTelemetry', () => { mcpSessionId: undefined, clientInfo: undefined, clientCapabilities: undefined, + consumer: 'mcp', }); }); }); diff --git a/code/addons/mcp/src/telemetry.ts b/code/addons/mcp/src/telemetry.ts index 4aef9165173e..1d46333ba977 100644 --- a/code/addons/mcp/src/telemetry.ts +++ b/code/addons/mcp/src/telemetry.ts @@ -18,6 +18,9 @@ export async function collectTelemetry({ mcpSessionId: server.ctx.sessionId, clientInfo: server.ctx.sessionInfo?.clientInfo, clientCapabilities: server.ctx.sessionInfo?.clientCapabilities, + // Which channel drove the call: the `storybook ai` CLI marks its requests with the proxy + // header; everything else is a direct MCP client. + consumer: server.ctx.custom?.cliClient ? 'cli' : 'mcp', ...payload, }); } catch (error) { diff --git a/code/addons/mcp/src/test-support/register-core-toolsets.ts b/code/addons/mcp/src/test-support/register-core-toolsets.ts index 25da66a907d4..882dbbf57ecf 100644 --- a/code/addons/mcp/src/test-support/register-core-toolsets.ts +++ b/code/addons/mcp/src/test-support/register-core-toolsets.ts @@ -11,6 +11,7 @@ import type { StoryIndex } from 'storybook/internal/types'; import { clearToolsetRegistry } from 'storybook/open-service'; import { createStoriesToolset, + createTestToolset, registerToolset, reviewToolset, } from 'storybook/internal/core-server'; @@ -20,7 +21,8 @@ const EMPTY_INDEX: StoryIndex = { v: 5, entries: {} }; export function registerCoreToolsetsForTest({ index = EMPTY_INDEX, reviewEnabled = true, -}: { index?: StoryIndex; reviewEnabled?: boolean } = {}) { + testToolset = true, +}: { index?: StoryIndex; reviewEnabled?: boolean; testToolset?: boolean } = {}) { clearToolsetRegistry(); const storyIndex = { getIndex: async () => index }; @@ -37,4 +39,15 @@ export function registerCoreToolsetsForTest({ }) ); registerToolset(reviewToolset); + // `testToolset: false` mirrors addon-vitest being absent or not enabled, where its `services` + // hook never runs and the `test` toolset stays unregistered. + if (testToolset) { + registerToolset( + createTestToolset({ + channel: { on: () => {}, off: () => {}, emit: () => {} } as never, + storyIndex, + a11yEnabled: false, + }) + ); + } } diff --git a/code/addons/mcp/src/tools/get-storybook-story-instructions.test.ts b/code/addons/mcp/src/tools/get-storybook-story-instructions.test.ts index d912c13a5c64..11e8d15770b3 100644 --- a/code/addons/mcp/src/tools/get-storybook-story-instructions.test.ts +++ b/code/addons/mcp/src/tools/get-storybook-story-instructions.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { McpServer } from 'tmcp'; import { ValibotJsonSchemaAdapter } from '@tmcp/adapter-valibot'; -import { getAddonVitestConstants } from './run-story-tests.ts'; +import { isAddonVitestEnabled } from '../utils/addon-vitest.ts'; import { addGetUIBuildingInstructionsTool, buildStorybookStoryInstructions, @@ -14,8 +14,8 @@ import { GET_UI_BUILDING_INSTRUCTIONS_TOOL_NAME, } from './tool-names.ts'; -vi.mock('./run-story-tests.ts', () => ({ - getAddonVitestConstants: vi.fn(), +vi.mock('../utils/addon-vitest.ts', () => ({ + isAddonVitestEnabled: vi.fn(), })); vi.mock('../utils/is-review-available.ts', () => ({ @@ -26,10 +26,7 @@ describe('getUIBuildingInstructionsTool', () => { let server: McpServer; beforeEach(async () => { - vi.mocked(getAddonVitestConstants).mockResolvedValue({ - TRIGGER_TEST_RUN_REQUEST: 'TRIGGER_TEST_RUN_REQUEST', - TRIGGER_TEST_RUN_RESPONSE: 'TRIGGER_TEST_RUN_RESPONSE', - }); + vi.mocked(isAddonVitestEnabled).mockResolvedValue(true); vi.mocked(getReviewStatus).mockResolvedValue({ available: false, @@ -69,7 +66,7 @@ describe('getUIBuildingInstructionsTool', () => { } ); - await addGetUIBuildingInstructionsTool(server); + await addGetUIBuildingInstructionsTool(server, undefined, { addonVitestAvailable: true }); }); async function getToolDescription(context: AddonContext) { diff --git a/code/addons/mcp/src/tools/get-storybook-story-instructions.ts b/code/addons/mcp/src/tools/get-storybook-story-instructions.ts index b203e7c15301..450e88b1f32e 100644 --- a/code/addons/mcp/src/tools/get-storybook-story-instructions.ts +++ b/code/addons/mcp/src/tools/get-storybook-story-instructions.ts @@ -1,6 +1,6 @@ import type { McpServer } from 'tmcp'; import type { Options } from 'storybook/internal/types'; -import { getAddonVitestConstants } from './run-story-tests.ts'; +import { isAddonVitestEnabled } from '../utils/addon-vitest.ts'; import { collectTelemetry } from '../telemetry.ts'; import { getFinalLinksGuidance } from '../instructions/build-server-instructions.ts'; import { getReviewStatus } from '../utils/is-review-available.ts'; @@ -47,10 +47,11 @@ export async function addGetUIBuildingInstructionsTool( server: McpServer, enabled: Parameters['tool']>[0]['enabled'] = () => server.ctx.custom?.toolsets?.dev ?? true, - { docsAvailable = false }: { docsAvailable?: boolean } = {} + { + docsAvailable = false, + addonVitestAvailable = false, + }: { docsAvailable?: boolean; addonVitestAvailable?: boolean } = {} ) { - const addonVitestAvailable = !!(await getAddonVitestConstants()); - server.tool( { name: GET_UI_BUILDING_INSTRUCTIONS_TOOL_NAME, @@ -198,7 +199,8 @@ export async function buildStorybookStoryInstructions( .replace('{{FINAL_LINKS_GUIDANCE}}', getFinalLinksGuidance(reviewEnabled)) .replace('{{CHANGED_STORY_FALLBACK_LINK_GUIDANCE}}', changedStoryFallbackLinkGuidance); - const resolvedAddonVitestAvailable = addonVitestAvailable ?? !!(await getAddonVitestConstants()); + const resolvedAddonVitestAvailable = + addonVitestAvailable ?? (await isAddonVitestEnabled(options)); const testToolsetAvailable = (toolsets?.test ?? true) && resolvedAddonVitestAvailable; if (testToolsetAvailable) { diff --git a/code/addons/mcp/src/tools/run-story-tests.test.ts b/code/addons/mcp/src/tools/run-story-tests.test.ts deleted file mode 100644 index ce76d4c60ab8..000000000000 --- a/code/addons/mcp/src/tools/run-story-tests.test.ts +++ /dev/null @@ -1,1167 +0,0 @@ -import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; -import { McpServer } from 'tmcp'; -import { ValibotJsonSchemaAdapter } from '@tmcp/adapter-valibot'; -import { addRunStoryTestsTool, getAddonVitestConstants } from './run-story-tests.ts'; -import type { AddonContext } from '../types.ts'; -import smallStoryIndexFixture from '../../fixtures/small-story-index.fixture.json' with { type: 'json' }; -import * as getStoryIndexModule from '../utils/get-story-index.ts'; -import type { TriggerTestRunResponsePayload } from '@storybook/addon-vitest/constants'; -import { RUN_STORY_TESTS_TOOL_NAME } from './tool-names.ts'; - -vi.mock('storybook/internal/csf', () => ({ - storyNameFromExport: (exportName: string) => exportName, -})); - -vi.mock('storybook/internal/node-logger', () => ({ - logger: { - info: vi.fn(), - debug: vi.fn(), - warn: vi.fn(), - }, -})); - -describe('getAddonVitestConstants', () => { - it('should return constants when addon-vitest is available', async () => { - const constants = await getAddonVitestConstants(); - expect(constants).toMatchInlineSnapshot(` - { - "TRIGGER_TEST_RUN_REQUEST": "storybook/test/trigger-test-run-request", - "TRIGGER_TEST_RUN_RESPONSE": "storybook/test/trigger-test-run-response", - } - `); - }); -}); - -describe('runStoryTestsTool', () => { - let server: McpServer; - let mockChannel: { - on: Mock; - off: Mock; - emit: Mock; - }; - - type ChannelResponse = Omit; - - const createTestContext = (): AddonContext => { - mockChannel = { - on: vi.fn(), - off: vi.fn(), - emit: vi.fn(), - }; - - return { - origin: 'http://localhost:6006', - options: { - channel: mockChannel, - } as any, - disableTelemetry: true, - toolsets: { - dev: true, - docs: true, - test: true, - }, - }; - }; - - /** - * Sets up the mock channel to respond with the given response when a test run is triggered. - */ - const setupChannelResponse = (response: ChannelResponse) => { - mockChannel.emit.mockImplementation((event, payload: any) => { - if (event === 'storybook/test/trigger-test-run-request') { - const onCallback = mockChannel.on.mock.calls.find( - (call) => call[0] === 'storybook/test/trigger-test-run-response' - )?.[1]; - if (onCallback) { - setTimeout(() => { - onCallback({ - requestId: payload.requestId, - ...response, - }); - }, 0); - } - } - }); - }; - - /** - * Calls the run-story-tests tool with optional focused stories. - * Uses relative paths from 'src/' to avoid absolute path issues in snapshots. - */ - const callTool = async ( - stories: Array<{ exportName: string; relativePath: string }> | undefined, - context: AddonContext, - options?: { a11y?: boolean } - ) => { - const storyArguments = stories - ? { - stories: stories.map((story) => ({ - exportName: story.exportName, - absoluteStoryPath: `${process.cwd()}/${story.relativePath}`, - })), - } - : {}; - - const request = { - jsonrpc: '2.0' as const, - id: 1, - method: 'tools/call', - params: { - name: RUN_STORY_TESTS_TOOL_NAME, - arguments: { - ...storyArguments, - ...(options?.a11y !== undefined && { a11y: options.a11y }), - }, - }, - }; - - return server.receive(request, { - sessionId: 'test-session', - custom: context, - }); - }; - - beforeEach(async () => { - const adapter = new ValibotJsonSchemaAdapter(); - server = new McpServer( - { - name: 'test-server', - version: '1.0.0', - description: 'Test server for run-story-tests tool', - }, - { - adapter, - capabilities: { - tools: { listChanged: true }, - }, - } - ).withContext(); - - // Initialize test session - await server.receive( - { - jsonrpc: '2.0', - id: 1, - method: 'initialize', - params: { - protocolVersion: '2025-06-18', - capabilities: {}, - clientInfo: { name: 'test', version: '1.0.0' }, - }, - }, - { - sessionId: 'test-session', - } - ); - - await addRunStoryTestsTool(server, { a11yEnabled: true }); - - // Mock getStoryIndex to return the fixture - vi.spyOn(getStoryIndexModule, 'getStoryIndex').mockResolvedValue(smallStoryIndexFixture as any); - }); - - it('should include visual a11y handling guidance in tool description', async () => { - const response = await server.receive( - { - jsonrpc: '2.0' as const, - id: 100, - method: 'tools/list', - params: {}, - }, - { - sessionId: 'test-session', - custom: createTestContext(), - } - ); - - const tools = response.result?.tools ?? []; - const runStoryTestsTool = tools.find((tool: any) => tool.name === RUN_STORY_TESTS_TOOL_NAME); - - expect(runStoryTestsTool?.description).toContain( - 'For visual/design accessibility violations (for example color contrast), ask the user before changing styles.' - ); - }); - - it('should return passing stories when all tests pass', async () => { - const testContext = createTestContext(); - - setupChannelResponse({ - status: 'completed', - result: { - triggeredBy: 'external:addon-mcp', - config: { coverage: false, a11y: false }, - storyIds: ['button--primary'], - totalTestCount: 1, - startedAt: Date.now(), - finishedAt: Date.now(), - coverageSummary: undefined, - componentTestCount: { success: 1, error: 0 }, - a11yCount: { success: 1, warning: 0, error: 0 }, - componentTestStatuses: [ - { - storyId: 'button--primary', - typeId: 'storybook/component-test', - value: 'status-value:success', - title: 'Component Test', - description: '', - }, - ], - a11yStatuses: [], - a11yReports: {}, - reports: {}, - unhandledErrors: [], - }, - }); - - const response = await callTool( - [{ exportName: 'Primary', relativePath: 'src/Button.stories.tsx' }], - testContext - ); - - expect(response.result?.content[0].text).toMatchInlineSnapshot(` - "## Passing Stories - - - button--primary" - `); - expect(mockChannel.emit).toHaveBeenCalledWith( - 'storybook/test/trigger-test-run-request', - expect.objectContaining({ - actor: 'addon-mcp', - storyIds: ['button--primary'], - config: { a11y: true }, - }) - ); - }); - - it('should run all tests when stories input is omitted', async () => { - const testContext = createTestContext(); - - setupChannelResponse({ - status: 'completed', - result: { - triggeredBy: 'external:addon-mcp', - config: { coverage: false, a11y: false }, - storyIds: ['button--primary', 'input--default'], - totalTestCount: 2, - startedAt: Date.now(), - finishedAt: Date.now(), - coverageSummary: undefined, - componentTestCount: { success: 2, error: 0 }, - a11yCount: { success: 2, warning: 0, error: 0 }, - componentTestStatuses: [ - { - storyId: 'button--primary', - typeId: 'storybook/component-test', - value: 'status-value:success', - title: 'Component Test', - description: '', - }, - { - storyId: 'input--default', - typeId: 'storybook/component-test', - value: 'status-value:success', - title: 'Component Test', - description: '', - }, - ], - a11yStatuses: [], - a11yReports: {}, - reports: {}, - unhandledErrors: [], - }, - }); - - const response = await callTool(undefined, testContext); - - expect(response.result?.content[0].text).toContain('button--primary'); - expect(response.result?.content[0].text).toContain('input--default'); - expect(mockChannel.emit).toHaveBeenCalledWith( - 'storybook/test/trigger-test-run-request', - expect.objectContaining({ - actor: 'addon-mcp', - storyIds: undefined, - config: { a11y: true }, - }) - ); - }); - - it('should run focused tests when stories input uses storyId', async () => { - const testContext = createTestContext(); - - setupChannelResponse({ - status: 'completed', - result: { - triggeredBy: 'external:addon-mcp', - config: { coverage: false, a11y: false }, - storyIds: ['button--primary'], - totalTestCount: 1, - startedAt: Date.now(), - finishedAt: Date.now(), - coverageSummary: undefined, - componentTestCount: { success: 1, error: 0 }, - a11yCount: { success: 1, warning: 0, error: 0 }, - componentTestStatuses: [ - { - storyId: 'button--primary', - typeId: 'storybook/component-test', - value: 'status-value:success', - title: 'Component Test', - description: '', - }, - ], - a11yStatuses: [], - a11yReports: {}, - reports: {}, - unhandledErrors: [], - }, - }); - - const request = { - jsonrpc: '2.0' as const, - id: 1, - method: 'tools/call', - params: { - name: RUN_STORY_TESTS_TOOL_NAME, - arguments: { - stories: [{ storyId: 'button--primary' }], - }, - }, - }; - - const response = await server.receive(request, { - sessionId: 'test-session', - custom: testContext, - }); - - expect(response.result?.content[0].text).toContain('button--primary'); - expect(mockChannel.emit).toHaveBeenCalledWith( - 'storybook/test/trigger-test-run-request', - expect.objectContaining({ - storyIds: ['button--primary'], - }) - ); - }); - - it('should pass a11y: false in config when disabled via input', async () => { - const testContext = createTestContext(); - - setupChannelResponse({ - status: 'completed', - result: { - triggeredBy: 'external:addon-mcp', - config: { coverage: false, a11y: false }, - storyIds: ['button--primary'], - totalTestCount: 1, - startedAt: Date.now(), - finishedAt: Date.now(), - coverageSummary: undefined, - componentTestCount: { success: 1, error: 0 }, - a11yCount: { success: 0, warning: 0, error: 0 }, - componentTestStatuses: [ - { - storyId: 'button--primary', - typeId: 'storybook/component-test', - value: 'status-value:success', - title: 'Component Test', - description: '', - }, - ], - a11yStatuses: [], - a11yReports: {}, - reports: {}, - unhandledErrors: [], - }, - }); - - const response = await callTool( - [{ exportName: 'Primary', relativePath: 'src/Button.stories.tsx' }], - testContext, - { a11y: false } - ); - - expect(response.result?.content[0].text).toMatchInlineSnapshot(` - "## Passing Stories - - - button--primary" - `); - expect(mockChannel.emit).toHaveBeenCalledWith( - 'storybook/test/trigger-test-run-request', - expect.objectContaining({ - actor: 'addon-mcp', - storyIds: ['button--primary'], - config: { a11y: false }, - }) - ); - }); - - it('should return failing stories with descriptions', async () => { - const testContext = createTestContext(); - - setupChannelResponse({ - status: 'completed', - result: { - triggeredBy: 'external:addon-mcp', - config: { coverage: false, a11y: false }, - storyIds: ['button--primary'], - totalTestCount: 1, - startedAt: Date.now(), - finishedAt: Date.now(), - coverageSummary: undefined, - componentTestCount: { success: 0, error: 1 }, - a11yCount: { success: 0, warning: 0, error: 0 }, - componentTestStatuses: [ - { - storyId: 'button--primary', - typeId: 'storybook/component-test', - value: 'status-value:error', - title: 'Component Test', - description: 'Expected element to be visible', - }, - ], - a11yStatuses: [], - a11yReports: {}, - reports: {}, - unhandledErrors: [], - }, - }); - - const response = await callTool( - [{ exportName: 'Primary', relativePath: 'src/Button.stories.tsx' }], - testContext - ); - - expect(response.result?.content[0].text).toMatchInlineSnapshot(` - "## Failing Stories - - ### button--primary - - Expected element to be visible" - `); - }); - - it('should show both passing and failing stories when there are mixed results', async () => { - const testContext = createTestContext(); - - setupChannelResponse({ - status: 'completed', - result: { - triggeredBy: 'external:addon-mcp', - config: { coverage: false, a11y: false }, - storyIds: ['button--primary', 'button--secondary'], - totalTestCount: 2, - startedAt: Date.now(), - finishedAt: Date.now(), - coverageSummary: undefined, - componentTestCount: { success: 1, error: 1 }, - a11yCount: { success: 0, warning: 0, error: 0 }, - componentTestStatuses: [ - { - storyId: 'button--primary', - typeId: 'storybook/component-test', - value: 'status-value:success', - title: 'Component Test', - description: '', - }, - { - storyId: 'button--secondary', - typeId: 'storybook/component-test', - value: 'status-value:error', - title: 'Component Test', - description: 'Expected button text to be "Secondary"', - }, - ], - a11yStatuses: [], - a11yReports: {}, - reports: {}, - unhandledErrors: [], - }, - }); - - const response = await callTool( - [{ exportName: 'Primary', relativePath: 'src/Button.stories.tsx' }], - testContext - ); - - expect(response.result?.content[0].text).toMatchInlineSnapshot(` - "## Passing Stories - - - button--primary - - ## Failing Stories - - ### button--secondary - - Expected button text to be "Secondary"" - `); - }); - - it('should include a11y violations in results', async () => { - const testContext = createTestContext(); - - setupChannelResponse({ - status: 'completed', - result: { - triggeredBy: 'external:addon-mcp', - config: { coverage: false, a11y: true }, - storyIds: ['button--primary'], - totalTestCount: 1, - startedAt: Date.now(), - finishedAt: Date.now(), - coverageSummary: undefined, - componentTestCount: { success: 1, error: 0 }, - a11yCount: { success: 0, warning: 1, error: 1 }, - componentTestStatuses: [ - { - storyId: 'button--primary', - typeId: 'storybook/component-test', - value: 'status-value:success', - title: 'Component Test', - description: '', - }, - ], - a11yStatuses: [], - a11yReports: { - 'button--primary': [ - { - violations: [ - { - id: 'color-contrast', - description: 'Color contrast ratio is insufficient', - nodes: [ - { - html: '', - impact: 'critical', - failureSummary: '2.5:1 (required: 4.5:1)', - linkPath: '/inspect/button--primary?inspectPath=button.0', - }, - ], - }, - ], - }, - ], - }, - reports: {}, - unhandledErrors: [], - }, - }); - - const response = await callTool( - [{ exportName: 'Primary', relativePath: 'src/Button.stories.tsx' }], - testContext - ); - - expect(response.result?.content[0].text).toMatchInlineSnapshot(` - "## Passing Stories - - - button--primary - - ## Accessibility Violations - - ### button--primary - color-contrast - - Color contrast ratio is insufficient - - #### Affected Elements - - **Impact**: critical - **Message**: 2.5:1 (required: 4.5:1) - **Element**: - **Inspect**: http://localhost:6006/inspect/button--primary?inspectPath=button.0" - `); - }); - - it('should include a11y violations when a11y input is omitted (defaults to true)', async () => { - const testContext = createTestContext(); - - setupChannelResponse({ - status: 'completed', - result: { - triggeredBy: 'external:addon-mcp', - config: { coverage: false, a11y: true }, - storyIds: ['button--primary'], - totalTestCount: 1, - startedAt: Date.now(), - finishedAt: Date.now(), - coverageSummary: undefined, - componentTestCount: { success: 1, error: 0 }, - a11yCount: { success: 0, warning: 1, error: 1 }, - componentTestStatuses: [ - { - storyId: 'button--primary', - typeId: 'storybook/component-test', - value: 'status-value:success', - title: 'Component Test', - description: '', - }, - ], - a11yStatuses: [], - a11yReports: { - 'button--primary': [ - { - violations: [ - { - id: 'color-contrast', - description: 'Color contrast ratio is insufficient', - nodes: [ - { - html: '', - impact: 'critical', - failureSummary: '2.5:1 (required: 4.5:1)', - linkPath: '/inspect/button--primary?inspectPath=button.0', - }, - ], - }, - ], - }, - ], - }, - reports: {}, - unhandledErrors: [], - }, - }); - - const response = await callTool( - [{ exportName: 'Primary', relativePath: 'src/Button.stories.tsx' }], - testContext - ); - - expect(response.result?.content[0].text).toContain('## Accessibility Violations'); - }); - - it('should collect telemetry with result counts when enabled', async () => { - const { telemetry } = await import('storybook/internal/telemetry'); - const testContext = { - ...createTestContext(), - disableTelemetry: false, - }; - - setupChannelResponse({ - status: 'completed', - result: { - triggeredBy: 'external:addon-mcp', - config: { coverage: false, a11y: true }, - storyIds: ['button--primary'], - totalTestCount: 1, - startedAt: Date.now(), - finishedAt: Date.now(), - coverageSummary: undefined, - componentTestCount: { success: 1, error: 0 }, - a11yCount: { success: 0, warning: 1, error: 0 }, - componentTestStatuses: [ - { - storyId: 'button--primary', - typeId: 'storybook/component-test', - value: 'status-value:success', - title: 'Component Test', - description: '', - }, - ], - a11yStatuses: [], - a11yReports: { - 'button--primary': [ - { - violations: [ - { - id: 'color-contrast', - description: 'Color contrast ratio is insufficient', - nodes: [ - { - html: '', - impact: 'critical', - failureSummary: '2.5:1 (required: 4.5:1)', - linkPath: '/inspect/button--primary?inspectPath=button.0', - }, - ], - }, - ], - }, - ], - }, - reports: {}, - unhandledErrors: [], - }, - }); - - await callTool( - [{ exportName: 'Primary', relativePath: 'src/Button.stories.tsx' }], - testContext - ); - - expect(telemetry).toHaveBeenCalledWith( - 'addon-mcp', - expect.objectContaining({ - event: 'tool:runStoryTests', - mcpSessionId: 'test-session', - toolset: 'test', - runA11y: true, - inputStoryCount: 1, - matchedStoryCount: 1, - passingStoryCount: 1, - failingStoryCount: 0, - a11yViolationCount: 1, - unhandledErrorCount: 0, - }) - ); - }); - - it('should handle unhandled errors', async () => { - const testContext = createTestContext(); - - setupChannelResponse({ - status: 'completed', - result: { - triggeredBy: 'external:addon-mcp', - config: { coverage: false, a11y: false }, - storyIds: ['button--primary'], - totalTestCount: 1, - startedAt: Date.now(), - finishedAt: Date.now(), - coverageSummary: undefined, - componentTestCount: { success: 0, error: 1 }, - a11yCount: { success: 0, warning: 0, error: 0 }, - componentTestStatuses: [], - a11yStatuses: [], - a11yReports: {}, - reports: {}, - unhandledErrors: [ - { - name: 'ReferenceError', - message: 'foo is not defined', - stack: 'ReferenceError: foo is not defined\n at Button.tsx:10:5', - VITEST_TEST_PATH: '/src/Button.stories.tsx', - VITEST_TEST_NAME: 'Button > Primary', - }, - ], - }, - }); - - const response = await callTool( - [{ exportName: 'Primary', relativePath: 'src/Button.stories.tsx' }], - testContext - ); - - expect(response.result?.content[0].text).toMatchInlineSnapshot(` - "## Unhandled Errors - - ### ReferenceError - - **Error message**: foo is not defined - **Path**: /src/Button.stories.tsx - **Test name**: Button > Primary - **Stack trace**: - ReferenceError: foo is not defined - at Button.tsx:10:5" - `); - }); - - it('should not report stories as passing when unhandled errors block the run', async () => { - const testContext = createTestContext(); - - // Simulate a case where tests couldn't run at all (e.g., Playwright not installed) - // componentTestCount.error is 0 because no actual test failures occurred - // but unhandled errors exist because the test runner couldn't start - setupChannelResponse({ - status: 'completed', - result: { - triggeredBy: 'external:addon-mcp', - config: { coverage: false, a11y: false }, - storyIds: ['button--primary'], - totalTestCount: 1, - startedAt: Date.now(), - finishedAt: Date.now(), - coverageSummary: undefined, - componentTestCount: { success: 0, error: 0 }, - a11yCount: { success: 0, warning: 0, error: 0 }, - componentTestStatuses: [], - a11yStatuses: [], - a11yReports: {}, - reports: {}, - unhandledErrors: [ - { - name: 'Error', - message: "browserType.launch: Executable doesn't exist at /path/to/chromium", - stack: "Error: browserType.launch: Executable doesn't exist\n at Browser.ts:100:5", - VITEST_TEST_PATH: '/src/Button.stories.tsx', - VITEST_TEST_NAME: 'Button > Primary', - }, - ], - }, - }); - - const response = await callTool( - [{ exportName: 'Primary', relativePath: 'src/Button.stories.tsx' }], - testContext - ); - - // Should NOT contain "Passing Stories" section since tests couldn't run - expect(response.result).toMatchInlineSnapshot(` - { - "content": [ - { - "text": "## Unhandled Errors - - ### Error - - **Error message**: browserType.launch: Executable doesn't exist at /path/to/chromium - **Path**: /src/Button.stories.tsx - **Test name**: Button > Primary - **Stack trace**: - Error: browserType.launch: Executable doesn't exist - at Browser.ts:100:5", - "type": "text", - }, - ], - } - `); - }); - - it('should show passing stories AND unhandled errors when some tests pass but others have errors', async () => { - const testContext = createTestContext(); - - // Simulate: 3 tests passed, 1 had an unhandled error (e.g., component import failed) - setupChannelResponse({ - status: 'completed', - result: { - triggeredBy: 'external:addon-mcp', - config: { coverage: false, a11y: false }, - storyIds: ['button--primary', 'button--secondary', 'button--disabled', 'button--loading'], - totalTestCount: 4, - startedAt: Date.now(), - finishedAt: Date.now(), - coverageSummary: undefined, - componentTestCount: { success: 3, error: 0 }, - a11yCount: { success: 0, warning: 0, error: 0 }, - componentTestStatuses: [ - { - storyId: 'button--primary', - typeId: 'storybook/component-test', - value: 'status-value:success', - title: 'Component Test', - description: '', - }, - { - storyId: 'button--secondary', - typeId: 'storybook/component-test', - value: 'status-value:success', - title: 'Component Test', - description: '', - }, - { - storyId: 'button--disabled', - typeId: 'storybook/component-test', - value: 'status-value:success', - title: 'Component Test', - description: '', - }, - // button--loading has no status because it had an unhandled error - ], - a11yStatuses: [], - a11yReports: {}, - reports: {}, - unhandledErrors: [ - { - name: 'SyntaxError', - message: 'Cannot find module ./LoadingSpinner', - stack: 'SyntaxError: Cannot find module ./LoadingSpinner\n at Button.tsx:5:1', - VITEST_TEST_PATH: '/src/Button.stories.tsx', - VITEST_TEST_NAME: 'Button > Loading', - }, - ], - }, - }); - - const response = await callTool( - [{ exportName: 'Primary', relativePath: 'src/Button.stories.tsx' }], - testContext - ); - - // Should show both passing stories AND unhandled errors - expect(response.result?.content[0].text).toMatchInlineSnapshot(` - "## Passing Stories - - - button--primary - - button--secondary - - button--disabled - - ## Unhandled Errors - - ### SyntaxError - - **Error message**: Cannot find module ./LoadingSpinner - **Path**: /src/Button.stories.tsx - **Test name**: Button > Loading - **Stack trace**: - SyntaxError: Cannot find module ./LoadingSpinner - at Button.tsx:5:1" - `); - }); - - it('should return error when no stories found', async () => { - const testContext = createTestContext(); - - const response = await callTool( - [ - { - exportName: 'NonExistent', - relativePath: 'src/NonExistent.stories.tsx', - }, - ], - testContext - ); - - // Contains absolute path, so use assertion instead of snapshot - expect(response.result?.content[0].text).toContain( - 'No stories found matching the provided input' - ); - }); - - it('should handle test run error status', async () => { - const testContext = createTestContext(); - - setupChannelResponse({ - status: 'error', - error: { message: 'Vitest failed to start' }, - }); - - const response = await callTool( - [{ exportName: 'Primary', relativePath: 'src/Button.stories.tsx' }], - testContext - ); - - expect(response.result).toMatchInlineSnapshot(` - { - "content": [ - { - "text": "Error: Vitest failed to start", - "type": "text", - }, - ], - "isError": true, - } - `); - }); - - it('should handle test run cancelled status', async () => { - const testContext = createTestContext(); - - setupChannelResponse({ status: 'cancelled' }); - - const response = await callTool( - [{ exportName: 'Primary', relativePath: 'src/Button.stories.tsx' }], - testContext - ); - - expect(response.result).toMatchInlineSnapshot(` - { - "content": [ - { - "text": "Error: Test run was cancelled", - "type": "text", - }, - ], - "isError": true, - } - `); - }); - - it('should ignore responses for other trigger requests', async () => { - const testContext = createTestContext(); - - mockChannel.emit.mockImplementation((event, payload: any) => { - if (event === 'storybook/test/trigger-test-run-request') { - const onCallback = mockChannel.on.mock.calls.find( - (call) => call[0] === 'storybook/test/trigger-test-run-response' - )?.[1]; - if (onCallback) { - setTimeout(() => { - /* A response for a different request must not settle this one. */ - onCallback({ - requestId: 'some-other-request', - status: 'error', - error: { message: 'Failure from another test run' }, - }); - onCallback({ - requestId: payload.requestId, - status: 'cancelled', - }); - }, 0); - } - } - }); - - const response = await callTool( - [{ exportName: 'Primary', relativePath: 'src/Button.stories.tsx' }], - testContext - ); - - expect(response.result).toMatchInlineSnapshot(` - { - "content": [ - { - "text": "Error: Test run was cancelled", - "type": "text", - }, - ], - "isError": true, - } - `); - }); - - it('should clean up listener after matching completed response', async () => { - const testContext = createTestContext(); - - setupChannelResponse({ - status: 'completed', - result: { - triggeredBy: 'external:addon-mcp', - config: { coverage: false, a11y: false }, - storyIds: ['button--primary'], - totalTestCount: 1, - startedAt: Date.now(), - finishedAt: Date.now(), - coverageSummary: undefined, - componentTestCount: { success: 1, error: 0 }, - a11yCount: { success: 1, warning: 0, error: 0 }, - componentTestStatuses: [ - { - storyId: 'button--primary', - typeId: 'storybook/component-test', - value: 'status-value:success', - title: 'Component Test', - description: '', - }, - ], - a11yStatuses: [], - a11yReports: {}, - reports: {}, - unhandledErrors: [], - }, - }); - - const response = await callTool( - [{ exportName: 'Primary', relativePath: 'src/Button.stories.tsx' }], - testContext - ); - - const responseHandler = mockChannel.on.mock.calls.find( - (call) => call[0] === 'storybook/test/trigger-test-run-response' - )?.[1]; - - expect(response.result?.content[0].text).toContain('## Passing Stories'); - expect(mockChannel.off).toHaveBeenCalledWith( - 'storybook/test/trigger-test-run-response', - responseHandler - ); - }); - - describe('queue behavior', () => { - const getResponseHandlers = () => - mockChannel.on.mock.calls - .filter((call) => call[0] === 'storybook/test/trigger-test-run-response') - .map((call) => call[1]); - - it('should process concurrent calls in order', async () => { - const testContext = createTestContext(); - const executionOrder: string[] = []; - const completionOrder: string[] = []; - - // Set up channel to track execution order and respond immediately - // Must call ALL registered handlers since concurrent calls register multiple handlers - mockChannel.emit.mockImplementation((event, payload: any) => { - if (event === 'storybook/test/trigger-test-run-request') { - const storyId = payload.storyIds[0]; - executionOrder.push(storyId); - - // Call all registered response handlers (they filter by requestId internally) - const responseHandlers = mockChannel.on.mock.calls - .filter((call) => call[0] === 'storybook/test/trigger-test-run-response') - .map((call) => call[1]); - - const response = { - requestId: payload.requestId, - status: 'completed', - result: { - triggeredBy: 'external:addon-mcp', - config: { coverage: false, a11y: false }, - storyIds: [storyId], - totalTestCount: 1, - startedAt: Date.now(), - finishedAt: Date.now(), - coverageSummary: undefined, - componentTestCount: { success: 1, error: 0 }, - a11yCount: { success: 0, warning: 0, error: 0 }, - componentTestStatuses: [ - { - storyId, - typeId: 'storybook/component-test', - value: 'status-value:success', - title: 'Component Test', - description: '', - }, - ], - a11yStatuses: [], - a11yReports: {}, - reports: {}, - unhandledErrors: [], - }, - }; - - responseHandlers.forEach((handler) => handler(response)); - } - }); - - // Fire 4 calls concurrently - they should execute in FIFO order due to queue - const call1 = callTool( - [{ exportName: 'Primary', relativePath: 'src/Button.stories.tsx' }], - testContext - ).then((r) => { - completionOrder.push('button--primary'); - return r; - }); - const call2 = callTool( - [{ exportName: 'Secondary', relativePath: 'src/Button.stories.tsx' }], - testContext - ).then((r) => { - completionOrder.push('button--secondary'); - return r; - }); - const call3 = callTool( - [{ exportName: 'Default', relativePath: 'src/Input.stories.tsx' }], - testContext - ).then((r) => { - completionOrder.push('input--default'); - return r; - }); - const call4 = callTool( - [{ exportName: 'Primary', relativePath: 'src/Button.stories.tsx' }], - testContext - ).then((r) => { - completionOrder.push('button--primary-2'); - return r; - }); - - // Wait for all calls to complete - const results = await Promise.all([call1, call2, call3, call4]); - - // All calls should succeed - expect(results.every((r) => r.result?.content[0]?.text?.includes('Passing Stories'))).toBe( - true - ); - - // Execution order should match call order (FIFO) - expect(executionOrder).toEqual([ - 'button--primary', - 'button--secondary', - 'input--default', - 'button--primary', - ]); - - // Completion order should also match call order - expect(completionOrder).toEqual([ - 'button--primary', - 'button--secondary', - 'input--default', - 'button--primary-2', - ]); - }); - }); -}); diff --git a/code/addons/mcp/src/tools/run-story-tests.ts b/code/addons/mcp/src/tools/run-story-tests.ts deleted file mode 100644 index aaa47c7770f0..000000000000 --- a/code/addons/mcp/src/tools/run-story-tests.ts +++ /dev/null @@ -1,585 +0,0 @@ -import type { McpServer } from 'tmcp'; -import { logger } from 'storybook/internal/node-logger'; -import * as v from 'valibot'; -import { getStoryIndex } from '../utils/get-story-index.ts'; -import { findStoryIds, type FoundStory, type NotFoundStory } from '../utils/find-story-ids.ts'; -import { errorToMCPContent } from '../utils/errors.ts'; -import { collectTelemetry } from '../telemetry.ts'; -import type { AddonContext } from '../types.ts'; -import { StoryInputArray } from '../types.ts'; -import type { - TriggerTestRunRequestPayload, - TriggerTestRunResponsePayload, -} from '@storybook/addon-vitest/constants'; -import type Channel from 'storybook/internal/channels'; -import type { StoryId } from 'storybook/internal/csf'; -import type { A11yReport } from '@storybook/addon-a11y'; -import { RUN_STORY_TESTS_TOOL_NAME } from './tool-names.ts'; - -/** - * Check if addon-vitest is available by trying to import its constants. - * Returns the constants if available, undefined otherwise. - */ -export async function getAddonVitestConstants() { - try { - const mod = await import('@storybook/addon-vitest/constants'); - return { - TRIGGER_TEST_RUN_REQUEST: mod.TRIGGER_TEST_RUN_REQUEST, - TRIGGER_TEST_RUN_RESPONSE: mod.TRIGGER_TEST_RUN_RESPONSE, - }; - } catch { - return undefined; - } -} - -export const RunStoryTestsInput = v.object({ - stories: v.optional( - v.pipe( - StoryInputArray, - v.description( - `Stories to test for focused feedback. Omit this field to run tests for all available stories. -Prefer running tests for specific stories while developing to get faster feedback, -and only omit this when you explicitly need to run all tests for comprehensive verification. -Prefer { storyId } when you don't already have story file context, since this avoids filesystem discovery. -Use { storyId } when IDs were discovered from documentation tools. -Use { absoluteStoryPath + exportName } only when you're currently working in a story file and already know those values.` - ) - ) - ), - a11y: v.optional( - v.pipe( - v.boolean(), - v.description( - 'Whether to run accessibility tests. Defaults to true. Disable if you only need component test results.' - ) - ), - true - ), -}); - -export function getRunStoryTestsToolDescription(a11yEnabled: boolean) { - return ( - `Run story tests. -Run them after editing anything that changes how the UI looks — components, stories, styles, CSS, themes, colors, or design tokens — shell-level substitutes like typecheck, lint, or package.json test scripts do not replace this. -Provide stories for focused runs (faster while iterating), -or omit stories to run all tests for full-project verification. -Use this continuously to monitor test results as you work on your UI components and stories. -Results will include passing/failing status` + - (a11yEnabled - ? `, and accessibility violation reports. -For visual/design accessibility violations (for example color contrast), ask the user before changing styles.` - : '.') - ); -} - -export function getRunStoryTestsToolMetadata({ a11yEnabled }: { a11yEnabled: boolean }) { - return { - name: RUN_STORY_TESTS_TOOL_NAME, - title: 'Storybook Tests', - description: getRunStoryTestsToolDescription(a11yEnabled), - schema: RunStoryTestsInput, - }; -} - -/** - * Creates a queue that ensures concurrent calls are executed in sequence. - * Call `wait()` to wait for your turn, then call the - * returned `done()` function when done to unblock the next caller. - */ -function createAsyncQueue() { - let tail: Promise = Promise.resolve(); - - /** - * Wait for all previously queued operations to complete, then return - * a `done` function that must be called when the current operation finishes. - */ - async function wait(): Promise<() => void> { - let done!: () => void; - const gate = new Promise((resolve) => { - done = resolve; - }); - - const previousTail = tail; - tail = previousTail.then( - () => gate, - () => gate - ); - - await previousTail.catch(() => {}); - - return done; - } - - return { wait }; -} - -export async function addRunStoryTestsTool( - server: McpServer, - { a11yEnabled }: { a11yEnabled: boolean }, - enabled: Parameters['tool']>[0]['enabled'] = () => - server.ctx.custom?.toolsets?.test ?? true -) { - const addonVitestConstants = await getAddonVitestConstants(); - const testRunQueue = createAsyncQueue(); - - server.tool( - { - ...getRunStoryTestsToolMetadata({ a11yEnabled }), - enabled: () => { - if (!addonVitestConstants) { - return false; - } - return typeof enabled === 'function' ? enabled() : enabled; - }, - }, - async (input: v.InferInput) => { - let done: (() => void) | undefined; - try { - done = await testRunQueue.wait(); - const runA11y = input.a11y ?? true; - - const { origin, options, disableTelemetry } = server.ctx.custom ?? {}; - - if (!origin) { - throw new Error('Origin is required in addon context'); - } - - if (!options) { - throw new Error('Options are required in addon context'); - } - - // Access channel from options (available at runtime even though types don't declare it) - const channel = (options as unknown as { channel: Channel }).channel; - if (!channel) { - throw new Error('Channel is not available'); - } - - let storyIds: string[] | undefined; - let inputStoryCount = 0; - - if (input.stories) { - const index = await getStoryIndex(options); - const resolvedStories = findStoryIds(index, input.stories); - - storyIds = resolvedStories - .filter((story): story is FoundStory => 'id' in story) - .map((story) => story.id); - inputStoryCount = input.stories.length; - - if (storyIds.length === 0) { - const errorMessages = resolvedStories - .filter((story): story is NotFoundStory => 'errorMessage' in story) - .map((story) => story.errorMessage) - .join('\n'); - - if (!disableTelemetry) { - await collectTelemetry({ - event: 'tool:runStoryTests', - server, - toolset: 'test', - runA11y, - inputStoryCount, - matchedStoryCount: 0, - passingStoryCount: 0, - failingStoryCount: 0, - a11yViolationCount: 0, - unhandledErrorCount: 0, - }); - } - - return { - content: [ - { - type: 'text', - text: `No stories found matching the provided input. - -${errorMessages}`, - }, - ], - }; - } - - logger.info(`Running focused tests for story IDs: ${storyIds.join(', ')}`); - } else { - logger.info('Running tests for all stories'); - } - - // Trigger test run via channel events - const responsePayload = await triggerTestRun( - channel, - addonVitestConstants!.TRIGGER_TEST_RUN_REQUEST, - addonVitestConstants!.TRIGGER_TEST_RUN_RESPONSE, - storyIds, - { a11y: runA11y } - ); - - const testResults = responsePayload.result; - if (!testResults) { - throw new Error('Test run response missing result data'); - } - - const { text, summary } = formatRunStoryTestResults({ - testResults, - runA11y, - origin, - }); - - if (!disableTelemetry) { - await collectTelemetry({ - event: 'tool:runStoryTests', - server, - toolset: 'test', - runA11y, - inputStoryCount, - matchedStoryCount: testResults.storyIds?.length ?? storyIds?.length ?? 0, - ...summary, - }); - } - - return { - content: [ - { - type: 'text', - text, - }, - ], - }; - } catch (error) { - return errorToMCPContent(error); - } finally { - try { - done?.(); - } catch (error) { - logger.warn(`Failed to release test run queue: ${String(error)}`); - } - } - } - ); -} - -/** - * Trigger a test run via Storybook channel events. - * This is the channel-based API for triggering tests in addon-vitest. - */ -function triggerTestRun( - channel: Channel, - triggerTestRunRequestEventName: string, - triggerTestRunResponseEventName: string, - storyIds: string[] | undefined, - config?: TriggerTestRunRequestPayload['config'] -): Promise { - /* - Flow overview: - 1) Create a unique request ID for this invocation. - 2) Subscribe to the response event and emit a request event. - 3) Wait for exactly one terminal outcome for this request ID: - - completed (with result) - - error - - cancelled - - emit failure - 4) On the first terminal outcome, run cleanup once and settle once. - */ - return new Promise((resolve, reject) => { - const requestId = `mcp-${Date.now()}`; - /* - Guard to ensure we never resolve/reject more than once. - This protects against races between timeout, responses, and emit failures. - */ - let settled = false; - - /* Always remove listeners and timer when this request is done. */ - const cleanup = () => { - channel.off(triggerTestRunResponseEventName, handleResponse); - }; - - /* - Single exit point for all terminal states. - It guarantees cleanup happens exactly once before resolve/reject. - */ - const settle = (callback: () => void) => { - if (settled) { - return; - } - - settled = true; - cleanup(); - callback(); - }; - - const handleResponse = (payload: TriggerTestRunResponsePayload) => { - /* Ignore responses from other trigger requests. */ - if (payload.requestId !== requestId) { - return; - } - - /* Map protocol response status to one terminal settlement path. */ - switch (payload.status) { - case 'completed': - /* Completed without result is considered malformed/incomplete. */ - if (payload.result) { - settle(() => resolve(payload)); - } else { - settle(() => reject(new Error('Test run completed but no result was returned'))); - } - break; - case 'error': - settle(() => - reject(new Error(payload.error?.message ?? 'Test run failed with unknown error')) - ); - break; - case 'cancelled': - settle(() => reject(new Error('Test run was cancelled'))); - break; - default: { - const exhaustive: never = payload.status; - settle(() => reject(new Error('Unexpected test run response'))); - return exhaustive; - } - } - }; - - /* Subscribe before emit so immediate/synchronous responders are not missed. */ - channel.on(triggerTestRunResponseEventName, handleResponse); - - const request = { - requestId, - actor: 'addon-mcp', - storyIds, - config, - } as TriggerTestRunRequestPayload; - - try { - /* - Start the run. If emit throws synchronously, settle through the same - path so cleanup still runs and callers get a proper rejection. - */ - channel.emit(triggerTestRunRequestEventName, request); - } catch (error) { - settle(() => reject(error instanceof Error ? error : new Error(String(error)))); - } - }); -} - -interface RunStoryTestsSummary { - passingStoryCount: number; - failingStoryCount: number; - a11yViolationCount: number; - unhandledErrorCount: number; -} - -interface A11yViolationNode { - impact?: string; - failureSummary?: string; - message?: string; - html?: string; - linkPath?: string; -} - -interface A11yViolation { - id: string; - description: string; - nodes: A11yViolationNode[]; -} - -type TestRunResult = NonNullable; - -function formatRunStoryTestResults({ - testResults, - runA11y, - origin, -}: { - testResults: TestRunResult; - runA11y: boolean; - origin: string; -}): { text: string; summary: RunStoryTestsSummary } { - const sections: string[] = []; - const componentTestStatuses = testResults.componentTestStatuses; - - const passingStories = componentTestStatuses.filter( - (status) => status.value === 'status-value:success' - ); - const failingStories = componentTestStatuses.filter( - (status) => status.value === 'status-value:error' - ); - - if (passingStories.length > 0) { - sections.push(formatPassingStoriesSection(passingStories)); - } - - if (failingStories.length > 0) { - sections.push(formatFailingStoriesSection(failingStories)); - } - - const a11yReports = testResults.a11yReports as Record; - const a11yViolationCount = runA11y ? countA11yViolations(a11yReports) : 0; - if (runA11y && a11yReports && Object.keys(a11yReports).length > 0) { - const a11ySection = formatA11yReportsSection({ a11yReports, origin }); - if (a11ySection) { - sections.push(a11ySection); - } - } - - if (testResults.unhandledErrors.length > 0) { - sections.push(formatUnhandledErrorsSection(testResults.unhandledErrors)); - } - - return { - text: sections.join('\n\n'), - summary: { - passingStoryCount: passingStories.length, - failingStoryCount: failingStories.length, - a11yViolationCount, - unhandledErrorCount: testResults.unhandledErrors.length, - }, - }; -} - -function formatPassingStoriesSection(passingStories: Array<{ storyId: string }>): string { - return `## Passing Stories - -- ${passingStories.map((status) => status.storyId).join('\n- ')}`; -} - -function formatFailingStoriesSection( - statuses: Array<{ storyId: string; description?: string }> -): string { - const entries = statuses.map( - (status) => - `### ${status.storyId} - -${status.description || 'No failure details available.'}` - ); - - return `## Failing Stories - -${entries.join('\n\n')}`; -} - -function formatA11yReportsSection({ - a11yReports, - origin, -}: { - a11yReports: Record; - origin: string; -}): string | undefined { - const a11yViolationSections: string[] = []; - - for (const [storyId, reports] of Object.entries(a11yReports)) { - for (const report of reports) { - if ('error' in report && report.error) { - a11yViolationSections.push(`### ${storyId} - Error - -${report.error.message}`); - continue; - } - - const violations = getA11yViolations(report); - if (violations.length === 0) { - continue; - } - - for (const violation of violations) { - const nodes = violation.nodes - .map((node) => { - const inspectLink = node.linkPath ? `${origin}${node.linkPath}` : undefined; - const parts = [] as string[]; - - if (node.impact) { - parts.push(`- **Impact**: ${node.impact}`); - } - - if (node.failureSummary || node.message) { - parts.push(` **Message**: ${node.failureSummary || node.message}`); - } - - parts.push(` **Element**: ${node.html || '(no html available)'}`); - - if (inspectLink) { - parts.push(` **Inspect**: ${inspectLink}`); - } - - return parts.join('\n'); - }) - .join('\n'); - - a11yViolationSections.push(`### ${storyId} - ${violation.id} - -${violation.description} - -#### Affected Elements -${nodes}`); - } - } - } - - if (a11yViolationSections.length === 0) { - return undefined; - } - - return `## Accessibility Violations - -${a11yViolationSections.join('\n\n')}`; -} - -function formatUnhandledErrorsSection( - errors: Array<{ - name?: string; - message?: string; - stack?: string; - VITEST_TEST_PATH?: string; - VITEST_TEST_NAME?: string; - }> -): string { - const formattedErrors = errors.map( - (unhandledError) => - `### ${unhandledError.name || 'Unknown Error'} - -**Error message**: ${unhandledError.message || 'No message available'} -**Path**: ${unhandledError.VITEST_TEST_PATH || 'No path available'} -**Test name**: ${unhandledError.VITEST_TEST_NAME || 'No test name available'} -**Stack trace**: -${unhandledError.stack || 'No stack trace available'}` - ); - - return `## Unhandled Errors - -${formattedErrors.join('\n\n')}`; -} - -function countA11yViolations(a11yReports: Record): number { - let count = 0; - - for (const reports of Object.values(a11yReports ?? {})) { - for (const report of reports) { - if ('error' in report && report.error) { - continue; - } - - count += getA11yViolations(report).length; - } - } - - return count; -} - -function getA11yViolations(report: A11yReport): A11yViolation[] { - if (!('violations' in report)) { - return []; - } - - const { violations } = report; - if (!Array.isArray(violations)) { - return []; - } - - return violations.map((violation) => ({ - id: violation.id, - description: violation.description, - nodes: violation.nodes.map((node) => ({ - impact: typeof node.impact === 'string' ? node.impact : undefined, - failureSummary: typeof node.failureSummary === 'string' ? node.failureSummary : undefined, - html: typeof node.html === 'string' ? node.html : undefined, - linkPath: typeof node.linkPath === 'string' ? node.linkPath : undefined, - })), - })); -} diff --git a/code/addons/mcp/src/tools/tool-registry.test.ts b/code/addons/mcp/src/tools/tool-registry.test.ts index a6756f250ac8..e9482a3b15e8 100644 --- a/code/addons/mcp/src/tools/tool-registry.test.ts +++ b/code/addons/mcp/src/tools/tool-registry.test.ts @@ -4,12 +4,7 @@ * onto the MCP `isError` flag is the generic unwrap's contract, pinned in `toolset-tools.test.ts`. */ -import { - clearToolsetRegistry, - defineToolset, - registerToolset, - type ToolsetMethodDescription, -} from 'storybook/open-service'; +import { clearToolsetRegistry, defineToolset, registerToolset } from 'storybook/open-service'; import * as v from 'valibot'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -24,28 +19,21 @@ vi.mock('storybook/internal/node-logger', () => ({ logger: { error: loggerError, warn: vi.fn(), info: vi.fn(), debug: vi.fn() }, })); -/** Every MCP tool currently served from a core toolset. */ -const TOOLSET_BACKED = ['preview-stories', 'get-changed-stories', 'get-stories-by-component']; - -/** Registers a stub `stories` toolset whose metadata resolution throws the given error. */ -function registerStoriesToolsetThrowing(error: Error) { - const stubMethod = (description: ToolsetMethodDescription) => ({ - schema: v.object({}), - description, - handler: async () => ({ ok: true, data: {}, markdown: '' }), - }); - +/** Registers a stub `test` toolset whose metadata resolution throws the given error. */ +function registerTestToolsetThrowing(error: Error) { registerToolset( defineToolset({ - id: 'stories', + id: 'test', description: 'stub', - telemetryGroup: 'dev', + telemetryGroup: 'test', methods: { - preview: stubMethod(() => { - throw error; - }), - changed: stubMethod('changed'), - findByComponent: stubMethod('find by component'), + run: { + schema: v.object({}), + description: () => { + throw error; + }, + handler: async () => ({ ok: true, data: {}, markdown: '' }), + }, }, }) as any ); @@ -65,19 +53,14 @@ function makeServer() { } describe('a broken tool row', () => { - // Every gate claims support while the `stories` toolset never registered — the wiring bug this - // containment exists for. + // The availability gate claims test support while the `test` toolset never registered — the + // wiring bug behind the addon-vitest installed-but-not-enabled outage. const context = { - availability: { - changeDetectionEnabled: true, - moduleGraphSupported: true, - docsEnabled: true, - a11yEnabled: false, - }, + availability: { testSupported: true, docsEnabled: true, a11yEnabled: false }, } as never; beforeEach(() => { - clearToolsetRegistry(); + registerCoreToolsetsForTest({ testToolset: false }); loggerError.mockClear(); }); @@ -86,63 +69,68 @@ describe('a broken tool row', () => { await expect(registerAddonMcpTools(server, context)).resolves.toBeUndefined(); - expect([...tools.keys()]).toEqual(expect.not.arrayContaining(TOOLSET_BACKED)); + expect([...tools.keys()]).not.toContain('run-story-tests'); expect([...tools.keys()]).toEqual( - expect.arrayContaining(['list-all-documentation', 'get-documentation']) + expect.arrayContaining(['preview-stories', 'list-all-documentation', 'get-documentation']) ); - for (const name of TOOLSET_BACKED) { - expect(loggerError).toHaveBeenCalledWith(expect.stringContaining(name)); - } + expect(loggerError).toHaveBeenCalledWith(expect.stringContaining('run-story-tests')); }); it('is dropped from the storybook ai metadata instead of failing the build', () => { - const names = getAddonToolMetadata(context).map((tool) => tool.name); + const metadata = getAddonToolMetadata(context); + const names = metadata.map((tool) => tool.name); - expect(names).toEqual(expect.not.arrayContaining(TOOLSET_BACKED)); - expect(names).toEqual( - expect.arrayContaining(['get-storybook-story-instructions', 'list-all-documentation']) - ); - expect(loggerError).toHaveBeenCalledWith(expect.stringContaining('preview-stories')); + expect(names).not.toContain('run-story-tests'); + expect(names).toEqual(expect.arrayContaining(['preview-stories', 'list-all-documentation'])); + expect(loggerError).toHaveBeenCalledWith(expect.stringContaining('run-story-tests')); }); it('contains only the missing-toolset case: any other adapter failure still fails fast', () => { - registerStoriesToolsetThrowing(new Error('broken description')); + registerTestToolsetThrowing(new Error('broken description')); expect(() => getAddonToolMetadata(context)).toThrow('broken description'); }); }); -describe('the migrated rows over the registry', () => { +describe('run-story-tests over the registry', () => { + // Availability narrowed so only rows whose toolsets are registered here resolve. const context = { - availability: { - changeDetectionEnabled: true, - moduleGraphSupported: true, - docsEnabled: false, - a11yEnabled: false, - }, + availability: { testSupported: true, docsEnabled: false, a11yEnabled: false }, } as never; beforeEach(() => { clearToolsetRegistry(); }); - // One integration probe through the real `stories` toolset: each row resolves off the registry - // and publishes the frozen name and title an MCP client sees. - it('serves every migrated row once its toolset is registered', async () => { - registerCoreToolsetsForTest(); + // One integration probe through the real `test` toolset: a crashed run must reach the MCP + // client flagged as an error with the report rendered. The per-status tag mapping lives on the + // definition (`test/definition.test.ts`), the tag-to-isError unwrap in `toolset-tools.test.ts`. + it('flags a crashed run as an error result end to end', async () => { + registerCoreToolsetsForTest({ testToolset: false }); + registerToolset( + defineToolset({ + id: 'test', + description: 'stub', + telemetryGroup: 'test', + methods: { + run: { + schema: v.object({}), + description: 'run', + handler: async () => ({ + ok: false, + data: { status: 'error', error: { message: 'vitest died' } }, + markdown: 'Error: vitest died', + }), + }, + }, + }) as any + ); const { server, tools } = makeServer(); - await registerAddonMcpTools(server, context); - expect([...tools.keys()]).toEqual(expect.arrayContaining(TOOLSET_BACKED)); - expect( - getAddonToolMetadata(context) - .filter((tool) => TOOLSET_BACKED.includes(tool.name)) - .map((tool) => tool.title) - ).toEqual([ - 'Get story preview URLs', - 'Get changed stories metadata', - 'Get stories for component files', - ]); + const result = await tools.get('run-story-tests')!({}); + + expect(result.isError).toBe(true); + expect(result.content).toEqual([{ type: 'text', text: 'Error: vitest died' }]); }); }); diff --git a/code/addons/mcp/src/tools/tool-registry.ts b/code/addons/mcp/src/tools/tool-registry.ts index 2b8901652f38..d62911690f1f 100644 --- a/code/addons/mcp/src/tools/tool-registry.ts +++ b/code/addons/mcp/src/tools/tool-registry.ts @@ -21,12 +21,11 @@ import { getStorybookStoryInstructionsToolMetadata, addGetUIBuildingInstructionsTool, } from './get-storybook-story-instructions.ts'; -import { getRunStoryTestsToolMetadata, addRunStoryTestsTool } from './run-story-tests.ts'; // The error class must come from the same entry as `getToolset` (which throws it, via // `toolset-tools.ts`); a copy from another core entry is a different constructor and // `instanceof` would silently fail. import { MCP_TOOL_NAMES, OpenServiceMissingToolsetError } from 'storybook/open-service'; -import { GET_UI_BUILDING_INSTRUCTIONS_TOOL_NAME, RUN_STORY_TESTS_TOOL_NAME } from './tool-names.ts'; +import { GET_UI_BUILDING_INSTRUCTIONS_TOOL_NAME } from './tool-names.ts'; import { getToolsetToolMetadata, registerToolsetTool, @@ -148,6 +147,7 @@ const addonToolDefinitions: AddonToolDefinition[] = [ register: (server, { availability, toolsets }, enabled) => addGetUIBuildingInstructionsTool(server, enabled, { docsAvailable: isToolsetEnabled('docs', toolsets) && availability.docsEnabled, + addonVitestAvailable: availability.testSupported, }), getLocalTool: ({ availability, toolsets, options }) => ({ call: async () => { @@ -188,15 +188,11 @@ const addonToolDefinitions: AddonToolDefinition[] = [ wrapSchema: withFriendlyErrors, }, }), - { - name: RUN_STORY_TESTS_TOOL_NAME, + fromToolset({ toolset: 'test', available: ({ availability }) => availability.testSupported, - getMetadata: ({ availability }) => - getRunStoryTestsToolMetadata({ a11yEnabled: availability.a11yEnabled }), - register: (server, { availability }, enabled) => - addRunStoryTestsTool(server, { a11yEnabled: availability.a11yEnabled }, enabled), - }, + options: { method: 'test.run' }, + }), { name: LIST_TOOL_NAME, toolset: 'docs', diff --git a/code/addons/mcp/src/types.ts b/code/addons/mcp/src/types.ts index 7a9df0177c4d..7e0cc3785ccc 100644 --- a/code/addons/mcp/src/types.ts +++ b/code/addons/mcp/src/types.ts @@ -1,7 +1,6 @@ import * as v from 'valibot'; import type { Options } from 'storybook/internal/types'; -import { GET_TOOL_NAME, LIST_TOOL_NAME, type StorybookContext } from '@storybook/mcp'; -import { GET_UI_BUILDING_INSTRUCTIONS_TOOL_NAME } from './tools/tool-names.ts'; +import type { StorybookContext } from '@storybook/mcp'; const isLiteralEndpointPathname = (endpoint: string) => { try { @@ -79,88 +78,11 @@ export type AddonContext = StorybookContext & { * Gates the `display-review` tool and the instruction variant. */ reviewEnabled?: boolean; -}; - -const StoryInputProps = { - /** - * Optional props to pass to the story. - */ - props: v.pipe( - v.optional(v.record(v.string(), v.any())), - v.description(`Optional custom props to pass to the story for rendering. Use this when you don't want to render the default story, -but you want to customize some args or other props. -You can look up the component's documentation using the ${GET_UI_BUILDING_INSTRUCTIONS_TOOL_NAME} tool to see what props are available.`) - ), /** - * Optional globals to set for the story. + * Whether this request came through the `storybook ai` CLI channel (marked by + * {@link STORYBOOK_MCP_PROXY_HEADER}) rather than from a direct MCP client. Telemetry reports it + * as the `consumer` field. */ - globals: v.pipe( - v.optional(v.record(v.string(), v.any())), - v.description(`Optional Storybook globals to set for the story preview. Globals are used for things like theme, locale, viewport, and other cross-cutting concerns. -Common globals include 'theme' (e.g., 'dark', 'light'), 'locale' (e.g., 'en', 'fr'), and 'backgrounds' (e.g., { value: '#000' }).`) - ), + cliClient?: boolean; }; - -/** - * Schema for a single story input when requesting story URLs. - */ -export const StoryInput = v.union([ - v.object({ - /** - * The export name of the story from the story file. - * Example: "Primary", "WithArgs", "Default" - */ - exportName: v.pipe( - v.string(), - v.description( - `The export name of the story from the story file. -Use this path-based shape only when you're already editing a .stories.* file and know the export names in that file. -If you do not already have story file context, prefer the storyId shape instead of searching files.` - ) - ), - - /** - * Optional explicit story name if different from the export name. - * This is used when a story has a custom name defined. - */ - explicitStoryName: v.pipe( - v.optional(v.string()), - v.description( - `If the story has an explicit name set via the "name" property, that is different from the export name, provide it here. -Otherwise don't set this.` - ) - ), - - /** - * Absolute file path to the story file. - */ - absoluteStoryPath: v.pipe( - v.string(), - v.description( - 'Absolute path to the story file. Use together with exportName only when story file context is already available.' - ) - ), - - ...StoryInputProps, - }), - v.object({ - storyId: v.pipe( - v.string(), - v.description( - `The full Storybook story ID (for example "button--primary"). -Prefer this shape whenever you are not already working in a specific story file. -Use IDs discovered from ${LIST_TOOL_NAME} (withStoryIds=true) or ${GET_TOOL_NAME}.` - ) - ), - - ...StoryInputProps, - }), -]); -export type StoryInput = v.InferOutput; - -/** - * Schema for the array of stories to fetch URLs for. - */ -export const StoryInputArray = v.array(StoryInput); -export type StoryInputArray = v.InferOutput; diff --git a/code/addons/mcp/src/utils/addon-vitest.ts b/code/addons/mcp/src/utils/addon-vitest.ts new file mode 100644 index 000000000000..d06fca05b1ed --- /dev/null +++ b/code/addons/mcp/src/utils/addon-vitest.ts @@ -0,0 +1,18 @@ +import type { Options } from 'storybook/internal/types'; + +/** + * Whether `@storybook/addon-vitest` is enabled in this project. + * + * Reads the `isAddonVitestEnabled` marker that addon's preset exports, so this is true exactly + * when its presets loaded — the same condition under which its `services` hook registers the + * `test` toolset. Installed-but-not-enabled (a hoisted monorepo dependency, or an addon removed + * from `main.ts` without uninstalling) must read false: the toolset never registers there, so + * offering the tool would make every test call fail. + */ +export async function isAddonVitestEnabled(options: Options): Promise { + try { + return await options.presets.apply('isAddonVitestEnabled', false); + } catch { + return false; + } +} diff --git a/code/addons/mcp/src/utils/find-story-ids.test.ts b/code/addons/mcp/src/utils/find-story-ids.test.ts deleted file mode 100644 index ace61680a756..000000000000 --- a/code/addons/mcp/src/utils/find-story-ids.test.ts +++ /dev/null @@ -1,254 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import { findStoryIds } from './find-story-ids.ts'; -import type { StoryIndex } from 'storybook/internal/types'; -import type { StoryInput } from '../types.ts'; - -vi.mock('storybook/internal/csf', () => ({ - storyNameFromExport: (exportName: string) => exportName, -})); - -vi.mock('storybook/internal/node-logger', () => ({ - logger: { - debug: vi.fn(), - }, -})); - -describe('findStoryIds', () => { - const mockStoryIndex: StoryIndex = { - v: 5, - entries: { - 'button--primary': { - type: 'story', - subtype: 'story', - id: 'button--primary', - name: 'Primary', - title: 'Button', - importPath: './src/Button.stories.tsx', - tags: ['story'], - }, - 'button--secondary': { - type: 'story', - subtype: 'story', - id: 'button--secondary', - name: 'Secondary', - title: 'Button', - importPath: './src/Button.stories.tsx', - tags: ['story'], - }, - 'input--default': { - type: 'story', - subtype: 'story', - id: 'input--default', - name: 'Default', - title: 'Input', - importPath: './src/Input.stories.tsx', - tags: ['story'], - }, - }, - }; - - it('should find a single story by export name', () => { - const stories = [ - { - exportName: 'Primary', - absoluteStoryPath: `${process.cwd()}/src/Button.stories.tsx`, - }, - ]; - - const result = findStoryIds(mockStoryIndex, stories); - - expect(result).toHaveLength(1); - expect(result[0]).toEqual({ - id: 'button--primary', - input: stories[0], - }); - }); - - it('should find multiple stories', () => { - const stories = [ - { - exportName: 'Primary', - absoluteStoryPath: `${process.cwd()}/src/Button.stories.tsx`, - }, - { - exportName: 'Secondary', - absoluteStoryPath: `${process.cwd()}/src/Button.stories.tsx`, - }, - { - exportName: 'Default', - absoluteStoryPath: `${process.cwd()}/src/Input.stories.tsx`, - }, - ]; - - const result = findStoryIds(mockStoryIndex, stories); - - expect(result).toHaveLength(3); - expect(result.map((entry) => (entry as { id: string }).id)).toEqual([ - 'button--primary', - 'button--secondary', - 'input--default', - ]); - }); - - it('should return not found for non-existent stories', () => { - const stories = [ - { - exportName: 'NonExistent', - absoluteStoryPath: `${process.cwd()}/src/NonExistent.stories.tsx`, - }, - ]; - - const result = findStoryIds(mockStoryIndex, stories); - - expect(result).toHaveLength(1); - expect((result[0] as { errorMessage: string }).errorMessage).toContain('No story found'); - expect((result[0] as { errorMessage: string }).errorMessage).toContain('NonExistent'); - expect((result[0] as { errorMessage: string }).errorMessage).toContain( - 'did you forget to pass the explicit story name?' - ); - }); - - it('should not include hint when explicit story name is provided but not found', () => { - const stories = [ - { - exportName: 'NonExistent', - explicitStoryName: 'NonExistent', - absoluteStoryPath: `${process.cwd()}/src/NonExistent.stories.tsx`, - }, - ]; - - const result = findStoryIds(mockStoryIndex, stories); - - expect(result).toHaveLength(1); - expect((result[0] as { errorMessage: string }).errorMessage).not.toContain( - 'did you forget to pass the explicit story name?' - ); - }); - - it('should find story by explicit story name', () => { - const stories = [ - { - exportName: 'SomeExport', - explicitStoryName: 'Primary', - absoluteStoryPath: `${process.cwd()}/src/Button.stories.tsx`, - }, - ]; - - const result = findStoryIds(mockStoryIndex, stories); - - expect(result).toHaveLength(1); - expect((result[0] as { id: string }).id).toBe('button--primary'); - }); - - it('should handle mix of found and not found stories', () => { - const stories = [ - { - exportName: 'Primary', - absoluteStoryPath: `${process.cwd()}/src/Button.stories.tsx`, - }, - { - exportName: 'NonExistent', - absoluteStoryPath: `${process.cwd()}/src/NonExistent.stories.tsx`, - }, - { - exportName: 'Default', - absoluteStoryPath: `${process.cwd()}/src/Input.stories.tsx`, - }, - ]; - - const result = findStoryIds(mockStoryIndex, stories); - - expect(result).toHaveLength(3); - expect((result[0] as { id: string }).id).toBe('button--primary'); - expect((result[1] as { errorMessage: string }).errorMessage).toContain('NonExistent'); - expect((result[2] as { id: string }).id).toBe('input--default'); - expect((result[1]!.input as StoryInput & { exportName: string }).exportName).toBe( - 'NonExistent' - ); - }); - - it('should return empty results for empty input', () => { - const result = findStoryIds(mockStoryIndex, []); - - expect(result).toHaveLength(0); - }); - - it('should not find story with wrong file path', () => { - const stories = [ - { - exportName: 'Primary', - absoluteStoryPath: `${process.cwd()}/src/WrongFile.stories.tsx`, - }, - ]; - - const result = findStoryIds(mockStoryIndex, stories); - - expect(result).toHaveLength(1); - expect((result[0] as { errorMessage: string }).errorMessage).toContain('WrongFile.stories.tsx'); - }); - - it('should find story by storyId input', () => { - const stories: StoryInput[] = [ - { - storyId: 'button--primary', - }, - ]; - - const result = findStoryIds(mockStoryIndex, stories); - - expect(result).toHaveLength(1); - expect((result[0] as { id: string }).id).toBe('button--primary'); - }); - - it('should return not found for non-existent storyId input', () => { - const stories: StoryInput[] = [ - { - storyId: 'button--does-not-exist', - }, - ]; - - const result = findStoryIds(mockStoryIndex, stories); - - expect(result).toHaveLength(1); - expect((result[0] as { errorMessage: string }).errorMessage).toContain( - 'button--does-not-exist' - ); - }); - - it('should match stories when cwd and absolute path use Windows separators', () => { - const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(String.raw`C:\repo`); - - const stories = [ - { - exportName: 'Primary', - absoluteStoryPath: String.raw`C:\repo\src\Button.stories.tsx`, - }, - ]; - - const result = findStoryIds(mockStoryIndex, stories); - - expect(result).toHaveLength(1); - expect((result[0] as { id: string }).id).toBe('button--primary'); - - cwdSpy.mockRestore(); - }); - - it('should preserve output order for mixed found and not found inputs', () => { - const stories: StoryInput[] = [ - { storyId: 'button--does-not-exist' }, - { exportName: 'Primary', absoluteStoryPath: `${process.cwd()}/src/Button.stories.tsx` }, - { storyId: 'input--default' }, - { exportName: 'Missing', absoluteStoryPath: `${process.cwd()}/src/Button.stories.tsx` }, - ]; - - const result = findStoryIds(mockStoryIndex, stories); - - expect(result).toHaveLength(4); - expect((result[0] as { errorMessage: string }).errorMessage).toContain( - 'button--does-not-exist' - ); - expect((result[1] as { id: string }).id).toBe('button--primary'); - expect((result[2] as { id: string }).id).toBe('input--default'); - expect((result[3] as { errorMessage: string }).errorMessage).toContain('Missing'); - }); -}); diff --git a/code/addons/mcp/src/utils/find-story-ids.ts b/code/addons/mcp/src/utils/find-story-ids.ts deleted file mode 100644 index c8370270f44c..000000000000 --- a/code/addons/mcp/src/utils/find-story-ids.ts +++ /dev/null @@ -1,105 +0,0 @@ -import path from 'node:path'; -import { normalizeStoryPath } from 'storybook/internal/common'; -import { storyNameFromExport } from 'storybook/internal/csf'; -import { logger } from 'storybook/internal/node-logger'; -import type { StoryIndex } from 'storybook/internal/types'; -import type { StoryInput } from '../types.ts'; -import { slash } from './slash.ts'; - -export interface FoundStory { - id: string; - input: StoryInput; -} - -export interface NotFoundStory { - input: StoryInput; - errorMessage: string; -} - -export type FindStoryIdsResult = FoundStory | NotFoundStory; - -function isStoryIdInput(input: StoryInput): input is StoryInput & { storyId: string } { - return 'storyId' in input; -} - -// Keep normalization consistent with StoryIndexGenerator's importPath handling, -// which normalizes story paths before storing and comparing index entries. -function normalizeImportPath(importPath: string): string { - const normalized = path.posix.normalize(slash(importPath)); - return slash(normalizeStoryPath(normalized)); -} - -/** - * Finds story IDs in the story index that match the given story inputs. - * - * @param index - The Storybook story index - * @param stories - Array of story inputs to search for - * @returns Array of per-input lookup results in the exact same order as the input stories - */ -export function findStoryIds(index: StoryIndex, stories: StoryInput[]): FindStoryIdsResult[] { - const entriesList = Object.values(index.entries); - const result: FindStoryIdsResult[] = []; - - for (const storyInput of stories) { - if (isStoryIdInput(storyInput)) { - const foundEntry = index.entries[storyInput.storyId]; - - if (foundEntry) { - logger.debug(`Found story ID: ${foundEntry.id}`); - result.push({ - id: foundEntry.id, - input: storyInput, - }); - } else { - logger.debug('No story found'); - result.push({ - input: storyInput, - errorMessage: `No story found for story ID "${storyInput.storyId}"`, - }); - } - - continue; - } - - const { exportName, explicitStoryName, absoluteStoryPath } = storyInput; - const normalizedCwd = slash(process.cwd()); - const normalizedAbsolutePath = slash(absoluteStoryPath); - const relativePath = normalizeImportPath( - path.posix.relative(normalizedCwd, normalizedAbsolutePath) - ); - - logger.debug('Searching for:'); - logger.debug({ - exportName, - explicitStoryName, - absoluteStoryPath, - relativePath, - }); - - const foundEntry = entriesList.find( - (entry) => - normalizeImportPath(entry.importPath) === relativePath && - [explicitStoryName, storyNameFromExport(exportName)].includes(entry.name) - ); - - if (foundEntry) { - logger.debug(`Found story ID: ${foundEntry.id}`); - result.push({ - id: foundEntry.id, - input: storyInput, - }); - } else { - logger.debug('No story found'); - let errorMessage = `No story found for export name "${exportName}" with absolute file path "${absoluteStoryPath}"`; - if (!explicitStoryName) { - errorMessage += ` (did you forget to pass the explicit story name?)`; - } - result.push({ - input: storyInput, - errorMessage, - }); - } - } - - return result; -} diff --git a/code/addons/mcp/src/utils/get-tool-availability.ts b/code/addons/mcp/src/utils/get-tool-availability.ts index 4e8a115cd54a..1824b30d3391 100644 --- a/code/addons/mcp/src/utils/get-tool-availability.ts +++ b/code/addons/mcp/src/utils/get-tool-availability.ts @@ -2,7 +2,7 @@ import type { Options } from 'storybook/internal/types'; import { isModuleGraphSupported } from './module-graph.ts'; import { getReviewStatus } from './is-review-available.ts'; import { getManifestStatus, type ManifestFeatures } from '../tools/is-manifest-available.ts'; -import { getAddonVitestConstants } from '../tools/run-story-tests.ts'; +import { isAddonVitestEnabled } from './addon-vitest.ts'; import { isAddonA11yEnabled } from './is-addon-a11y-enabled.ts'; export interface ToolAvailability { @@ -25,7 +25,10 @@ export interface ToolAvailability { docsHasManifests: boolean; /** The component-manifest feature flag is enabled (drives the docs "why disabled" copy). */ docsFeatureEnabled: boolean; - /** `@storybook/addon-vitest` is installed. Gates the `test` toolset (`run-story-tests`). */ + /** + * `@storybook/addon-vitest` is enabled (not merely installed), matching the condition under + * which it registers the `test` toolset. Gates the `test` toolset (`run-story-tests`). + */ testSupported: boolean; /** `@storybook/addon-a11y` is enabled. Gates the accessibility sub-feature of `run-story-tests`. */ a11yEnabled: boolean; @@ -91,12 +94,12 @@ export async function getToolAvailability( | (ManifestFeatures & { changeDetection?: boolean; experimentalReview?: boolean }) | undefined); - const [moduleGraphSupported, reviewStatus, manifestStatus, addonVitestConstants, a11yEnabled] = + const [moduleGraphSupported, reviewStatus, manifestStatus, addonVitestEnabled, a11yEnabled] = await Promise.all([ moduleGraphSupportedOverride ?? isModuleGraphSupported(), getReviewStatus(options, { features: resolvedFeatures }), getManifestStatus(options), - getAddonVitestConstants(), + isAddonVitestEnabled(options), isAddonA11yEnabled(options), ]); @@ -108,7 +111,7 @@ export async function getToolAvailability( docsEnabled: manifestStatus.available, docsHasManifests: manifestStatus.hasManifests, docsFeatureEnabled: manifestStatus.hasFeatureFlag, - testSupported: !!addonVitestConstants, + testSupported: addonVitestEnabled, a11yEnabled, docgenServer: manifestStatus.docgenServer, }; diff --git a/code/addons/mcp/src/utils/slash.ts b/code/addons/mcp/src/utils/slash.ts deleted file mode 100644 index 039daa38dc1d..000000000000 --- a/code/addons/mcp/src/utils/slash.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * Normalize paths to forward slashes for cross-platform compatibility - * Storybook import paths always use forward slashes - */ -export function slash(path: string) { - return path.replace(/\\/g, '/'); -} diff --git a/code/addons/vitest/src/preset.test.ts b/code/addons/vitest/src/preset.test.ts new file mode 100644 index 000000000000..ce77142db42a --- /dev/null +++ b/code/addons/vitest/src/preset.test.ts @@ -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(); + }); +}); diff --git a/code/addons/vitest/src/preset.ts b/code/addons/vitest/src/preset.ts index 0b17ab781ddc..e2e2f439fc4e 100644 --- a/code/addons/vitest/src/preset.ts +++ b/code/addons/vitest/src/preset.ts @@ -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'; @@ -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'; @@ -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 => { + const storyIndexGenerator = + await options.presets.apply>('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'); diff --git a/code/core/src/core-server/index.ts b/code/core/src/core-server/index.ts index b12640372914..328f62e5c000 100644 --- a/code/core/src/core-server/index.ts +++ b/code/core/src/core-server/index.ts @@ -65,6 +65,11 @@ export { * 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 { 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'; diff --git a/code/core/src/shared/open-service/toolsets/test/definition.test.ts b/code/core/src/shared/open-service/toolsets/test/definition.test.ts index f8609a236b21..c90df40a7b2c 100644 --- a/code/core/src/shared/open-service/toolsets/test/definition.test.ts +++ b/code/core/src/shared/open-service/toolsets/test/definition.test.ts @@ -3,7 +3,8 @@ import type { StoryIndex } from 'storybook/internal/types'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import * as v from 'valibot'; -import type { TestRunOutput } from './definition.ts'; +import type { ToolsetCtx } from '../../toolset-definition.ts'; +import type { TestRunOutput, TestRunResult } from './definition.ts'; import { createTestToolset } from './definition.ts'; import { runStoryTests } from './run.ts'; @@ -12,21 +13,54 @@ vi.mock('./run.ts', { spy: true }); const index = { v: 5, entries: {} } as StoryIndex; const getIndex = vi.fn(); const storyIndex = { getIndex }; -const completedRun: TestRunOutput = { - status: 'completed', - result: { - config: { a11y: true }, - componentTestStatuses: [], - a11yStatuses: [], - componentTestCount: { success: 2, error: 0 }, - a11yCount: { success: 1, warning: 0, error: 0 }, - a11yReports: {}, - reports: {}, - totalTestCount: 3, - unhandledErrors: [], - }, +const telemetry = vi.fn(); +const channel = {} as never; + +const ctx = { + consumer: 'cli', + origin: 'http://localhost:6006', + getService: vi.fn() as ToolsetCtx['getService'], + telemetry, +} satisfies ToolsetCtx; + +const mcpCtx = { ...ctx, consumer: 'mcp' } satisfies ToolsetCtx; + +const baseResult: TestRunResult = { + config: { coverage: false, a11y: false }, + componentTestStatuses: [], + a11yStatuses: [], + componentTestCount: { success: 0, error: 0 }, + a11yCount: { success: 0, warning: 0, error: 0 }, + a11yReports: {}, + reports: {}, + unhandledErrors: [], }; +function componentTest( + storyId: string, + value: 'status-value:success' | 'status-value:error', + description = '' +) { + return { + storyId, + typeId: 'storybook/component-test', + value, + title: 'Component Test', + description, + }; +} + +function completed(result: Partial = {}): TestRunOutput { + return { status: 'completed', result: { ...baseResult, ...result } }; +} + +const completedRun = completed({ + componentTestCount: { success: 2, error: 0 }, + a11yCount: { success: 1, warning: 0, error: 0 }, + totalTestCount: 3, +}); + +let toolset: ReturnType; let pendingRun: Promise | undefined; beforeEach(() => { @@ -34,27 +68,27 @@ beforeEach(() => { pendingRun = undefined; getIndex.mockResolvedValue(index); vi.mocked(runStoryTests).mockImplementation(() => pendingRun ?? Promise.resolve(completedRun)); + toolset = createTestToolset({ channel, storyIndex, a11yEnabled: true }); }); -describe('test API', () => { - it('renders a run summary alongside the structured result', async () => { - const channel = {} as never; - const testToolset = createTestToolset({ channel, storyIndex }); +function runTests( + input: v.InferInput = {}, + runCtx: ToolsetCtx = ctx +) { + return toolset.methods.run.handler(v.parse(toolset.methods.run.schema, input), runCtx); +} - const outcome = await testToolset.methods.run.handler( - v.parse(testToolset.methods.run.schema, {}) - ); +/** Runs and renders the way the MCP adapter does: one handler call, markdown from the outcome. */ +async function runForMcp(input: v.InferInput = {}) { + return runTests(input, mcpCtx); +} - expect(outcome).toEqual({ - ok: true, - data: completedRun, - markdown: [ - '# Test run completed', - '- Total tests: 3', - '- Component tests: 2 passed, 0 failed', - '- Accessibility tests: 1 passed, 0 warnings, 0 failed', - ].join('\n'), - }); +describe('test API', () => { + it('runs all stories and reports the requested a11y flag alongside the outcome', async () => { + const outcome = await runTests(); + + expect(outcome.ok).toBe(true); + expect(outcome.data).toEqual({ ...completedRun, a11y: true }); expect(runStoryTests).toHaveBeenCalledWith({ channel, getIndex, @@ -63,24 +97,302 @@ describe('test API', () => { }); }); + it('summarizes counts for the CLI consumer', async () => { + const outcome = await runTests(); + + expect(outcome.markdown).toBe( + [ + '# Test run completed', + '- Total tests: 3', + '- Component tests: 2 passed, 0 failed', + '- Accessibility tests: 1 passed, 0 warnings, 0 failed', + ].join('\n') + ); + }); + it('serializes concurrent test runs for one API registration', async () => { - const testToolset = createTestToolset({ channel: {} as never, storyIndex }); let completePendingRun!: () => void; pendingRun = new Promise((resolve) => { completePendingRun = () => resolve(completedRun); }); - const input = v.parse(testToolset.methods.run.schema, {}); - const firstRun = testToolset.methods.run.handler(input); + const firstRun = runTests(); await vi.waitFor(() => expect(runStoryTests).toHaveBeenCalledOnce()); - const secondRun = testToolset.methods.run.handler(input); + const secondRun = runTests(); await Promise.resolve(); expect(runStoryTests).toHaveBeenCalledOnce(); completePendingRun(); await firstRun; - await expect(secondRun).resolves.toMatchObject({ ok: true, data: completedRun }); + await expect(secondRun).resolves.toMatchObject({ + ok: true, + data: { ...completedRun, a11y: true }, + }); expect(runStoryTests).toHaveBeenCalledTimes(2); }); + + describe('description', () => { + it('promises accessibility reports when a11y is enabled', () => { + expect(toolset.methods.run.description).toContain( + 'For visual/design accessibility violations (for example color contrast), ask the user before changing styles.' + ); + }); + + it('makes no accessibility promise when a11y is disabled', () => { + const withoutA11y = createTestToolset({ channel, storyIndex, a11yEnabled: false }); + + expect(withoutA11y.methods.run.description).not.toContain('accessibility'); + expect(withoutA11y.methods.run.description).toContain( + 'Results will include passing/failing status.' + ); + }); + }); + + describe('MCP rendering', () => { + it('lists passing stories', async () => { + vi.mocked(runStoryTests).mockResolvedValue( + completed({ + storyIds: ['button--primary'], + totalTestCount: 1, + componentTestCount: { success: 1, error: 0 }, + componentTestStatuses: [componentTest('button--primary', 'status-value:success')], + }) + ); + + expect((await runForMcp()).markdown).toBe(`## Passing Stories + +- button--primary`); + }); + + it('lists failing stories with their descriptions', async () => { + vi.mocked(runStoryTests).mockResolvedValue( + completed({ + componentTestCount: { success: 1, error: 1 }, + componentTestStatuses: [ + componentTest('button--primary', 'status-value:success'), + componentTest( + 'button--secondary', + 'status-value:error', + 'Expected button text to be "Secondary"' + ), + ], + }) + ); + + expect((await runForMcp()).markdown).toBe(`## Passing Stories + +- button--primary + +## Failing Stories + +### button--secondary + +Expected button text to be "Secondary"`); + }); + + it('reports accessibility violations with inspect links built from the origin', async () => { + vi.mocked(runStoryTests).mockResolvedValue( + completed({ + componentTestCount: { success: 1, error: 0 }, + a11yCount: { success: 0, warning: 1, error: 1 }, + componentTestStatuses: [componentTest('button--primary', 'status-value:success')], + a11yReports: { + 'button--primary': [ + { + violations: [ + { + id: 'color-contrast', + description: 'Color contrast ratio is insufficient', + nodes: [ + { + html: '', + impact: 'critical', + failureSummary: '2.5:1 (required: 4.5:1)', + linkPath: '/inspect/button--primary?inspectPath=button.0', + }, + ], + }, + ], + }, + ], + }, + }) + ); + + expect((await runForMcp()).markdown).toBe(`## Passing Stories + +- button--primary + +## Accessibility Violations + +### button--primary - color-contrast + +Color contrast ratio is insufficient + +#### Affected Elements +- **Impact**: critical + **Message**: 2.5:1 (required: 4.5:1) + **Element**: + **Inspect**: http://localhost:6006/inspect/button--primary?inspectPath=button.0`); + }); + + it('omits accessibility violations when the run disabled a11y', async () => { + vi.mocked(runStoryTests).mockResolvedValue( + completed({ + componentTestStatuses: [componentTest('button--primary', 'status-value:success')], + a11yReports: { + 'button--primary': [ + { + violations: [ + { + id: 'color-contrast', + description: 'Color contrast ratio is insufficient', + nodes: [{ html: '', impact: 'critical' }], + }, + ], + }, + ], + }, + }) + ); + + expect((await runForMcp({ a11y: false })).markdown).toBe(`## Passing Stories + +- button--primary`); + }); + + it('reports unhandled errors without claiming any story passed', async () => { + vi.mocked(runStoryTests).mockResolvedValue( + completed({ + unhandledErrors: [ + { + name: 'ReferenceError', + message: 'foo is not defined', + stack: 'ReferenceError: foo is not defined\n at Button.tsx:10:5', + VITEST_TEST_PATH: '/src/Button.stories.tsx', + VITEST_TEST_NAME: 'Button > Primary', + }, + ], + }) + ); + + expect((await runForMcp()).markdown).toBe(`## Unhandled Errors + +### ReferenceError + +**Error message**: foo is not defined +**Path**: /src/Button.stories.tsx +**Test name**: Button > Primary +**Stack trace**: +ReferenceError: foo is not defined + at Button.tsx:10:5`); + }); + + it('returns the per-selector lookup failures when nothing matched', async () => { + vi.mocked(runStoryTests).mockResolvedValue({ + status: 'no-stories', + notFoundMessages: [ + 'No story found for story ID "missing--story"', + 'No story found for story ID "gone--story"', + ], + }); + + const outcome = await runForMcp({ + stories: [{ storyId: 'missing--story' }, { storyId: 'gone--story' }], + }); + + expect(outcome.ok).toBe(true); + expect(outcome.markdown).toBe(`No stories found matching the provided input. + +No story found for story ID "missing--story" +No story found for story ID "gone--story"`); + }); + + it('flags a failed run as a failure while still rendering the error line', async () => { + vi.mocked(runStoryTests).mockResolvedValue({ + status: 'error', + error: { message: 'Vitest failed to start' }, + }); + + const outcome = await runForMcp(); + + expect(outcome.ok).toBe(false); + expect(outcome.markdown).toBe('Error: Vitest failed to start'); + }); + + it('flags a cancelled run as a failure while still rendering the error line', async () => { + vi.mocked(runStoryTests).mockResolvedValue({ status: 'cancelled' }); + + const outcome = await runForMcp(); + + expect(outcome.ok).toBe(false); + expect(outcome.markdown).toBe('Error: Test run was cancelled'); + }); + }); + + describe('telemetry', () => { + it('reports result counts for a completed run', async () => { + vi.mocked(runStoryTests).mockResolvedValue( + completed({ + storyIds: ['button--primary'], + componentTestCount: { success: 1, error: 0 }, + a11yCount: { success: 0, warning: 1, error: 0 }, + componentTestStatuses: [componentTest('button--primary', 'status-value:success')], + a11yReports: { + 'button--primary': [ + { + violations: [ + { + id: 'color-contrast', + description: 'Color contrast ratio is insufficient', + nodes: [{ html: '', impact: 'critical' }], + }, + ], + }, + ], + }, + }) + ); + + await runTests({ stories: [{ storyId: 'button--primary' }] }); + + expect(telemetry).toHaveBeenCalledWith('tool:runStoryTests', { + runA11y: true, + inputStoryCount: 1, + matchedStoryCount: 1, + passingStoryCount: 1, + failingStoryCount: 0, + a11yViolationCount: 1, + unhandledErrorCount: 0, + }); + }); + + it('reports zeroed counts when no story matched', async () => { + vi.mocked(runStoryTests).mockResolvedValue({ + status: 'no-stories', + notFoundMessages: ['No story found for story ID "missing--story"'], + }); + + await runTests({ stories: [{ storyId: 'missing--story' }], a11y: false }); + + expect(telemetry).toHaveBeenCalledWith('tool:runStoryTests', { + runA11y: false, + inputStoryCount: 1, + matchedStoryCount: 0, + passingStoryCount: 0, + failingStoryCount: 0, + a11yViolationCount: 0, + unhandledErrorCount: 0, + }); + }); + + it('stays silent for a run that never reached a verdict', async () => { + vi.mocked(runStoryTests).mockResolvedValue({ status: 'cancelled' }); + + await runTests(); + + expect(telemetry).not.toHaveBeenCalled(); + }); + }); }); diff --git a/code/core/src/shared/open-service/toolsets/test/definition.ts b/code/core/src/shared/open-service/toolsets/test/definition.ts index e690f73e6398..c2c259300fe1 100644 --- a/code/core/src/shared/open-service/toolsets/test/definition.ts +++ b/code/core/src/shared/open-service/toolsets/test/definition.ts @@ -1,9 +1,9 @@ import * as v from 'valibot'; -import { defineToolset, type ToolsetOutcome } from '../../toolset-definition.ts'; +import { defineToolset, type ToolsetCtx, type ToolsetOutcome } from '../../toolset-definition.ts'; import type { StoryIndexAccess } from '../stories/definition.ts'; import { storyInputArraySchema } from '../stories/story-input.ts'; -import { formatTestRun } from './format.ts'; +import { formatTestRun, summarizeTestRun } from './format.ts'; import { createAsyncQueue, runStoryTests, type TestChannel } from './run.ts'; const errorLikeSchema: v.GenericSchema = v.object({ @@ -62,6 +62,8 @@ const testRunResultSchema = v.object({ const testRunOutputSchema = v.variant('status', [ v.object({ status: v.literal('no-stories'), + /** Per-selector lookup failures. When nothing matched, they are the whole answer. */ + notFoundMessages: v.array(v.string()), }), v.object({ status: v.literal('completed'), @@ -82,16 +84,116 @@ const testRunOutputSchema = v.variant('status', [ export type TestRunResult = v.InferOutput; export type TestRunOutput = v.InferOutput; +/** + * What `run` renders: the run result plus whether accessibility tests were part of this run, which + * the result payload itself does not state. + */ +export type TestRunData = TestRunOutput & { a11y: boolean }; + +/** + * The outcome split for `test.run`: a crashed or cancelled run is a failure that still carries its + * full report, so clients keying on the tag cannot count it as a pass while agents keep the + * diagnostic detail. + */ +export type TestRunSuccessData = Extract & { + a11y: boolean; +}; + +export type TestRunFailureData = Extract & { + a11y: boolean; +}; + +const runInputSchema = v.object({ + stories: v.optional( + v.pipe( + storyInputArraySchema, + v.description( + `Stories to test for focused feedback. Omit this field to run tests for all available stories. +Prefer running tests for specific stories while developing to get faster feedback, +and only omit this when you explicitly need to run all tests for comprehensive verification. +Prefer { storyId } when you don't already have story file context, since this avoids filesystem discovery. +Use { storyId } when IDs were discovered from documentation tools. +Use { absoluteStoryPath + exportName } only when you're currently working in a story file and already know those values.` + ) + ) + ), + a11y: v.optional( + v.pipe( + v.boolean(), + v.description( + 'Whether to run accessibility tests. Defaults to true. Disable if you only need component test results.' + ) + ), + true + ), +}); + +type RunInput = v.InferOutput; + +/** + * The accessibility half of this tool's contract only holds when addon-a11y is enabled, so the + * promise is dropped from the description rather than made and then broken. + */ +function describeRun(a11yEnabled: boolean): string { + return ( + `Run story tests. +Run them after editing anything that changes how the UI looks — components, stories, styles, CSS, themes, colors, or design tokens — shell-level substitutes like typecheck, lint, or package.json test scripts do not replace this. +Provide stories for focused runs (faster while iterating), +or omit stories to run all tests for full-project verification. +Use this continuously to monitor test results as you work on your UI components and stories. +Results will include passing/failing status` + + (a11yEnabled + ? `, and accessibility violation reports. +For visual/design accessibility violations (for example color contrast), ask the user before changing styles.` + : '.') + ); +} + +/** + * Reports a run that reached a verdict. A run that never got one — a channel error, a cancellation — + * stays silent, so the event counts runs whose numbers mean something. + */ +async function reportRunTelemetry(data: TestRunData, input: RunInput, ctx: ToolsetCtx) { + const inputStoryCount = input.stories?.length ?? 0; + + if (data.status === 'no-stories') { + await ctx.telemetry?.('tool:runStoryTests', { + runA11y: data.a11y, + inputStoryCount, + matchedStoryCount: 0, + passingStoryCount: 0, + failingStoryCount: 0, + a11yViolationCount: 0, + unhandledErrorCount: 0, + }); + return; + } + + if (data.status !== 'completed') { + return; + } + + await ctx.telemetry?.('tool:runStoryTests', { + runA11y: data.a11y, + inputStoryCount, + // A partially resolved selector list never reaches a run, so every input matched by this point. + matchedStoryCount: data.result.storyIds?.length ?? inputStoryCount, + ...summarizeTestRun(data.result, data.a11y), + }); +} + export type CreateTestToolsetOptions = { channel: TestChannel; storyIndex: StoryIndexAccess; + /** Whether accessibility tests run alongside component tests (addon-a11y enabled). */ + a11yEnabled: boolean; }; /** * Creates the public test API. Each registration owns a queue because addon-vitest supports one * live test run at a time. */ -export function createTestToolset({ channel, storyIndex }: CreateTestToolsetOptions) { +export function createTestToolset({ channel, storyIndex, a11yEnabled }: CreateTestToolsetOptions) { const queue = createAsyncQueue(); return defineToolset({ @@ -100,33 +202,28 @@ export function createTestToolset({ channel, storyIndex }: CreateTestToolsetOpti telemetryGroup: 'test', methods: { run: { - schema: v.object({ - stories: v.optional( - v.pipe( - storyInputArraySchema, - v.description('Stories to test. Omit to run all available stories.') - ) - ), - a11y: v.optional( - v.pipe( - v.boolean(), - v.description('Whether to include accessibility tests. Defaults to true.') - ), - true - ), - }), - description: - 'Runs story tests for the given selectors, or all stories when stories is omitted.', - handler: async (input): Promise> => { + schema: runInputSchema, + description: describeRun(a11yEnabled), + handler: async ( + input, + ctx + ): Promise> => { const done = await queue.wait(); try { - const result = await runStoryTests({ + const output = await runStoryTests({ channel, getIndex: storyIndex.getIndex, stories: input.stories, a11y: input.a11y, }); - return { ok: true, data: result, markdown: formatTestRun(result) }; + const data: TestRunData = { ...output, a11y: input.a11y }; + + await reportRunTelemetry(data, input, ctx); + + const markdown = formatTestRun(data, ctx); + return data.status === 'error' || data.status === 'cancelled' + ? { ok: false, data, markdown } + : { ok: true, data, markdown }; } finally { done(); } diff --git a/code/core/src/shared/open-service/toolsets/test/format.ts b/code/core/src/shared/open-service/toolsets/test/format.ts index fa0afa991f77..b5b701cc8837 100644 --- a/code/core/src/shared/open-service/toolsets/test/format.ts +++ b/code/core/src/shared/open-service/toolsets/test/format.ts @@ -1,16 +1,280 @@ -import type { TestRunOutput } from './definition.ts'; +import type { ToolsetCtx } from '../../toolset-definition.ts'; +import type { TestRunData, TestRunResult } from './definition.ts'; -/** Formats a test run result as a compact summary for humans and agents. */ -export function formatTestRun(output: TestRunOutput): string { - switch (output.status) { +type ComponentTestStatus = TestRunResult['componentTestStatuses'][number]; + +/** + * Shapes of the addon-vitest / addon-a11y payloads that travel over the channel. The run result is + * not validated on arrival, so these describe what the formatter reads rather than what it is + * promised — every access below stays defensive for that reason. + */ +type A11yViolationNode = { + impact?: string; + failureSummary?: string; + html?: string; + linkPath?: string; +}; + +type A11yViolation = { + id: string; + description: string; + nodes: A11yViolationNode[]; +}; + +type A11yReport = { + error?: { message: string }; + violations?: unknown; +}; + +type UnhandledError = { + name?: string; + message?: string; + stack?: string; + VITEST_TEST_PATH?: string; + VITEST_TEST_NAME?: string; +}; + +export type TestRunSummary = { + passingStoryCount: number; + failingStoryCount: number; + a11yViolationCount: number; + unhandledErrorCount: number; +}; + +function isPassing(status: ComponentTestStatus): boolean { + return status.value === 'status-value:success'; +} + +function isFailing(status: ComponentTestStatus): boolean { + return status.value === 'status-value:error'; +} + +function getA11yReports(result: TestRunResult): Record { + return result.a11yReports as Record; +} + +function getA11yViolations(report: A11yReport): A11yViolation[] { + if (!('violations' in report)) { + return []; + } + + const { violations } = report; + if (!Array.isArray(violations)) { + return []; + } + + return violations.map((violation) => ({ + id: violation.id, + description: violation.description, + nodes: violation.nodes.map((node: Record) => ({ + impact: typeof node.impact === 'string' ? node.impact : undefined, + failureSummary: typeof node.failureSummary === 'string' ? node.failureSummary : undefined, + html: typeof node.html === 'string' ? node.html : undefined, + linkPath: typeof node.linkPath === 'string' ? node.linkPath : undefined, + })), + })); +} + +function countA11yViolations(a11yReports: Record): number { + let count = 0; + + for (const reports of Object.values(a11yReports ?? {})) { + for (const report of reports) { + if ('error' in report && report.error) { + continue; + } + + count += getA11yViolations(report).length; + } + } + + return count; +} + +/** Result counts, shared with the handler so telemetry reports the numbers the text shows. */ +export function summarizeTestRun(result: TestRunResult, a11y: boolean): TestRunSummary { + return { + passingStoryCount: result.componentTestStatuses.filter(isPassing).length, + failingStoryCount: result.componentTestStatuses.filter(isFailing).length, + a11yViolationCount: a11y ? countA11yViolations(getA11yReports(result)) : 0, + unhandledErrorCount: result.unhandledErrors.length, + }; +} + +function formatPassingStoriesSection(passingStories: ComponentTestStatus[]): string { + return `## Passing Stories + +- ${passingStories.map((status) => status.storyId).join('\n- ')}`; +} + +function formatFailingStoriesSection(statuses: ComponentTestStatus[]): string { + const entries = statuses.map( + (status) => + `### ${status.storyId} + +${status.description || 'No failure details available.'}` + ); + + return `## Failing Stories + +${entries.join('\n\n')}`; +} + +function formatA11yReportsSection({ + a11yReports, + origin, +}: { + a11yReports: Record; + origin?: string; +}): string | undefined { + const a11yViolationSections: string[] = []; + + for (const [storyId, reports] of Object.entries(a11yReports)) { + for (const report of reports) { + if ('error' in report && report.error) { + a11yViolationSections.push(`### ${storyId} - Error + +${report.error.message}`); + continue; + } + + const violations = getA11yViolations(report); + if (violations.length === 0) { + continue; + } + + for (const violation of violations) { + const nodes = violation.nodes + .map((node) => { + const inspectLink = origin && node.linkPath ? `${origin}${node.linkPath}` : undefined; + const parts: string[] = []; + + if (node.impact) { + parts.push(`- **Impact**: ${node.impact}`); + } + + if (node.failureSummary) { + parts.push(` **Message**: ${node.failureSummary}`); + } + + parts.push(` **Element**: ${node.html || '(no html available)'}`); + + if (inspectLink) { + parts.push(` **Inspect**: ${inspectLink}`); + } + + return parts.join('\n'); + }) + .join('\n'); + + a11yViolationSections.push(`### ${storyId} - ${violation.id} + +${violation.description} + +#### Affected Elements +${nodes}`); + } + } + } + + if (a11yViolationSections.length === 0) { + return undefined; + } + + return `## Accessibility Violations + +${a11yViolationSections.join('\n\n')}`; +} + +function formatUnhandledErrorsSection(errors: UnhandledError[]): string { + const formattedErrors = errors.map( + (unhandledError) => + `### ${unhandledError.name || 'Unknown Error'} + +**Error message**: ${unhandledError.message || 'No message available'} +**Path**: ${unhandledError.VITEST_TEST_PATH || 'No path available'} +**Test name**: ${unhandledError.VITEST_TEST_NAME || 'No test name available'} +**Stack trace**: +${unhandledError.stack || 'No stack trace available'}` + ); + + return `## Unhandled Errors + +${formattedErrors.join('\n\n')}`; +} + +/** + * Only the sections that carry information are emitted, so a run with nothing to report renders as + * an empty string. + */ +function formatCompletedRun( + result: TestRunResult, + { a11y, origin }: { a11y: boolean; origin?: string } +): string { + const sections: string[] = []; + const passingStories = result.componentTestStatuses.filter(isPassing); + const failingStories = result.componentTestStatuses.filter(isFailing); + + if (passingStories.length > 0) { + sections.push(formatPassingStoriesSection(passingStories)); + } + + if (failingStories.length > 0) { + sections.push(formatFailingStoriesSection(failingStories)); + } + + const a11yReports = getA11yReports(result); + if (a11y && a11yReports && Object.keys(a11yReports).length > 0) { + const a11ySection = formatA11yReportsSection({ a11yReports, origin }); + if (a11ySection) { + sections.push(a11ySection); + } + } + + if (result.unhandledErrors.length > 0) { + sections.push(formatUnhandledErrorsSection(result.unhandledErrors as UnhandledError[])); + } + + return sections.join('\n\n'); +} + +/** + * Agent-facing rendering, kept identical to the `run-story-tests` MCP tool. + * + * Failed and cancelled runs read as `Error: …` because that tool surfaced them by throwing; the + * `isError` flag that accompanied them belongs to the adapter, not to text. + */ +function formatTestRunForAgent(data: TestRunData, ctx: ToolsetCtx): string { + switch (data.status) { + case 'no-stories': + return `No stories found matching the provided input. + +${data.notFoundMessages.join('\n')}`; + case 'completed': + return formatCompletedRun(data.result, { a11y: data.a11y, origin: ctx.origin }); + case 'error': + return `Error: ${data.error.message}`; + case 'cancelled': + return 'Error: Test run was cancelled'; + default: { + // Type-only: a new outcome must get its own agent-facing text rather than falling through. + const _exhaustive: never = data; + return _exhaustive; + } + } +} + +/** Compact summary for a human reading terminal output. */ +function formatTestRunSummary(data: TestRunData): string { + switch (data.status) { case 'no-stories': return '# No stories matched\nNo story tests were run.'; case 'error': - return `# Test run failed\n${output.error.message}`; + return `# Test run failed\n${data.error.message}`; case 'cancelled': return '# Test run cancelled\nThe test run was cancelled.'; case 'completed': { - const { componentTestCount, a11yCount, totalTestCount } = output.result; + const { componentTestCount, a11yCount, totalTestCount } = data.result; const total = totalTestCount ?? componentTestCount.success + @@ -26,5 +290,14 @@ export function formatTestRun(output: TestRunOutput): string { `- Accessibility tests: ${a11yCount.success} passed, ${a11yCount.warning} warnings, ${a11yCount.error} failed`, ].join('\n'); } + default: { + // Type-only: a new outcome must get its own summary rather than falling through. + const _exhaustive: never = data; + return _exhaustive; + } } } + +export function formatTestRun(data: TestRunData, ctx: ToolsetCtx): string { + return ctx.consumer === 'mcp' ? formatTestRunForAgent(data, ctx) : formatTestRunSummary(data); +} diff --git a/code/core/src/shared/open-service/toolsets/test/run.test.ts b/code/core/src/shared/open-service/toolsets/test/run.test.ts index 1f5fb78449a9..656f7900278e 100644 --- a/code/core/src/shared/open-service/toolsets/test/run.test.ts +++ b/code/core/src/shared/open-service/toolsets/test/run.test.ts @@ -92,7 +92,7 @@ describe('createAsyncQueue', () => { }); describe('runStoryTests', () => { - it('returns an error when any focused selector is unresolved', async () => { + it('returns no-stories with the per-selector messages when nothing matched', async () => { const channel = createMockChannel(); const emitSpy = vi.spyOn(channel, 'emit'); @@ -103,8 +103,8 @@ describe('runStoryTests', () => { }); expect(result).toEqual({ - status: 'error', - error: { message: expect.stringContaining('missing--story') }, + status: 'no-stories', + notFoundMessages: ['No story found for story ID "missing--story"'], }); expect(emitSpy).not.toHaveBeenCalled(); }); @@ -133,7 +133,7 @@ describe('runStoryTests', () => { stories: [], }); - expect(result).toEqual({ status: 'no-stories' }); + expect(result).toEqual({ status: 'no-stories', notFoundMessages: [] }); expect(emitSpy).not.toHaveBeenCalled(); }); diff --git a/code/core/src/shared/open-service/toolsets/test/run.ts b/code/core/src/shared/open-service/toolsets/test/run.ts index 2ba20799cce3..06bc1953c920 100644 --- a/code/core/src/shared/open-service/toolsets/test/run.ts +++ b/code/core/src/shared/open-service/toolsets/test/run.ts @@ -172,24 +172,26 @@ export async function runStoryTests({ if (stories) { const index = await getIndex(); const resolved = findStoryIds(index, stories); - const unresolved = resolved.filter((story): story is NotFoundStory => !('entry' in story)); + const found = resolved.filter((story): story is FoundStory => 'entry' in story); + const notFoundMessages = resolved + .filter((story): story is NotFoundStory => !('entry' in story)) + .map((story) => story.errorMessage); + + // Nothing to run: the per-selector failures are the answer, so they travel with the outcome. + if (found.length === 0) { + return { status: 'no-stories', notFoundMessages }; + } - if (unresolved.length > 0) { + // Running the matched subset would report a verdict for stories that were never run, so a + // partially resolved selector list fails instead. + if (notFoundMessages.length > 0) { return { status: 'error', - error: { - message: unresolved.map((story) => story.errorMessage).join('; '), - }, + error: { message: notFoundMessages.join('; ') }, }; } - storyIds = resolved - .filter((story): story is FoundStory => 'entry' in story) - .map((s) => s.entry.id); - - if (storyIds.length === 0) { - return { status: 'no-stories' }; - } + storyIds = found.map((story) => story.entry.id); } let response: TriggerTestRunResponse; diff --git a/test-storybooks/mcp/.storybook-no-vitest/main.ts b/test-storybooks/mcp/.storybook-no-vitest/main.ts new file mode 100644 index 000000000000..de0b7cd38494 --- /dev/null +++ b/test-storybooks/mcp/.storybook-no-vitest/main.ts @@ -0,0 +1,15 @@ +import { defineMain } from '@storybook/react-vite/node'; +import baseConfig from '../.storybook/main'; + +/** + * The adversarial "installed but not enabled" configuration: `@storybook/addon-vitest` stays a + * dependency of this package (hoisted, importable), but is removed from `addons`, so its + * `services` hook never runs and the `test` toolset never registers. The MCP endpoint must keep + * serving every other tool — one absent addon must never take down the whole agent surface. + */ +const config = defineMain({ + ...baseConfig, + addons: (baseConfig.addons ?? []).filter((addon) => addon !== '@storybook/addon-vitest'), +}); + +export default config; diff --git a/test-storybooks/mcp/.storybook-no-vitest/preview.ts b/test-storybooks/mcp/.storybook-no-vitest/preview.ts new file mode 100644 index 000000000000..69260e56bce3 --- /dev/null +++ b/test-storybooks/mcp/.storybook-no-vitest/preview.ts @@ -0,0 +1 @@ +export { default } from '../.storybook/preview'; diff --git a/test-storybooks/mcp/tests/mcp-addon-vitest-not-enabled.e2e.test.ts b/test-storybooks/mcp/tests/mcp-addon-vitest-not-enabled.e2e.test.ts new file mode 100644 index 000000000000..62280d0165c1 --- /dev/null +++ b/test-storybooks/mcp/tests/mcp-addon-vitest-not-enabled.e2e.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { x } from 'tinyexec'; +import { mcpRequest, waitForMcpEndpoint, killPort, startStorybook, stopStorybook } from './helpers'; + +/** + * The addon-vitest installed-but-not-enabled scenario (a hoisted monorepo dependency, or an addon + * removed from `main.ts` without uninstalling): its `services` hook never runs, so the `test` + * toolset never registers. The availability gate must agree — the endpoint serves everything + * except `run-story-tests`, instead of failing every request because one tool's toolset is + * missing. + */ + +const PORT = 6010; +const MCP_ENDPOINT = `http://localhost:${PORT}/mcp`; +const STARTUP_TIMEOUT = 60_000; + +let storybookProcess: ReturnType | null = null; + +describe('MCP endpoint with addon-vitest installed but not enabled', () => { + beforeAll(async () => { + await killPort(PORT); + storybookProcess = startStorybook('.storybook-no-vitest', PORT); + await waitForMcpEndpoint(MCP_ENDPOINT); + }, STARTUP_TIMEOUT); + + afterAll(async () => { + await stopStorybook(storybookProcess); + storybookProcess = null; + }); + + it('serves every tool except run-story-tests', async () => { + const response = await mcpRequest(MCP_ENDPOINT, 'tools/list'); + + const names = response.result.tools.map((tool: { name: string }) => tool.name).sort(); + expect(names).toEqual([ + 'display-review', + 'get-changed-stories', + 'get-documentation', + 'get-documentation-for-story', + 'get-stories-by-component', + 'get-storybook-story-instructions', + 'list-all-documentation', + 'preview-stories', + ]); + }); + + it('still answers tool calls', async () => { + const response = await mcpRequest(MCP_ENDPOINT, 'tools/call', { + name: 'get-storybook-story-instructions', + arguments: {}, + }); + + expect(response.result.isError).toBeUndefined(); + expect(response.result.content[0].text).toContain('Storybook'); + }); +});