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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 0 additions & 7 deletions code/addons/review/build-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,6 @@ const config: BuildEntries = {
dts: false,
},
],
node: [
{
exportEntries: ['./preset'],
entryPoint: './src/preset.ts',
dts: false,
},
],
},
};

Expand Down
3 changes: 1 addition & 2 deletions code/addons/review/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,7 @@
"default": "./dist/index.js"
},
"./manager": "./dist/manager.js",
"./package.json": "./package.json",
"./preset": "./dist/preset.js"
"./package.json": "./package.json"
},
"files": [
"dist/**/*",
Expand Down
1 change: 0 additions & 1 deletion code/addons/review/preset.js

This file was deleted.

37 changes: 9 additions & 28 deletions code/addons/review/src/constants.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,17 @@
// Core-owned namespace for the review ingest contract: channel events, session
// keys, status type id, and page/route ids all live under `storybook/review/*`.
// The external `@storybook/addon-mcp` producer must emit the same namespace.
export const ADDON_ID = 'storybook/review';
export const PAGE_ID = `${ADDON_ID}/page`;
// The channel events are the core-owned ingest contract; re-export the single
// source so the manager and the external `@storybook/addon-mcp` producer agree.
export { REVIEW_EVENTS as EVENTS, REVIEW_NAMESPACE as ADDON_ID } from 'storybook/internal/types';

import { REVIEW_NAMESPACE } from 'storybook/internal/types';

export const PAGE_ID = `${REVIEW_NAMESPACE}/page`;
export const REVIEW_CHANGES_URL = '/review/';

// sessionStorage key for the canvas search to return to when leaving review
// mode (both summary back-to-Storybook and dismiss). Captured while browsing
// stories/docs outside review mode, so it points at the pre-review canvas.
export const PRE_REVIEW_RETURN_KEY = `${ADDON_ID}/pre-review-return`;
export const PRE_REVIEW_RETURN_KEY = `${REVIEW_NAMESPACE}/pre-review-return`;

// sessionStorage marker deduplicating the one-time auto-enter on first landing
// on the review summary. Reset on dismiss and when a new review payload arrives.
export const AUTO_ENTERED_SESSION_KEY = `${ADDON_ID}/auto-entered`;

// `@storybook/addon-mcp` display-review tool call emits this event with the raw agent payload.
const PUSH_REVIEW = `${ADDON_ID}/push-review`;
// Display agent review in the UI
const DISPLAY_REVIEW = `${ADDON_ID}/display-review`;
// Requests for the cached state of the agent review
const REQUEST_REVIEW = `${ADDON_ID}/request-review`;
// Server signals that a source file changed after the cached review was created,
// so the open review page should surface a "may be stale" banner.
const REVIEW_STALE = `${ADDON_ID}/review-stale`;
const DISMISS_REVIEW = `${ADDON_ID}/dismiss-review`;
const REVIEW_DISMISSED = `${ADDON_ID}/review-dismissed`;

export const EVENTS = {
PUSH_REVIEW,
DISPLAY_REVIEW,
REQUEST_REVIEW,
REVIEW_STALE,
DISMISS_REVIEW,
REVIEW_DISMISSED,
};
export const AUTO_ENTERED_SESSION_KEY = `${REVIEW_NAMESPACE}/auto-entered`;
43 changes: 3 additions & 40 deletions code/addons/review/src/review-state.ts
Original file line number Diff line number Diff line change
@@ -1,40 +1,3 @@
/**
* The review payload an agent pushes via the `display-review` MCP tool.
*
* Flow:
* MCP `display-review` tool → emit PUSH_REVIEW on the Storybook channel
* → this addon's server preset stamps `createdAt` and caches it
* → emits DISPLAY_REVIEW to all open tabs (or replays on REQUEST_REVIEW).
*
* This mirrors the canonical valibot schema in `@storybook/addon-mcp` →
* `tools/display-review.ts`. This side only renders the data — it does
* not validate — so it needs the type, not the validator. Keep `title` /
* `description` / `collections` in sync with that schema.
*/

export type CollectionKind = 'atomic' | 'consumer' | 'transitive' | 'catch-all';

export interface ReviewCollection {
title: string;
rationale: string;
storyIds: string[];
kind?: CollectionKind;
}

export interface ReviewState {
title: string;
description: string;
collections: ReviewCollection[];
changedFiles?: string[];
/**
* Server-side creation timestamp (unix ms) assigned when PUSH_REVIEW is
* received; used for live "Created x minutes ago" UI in the summary.
*/
createdAt?: number;
/**
* Set server-side once a watched source file changes after `createdAt`.
* Drives the "this review may be stale" banner. Persisted on the cached
* review so REQUEST_REVIEW replays it to late/refreshed tabs.
*/
stale?: boolean;
}
// The review payload type is the core-owned ingest contract. Re-exported here so
// the addon's manager code keeps importing it from a local path.
export type { CollectionKind, ReviewCollection, ReviewState } from 'storybook/internal/types';
2 changes: 2 additions & 0 deletions code/core/src/core-server/presets/common-preset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import { initCreateNewStoryChannel } from '../server-channel/create-new-story-ch
import { initFileSearchChannel } from '../server-channel/file-search-channel.ts';
import { initGhostStoriesChannel } from '../server-channel/ghost-stories-channel.ts';
import { initOpenInEditorChannel } from '../server-channel/open-in-editor-channel.ts';
import { initReviewChannel } from '../server-channel/review-channel.ts';
import { initTelemetryChannel } from '../server-channel/telemetry-channel.ts';
import { initializeChecklist } from '../utils/checklist.ts';
import { defaultFavicon, defaultStaticDirs } from '../utils/constants.ts';
Expand Down Expand Up @@ -289,6 +290,7 @@ export const experimental_serverChannel = async (
initCreateNewStoryChannel(channel, options);
initGhostStoriesChannel(channel, options);
initOpenInEditorChannel(channel);
initReviewChannel(channel);
initTelemetryChannel(channel);

return channel;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import type { Channel } from 'storybook/internal/channels';
import type { Options } from 'storybook/internal/types';

import { EVENTS } from './constants.ts';
import type { ReviewState } from './review-state.ts';
import { __resetCache, experimental_serverChannel } from './preset.ts';
import { REVIEW_EVENTS } from '../../shared/review/events.ts';
import type { ReviewState } from '../../shared/review/review-state.ts';
import { initReviewChannel } from './review-channel.ts';

function createMockSubscribe() {
let captured: (() => void) | undefined;
Expand Down Expand Up @@ -60,11 +59,10 @@ const sampleReview: ReviewState = {
],
};

describe('addon-review experimental_serverChannel', () => {
describe('initReviewChannel', () => {
const NOW = 1_700_000_000_000;

beforeEach(() => {
__resetCache();
vi.spyOn(Date, 'now').mockReturnValue(NOW);
});

Expand All @@ -75,12 +73,12 @@ describe('addon-review experimental_serverChannel', () => {
it('on PUSH_REVIEW, stamps createdAt, caches, and broadcasts DISPLAY_REVIEW', async () => {
const { channel, emitted } = createMockChannel();

await experimental_serverChannel(channel, {} as Options, {});
await (channel as any).fire(EVENTS.PUSH_REVIEW, sampleReview);
initReviewChannel(channel);
await (channel as any).fire(REVIEW_EVENTS.PUSH_REVIEW, sampleReview);

expect(emitted).toEqual([
{
event: EVENTS.DISPLAY_REVIEW,
event: REVIEW_EVENTS.DISPLAY_REVIEW,
payload: { ...sampleReview, createdAt: NOW },
},
]);
Expand All @@ -90,12 +88,12 @@ describe('addon-review experimental_serverChannel', () => {
const { channel, emitted } = createMockChannel();
const payloadWithStale: ReviewState = { ...sampleReview, stale: true };

await experimental_serverChannel(channel, {} as Options, {});
await (channel as any).fire(EVENTS.PUSH_REVIEW, payloadWithStale);
initReviewChannel(channel);
await (channel as any).fire(REVIEW_EVENTS.PUSH_REVIEW, payloadWithStale);

expect(emitted).toEqual([
{
event: EVENTS.DISPLAY_REVIEW,
event: REVIEW_EVENTS.DISPLAY_REVIEW,
payload: { ...sampleReview, createdAt: NOW },
},
]);
Expand All @@ -105,69 +103,69 @@ describe('addon-review experimental_serverChannel', () => {
it('on REQUEST_REVIEW with no cached state, emits nothing', async () => {
const { channel, emitted } = createMockChannel();

await experimental_serverChannel(channel, {} as Options, {});
await (channel as any).fire(EVENTS.REQUEST_REVIEW);
initReviewChannel(channel);
await (channel as any).fire(REVIEW_EVENTS.REQUEST_REVIEW);

expect(emitted).toEqual([]);
});

it('on REQUEST_REVIEW after a PUSH_REVIEW, replays the cached payload', async () => {
const { channel, emitted } = createMockChannel();

await experimental_serverChannel(channel, {} as Options, {});
await (channel as any).fire(EVENTS.PUSH_REVIEW, sampleReview);
initReviewChannel(channel);
await (channel as any).fire(REVIEW_EVENTS.PUSH_REVIEW, sampleReview);
emitted.length = 0;
await (channel as any).fire(EVENTS.REQUEST_REVIEW);
await (channel as any).fire(REVIEW_EVENTS.REQUEST_REVIEW);

expect(emitted).toEqual([
{ event: EVENTS.DISPLAY_REVIEW, payload: { ...sampleReview, createdAt: NOW } },
{ event: REVIEW_EVENTS.DISPLAY_REVIEW, payload: { ...sampleReview, createdAt: NOW } },
]);
});

it('registers exactly one listener per cross-repo event', async () => {
const { channel } = createMockChannel();

await experimental_serverChannel(channel, {} as Options, {
initReviewChannel(channel, {
subscribeToModuleGraphChanges: vi.fn(() => () => {}),
});

expect(channel.on).toHaveBeenCalledWith(EVENTS.PUSH_REVIEW, expect.any(Function));
expect(channel.on).toHaveBeenCalledWith(EVENTS.REQUEST_REVIEW, expect.any(Function));
expect(channel.on).toHaveBeenCalledWith(EVENTS.DISMISS_REVIEW, expect.any(Function));
expect(channel.on).toHaveBeenCalledWith(REVIEW_EVENTS.PUSH_REVIEW, expect.any(Function));
expect(channel.on).toHaveBeenCalledWith(REVIEW_EVENTS.REQUEST_REVIEW, expect.any(Function));
expect(channel.on).toHaveBeenCalledWith(REVIEW_EVENTS.DISMISS_REVIEW, expect.any(Function));
expect(channel.on).toHaveBeenCalledTimes(3);
});

it('on DISMISS_REVIEW, clears cache and emits REVIEW_DISMISSED with return search', async () => {
const { channel, emitted } = createMockChannel();

await experimental_serverChannel(channel, {} as Options, {});
await (channel as any).fire(EVENTS.PUSH_REVIEW, sampleReview);
initReviewChannel(channel);
await (channel as any).fire(REVIEW_EVENTS.PUSH_REVIEW, sampleReview);
emitted.length = 0;
await (channel as any).fire(EVENTS.DISMISS_REVIEW, '?path=/story/foo');
await (channel as any).fire(REVIEW_EVENTS.DISMISS_REVIEW, '?path=/story/foo');

expect(emitted).toEqual([{ event: EVENTS.REVIEW_DISMISSED, payload: '?path=/story/foo' }]);
expect(emitted).toEqual([
{ event: REVIEW_EVENTS.REVIEW_DISMISSED, payload: '?path=/story/foo' },
]);

emitted.length = 0;
await (channel as any).fire(EVENTS.REQUEST_REVIEW);
await (channel as any).fire(REVIEW_EVENTS.REQUEST_REVIEW);
expect(emitted).toEqual([]);
});

describe('staleness', () => {
const setup = async () => {
const setup = () => {
const { channel, emitted } = createMockChannel();
const { subscribeToModuleGraphChanges, fireChange } = createMockSubscribe();
await experimental_serverChannel(channel, {} as Options, {
subscribeToModuleGraphChanges,
});
initReviewChannel(channel, { subscribeToModuleGraphChanges });
return { channel, emitted, fireChange };
};

const staleOf = (emitted: Array<{ event: string; payload: unknown }>) =>
emitted.filter((e) => e.event === EVENTS.REVIEW_STALE);
emitted.filter((e) => e.event === REVIEW_EVENTS.REVIEW_STALE);

it('marks the cached review stale and emits REVIEW_STALE after the grace window', async () => {
const { channel, emitted, fireChange } = await setup();
await (channel as any).fire(EVENTS.PUSH_REVIEW, sampleReview);
const { channel, emitted, fireChange } = setup();
await (channel as any).fire(REVIEW_EVENTS.PUSH_REVIEW, sampleReview);

// Past the grace window relative to createdAt (NOW).
vi.spyOn(Date, 'now').mockReturnValue(NOW + 2000);
Expand All @@ -177,10 +175,10 @@ describe('addon-review experimental_serverChannel', () => {

// Replay to a late tab carries the staleness on the cached state.
emitted.length = 0;
await (channel as any).fire(EVENTS.REQUEST_REVIEW);
await (channel as any).fire(REVIEW_EVENTS.REQUEST_REVIEW);
expect(emitted).toEqual([
{
event: EVENTS.DISPLAY_REVIEW,
event: REVIEW_EVENTS.DISPLAY_REVIEW,
payload: {
...sampleReview,
createdAt: NOW,
Expand All @@ -191,20 +189,20 @@ describe('addon-review experimental_serverChannel', () => {
});

it('ignores source changes within the grace window', async () => {
const { channel, emitted, fireChange } = await setup();
await (channel as any).fire(EVENTS.PUSH_REVIEW, sampleReview);
const { channel, emitted, fireChange } = setup();
await (channel as any).fire(REVIEW_EVENTS.PUSH_REVIEW, sampleReview);

// Date.now is still NOW (mocked in beforeEach) → within grace.
fireChange();

expect(staleOf(emitted)).toHaveLength(0);
emitted.length = 0;
await (channel as any).fire(EVENTS.REQUEST_REVIEW);
await (channel as any).fire(REVIEW_EVENTS.REQUEST_REVIEW);
expect((emitted[0].payload as ReviewState).stale).toBeUndefined();
});

it('ignores source changes when no review is cached', async () => {
const { emitted, fireChange } = await setup();
it('ignores source changes when no review is cached', () => {
const { emitted, fireChange } = setup();

vi.spyOn(Date, 'now').mockReturnValue(NOW + 2000);
fireChange();
Expand All @@ -213,8 +211,8 @@ describe('addon-review experimental_serverChannel', () => {
});

it('emits REVIEW_STALE only once across multiple changes', async () => {
const { channel, emitted, fireChange } = await setup();
await (channel as any).fire(EVENTS.PUSH_REVIEW, sampleReview);
const { channel, emitted, fireChange } = setup();
await (channel as any).fire(REVIEW_EVENTS.PUSH_REVIEW, sampleReview);

vi.spyOn(Date, 'now').mockReturnValue(NOW + 2000);
fireChange();
Expand All @@ -225,17 +223,17 @@ describe('addon-review experimental_serverChannel', () => {
});

it('resets staleness when a new review is pushed', async () => {
const { channel, emitted, fireChange } = await setup();
await (channel as any).fire(EVENTS.PUSH_REVIEW, sampleReview);
const { channel, emitted, fireChange } = setup();
await (channel as any).fire(REVIEW_EVENTS.PUSH_REVIEW, sampleReview);

vi.spyOn(Date, 'now').mockReturnValue(NOW + 2000);
fireChange();
expect(staleOf(emitted)).toHaveLength(1);

// A fresh push re-anchors createdAt and clears stale.
await (channel as any).fire(EVENTS.PUSH_REVIEW, sampleReview);
await (channel as any).fire(REVIEW_EVENTS.PUSH_REVIEW, sampleReview);
emitted.length = 0;
await (channel as any).fire(EVENTS.REQUEST_REVIEW);
await (channel as any).fire(REVIEW_EVENTS.REQUEST_REVIEW);
expect((emitted[0].payload as ReviewState).stale).toBeUndefined();
});
});
Expand Down
Loading