Skip to content
1 change: 1 addition & 0 deletions code/core/src/manager/globals/exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -650,6 +650,7 @@ export default {
'Location',
'LocationProvider',
'Match',
'MemoryRouter',
'Route',
'buildArgsParam',
'deepDiff',
Expand Down
209 changes: 209 additions & 0 deletions code/core/src/manager/index.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
import type { Channel } from 'storybook/internal/channels';
import { CHANNEL_CREATED, CHANNEL_WS_DISCONNECT } from 'storybook/internal/core-events';
import { MemoryRouter } from 'storybook/internal/router';
import type { Addon_Config, Addon_Types } from 'storybook/internal/types';
import type { API_PreparedStoryIndex } from 'storybook/internal/types';

import { global } from '@storybook/global';
import { FailedIcon } from '@storybook/icons';

import { HelmetProvider } from 'react-helmet-async';
import type { API, AddonStore } from 'storybook/manager-api';
import { addons, mockChannel } from 'storybook/manager-api';
import { screen, within } from 'storybook/test';
import { color } from 'storybook/theming';

import preview from '../../../.storybook/preview';
import { Main } from './index';
import Provider from './provider';

const WS_DISCONNECTED_NOTIFICATION_ID = 'CORE/WS_DISCONNECTED';

const channel = mockChannel() as unknown as Channel;

const originalGetItem = Storage.prototype.getItem;
const originalSetItem = Storage.prototype.setItem;
const originalClear = Storage.prototype.clear;

const mockStoryIndex: API_PreparedStoryIndex = {
v: 5,
entries: {
'example-button--primary': {
type: 'story',
subtype: 'story',
id: 'example-button--primary',
title: 'Example/Button',
name: 'Primary',
importPath: './example-button.stories.tsx',
parameters: {},
},
},
};

class ReactProvider extends Provider {
addons: AddonStore;

channel: Channel;

wsDisconnected = false;

constructor() {
super();

addons.setChannel(channel);
channel.emit(CHANNEL_CREATED);

this.addons = addons;
this.channel = channel;
global.__STORYBOOK_ADDONS_CHANNEL__ = channel;
}

getElements(type: Addon_Types) {
return this.addons.getElements(type);
}

getConfig(): Addon_Config {
return this.addons.getConfig();
}

handleAPI(api: API) {
this.addons.loadAddons(api);

// Initialize story index with mock data
api.setIndex(mockStoryIndex).then(() => {
// Mark preview as initialized so the iframe doesn't show a spinner
api.setPreviewInitialized();

// Set the current story to example-button--primary after the index is initialized
// This navigates to the story URL, which will cause the iframe to load the correct story
api.selectStory('example-button--primary', undefined, { viewMode: 'story' });
});

this.channel.on(CHANNEL_WS_DISCONNECT, (ev) => {
const TIMEOUT_CODE = 3008;
this.wsDisconnected = true;

api.addNotification({
id: WS_DISCONNECTED_NOTIFICATION_ID,
content: {
headline: ev.code === TIMEOUT_CODE ? 'Server timed out' : 'Connection lost',
subHeadline: 'Please restart your Storybook server and reload the page',
},
icon: <FailedIcon color={color.negative} />,
link: undefined,
});
});
}
}

const meta = preview.meta({
title: 'Main',
component: Main,
args: {
provider: new ReactProvider(),
},
parameters: {
layout: 'fullscreen',
chromatic: {
disableSnapshot: true,
},
},
beforeEach: () => {
global.PREVIEW_URL = 'about:blank';

Storage.prototype.getItem = () => null;
Storage.prototype.setItem = () => {};
Storage.prototype.clear = () => {};
},
afterEach: () => {
Storage.prototype.getItem = originalGetItem;
Storage.prototype.setItem = originalSetItem;
Storage.prototype.clear = originalClear;
},
Comment on lines +111 to +122
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

global.PREVIEW_URL is not restored in afterEach.

The beforeEach hook sets global.PREVIEW_URL = 'about:blank' but afterEach doesn't restore the original value, which could leak state to other tests.

🔧 Suggested fix
+const originalPreviewUrl = global.PREVIEW_URL;
+
 const meta = preview.meta({
   // ...
   beforeEach: () => {
     global.PREVIEW_URL = 'about:blank';

     Storage.prototype.getItem = () => null;
     Storage.prototype.setItem = () => {};
     Storage.prototype.clear = () => {};
   },
   afterEach: () => {
+    global.PREVIEW_URL = originalPreviewUrl;
     Storage.prototype.getItem = originalGetItem;
     Storage.prototype.setItem = originalSetItem;
     Storage.prototype.clear = originalClear;
   },
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
beforeEach: () => {
global.PREVIEW_URL = 'about:blank';
Storage.prototype.getItem = () => null;
Storage.prototype.setItem = () => {};
Storage.prototype.clear = () => {};
},
afterEach: () => {
Storage.prototype.getItem = originalGetItem;
Storage.prototype.setItem = originalSetItem;
Storage.prototype.clear = originalClear;
},
const originalGetItem = Storage.prototype.getItem;
const originalSetItem = Storage.prototype.setItem;
const originalClear = Storage.prototype.clear;
const originalPreviewUrl = global.PREVIEW_URL;
const meta = preview.meta({
// ... other configuration
beforeEach: () => {
global.PREVIEW_URL = 'about:blank';
Storage.prototype.getItem = () => null;
Storage.prototype.setItem = () => {};
Storage.prototype.clear = () => {};
},
afterEach: () => {
global.PREVIEW_URL = originalPreviewUrl;
Storage.prototype.getItem = originalGetItem;
Storage.prototype.setItem = originalSetItem;
Storage.prototype.clear = originalClear;
},
🤖 Prompt for AI Agents
In @code/core/src/manager/index.stories.tsx around lines 108 - 119, Store the
original global.PREVIEW_URL before mutating it in the beforeEach (e.g., const
originalPreviewUrl = global.PREVIEW_URL) and then restore it in afterEach by
assigning global.PREVIEW_URL = originalPreviewUrl; update the
beforeEach/afterEach blocks around the existing Storage.prototype mocks so the
test suite doesn't leak the preview URL state.

decorators: [
(Story) => (
<HelmetProvider key="helmet.Provider">
<MemoryRouter key="location.provider">
<Story />
</MemoryRouter>
</HelmetProvider>
),
],
});

export default meta;

export const Default = meta.story({});

export const ToggleSidebar = meta.story({
play: async ({ canvas, userEvent }) => {
await userEvent.click(await canvas.findByLabelText('Settings'));
await userEvent.click(await screen.findByRole('button', { name: /Show sidebar/i }));
},
});

export const ToggleToolbar = meta.story({
play: async ({ canvas, userEvent }) => {
await userEvent.click(await canvas.findByLabelText('Settings'));
await userEvent.click(await screen.findByRole('button', { name: /Show toolbar/i }));
},
});

export const TogglePanel = meta.story({
play: async ({ canvas, userEvent }) => {
await userEvent.click(await canvas.findByLabelText('Settings'));
await userEvent.click(await screen.findByRole('button', { name: /Show addons panel/i }));
},
});

export const RightPanel = meta.story({
play: async ({ canvasElement, userEvent }) => {
const panel = within(canvasElement.querySelector('#storybook-panel-root') as HTMLElement);
await userEvent.click(await panel.findByLabelText('Move addon panel to right'));
},
Comment on lines +160 to +163
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Potential null reference in querySelector assertion.

If the #storybook-panel-root element doesn't exist when the play function runs, querySelector returns null, and within(null as HTMLElement) will cause a runtime error with a confusing message.

Consider using a safer approach:

🐛 Suggested fix
 export const RightPanel = meta.story({
   play: async ({ canvasElement, userEvent }) => {
-    const panel = within(canvasElement.querySelector('#storybook-panel-root') as HTMLElement);
+    const panelRoot = canvasElement.querySelector('#storybook-panel-root');
+    if (!panelRoot) {
+      throw new Error('Panel root element not found');
+    }
+    const panel = within(panelRoot as HTMLElement);
     await userEvent.click(await panel.findByLabelText('Move addon panel to right'));
   },
 });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
play: async ({ canvasElement, userEvent }) => {
const panel = within(canvasElement.querySelector('#storybook-panel-root') as HTMLElement);
await userEvent.click(await panel.findByLabelText('Move addon panel to right'));
},
play: async ({ canvasElement, userEvent }) => {
const panelRoot = canvasElement.querySelector('#storybook-panel-root');
if (!panelRoot) {
throw new Error('Panel root element not found');
}
const panel = within(panelRoot as HTMLElement);
await userEvent.click(await panel.findByLabelText('Move addon panel to right'));
},
🤖 Prompt for AI Agents
In @code/core/src/manager/index.stories.tsx around lines 156 - 159, The play
function uses within(canvasElement.querySelector('#storybook-panel-root') as
HTMLElement) which will throw a confusing runtime error if querySelector returns
null; change it to first assign const panelRoot =
canvasElement.querySelector('#storybook-panel-root'); then guard it (if
(!panelRoot) throw new Error('storybook panel root (#storybook-panel-root) not
found in play()')); finally call within(panelRoot) and proceed with await
userEvent.click(await panel.findByLabelText('Move addon panel to right')); this
ensures a clear error or safe execution when the element is missing.

});
Comment on lines +159 to +164
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add null check for querySelector result.

querySelector can return null if the element doesn't exist. The direct cast to HTMLElement masks this, leading to confusing errors downstream.

🔧 Suggested fix
 export const RightPanel = meta.story({
   play: async ({ canvasElement, userEvent }) => {
-    const panel = within(canvasElement.querySelector('#storybook-panel-root') as HTMLElement);
+    const panelRoot = canvasElement.querySelector('#storybook-panel-root');
+    if (!panelRoot) {
+      throw new Error('Panel root element not found');
+    }
+    const panel = within(panelRoot as HTMLElement);
     await userEvent.click(await panel.findByLabelText('Move addon panel to right'));
   },
 });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const RightPanel = meta.story({
play: async ({ canvasElement, userEvent }) => {
const panel = within(canvasElement.querySelector('#storybook-panel-root') as HTMLElement);
await userEvent.click(await panel.findByLabelText('Move addon panel to right'));
},
});
export const RightPanel = meta.story({
play: async ({ canvasElement, userEvent }) => {
const panelRoot = canvasElement.querySelector('#storybook-panel-root');
if (!panelRoot) {
throw new Error('Panel root element not found');
}
const panel = within(panelRoot as HTMLElement);
await userEvent.click(await panel.findByLabelText('Move addon panel to right'));
},
});
🤖 Prompt for AI Agents
In @code/core/src/manager/index.stories.tsx around lines 156 - 161, The play
function in RightPanel calls
canvasElement.querySelector('#storybook-panel-root') and force-casts to
HTMLElement which can be null; update the play implementation to first capture
the result into a variable, check for null (e.g., if (!panelRoot) return or
throw a clear error), only then cast to HTMLElement and call within(panelRoot);
ensure subsequent calls (panel.findByLabelText) are only invoked when panelRoot
exists so you avoid downstream null dereferences in meta.story's play.


export const FullScreen = meta.story({
play: async ({ canvas, userEvent }) => {
await userEvent.click(await canvas.findByRole('button', { name: /Enter full screen/i }));
},
});

export const ShareMenu = meta.story({
play: async ({ canvas, userEvent }) => {
await userEvent.click(await canvas.findByRole('button', { name: /Share/i }));
},
});

export const ConnectionLost = meta.story({
play: async () => {
channel.emit(CHANNEL_WS_DISCONNECT, { code: 3007 });
},
});

export const ServerTimedOut = meta.story({
play: async () => {
channel.emit(CHANNEL_WS_DISCONNECT, { code: 3008 });
},
});

export const AboutPage = meta.story({
play: async ({ canvas, userEvent }) => {
await userEvent.click(await canvas.findByLabelText('Settings'));
await userEvent.click(await screen.findByRole('link', { name: /About your Storybook/i }));
},
});

export const GuidePage = meta.story({
play: async ({ canvas, userEvent }) => {
await userEvent.click(await canvas.findByLabelText('Settings'));
await userEvent.click(await screen.findByRole('link', { name: /Onboarding guide/i }));
},
});

export const ShortcutsPage = meta.story({
play: async ({ canvas, userEvent }) => {
await userEvent.click(await canvas.findByLabelText('Settings'));
await userEvent.click(await screen.findByRole('link', { name: /Keyboard shortcuts/i }));
},
});
2 changes: 1 addition & 1 deletion code/core/src/manager/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export const Root: FC<RootProps> = ({ provider }) => (
</HelmetProvider>
);

const Main: FC<{ provider: Provider }> = ({ provider }) => {
export const Main: FC<{ provider: Provider }> = ({ provider }) => {
const navigate = useNavigate();

return (
Expand Down
1 change: 1 addition & 0 deletions code/core/src/router/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -142,3 +142,4 @@ export { Route, Match };

export const LocationProvider: typeof R.BrowserRouter = (...args) => R.BrowserRouter(...args);
export const BaseLocationProvider: typeof R.Router = (...args) => R.Router(...args);
export const MemoryRouter: typeof R.MemoryRouter = (...args) => R.MemoryRouter(...args);