Merge branch 'next' into valentin/drop-tooling-support

This commit is contained in:
Valentin Palkovic 2025-03-28 12:32:15 +01:00 committed by GitHub
commit dd6edc6897
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
74 changed files with 11192 additions and 1847 deletions

View File

@ -1,3 +1,7 @@
## 8.6.11
- Angular: Fix zone.js support for Angular libraries - [#30941](https://github.com/storybookjs/storybook/pull/30941), thanks @valentinpalkovic!
## 8.6.10
- Addon-docs: Fix non-string handling in Stories block - [#30913](https://github.com/storybookjs/storybook/pull/30913), thanks @JamesIves!

View File

@ -5,7 +5,7 @@ import react from '@vitejs/plugin-react';
import { defineMain } from '../frameworks/react-vite/src/node';
const componentsPath = join(__dirname, '../core/src/components/index.ts');
const managerApiPath = join(__dirname, '../core/src/manager-api/index.ts');
const managerApiPath = join(__dirname, '../core/src/manager-api/index.mock.ts');
const imageContextPath = join(__dirname, '../frameworks/nextjs/src/image-context.ts');
const config = defineMain({
@ -123,7 +123,9 @@ const config = defineMain({
'storybook/manager-api': managerApiPath,
'sb-original/image-context': imageContextPath,
}
: {}),
: {
'storybook/manager-api': managerApiPath,
}),
},
},
plugins: [react()],

View File

@ -56,13 +56,9 @@ const Centered = styled.span(({ theme }) => ({
},
}));
const Count = styled(Badge)({
padding: 4,
minWidth: 24,
});
export const A11YPanel: React.FC = () => {
const {
tab,
results,
status,
handleManual,
@ -80,7 +76,9 @@ export const A11YPanel: React.FC = () => {
label: (
<Tab>
Violations
<Count status="neutral">{violations.length}</Count>
<Badge compact status={tab === 'violations' ? 'active' : 'neutral'}>
{violations.length}
</Badge>
</Tab>
),
panel: (
@ -100,7 +98,9 @@ export const A11YPanel: React.FC = () => {
label: (
<Tab>
Passes
<Count status="neutral">{passes.length}</Count>
<Badge compact status={tab === 'passes' ? 'active' : 'neutral'}>
{passes.length}
</Badge>
</Tab>
),
panel: (
@ -120,7 +120,9 @@ export const A11YPanel: React.FC = () => {
label: (
<Tab>
Inconclusive
<Count status="neutral">{incomplete.length}</Count>
<Badge compact status={tab === 'incomplete' ? 'active' : 'neutral'}>
{incomplete.length}
</Badge>
</Tab>
),
panel: (
@ -137,7 +139,7 @@ export const A11YPanel: React.FC = () => {
type: RuleType.INCOMPLETION,
},
];
}, [results, handleSelectionChange, selectedItems, toggleOpen]);
}, [tab, results, handleSelectionChange, selectedItems, toggleOpen]);
return (
<>

View File

@ -31,12 +31,22 @@ const Info = styled.div({
flexDirection: 'column',
});
const RuleId = styled.div(({ theme }) => ({
display: 'block',
color: theme.textMutedColor,
marginTop: -10,
marginBottom: 10,
'@container (min-width: 800px)': {
display: 'none',
},
}));
const Description = styled.p({
margin: 0,
});
const Wrapper = styled.div({
containerType: 'inline-size',
display: 'flex',
flexDirection: 'column',
padding: '0 15px 20px 15px',
@ -56,7 +66,10 @@ const Content = styled.div<{ side: 'left' | 'right' }>(({ theme, side }) => ({
display: side === 'left' ? 'flex' : 'none',
flexDirection: 'column',
gap: 15,
margin: side === 'left' ? '15px 0 20px 0' : 0,
margin: side === 'left' ? '15px 0' : 0,
padding: side === 'left' ? '0 15px' : 0,
borderLeft: side === 'left' ? `1px solid ${theme.color.border}` : 'none',
'&:focus-visible': {
outline: 'none',
borderRadius: 4,
@ -124,12 +137,13 @@ interface DetailsProps {
export const Details = ({ item, type, selection, handleSelectionChange }: DetailsProps) => (
<Wrapper>
<Info>
<RuleId>{item.id}</RuleId>
<Description>
{item.description.endsWith('.') ? item.description : `${item.description}.`}
{item.description.endsWith('.') ? item.description : `${item.description}.`}{' '}
<Link href={item.helpUrl} target="_blank" withArrow>
How to resolve this
</Link>
</Description>
<Link href={item.helpUrl} target="_blank" withArrow>
Learn how to resolve this violation
</Link>
</Info>
<Tabs.Root

View File

@ -27,8 +27,8 @@ const HeaderBar = styled.div(({ theme }) => ({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: 5,
padding: '6px 5px 6px 15px',
gap: 6,
padding: '6px 10px 6px 15px',
minHeight: 40,
background: 'none',
color: 'inherit',
@ -40,26 +40,29 @@ const HeaderBar = styled.div(({ theme }) => ({
},
}));
const Title = styled.div(({ theme }) => ({
const Title = styled.div({
display: 'flex',
flexGrow: 1,
fontWeight: theme.typography.weight.bold,
gap: 6,
});
const RuleId = styled.div(({ theme }) => ({
display: 'none',
color: theme.textMutedColor,
'@container (min-width: 800px)': {
display: 'block',
},
}));
const RuleId = styled.span<{ onClick: (event: React.SyntheticEvent<Element>) => void }>(
({ theme }) => ({
display: 'none',
whiteSpace: 'nowrap',
cursor: 'text',
margin: 2,
color: theme.textMutedColor,
fontSize: theme.typography.size.s1,
fontWeight: theme.typography.weight.bold,
'@container (min-width: 800px)': {
display: 'inline-block',
},
})
);
const Count = styled.div(({ theme }) => ({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: theme.textMutedColor,
width: 28,
height: 28,
}));
export interface ReportProps {
items: Result[];
@ -90,8 +93,11 @@ export const Report: FC<ReportProps> = ({
role="button"
data-active={!!selection}
>
<Title>{item.help}</Title>
<RuleId onClick={(event) => event.stopPropagation()}>{item.id}</RuleId>
<Title>
<strong>{item.help}</strong>
<RuleId>{item.id}</RuleId>
</Title>
<Count>{item.nodes.length}</Count>
<IconButton onClick={(event) => toggleOpen(event, type, item)}>
<Icon style={{ transform: `rotate(${selection ? -180 : 0}deg)` }} />
</IconButton>

View File

@ -10,6 +10,7 @@ import './manager';
vi.mock('storybook/manager-api');
const mockedApi = vi.mocked<api.API>(api as any);
mockedApi.useStorybookApi = vi.fn(() => ({ getSelectedPanel: vi.fn() }));
mockedApi.useAddonState = vi.fn();
const mockedAddons = vi.mocked(api.addons);
const registrationImpl = mockedAddons.register.mock.calls[0][1];
@ -44,21 +45,18 @@ describe('A11yManager', () => {
// when / then
expect(title()).toMatchInlineSnapshot(`
<div>
<Spaced
col={1}
>
<span
style={
{
"display": "inline-block",
"verticalAlign": "middle",
}
}
>
Accessibility
</span>
</Spaced>
<div
style={
{
"alignItems": "center",
"display": "flex",
"gap": 6,
}
}
>
<span>
Accessibility
</span>
</div>
`);
});
@ -77,26 +75,24 @@ describe('A11yManager', () => {
// when / then
expect(title()).toMatchInlineSnapshot(`
<div>
<Spaced
col={1}
<div
style={
{
"alignItems": "center",
"display": "flex",
"gap": 6,
}
}
>
<span>
Accessibility
</span>
<Badge
compact={true}
status="neutral"
>
<span
style={
{
"display": "inline-block",
"verticalAlign": "middle",
}
}
>
Accessibility
</span>
<Badge
status="neutral"
>
3
</Badge>
</Spaced>
3
</Badge>
</div>
`);
});

View File

@ -1,8 +1,8 @@
import React from 'react';
import { Badge, Spaced } from 'storybook/internal/components';
import { Badge } from 'storybook/internal/components';
import { addons, types, useAddonState } from 'storybook/manager-api';
import { addons, types, useAddonState, useStorybookApi } from 'storybook/manager-api';
import { A11YPanel } from './components/A11YPanel';
import type { Results } from './components/A11yContext';
@ -11,19 +11,24 @@ import { VisionSimulator } from './components/VisionSimulator';
import { ADDON_ID, PANEL_ID, PARAM_KEY } from './constants';
const Title = () => {
const api = useStorybookApi();
const selectedPanel = api.getSelectedPanel();
const [addonState] = useAddonState<Results>(ADDON_ID);
const violationsNb = addonState?.violations?.length || 0;
const incompleteNb = addonState?.incomplete?.length || 0;
const count = violationsNb + incompleteNb;
const suffix = count === 0 ? '' : <Badge status="neutral">{count}</Badge>;
const suffix =
count === 0 ? null : (
<Badge compact status={selectedPanel === PANEL_ID ? 'active' : 'neutral'}>
{count}
</Badge>
);
return (
<div>
<Spaced col={1}>
<span style={{ display: 'inline-block', verticalAlign: 'middle' }}>Accessibility</span>
{suffix}
</Spaced>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span>Accessibility</span>
{suffix}
</div>
);
};

View File

@ -120,10 +120,10 @@
"vitest": "^3.0.9"
},
"peerDependencies": {
"@vitest/browser": "^2.1.1 || ^3.0.0",
"@vitest/runner": "^2.1.1 || ^3.0.0",
"@vitest/browser": "^3.0.0",
"@vitest/runner": "^3.0.0",
"storybook": "workspace:^",
"vitest": "^2.1.1 || ^3.0.0"
"vitest": "^3.0.0"
},
"peerDependenciesMeta": {
"@vitest/browser": {

View File

@ -1,11 +1,11 @@
import React, { type ComponentProps, useEffect } from 'react';
import React, { type ComponentProps } from 'react';
import { Link as LinkComponent } from 'storybook/internal/components';
import { type TestProviderConfig, type TestProviderState } from 'storybook/internal/core-events';
import type { TestProviderState } from 'storybook/internal/types';
import { styled } from 'storybook/theming';
import type { TestResultResult } from '../node/reporter';
import type { StoreState } from '../types';
import { GlobalErrorContext } from './GlobalErrorModal';
import { RelativeTime } from './RelativeTime';
@ -22,42 +22,59 @@ const PositiveText = styled.span(({ theme }) => ({
}));
interface DescriptionProps extends Omit<ComponentProps<typeof Wrapper>, 'results'> {
state: TestProviderConfig & TestProviderState;
watching: boolean;
storeState: StoreState;
testProviderState: TestProviderState;
entryId?: string;
results?: TestResultResult[];
isSettingsUpdated: boolean;
}
export function Description({ state, watching, entryId, results, ...props }: DescriptionProps) {
export function Description({
entryId,
storeState,
testProviderState,
isSettingsUpdated,
...props
}: DescriptionProps) {
const { setModalOpen } = React.useContext(GlobalErrorContext);
const errorMessage = state.error?.message;
const { componentTestCount, totalTestCount, unhandledErrors, finishedAt } = storeState.currentRun;
const finishedTestCount = componentTestCount.success + componentTestCount.error;
let description: string | React.ReactNode = 'Not run';
if (state.running) {
description = state.progress
? `Testing... ${state.progress.numPassedTests}/${state.progress.numTotalTests}`
: 'Starting...';
} else if (entryId && results?.length) {
description = `Ran ${results.length} ${results.length === 1 ? 'test' : 'tests'}`;
} else if (state.failed && !errorMessage) {
description = 'Failed';
} else if (state.crashed || (state.failed && errorMessage)) {
if (isSettingsUpdated) {
description = <PositiveText>Settings updated</PositiveText>;
} else if (testProviderState === 'test-provider-state:running') {
description =
(finishedTestCount ?? 0) === 0
? 'Starting...'
: `Testing... ${finishedTestCount}/${totalTestCount}`;
} else if (testProviderState === 'test-provider-state:crashed') {
description = setModalOpen ? (
<LinkComponent isButton onClick={() => setModalOpen(true)}>
{state.error?.name || 'View full error'}
View full error
</LinkComponent>
) : (
state.error?.name || 'Failed'
'Crashed'
);
} else if (state.progress?.finishedAt) {
} else if (unhandledErrors.length > 0) {
const unhandledErrorDescription = `View ${unhandledErrors.length} unhandled error${unhandledErrors?.length > 1 ? 's' : ''}`;
description = setModalOpen ? (
<LinkComponent isButton onClick={() => setModalOpen(true)}>
{unhandledErrorDescription}
</LinkComponent>
) : (
unhandledErrorDescription
);
} else if (entryId && totalTestCount) {
description = `Ran ${totalTestCount} ${totalTestCount === 1 ? 'test' : 'tests'}`;
} else if (finishedAt) {
description = (
<>
Ran {state.progress.numTotalTests} {state.progress.numTotalTests === 1 ? 'test' : 'tests'}{' '}
<RelativeTime timestamp={state.progress.finishedAt} />
Ran {totalTestCount} {totalTestCount === 1 ? 'test' : 'tests'}{' '}
<RelativeTime timestamp={finishedAt} />
</>
);
} else if (watching) {
} else if (storeState.watching) {
description = 'Watching for file changes';
}

View File

@ -6,6 +6,7 @@ import { ManagerContext } from 'storybook/manager-api';
import { expect, fn, userEvent, within } from 'storybook/test';
import dedent from 'ts-dedent';
import { storeOptions } from '../constants';
import { GlobalErrorContext, GlobalErrorModal } from './GlobalErrorModal';
type Story = StoryObj<typeof meta>;
@ -22,51 +23,59 @@ const managerContext: any = {
const meta = {
component: GlobalErrorModal,
decorators: [
(storyFn) => (
<ManagerContext.Provider value={managerContext}>
<div
style={{
width: '100%',
minWidth: '1200px',
height: '800px',
background:
'repeating-linear-gradient(45deg, #000000, #ffffff 50px, #ffffff 50px, #ffffff 80px)',
}}
>
{storyFn()}
</div>
</ManagerContext.Provider>
),
(storyFn) => {
const [isModalOpen, setModalOpen] = useState(false);
return (
<ManagerContext.Provider value={managerContext}>
<GlobalErrorContext.Provider value={{ isModalOpen, setModalOpen }}>
<div
style={{
width: '100%',
minWidth: '1200px',
height: '800px',
background:
'repeating-linear-gradient(45deg, #000000, #ffffff 50px, #ffffff 50px, #ffffff 80px)',
}}
>
{storyFn()}
</div>
<button onClick={() => setModalOpen(true)}>Open modal</button>
</GlobalErrorContext.Provider>
</ManagerContext.Provider>
);
},
],
args: {
onRerun: fn(),
storeState: storeOptions.initialState,
},
} satisfies Meta<typeof GlobalErrorModal>;
export default meta;
export const Default: Story = {
render: (props) => {
const [isModalOpen, setModalOpen] = useState(false);
const error = dedent`
ReferenceError: FAIL is not defined
at Constraint.execute (the-best-file.js:525:2)
at Constraint.recalculate (the-best-file.js:424:21)
at Planner.addPropagate (the-best-file.js:701:6)
at Constraint.satisfy (the-best-file.js:184:15)
at Planner.incrementalAdd (the-best-file.js:591:21)
at Constraint.addConstraint (the-best-file.js:162:10)
at Constraint.BinaryConstraint (the-best-file.js:346:7)
at Constraint.EqualityConstraint (the-best-file.js:515:38)
at chainTest (the-best-file.js:807:6)
at deltaBlue (the-best-file.js:879:2)`;
return (
<GlobalErrorContext.Provider value={{ isModalOpen, setModalOpen, error }}>
<GlobalErrorModal {...props} />
<button onClick={() => setModalOpen(true)}>Open modal</button>
</GlobalErrorContext.Provider>
);
export const FatalError: Story = {
args: {
onRerun: fn(),
storeState: {
...storeOptions.initialState,
fatalError: {
message: 'Some fatal error message',
error: {
message: dedent`
ReferenceError: FAIL is not defined
at Constraint.execute (the-best-file.js:525:2)
at Constraint.recalculate (the-best-file.js:424:21)
at Planner.addPropagate (the-best-file.js:701:6)
at Constraint.satisfy (the-best-file.js:184:15)
at Planner.incrementalAdd (the-best-file.js:591:21)
at Constraint.addConstraint (the-best-file.js:162:10)
at Constraint.BinaryConstraint (the-best-file.js:346:7)
at Constraint.EqualityConstraint (the-best-file.js:515:38)
at chainTest (the-best-file.js:807:6)
at deltaBlue (the-best-file.js:879:2)`,
},
},
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement.parentElement!);
@ -75,3 +84,65 @@ export const Default: Story = {
await expect(canvas.findByText('Storybook Tests error details')).resolves.toBeInTheDocument();
},
};
export const UnhandledErrors: Story = {
...FatalError,
args: {
onRerun: fn(),
storeState: {
...storeOptions.initialState,
currentRun: {
...storeOptions.initialState.currentRun,
unhandledErrors: [
{
name: 'Error',
message: 'this is an error thrown in a setTimeout in play',
stack: dedent`Error: this is an error thrown in a setTimeout in play
at http://localhost:63315/some/absolute/path/to/file.js?import&browserv=1742507455852:74:13`,
VITEST_TEST_PATH: '/some/absolute/path/to/file.js',
VITEST_TEST_NAME: 'My test',
stacks: [
{
file: '/some/absolute/path/to/file.js',
line: 74,
column: 13,
method: 'someMethod',
},
{
file: '/some/absolute/path/to/other/file.js',
line: 123,
column: 45,
method: 'someOtherMethod',
},
],
},
{
name: 'Error',
message: 'this is an error rejected in play',
stack: dedent`Error: this is an error rejected in play
at play (http://localhost:63315/some/absolute/path/to/file.js?import&browserv=1742507682505:73:20)
at runStory (http://localhost:63315/@fs/some/absolute/path/to/.vite/deps/chunk-YVH55Y2L.js?v=77e3ac43:31517:11)
at async http://localhost:63315/@fs/some/absolute/path/to/.vite/deps/@storybook_addon-test_internal_test-utils.js?v=59e7fce5:121:5
at async http://localhost:63315/@fs/some/absolute/path/to/@vitest/runner/dist/index.js?v=77e3ac43:573:22`,
VITEST_TEST_PATH: '/some/absolute/path/to/file.js',
VITEST_TEST_NAME: 'My other test',
stacks: [
{
file: '/some/absolute/path/to/file.js',
line: 73,
column: 20,
method: 'play',
},
{
file: '/some/absolute/path/to/.vite/deps/chunk-YVH55Y2L.js',
line: 31517,
column: 11,
method: 'runStory',
},
],
},
],
},
},
},
};

View File

@ -8,6 +8,7 @@ import { useStorybookApi } from 'storybook/manager-api';
import { styled } from 'storybook/theming';
import { DOCUMENTATION_FATAL_ERROR_LINK } from '../constants';
import type { ErrorLike, StoreState } from '../types';
const ModalBar = styled.div({
display: 'flex',
@ -22,13 +23,14 @@ const ModalActionBar = styled.div({
alignItems: 'center',
});
const ModalTitle = styled.div(({ theme: { typography } }) => ({
const ModalTitle = styled(Modal.Title)(({ theme: { typography } }) => ({
fontSize: typography.size.s2,
fontWeight: typography.weight.bold,
}));
const ModalStackTrace = styled.pre(({ theme }) => ({
whiteSpace: 'pre-wrap',
wordWrap: 'break-word',
overflow: 'auto',
maxHeight: '60vh',
margin: 0,
@ -46,20 +48,35 @@ const TroubleshootLink = styled.a(({ theme }) => ({
export const GlobalErrorContext = React.createContext<{
isModalOpen: boolean;
setModalOpen: (isOpen: boolean) => void;
error?: string;
}>({
isModalOpen: false,
setModalOpen: () => {},
error: undefined,
});
interface GlobalErrorModalProps {
onRerun: () => void;
storeState: StoreState;
}
export function GlobalErrorModal({ onRerun }: GlobalErrorModalProps) {
function ErrorCause({ error }: { error: ErrorLike }) {
if (!error) {
return null;
}
return (
<div>
<h4>
Caused by: {error.name || 'Error'}: {error.message}
</h4>
{error.stack && <pre>{error.stack}</pre>}
{error.cause && <ErrorCause error={error.cause} />}
</div>
);
}
export function GlobalErrorModal({ onRerun, storeState }: GlobalErrorModalProps) {
const api = useStorybookApi();
const { error, isModalOpen, setModalOpen } = useContext(GlobalErrorContext);
const { isModalOpen, setModalOpen } = useContext(GlobalErrorContext);
const handleClose = () => setModalOpen(false);
const troubleshootURL = api.getDocsUrl({
@ -68,6 +85,68 @@ export function GlobalErrorModal({ onRerun }: GlobalErrorModalProps) {
renderer: true,
});
const {
fatalError,
currentRun: { unhandledErrors },
} = storeState;
const content = fatalError ? (
<>
<p>{fatalError.error.name || 'Error'}</p>
{fatalError.message && <p>{fatalError.message}</p>}
{fatalError.error.message && <p>{fatalError.error.message}</p>}
{fatalError.error.stack && <p>{fatalError.error.stack}</p>}
{fatalError.error.cause && <ErrorCause error={fatalError.error.cause} />}
</>
) : unhandledErrors.length > 0 ? (
<ol>
{unhandledErrors.map((error) => (
<li key={error.name + error.message}>
<p>
{error.name}: {error.message}
</p>
{error.VITEST_TEST_PATH && (
<p>
This error originated in "<b>{error.VITEST_TEST_PATH}</b>". It doesn't mean the error
was thrown inside the file itself, but while it was running.
</p>
)}
{error.VITEST_TEST_NAME && (
<>
<p>
The latest test that might've caused the error is "<b>{error.VITEST_TEST_NAME}</b>".
It might mean one of the following:
</p>
<ul>
<li>The error was thrown, while Vitest was running this test.</li>
<li>
If the error occurred after the test had been completed, this was the last
documented test before it was thrown.
</li>
</ul>
</>
)}
{error.stacks && (
<>
<p>
<b>Stacks:</b>
</p>
<ul>
{error.stacks.map((stack) => (
<li key={stack.file + stack.line + stack.column}>
{stack.file}:{stack.line}:{stack.column} - {stack.method || 'unknown method'}
</li>
))}
</ul>
</>
)}
{error.stack && <p>{error.stack}</p>}
{error.cause ? <ErrorCause error={error.cause as ErrorLike} /> : null}
</li>
))}
</ol>
) : null;
return (
<Modal onEscapeKeyDown={handleClose} onInteractOutside={handleClose} open={isModalOpen}>
<ModalBar>
@ -88,7 +167,7 @@ export function GlobalErrorModal({ onRerun }: GlobalErrorModalProps) {
</ModalActionBar>
</ModalBar>
<ModalStackTrace>
{error}
{content}
<br />
<br />
Troubleshoot:{' '}

View File

@ -0,0 +1,37 @@
import type { FC } from 'react';
import React from 'react';
import { type API } from 'storybook/internal/manager-api';
import type { API_HashEntry } from 'storybook/internal/types';
import { useTestProvider } from '../use-test-provider-state';
import { TestProviderRender } from './TestProviderRender';
type SidebarContextMenuProps = {
api: API;
context: API_HashEntry;
};
export const SidebarContextMenu: FC<SidebarContextMenuProps> = ({ context, api }) => {
const {
testProviderState,
componentTestStatusValueToStoryIds,
a11yStatusValueToStoryIds,
storeState,
setStoreState,
} = useTestProvider(api, context.id);
return (
<TestProviderRender
api={api}
entry={context}
style={{ minWidth: 240 }}
testProviderState={testProviderState}
componentTestStatusValueToStoryIds={componentTestStatusValueToStoryIds}
a11yStatusValueToStoryIds={a11yStatusValueToStoryIds}
storeState={storeState}
setStoreState={setStoreState}
isSettingsUpdated={false}
/>
);
};

View File

@ -1,20 +1,18 @@
import React from 'react';
import type { TestProviderConfig, TestProviderState } from 'storybook/internal/core-events';
import { Addon_TypesEnum } from 'storybook/internal/types';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { ManagerContext, addons } from 'storybook/manager-api';
import { expect, fn, userEvent } from 'storybook/test';
import { fn } from 'storybook/test';
import { styled } from 'storybook/theming';
import { ADDON_ID as A11Y_ADDON_ID } from '../../../a11y/src/constants';
import { type Details, storeOptions } from '../constants';
import { storeOptions } from '../constants';
import { store as mockStore } from '../manager-store.mock';
import { TestProviderRender } from './TestProviderRender';
type Story = StoryObj<typeof TestProviderRender>;
const managerContext: any = {
state: {
testProviders: {
@ -29,43 +27,10 @@ const managerContext: any = {
getDocsUrl: fn(({ subpath }) => `https://storybook.js.org/docs/${subpath}`).mockName(
'api::getDocsUrl'
),
emit: fn().mockName('api::emit'),
updateTestProviderState: fn().mockName('api::updateTestProviderState'),
},
};
const config: TestProviderConfig = {
id: 'test-provider-id',
name: 'Test Provider',
type: Addon_TypesEnum.experimental_TEST_PROVIDER,
runnable: true,
};
const baseState: TestProviderState<Details> = {
cancellable: true,
cancelling: false,
crashed: false,
error: undefined,
failed: false,
running: false,
details: {
testResults: [
{
endTime: 0,
startTime: 0,
status: 'passed',
message: 'All tests passed',
results: [
{
storyId: 'story-id',
status: 'passed',
duration: 100,
testRunId: 'test-run-id',
reports: [],
},
],
},
],
findAllLeafStoryIds: fn((entryId) => [entryId]),
selectStory: fn().mockName('api::selectStory'),
setSelectedPanel: fn().mockName('api::setSelectedPanel'),
togglePanel: fn().mockName('api::togglePanel'),
},
};
@ -76,15 +41,29 @@ const Content = styled.div({
gap: '12px',
});
export default {
const meta = {
title: 'TestProviderRender',
component: TestProviderRender,
args: {
state: {
...config,
...baseState,
},
api: managerContext.api,
testProviderState: 'test-provider-state:pending',
componentTestStatusValueToStoryIds: {
'status-value:error': [],
'status-value:success': [],
'status-value:pending': [],
'status-value:warning': [],
'status-value:unknown': [],
},
a11yStatusValueToStoryIds: {
'status-value:error': [],
'status-value:success': [],
'status-value:pending': [],
'status-value:warning': [],
'status-value:unknown': [],
},
storeState: storeOptions.initialState,
setStoreState: fn(),
isSettingsUpdated: false,
},
decorators: [
(StoryFn) => (
@ -108,181 +87,245 @@ export default {
mockStore.setState(storeOptions.initialState);
};
},
} as Meta<typeof TestProviderRender>;
} satisfies Meta<typeof TestProviderRender>;
export const Default: Story = {
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const Starting: Story = {
args: {
state: {
...config,
...baseState,
testProviderState: 'test-provider-state:running',
},
};
export const Testing: Story = {
args: {
testProviderState: 'test-provider-state:running',
storeState: {
...storeOptions.initialState,
currentRun: {
...storeOptions.initialState.currentRun,
componentTestCount: {
success: 30,
error: 0,
},
totalTestCount: 100,
},
},
},
};
export const Running: Story = {
export const TestingWithStatuses: Story = {
args: {
state: {
...config,
...baseState,
running: true,
testProviderState: 'test-provider-state:running',
storeState: {
...storeOptions.initialState,
config: {
coverage: true,
a11y: true,
},
currentRun: {
...storeOptions.initialState.currentRun,
componentTestCount: {
success: 30,
error: 0,
},
totalTestCount: 100,
},
},
componentTestStatusValueToStoryIds: {
...meta.args.componentTestStatusValueToStoryIds,
'status-value:error': ['story-id-1', 'story-id-2'],
},
a11yStatusValueToStoryIds: {
...meta.args.a11yStatusValueToStoryIds,
'status-value:warning': ['story-id-3', 'story-id-4', 'story-id-5'],
},
},
};
export const Watching: Story = {
args: {
state: {
...config,
...baseState,
storeState: {
...storeOptions.initialState,
watching: true,
},
},
beforeEach: async () => {
mockStore.setState((s) => ({ ...s, watching: true }));
},
};
export const TogglingSettings: Story = {
export const Crashed: Story = {
args: {
state: {
...config,
...baseState,
details: {
testResults: [],
testProviderState: 'test-provider-state:crashed',
storeState: {
...storeOptions.initialState,
fatalError: {
message: 'Error message',
error: {
name: 'Error',
message: 'Error message',
stack: 'Error stack',
},
},
},
},
play: async ({ canvas, step }) => {
await step('Enable coverage', async () => {
(await canvas.findByLabelText('Coverage')).click();
await expect(mockStore.setState).toHaveBeenCalledOnce();
mockStore.setState.mockClear();
});
};
await step('Enable watch mode', async () => {
(await canvas.findByLabelText('Enable watch mode')).click();
await expect(mockStore.setState).toHaveBeenCalledOnce();
export const UnhandledErrors: Story = {
args: {
testProviderState: 'test-provider-state:succeeded',
storeState: {
...storeOptions.initialState,
currentRun: {
...storeOptions.initialState.currentRun,
unhandledErrors: [
{
name: 'Error',
message: 'Error message',
stack: 'Error stack',
VITEST_TEST_PATH: '/test/path/test-name',
VITEST_TEST_NAME: 'Test name',
},
{
name: 'Error',
message: 'Other Error message',
stack: 'Other Error stack',
VITEST_TEST_PATH: '/test/path/other-test-name',
VITEST_TEST_NAME: 'Other Test name',
},
],
},
},
},
};
await expect(await canvas.findByLabelText('Coverage (unavailable)')).not.toBeDisabled();
});
export const ComponentTestsSucceeded: Story = {
args: {
testProviderState: 'test-provider-state:succeeded',
componentTestStatusValueToStoryIds: {
...meta.args.componentTestStatusValueToStoryIds,
'status-value:success': ['story-id-1'],
},
},
};
export const ComponentTestsFailed: Story = {
args: {
testProviderState: 'test-provider-state:succeeded',
componentTestStatusValueToStoryIds: {
...meta.args.componentTestStatusValueToStoryIds,
'status-value:error': ['story-id-1'],
},
},
};
export const CoverageEnabled: Story = {
args: Default.args,
beforeEach: async () => {
mockStore.setState({
...storeOptions.initialState,
config: { ...storeOptions.initialState.config, coverage: true },
});
},
play: async ({ canvas }) => {
userEvent.hover(await canvas.findByLabelText(/Coverage status:/));
args: {
storeState: {
...meta.args.storeState,
config: { ...meta.args.storeState.config, coverage: true },
},
},
};
export const CoverageCalculating: Story = {
...CoverageEnabled,
args: Running.args,
play: CoverageEnabled.play,
export const RunningWithCoverageEnabled: Story = {
args: {
...CoverageEnabled.args,
testProviderState: 'test-provider-state:running',
},
};
export const CoverageNegative: Story = {
...CoverageEnabled,
args: {
state: {
...config,
...baseState,
details: {
testResults: [],
coverageSummary: {
percentage: 20,
status: 'negative',
},
storeState: {
...CoverageEnabled.args!.storeState!,
currentRun: {
...meta.args.storeState.currentRun,
coverageSummary: { percentage: 20, status: 'negative' },
},
},
},
};
export const CoverageWarning: Story = {
...CoverageEnabled,
args: {
state: {
...config,
...baseState,
details: {
testResults: [],
coverageSummary: {
percentage: 50,
status: 'warning',
},
storeState: {
...CoverageEnabled.args!.storeState!,
currentRun: {
...meta.args.storeState.currentRun,
coverageSummary: { percentage: 50, status: 'warning' },
},
},
},
};
export const CoveragePositive: Story = {
...CoverageEnabled,
args: {
state: {
...config,
...baseState,
details: {
testResults: [],
coverageSummary: {
percentage: 80,
status: 'positive',
},
storeState: {
...CoverageEnabled.args!.storeState!,
currentRun: {
...meta.args.storeState.currentRun,
coverageSummary: { percentage: 80, status: 'positive' },
},
},
},
};
export const AccessibilityEnabled: Story = {
args: Default.args,
beforeEach: async () => {
mockStore.setState({
...storeOptions.initialState,
config: { ...storeOptions.initialState.config, a11y: true },
});
},
play: async ({ canvas }) => {
userEvent.hover(await canvas.findByLabelText(/Accessibility status:/));
args: {
storeState: {
...meta.args.storeState,
config: { ...meta.args.storeState.config, a11y: true },
},
},
};
export const AccessibilityViolations: Story = {
args: {
state: {
...config,
...baseState,
details: {
testResults: [
{
endTime: 0,
startTime: 0,
status: 'passed',
message: 'All tests passed',
results: [
{
storyId: 'story-id',
status: 'passed',
duration: 100,
testRunId: 'test-run-id',
reports: [{ type: 'a11y', status: 'warning', result: { violations: [] } }],
},
],
},
],
},
...AccessibilityEnabled.args,
testProviderState: 'test-provider-state:succeeded',
a11yStatusValueToStoryIds: {
...meta.args.a11yStatusValueToStoryIds,
'status-value:warning': ['story-id-1', 'story-id-2', 'story-id-3'],
},
},
beforeEach: async () => {
mockStore.setState({
...storeOptions.initialState,
config: { ...storeOptions.initialState.config, a11y: true },
});
},
play: async ({ canvas }) => {
userEvent.hover(await canvas.findByLabelText(/Accessibility status:/));
};
export const AccessibilityViolationsWithErrors: Story = {
args: {
...AccessibilityEnabled.args,
testProviderState: 'test-provider-state:succeeded',
a11yStatusValueToStoryIds: {
...meta.args.a11yStatusValueToStoryIds,
'status-value:warning': ['story-id-1', 'story-id-2', 'story-id-5'],
'status-value:error': ['story-id-3', 'story-id-4'],
},
},
};
export const SettingsUpdated: Story = {
args: {
...meta.args,
isSettingsUpdated: true,
},
};
export const InSidebarContextMenu: Story = {
args: {
...meta.args,
testProviderState: 'test-provider-state:succeeded',
entry: {
id: 'story-id-1',
type: 'story',
name: 'Example Story',
tags: [],
title: 'Example Story',
importPath: './path/to/story',
prepared: true,
parent: 'parent-id',
depth: 1,
},
},
};

View File

@ -1,4 +1,4 @@
import React, { type ComponentProps, type FC, useMemo } from 'react';
import React, { type ComponentProps, type FC } from 'react';
import {
IconButton,
@ -7,21 +7,18 @@ import {
TooltipNote,
WithTooltip,
} from 'storybook/internal/components';
import { type TestProviderConfig, type TestProviderState } from 'storybook/internal/core-events';
import type { API_HashEntry, TestProviderState } from 'storybook/internal/types';
import { EyeIcon, InfoIcon, PlayHollowIcon, StopAltIcon } from '@storybook/icons';
import { store } from '#manager-store';
import { addons, experimental_useUniversalStore } from 'storybook/manager-api';
import { addons } from 'storybook/manager-api';
import type { API } from 'storybook/manager-api';
import { styled } from 'storybook/theming';
import {
ADDON_ID as A11Y_ADDON_ID,
PANEL_ID as A11y_ADDON_PANEL_ID,
} from '../../../a11y/src/constants';
import { type Details, PANEL_ID } from '../constants';
import { type TestStatus } from '../node/reporter';
import { A11Y_ADDON_ID, A11Y_PANEL_ID, PANEL_ID } from '../constants';
import type { StoreState } from '../types';
import type { StatusValueToStoryIds } from '../use-test-provider-state';
import { Description } from './Description';
import { TestStatusIcon } from './TestStatusIcon';
@ -83,165 +80,105 @@ const StopIcon = styled(StopAltIcon)({
width: 10,
});
const statusOrder: TestStatus[] = ['failed', 'warning', 'pending', 'passed', 'skipped'];
const statusMap: Record<TestStatus | 'unknown', ComponentProps<typeof TestStatusIcon>['status']> = {
failed: 'negative',
warning: 'warning',
passed: 'positive',
skipped: 'unknown',
pending: 'pending',
unknown: 'unknown',
const openPanel = ({ api, panelId, entryId }: { api: API; panelId: string; entryId?: string }) => {
const story = entryId ? api.findAllLeafStoryIds(entryId)[0] : undefined;
if (story) {
api.selectStory(story);
}
api.setSelectedPanel(panelId);
api.togglePanel(true);
};
type TestProviderRenderProps = {
api: API;
state: TestProviderConfig & TestProviderState<Details>;
entryId?: string;
testProviderState: TestProviderState;
componentTestStatusValueToStoryIds: StatusValueToStoryIds;
a11yStatusValueToStoryIds: StatusValueToStoryIds;
storeState: StoreState;
setStoreState: (typeof store)['setState'];
isSettingsUpdated: boolean;
entry?: API_HashEntry;
} & ComponentProps<typeof Container>;
export const TestProviderRender: FC<TestProviderRenderProps> = ({
state,
api,
entryId,
entry,
testProviderState,
storeState,
setStoreState,
componentTestStatusValueToStoryIds,
a11yStatusValueToStoryIds,
isSettingsUpdated,
...props
}) => {
const coverageSummary = state.details?.coverageSummary;
const { config, watching, cancelling, currentRun, fatalError } = storeState;
const finishedTestCount =
currentRun.componentTestCount.success + currentRun.componentTestCount.error;
const isA11yAddon = addons.experimental_getRegisteredAddons().includes(A11Y_ADDON_ID);
const hasA11yAddon = addons.experimental_getRegisteredAddons().includes(A11Y_ADDON_ID);
const [{ config, watching }, setStoreState] = experimental_useUniversalStore(store);
const isRunning = testProviderState === 'test-provider-state:running';
const isStarting = isRunning && finishedTestCount === 0;
const isStoryEntry = entryId?.includes('--') ?? false;
const [componentTestStatusIcon, componentTestStatusLabel]: [
ComponentProps<typeof TestStatusIcon>['status'],
string,
] = fatalError
? ['critical', 'Local tests crashed']
: componentTestStatusValueToStoryIds['status-value:error'].length > 0
? ['negative', 'Component tests failed']
: isRunning
? ['unknown', 'Testing in progress']
: componentTestStatusValueToStoryIds['status-value:success'].length > 0
? ['positive', 'Component tests passed']
: ['unknown', 'Run tests to see results'];
const a11yResults = useMemo(() => {
if (!isA11yAddon) {
return [];
}
return state.details?.testResults?.flatMap((result) =>
result.results
.filter(Boolean)
.filter((r) => !entryId || r.storyId === entryId || r.storyId?.startsWith(`${entryId}-`))
.map((r) => r.reports.find((report) => report.type === 'a11y'))
);
}, [isA11yAddon, state.details?.testResults, entryId]);
const a11yStatus = useMemo<'positive' | 'warning' | 'negative' | 'pending' | 'unknown'>(() => {
if (!isA11yAddon || config.a11y === false) {
return 'unknown';
}
if (state.running) {
return 'pending';
}
const definedA11yResults = a11yResults?.filter(Boolean) ?? [];
if (!definedA11yResults || definedA11yResults.length === 0) {
return 'unknown';
}
const failed = definedA11yResults.some((result) => result?.status === 'failed');
const warning = definedA11yResults.some((result) => result?.status === 'warning');
if (failed) {
return 'negative';
} else if (warning) {
return 'warning';
}
return 'positive';
}, [state.running, isA11yAddon, config.a11y, a11yResults]);
const a11yNotPassedAmount = config?.a11y
? a11yResults?.filter((result) => result?.status === 'failed' || result?.status === 'warning')
.length
: undefined;
const a11ySkippedAmount =
state.running || !config?.a11y ? null : a11yResults?.filter((result) => !result).length;
const a11ySkippedSuffix = a11ySkippedAmount
? a11ySkippedAmount === 1 && isStoryEntry
? ' (skipped)'
: ` (${a11ySkippedAmount} skipped)`
: '';
const storyId = isStoryEntry ? entryId : undefined;
const results = (state.details?.testResults || [])
.flatMap((test) => {
if (!entryId) {
return test.results;
}
return test.results.filter((result) =>
storyId ? result.storyId === storyId : result.storyId?.startsWith(`${entryId}-`)
);
})
.sort((a, b) => statusOrder.indexOf(a.status) - statusOrder.indexOf(b.status));
const componentTestsNotPassedAmount = results?.filter(
(result) => result.status === 'failed'
).length;
const status = results[0]?.status ?? (state.running ? 'pending' : 'unknown');
const openPanel = (panelId: string, targetStoryId?: string) => {
if (targetStoryId) {
api.selectStory(targetStoryId);
}
api.setSelectedPanel(panelId);
api.togglePanel(true);
};
const openTestsPanel = () => {
const currentStoryId = api.getCurrentStoryData().id;
const currentStoryNotPassed = results.some(
(r) => r.storyId === currentStoryId && ['failed', 'warning'].includes(r.status)
);
if (currentStoryNotPassed) {
openPanel(PANEL_ID);
} else {
const firstNotPassed = results.find((r) => ['failed', 'warning'].includes(r.status));
openPanel(PANEL_ID, firstNotPassed?.storyId);
}
};
const openA11yPanel = () => {
const currentStoryId = api.getCurrentStoryData().id;
const currentStoryNotPassed = results.some(
(r) =>
r.storyId === currentStoryId &&
r.reports.some((rep) => rep.type === 'a11y' && ['failed', 'warning'].includes(rep.status))
);
if (currentStoryNotPassed) {
openPanel(A11y_ADDON_PANEL_ID);
} else {
const firstNotPassed = results.find((r) =>
r.reports.some((rep) => rep.type === 'a11y' && ['failed', 'warning'].includes(rep.status))
);
openPanel(A11y_ADDON_PANEL_ID, firstNotPassed?.storyId);
}
};
const [a11yStatusIcon, a11yStatusLabel]: [
ComponentProps<typeof TestStatusIcon>['status'],
string,
] = fatalError
? ['critical', 'Local tests crashed']
: a11yStatusValueToStoryIds['status-value:error'].length > 0
? ['negative', 'Accessibility tests failed']
: a11yStatusValueToStoryIds['status-value:warning'].length > 0
? ['warning', 'Accessibility tests failed']
: isRunning
? ['unknown', 'Testing in progress']
: a11yStatusValueToStoryIds['status-value:success'].length > 0
? ['positive', 'Accessibility tests passed']
: ['unknown', 'Run tests to see accessibility results'];
return (
<Container {...props}>
<Heading>
<Info>
<Title id="testing-module-title" crashed={state.crashed}>
{state.crashed ? 'Local tests failed' : 'Run local tests'}
<Title
id="testing-module-title"
crashed={
testProviderState === 'test-provider-state:crashed' ||
fatalError !== undefined ||
currentRun.unhandledErrors.length > 0
}
>
{currentRun.unhandledErrors.length === 1
? 'Local tests completed with an error'
: currentRun.unhandledErrors.length > 1
? 'Local tests completed with errors'
: fatalError
? 'Local tests didnt complete'
: 'Run local tests'}
</Title>
<Description
id="testing-module-description"
state={state}
entryId={entryId}
results={results}
watching={watching}
storeState={storeState}
testProviderState={testProviderState}
entryId={entry?.id}
isSettingsUpdated={isSettingsUpdated}
/>
</Info>
<Actions>
{!entryId && (
{!entry && (
<WithTooltip
hasChrome={false}
trigger="hover"
@ -252,54 +189,69 @@ export const TestProviderRender: FC<TestProviderRenderProps> = ({
size="medium"
active={watching}
onClick={() =>
setStoreState((s) => ({
...s,
watching: !watching,
}))
store.send({
type: 'TOGGLE_WATCHING',
payload: {
to: !watching,
},
})
}
disabled={state.running}
disabled={isRunning}
>
<EyeIcon />
</IconButton>
</WithTooltip>
)}
{state.runnable && (
<>
{state.running && state.cancellable ? (
<WithTooltip
hasChrome={false}
trigger="hover"
tooltip={<TooltipNote note="Stop test run" />}
{isRunning ? (
<WithTooltip
hasChrome={false}
trigger="hover"
tooltip={<TooltipNote note={cancelling ? 'Stopping...' : 'Stop test run'} />}
>
<IconButton
aria-label={cancelling ? 'Stopping...' : 'Stop test run'}
padding="none"
size="medium"
onClick={() =>
store.send({
type: 'CANCEL_RUN',
})
}
disabled={cancelling || isStarting}
>
<Progress
percentage={
finishedTestCount && storeState.currentRun.totalTestCount
? (finishedTestCount / storeState.currentRun.totalTestCount) * 100
: undefined
}
>
<IconButton
aria-label="Stop test run"
padding="none"
size="medium"
onClick={() => api.cancelTestProvider(state.id)}
disabled={state.cancelling}
>
<Progress percentage={state.progress?.percentageCompleted}>
<StopIcon />
</Progress>
</IconButton>
</WithTooltip>
) : (
<WithTooltip
hasChrome={false}
trigger="hover"
tooltip={<TooltipNote note="Start test run" />}
>
<IconButton
aria-label="Start test run"
size="medium"
onClick={() => api.runTestProvider(state.id, { entryId })}
disabled={state.running}
>
<PlayHollowIcon />
</IconButton>
</WithTooltip>
)}
</>
<StopIcon />
</Progress>
</IconButton>
</WithTooltip>
) : (
<WithTooltip
hasChrome={false}
trigger="hover"
tooltip={<TooltipNote note="Start test run" />}
>
<IconButton
aria-label="Start test run"
size="medium"
onClick={() =>
store.send({
type: 'TRIGGER_RUN',
payload: {
storyIds: entry ? api.findAllLeafStoryIds(entry.id) : undefined,
triggeredBy: entry ? entry.type : 'global',
},
})
}
>
<PlayHollowIcon />
</IconButton>
</WithTooltip>
)}
</Actions>
</Heading>
@ -309,140 +261,131 @@ export const TestProviderRender: FC<TestProviderRenderProps> = ({
<ListItem
as="label"
title="Component tests"
icon={entryId ? null : <Checkbox type="checkbox" checked disabled />}
icon={entry ? null : <Checkbox type="checkbox" checked disabled />}
/>
<WithTooltip
hasChrome={false}
trigger="hover"
tooltip={
<TooltipNote note={status === 'failed' ? 'View error' : 'View test details'} />
}
tooltip={<TooltipNote note={componentTestStatusLabel} />}
>
<IconButton size="medium" disabled={!results?.length} onClick={openTestsPanel}>
{state.crashed ? (
<TestStatusIcon status="critical" aria-label="Test status: crashed" />
) : (
<TestStatusIcon status={statusMap[status]} aria-label={`Test status: ${status}`} />
)}
{componentTestsNotPassedAmount || null}
<IconButton
size="medium"
disabled={
componentTestStatusValueToStoryIds['status-value:error'].length === 0 &&
componentTestStatusValueToStoryIds['status-value:warning'].length === 0 &&
componentTestStatusValueToStoryIds['status-value:success'].length === 0
}
onClick={() => {
openPanel({
api,
panelId: PANEL_ID,
entryId:
componentTestStatusValueToStoryIds['status-value:error'][0] ??
componentTestStatusValueToStoryIds['status-value:warning'][0] ??
componentTestStatusValueToStoryIds['status-value:success'][0] ??
entry?.id,
});
}}
>
<TestStatusIcon
status={componentTestStatusIcon}
aria-label={componentTestStatusLabel}
isRunning={isRunning}
/>
{componentTestStatusValueToStoryIds['status-value:error'].length +
componentTestStatusValueToStoryIds['status-value:warning'].length || null}
</IconButton>
</WithTooltip>
</Row>
{!entryId && (
<>
{coverageSummary ? (
<Row>
<ListItem
as="label"
title="Coverage"
icon={
entryId ? null : (
<Checkbox
type="checkbox"
checked={config.coverage}
onChange={() =>
setStoreState((s) => ({
...s,
config: { ...s.config, coverage: !config.coverage },
}))
}
/>
)
}
/>
<WithTooltip
hasChrome={false}
trigger="hover"
tooltip={<TooltipNote note="View report" />}
>
<IconButton asChild size="medium">
<a
href="/coverage/index.html"
target="_blank"
aria-label="Open coverage report"
>
<TestStatusIcon
percentage={coverageSummary.percentage}
status={coverageSummary.status}
aria-label={`Coverage status: ${coverageSummary.status}`}
/>
{coverageSummary.percentage ? (
<span aria-label={`${coverageSummary.percentage} percent coverage`}>
{coverageSummary.percentage}%
</span>
) : null}
</a>
</IconButton>
</WithTooltip>
</Row>
) : (
<Row>
<ListItem
as="label"
title={watching ? <Muted>Coverage (unavailable)</Muted> : <>Coverage</>}
icon={
entryId ? null : (
<Checkbox
type="checkbox"
checked={config.coverage}
onChange={() =>
setStoreState((s) => ({
...s,
config: { ...s.config, coverage: !config.coverage },
}))
}
/>
)
}
/>
{watching ? (
<WithTooltip
hasChrome={false}
trigger="hover"
tooltip={<TooltipNote note="Coverage is unavailable in watch mode" />}
>
<IconButton size="medium" disabled>
<InfoIcon aria-label="Coverage is unavailable in watch mode" />
</IconButton>
</WithTooltip>
) : (
<WithTooltip
hasChrome={false}
trigger="hover"
tooltip={
<TooltipNote
note={
state.running && config.coverage
? 'Calculating...'
: 'Run tests to calculate coverage'
}
/>
}
>
<IconButton size="medium" disabled>
<TestStatusIcon
status={state.running && config.coverage ? 'pending' : 'unknown'}
aria-label={`Coverage status: unknown`}
/>
</IconButton>
</WithTooltip>
)}
</Row>
)}
</>
)}
{isA11yAddon && (
{!entry && (
<Row>
<ListItem
as="label"
title={`Accessibility${a11ySkippedSuffix}`}
title={watching ? <Muted>Coverage (unavailable)</Muted> : 'Coverage'}
icon={
entryId ? null : (
<Checkbox
type="checkbox"
checked={config.coverage}
disabled={isRunning}
onChange={() =>
setStoreState((s) => ({
...s,
config: { ...s.config, coverage: !config.coverage },
}))
}
/>
}
/>
<WithTooltip
hasChrome={false}
trigger="hover"
tooltip={
<TooltipNote
note={
watching
? 'Unavailable in watch mode'
: currentRun.triggeredBy && currentRun.triggeredBy !== 'global'
? 'Unavailable when running focused tests'
: isRunning
? 'Testing in progress'
: currentRun.coverageSummary
? 'View coverage report'
: fatalError
? 'Local tests crashed'
: 'Run tests to calculate coverage'
}
/>
}
>
{watching || (currentRun.triggeredBy && currentRun.triggeredBy !== 'global') ? (
<IconButton size="medium" disabled>
<InfoIcon
aria-label={
watching
? `Coverage is unavailable in watch mode`
: `Coverage is unavailable when running focused tests`
}
/>
</IconButton>
) : currentRun.coverageSummary ? (
<IconButton asChild size="medium">
<a href="/coverage/index.html" target="_blank" aria-label="Open coverage report">
<TestStatusIcon
isRunning={isRunning}
percentage={currentRun.coverageSummary.percentage}
status={currentRun.coverageSummary.status}
aria-label={`Coverage status: ${currentRun.coverageSummary.status}`}
/>
<span aria-label={`${currentRun.coverageSummary.percentage} percent coverage`}>
{currentRun.coverageSummary.percentage}%
</span>
</a>
</IconButton>
) : (
<IconButton size="medium" disabled>
<TestStatusIcon
isRunning={isRunning}
status={fatalError ? 'critical' : 'unknown'}
aria-label="Coverage status: unknown"
/>
</IconButton>
)}
</WithTooltip>
</Row>
)}
{hasA11yAddon && (
<Row>
<ListItem
as="label"
title="Accessibility"
icon={
entry ? null : (
<Checkbox
type="checkbox"
checked={config.a11y}
disabled={isRunning}
onChange={() =>
setStoreState((s) => ({
...s,
@ -456,22 +399,34 @@ export const TestProviderRender: FC<TestProviderRenderProps> = ({
<WithTooltip
hasChrome={false}
trigger="hover"
tooltip={
<TooltipNote
note={
state.running && config.a11y
? 'Testing in progress'
: 'View accessibility results'
}
/>
}
tooltip={<TooltipNote note={a11yStatusLabel} />}
>
<IconButton size="medium" disabled={!a11yResults?.length} onClick={openA11yPanel}>
<IconButton
size="medium"
disabled={
a11yStatusValueToStoryIds['status-value:error'].length === 0 &&
a11yStatusValueToStoryIds['status-value:warning'].length === 0 &&
a11yStatusValueToStoryIds['status-value:success'].length === 0
}
onClick={() => {
openPanel({
api,
entryId:
a11yStatusValueToStoryIds['status-value:error'][0] ??
a11yStatusValueToStoryIds['status-value:warning'][0] ??
a11yStatusValueToStoryIds['status-value:success'][0] ??
entry?.id,
panelId: A11Y_PANEL_ID,
});
}}
>
<TestStatusIcon
status={a11yStatus}
aria-label={`Accessibility status: ${a11yStatus}`}
status={a11yStatusIcon}
aria-label={a11yStatusLabel}
isRunning={isRunning}
/>
{isStoryEntry ? null : a11yNotPassedAmount || null}
{a11yStatusValueToStoryIds['status-value:error'].length +
a11yStatusValueToStoryIds['status-value:warning'].length || null}
</IconButton>
</WithTooltip>
</Row>

View File

@ -1,9 +1,20 @@
import React from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { TestStatusIcon } from './TestStatusIcon';
const meta = {
component: TestStatusIcon,
args: {
isRunning: false,
},
render: (args) => (
<div style={{ display: 'flex', gap: 12 }}>
<TestStatusIcon {...args} />
<TestStatusIcon {...args} isRunning />
</div>
),
} satisfies Meta<typeof TestStatusIcon>;
export default meta;
@ -16,12 +27,6 @@ export const Unknown: Story = {
},
};
export const Pending: Story = {
args: {
status: 'pending',
},
};
export const Positive: Story = {
args: {
status: 'positive',

View File

@ -1,7 +1,8 @@
import { styled } from 'storybook/theming';
export const TestStatusIcon = styled.div<{
status: 'pending' | 'positive' | 'warning' | 'negative' | 'critical' | 'unknown';
isRunning: boolean;
status: 'positive' | 'warning' | 'negative' | 'critical' | 'unknown';
percentage?: number;
}>(
({ percentage }) => ({
@ -13,11 +14,9 @@ export const TestStatusIcon = styled.div<{
: 'var(--status-color)',
borderRadius: '50%',
}),
({ status, theme }) =>
status === 'pending' && {
({ isRunning, theme }) =>
isRunning && {
animation: `${theme.animation.glow} 1.5s ease-in-out infinite`,
'--status-color': theme.color.mediumdark,
'--status-background': `${theme.color.mediumdark}66`,
},
({ status, theme }) =>
status === 'positive' && {

View File

@ -1,15 +1,19 @@
import type { TestResult } from './node/reporter';
import type { StoreOptions } from 'storybook/internal/types';
import type { StoreState } from './types';
export const ADDON_ID = 'storybook/test';
export const TEST_PROVIDER_ID = `${ADDON_ID}/test-provider`;
export const PANEL_ID = `${ADDON_ID}/panel`;
export const STORYBOOK_ADDON_TEST_CHANNEL = 'STORYBOOK_ADDON_TEST_CHANNEL';
export const A11Y_ADDON_ID = 'storybook/a11y';
export const A11Y_PANEL_ID = `${A11Y_ADDON_ID}/panel`;
export const TUTORIAL_VIDEO_LINK = 'https://youtu.be/Waht9qq7AoA';
export const DOCUMENTATION_LINK = 'writing-tests/test-addon';
export const DOCUMENTATION_FATAL_ERROR_LINK = `${DOCUMENTATION_LINK}#what-happens-if-vitest-itself-has-an-error`;
export const A11Y_PANEL_ID = 'storybook/a11y/panel';
export const COVERAGE_DIRECTORY = 'coverage';
export const SUPPORTED_FRAMEWORKS = [
@ -20,22 +24,6 @@ export const SUPPORTED_FRAMEWORKS = [
export const SUPPORTED_RENDERERS = ['@storybook/react', '@storybook/svelte', '@storybook/vue3'];
export type Details = {
testResults: TestResult[];
coverageSummary?: {
status: 'positive' | 'warning' | 'negative' | 'unknown';
percentage: number;
};
};
export type StoreState = {
config: {
coverage: boolean;
a11y: boolean;
};
watching: boolean;
};
export const storeOptions = {
id: ADDON_ID,
initialState: {
@ -44,10 +32,37 @@ export const storeOptions = {
a11y: false,
},
watching: false,
cancelling: false,
fatalError: undefined,
indexUrl: undefined,
currentRun: {
triggeredBy: undefined,
config: {
coverage: false,
a11y: false,
},
componentTestCount: {
success: 0,
error: 0,
},
a11yCount: {
success: 0,
warning: 0,
error: 0,
},
storyIds: undefined,
totalTestCount: undefined,
startedAt: undefined,
finishedAt: undefined,
unhandledErrors: [],
coverageSummary: undefined,
},
},
};
} satisfies StoreOptions<StoreState>;
export const STORE_CHANNEL_EVENT_NAME = `UNIVERSAL_STORE:${storeOptions.id}`;
export const STATUS_STORE_CHANNEL_EVENT_NAME = 'UNIVERSAL_STORE:storybook/status';
export const TEST_PROVIDER_STORE_CHANNEL_EVENT_NAME = 'UNIVERSAL_STORE:storybook/test-provider';
export const STATUS_TYPE_ID_COMPONENT_TEST = 'storybook/component-test';
export const STATUS_TYPE_ID_A11Y = 'storybook/a11y';

View File

@ -1,21 +1,45 @@
import type {
StatusStoreByTypeId,
TestProviderState,
TestProviderStoreById,
} from 'storybook/internal/types';
import { experimental_MockUniversalStore } from 'storybook/manager-api';
import * as testUtils from 'storybook/test';
import { storeOptions } from './constants';
import {
ADDON_ID,
STATUS_TYPE_ID_A11Y,
STATUS_TYPE_ID_COMPONENT_TEST,
storeOptions,
} from './constants';
export const store = testUtils.mocked(new experimental_MockUniversalStore(storeOptions, testUtils));
export const componentTestStatusStore = {
get: testUtils.fn(() => ({})),
export const componentTestStatusStore: StatusStoreByTypeId = {
typeId: STATUS_TYPE_ID_COMPONENT_TEST,
getAll: testUtils.fn(() => ({})),
set: testUtils.fn(),
onStatusChange: testUtils.fn(() => () => {}),
onAllStatusChange: testUtils.fn(() => () => {}),
onSelect: testUtils.fn(() => () => {}),
unset: testUtils.fn(),
};
export const a11yStatusStore = {
get: testUtils.fn(() => ({})),
export const a11yStatusStore: StatusStoreByTypeId = {
typeId: STATUS_TYPE_ID_A11Y,
getAll: testUtils.fn(() => ({})),
set: testUtils.fn(),
onStatusChange: testUtils.fn(() => () => {}),
onAllStatusChange: testUtils.fn(() => () => {}),
onSelect: testUtils.fn(() => () => {}),
unset: testUtils.fn(),
};
export const testProviderStore: TestProviderStoreById = {
testProviderId: ADDON_ID,
getState: testUtils.fn(() => 'test-provider-state:pending' as TestProviderState),
setState: testUtils.fn(),
runWithState: testUtils.fn(),
settingsChanged: testUtils.fn(),
onRunAll: testUtils.fn(() => () => {}),
onClearAll: testUtils.fn(() => () => {}),
};

View File

@ -1,16 +1,22 @@
import { experimental_UniversalStore, experimental_getStatusStore } from 'storybook/manager-api';
import {
experimental_UniversalStore,
experimental_getStatusStore,
experimental_getTestProviderStore,
} from 'storybook/manager-api';
import {
ADDON_ID,
STATUS_TYPE_ID_A11Y,
STATUS_TYPE_ID_COMPONENT_TEST,
type StoreState,
storeOptions,
} from './constants';
import type { StoreEvent, StoreState } from './types';
export const store = experimental_UniversalStore.create<StoreState>({
export const store = experimental_UniversalStore.create<StoreState, StoreEvent>({
...storeOptions,
leader: (globalThis as any).CONFIG_TYPE === 'PRODUCTION',
});
export const componentTestStatusStore = experimental_getStatusStore(STATUS_TYPE_ID_COMPONENT_TEST);
export const a11yStatusStore = experimental_getStatusStore(STATUS_TYPE_ID_A11Y);
export const testProviderStore = experimental_getTestProviderStore(ADDON_ID);

View File

@ -1,31 +1,20 @@
import React, { useState } from 'react';
import type { StatusValue } from 'storybook/internal/types';
import { type Addon_TestProviderType, Addon_TypesEnum } from 'storybook/internal/types';
import { a11yStatusStore, componentTestStatusStore, store } from '#manager-store';
import {
a11yStatusStore,
componentTestStatusStore,
store,
testProviderStore,
} from '#manager-store';
import { addons } from 'storybook/manager-api';
import { GlobalErrorContext, GlobalErrorModal } from './components/GlobalErrorModal';
import { SidebarContextMenu } from './components/SidebarContextMenu';
import { TestProviderRender } from './components/TestProviderRender';
import {
A11Y_PANEL_ID,
ADDON_ID,
type Details,
PANEL_ID,
STATUS_TYPE_ID_A11Y,
STATUS_TYPE_ID_COMPONENT_TEST,
TEST_PROVIDER_ID,
} from './constants';
import type { TestStatus } from './node/reporter';
const statusMap: Record<TestStatus, StatusValue> = {
pending: 'status-value:pending',
passed: 'status-value:success',
warning: 'status-value:warning',
failed: 'status-value:error',
skipped: 'status-value:unknown',
};
import { A11Y_PANEL_ID, ADDON_ID, PANEL_ID, TEST_PROVIDER_ID } from './constants';
import { useTestProvider } from './use-test-provider-state';
addons.register(ADDON_ID, (api) => {
const storybookBuilder = (globalThis as any).STORYBOOK_BUILDER || '';
@ -40,106 +29,71 @@ addons.register(ADDON_ID, (api) => {
a11yStatusStore.onSelect(() => {
openPanel(A11Y_PANEL_ID);
});
testProviderStore.onRunAll(() => {
store.send({
type: 'TRIGGER_RUN',
payload: {
triggeredBy: 'run-all',
},
});
});
store.untilReady().then(() => {
store.setState((state) => ({
...state,
indexUrl: new URL('index.json', window.location.href).toString(),
}));
});
addons.add(TEST_PROVIDER_ID, {
type: Addon_TypesEnum.experimental_TEST_PROVIDER,
runnable: true,
name: 'Component tests',
// @ts-expect-error: TODO: Fix types
render: (state) => {
render: () => {
const [isModalOpen, setModalOpen] = useState(false);
const {
storeState,
setStoreState,
testProviderState,
componentTestStatusValueToStoryIds,
a11yStatusValueToStoryIds,
isSettingsUpdated,
} = useTestProvider(api);
return (
<GlobalErrorContext.Provider
value={{ error: state.error?.message, isModalOpen, setModalOpen }}
>
<TestProviderRender api={api} state={state} />
<GlobalErrorContext.Provider value={{ isModalOpen, setModalOpen }}>
<TestProviderRender
api={api}
storeState={storeState}
setStoreState={setStoreState}
isSettingsUpdated={isSettingsUpdated}
testProviderState={testProviderState}
componentTestStatusValueToStoryIds={componentTestStatusValueToStoryIds}
a11yStatusValueToStoryIds={a11yStatusValueToStoryIds}
/>
<GlobalErrorModal
storeState={storeState}
onRerun={() => {
setModalOpen(false);
api.runTestProvider(TEST_PROVIDER_ID);
store.send({
type: 'TRIGGER_RUN',
payload: {
triggeredBy: 'global',
},
});
}}
/>
</GlobalErrorContext.Provider>
);
},
// @ts-expect-error: TODO: Fix types
sidebarContextMenu: ({ context, state }) => {
sidebarContextMenu: ({ context }) => {
if (context.type === 'docs') {
return null;
}
if (context.type === 'story' && !context.tags.includes('test')) {
return null;
}
return (
<TestProviderRender
api={api}
state={state}
entryId={context.id}
style={{ minWidth: 240 }}
/>
);
return <SidebarContextMenu context={context} api={api} />;
},
// @ts-expect-error: TODO: Fix types
stateUpdater: (state, update) => {
const updated = {
...state,
...update,
details: { ...state.details, ...update.details },
};
if ((!state.running && update.running) || store.getState().watching) {
// Clear coverage data when starting test run or enabling watch mode
delete updated.details.coverageSummary;
}
if (update.details?.testResults) {
componentTestStatusStore.set(
update.details.testResults.flatMap((testResult) =>
testResult.results
.filter(({ storyId }) => storyId)
.map(({ storyId, status, testRunId, ...rest }) => {
return {
storyId,
typeId: STATUS_TYPE_ID_COMPONENT_TEST,
value: statusMap[status],
title: 'Component tests',
description:
'failureMessages' in rest && rest.failureMessages
? rest.failureMessages.join('\n')
: '',
data: { testRunId },
sidebarContextMenu: false,
};
})
)
);
a11yStatusStore.set(
update.details.testResults.flatMap((testResult) =>
testResult.results
.filter(({ storyId, reports }) => {
const a11yReport = reports.find((r: any) => r.type === 'a11y');
return storyId && a11yReport;
})
.map(({ storyId, testRunId, reports }) => {
const a11yReport = reports.find((r: any) => r.type === 'a11y')!;
return {
storyId,
typeId: STATUS_TYPE_ID_A11Y,
value: statusMap[a11yReport.status],
title: 'Accessibility tests',
description: '',
data: { testRunId },
sidebarContextMenu: false,
};
})
)
);
}
return updated;
},
} satisfies Omit<Addon_TestProviderType<Details>, 'id'>);
} satisfies Omit<Addon_TestProviderType, 'id'>);
}
});

View File

@ -1,16 +1,14 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { Channel, type ChannelTransport } from 'storybook/internal/channels';
import {
TESTING_MODULE_CANCEL_TEST_RUN_REQUEST,
TESTING_MODULE_PROGRESS_REPORT,
TESTING_MODULE_RUN_REQUEST,
} from 'storybook/internal/core-events';
// eslint-disable-next-line depend/ban-dependencies
import { execaNode } from 'execa';
import { storeOptions } from '../constants';
import { log } from '../logger';
import type { StoreEvent } from '../types';
import type { StoreState } from '../types';
import { killTestRunner, runTestRunner } from './boot-test-runner';
let stdout: (chunk: any) => void;
@ -43,6 +41,26 @@ vi.mock('../logger', () => ({
log: vi.fn(),
}));
let statusStoreSubscriber = vi.hoisted(() => undefined);
let testProviderStoreSubscriber = vi.hoisted(() => undefined);
vi.mock('storybook/internal/core-server', async (importOriginal) => {
const actual = await importOriginal<typeof import('storybook/internal/core-server')>();
return {
...actual,
internal_universalStatusStore: {
subscribe: (listener: any) => {
statusStoreSubscriber = listener;
},
},
internal_universalTestProviderStore: {
subscribe: (listener: any) => {
testProviderStoreSubscriber = listener;
},
},
};
});
beforeEach(() => {
vi.useFakeTimers();
killTestRunner();
@ -56,8 +74,17 @@ const transport = { setHandler: vi.fn(), send: vi.fn() } satisfies ChannelTransp
const mockChannel = new Channel({ transport });
describe('bootTestRunner', () => {
let mockStore: any;
beforeEach(async () => {
const { experimental_MockUniversalStore: MockUniversalStore } = await import(
'storybook/internal/core-server'
);
mockStore = new MockUniversalStore<StoreState, StoreEvent>(storeOptions);
});
it('should execute vitest.js', async () => {
runTestRunner(mockChannel);
runTestRunner(mockChannel, mockStore);
expect(execaNode).toHaveBeenCalledWith(expect.stringMatching(/vitest\.mjs$/), {
env: {
NODE_ENV: 'test',
@ -69,7 +96,7 @@ describe('bootTestRunner', () => {
});
it('should log stdout and stderr', async () => {
runTestRunner(mockChannel);
runTestRunner(mockChannel, mockStore);
stdout('foo');
stderr('bar');
expect(log).toHaveBeenCalledWith('foo');
@ -78,7 +105,7 @@ describe('bootTestRunner', () => {
it('should wait for vitest to be ready', async () => {
let ready;
const promise = runTestRunner(mockChannel).then(() => {
const promise = runTestRunner(mockChannel, mockStore).then(() => {
ready = true;
});
expect(ready).toBeUndefined();
@ -88,35 +115,42 @@ describe('bootTestRunner', () => {
});
it('should abort if vitest doesnt become ready in time', async () => {
const promise = runTestRunner(mockChannel);
const promise = runTestRunner(mockChannel, mockStore);
vi.advanceTimersByTime(30001);
await expect(promise).rejects.toThrow();
});
it('should forward channel events', async () => {
runTestRunner(mockChannel);
it('should forward universal store events', async () => {
runTestRunner(mockChannel, mockStore);
message({ type: 'ready' });
message({ type: TESTING_MODULE_PROGRESS_REPORT, args: ['foo'] });
expect(mockChannel.last(TESTING_MODULE_PROGRESS_REPORT)).toEqual(['foo']);
mockChannel.emit(TESTING_MODULE_RUN_REQUEST, 'foo');
mockStore.send({ type: 'TRIGGER_RUN', payload: { triggeredBy: 'global', storyIds: ['foo'] } });
expect(child.send).toHaveBeenCalledWith({
args: ['foo'],
args: [
{
event: {
payload: { storyIds: ['foo'], triggeredBy: 'global' },
type: 'TRIGGER_RUN',
},
eventInfo: {
actor: {
environment: 'MOCK',
id: expect.any(String),
type: 'LEADER',
},
},
},
],
from: 'server',
type: TESTING_MODULE_RUN_REQUEST,
type: 'UNIVERSAL_STORE:storybook/test',
});
mockChannel.emit(TESTING_MODULE_CANCEL_TEST_RUN_REQUEST, 'qux');
expect(child.send).toHaveBeenCalledWith({
args: ['qux'],
from: 'server',
type: TESTING_MODULE_CANCEL_TEST_RUN_REQUEST,
});
message({ type: 'some-event', args: ['foo'] });
expect(mockChannel.last('some-event')).toEqual(['foo']);
});
it('should resend init event', async () => {
runTestRunner(mockChannel, 'init', ['foo']);
runTestRunner(mockChannel, mockStore, 'init', ['foo']);
message({ type: 'ready' });
expect(child.send).toHaveBeenCalledWith({
args: ['foo'],

View File

@ -2,18 +2,22 @@ import { type ChildProcess } from 'node:child_process';
import type { Channel } from 'storybook/internal/channels';
import {
TESTING_MODULE_CANCEL_TEST_RUN_REQUEST,
TESTING_MODULE_CRASH_REPORT,
TESTING_MODULE_RUN_REQUEST,
type TestingModuleCrashReportPayload,
} from 'storybook/internal/core-events';
internal_universalStatusStore,
internal_universalTestProviderStore,
} from 'storybook/internal/core-server';
import type { EventInfo } from 'storybook/internal/types';
// eslint-disable-next-line depend/ban-dependencies
import { execaNode } from 'execa';
import { join } from 'pathe';
import { STORE_CHANNEL_EVENT_NAME, TEST_PROVIDER_ID } from '../constants';
import {
STATUS_STORE_CHANNEL_EVENT_NAME,
STORE_CHANNEL_EVENT_NAME,
TEST_PROVIDER_STORE_CHANNEL_EVENT_NAME,
} from '../constants';
import { log } from '../logger';
import type { Store } from '../types';
const MAX_START_TIME = 30000;
@ -26,35 +30,32 @@ const eventQueue: { type: string; args?: any[] }[] = [];
let child: null | ChildProcess;
let ready = false;
let unsubscribeStore: () => void;
let unsubscribeStatusStore: () => void;
let unsubscribeTestProviderStore: () => void;
const bootTestRunner = async (channel: Channel) => {
let stderr: string[] = [];
function reportFatalError(e: any) {
channel.emit(TESTING_MODULE_CRASH_REPORT, {
providerId: TEST_PROVIDER_ID,
error: {
message: String(e),
},
} as TestingModuleCrashReportPayload);
}
const forwardRun = (...args: any[]) =>
child?.send({ args, from: 'server', type: TESTING_MODULE_RUN_REQUEST });
const forwardCancel = (...args: any[]) =>
child?.send({ args, from: 'server', type: TESTING_MODULE_CANCEL_TEST_RUN_REQUEST });
const forwardStore = (...args: any) => {
child?.send({ args, from: 'server', type: STORE_CHANNEL_EVENT_NAME });
const forwardUniversalStoreEvent =
(storeEventName: string) => (event: any, eventInfo: EventInfo) => {
child?.send({
type: storeEventName,
args: [{ event, eventInfo }],
from: 'server',
});
};
const bootTestRunner = async (channel: Channel, store: Store) => {
let stderr: string[] = [];
const killChild = () => {
channel.off(TESTING_MODULE_RUN_REQUEST, forwardRun);
channel.off(TESTING_MODULE_CANCEL_TEST_RUN_REQUEST, forwardCancel);
channel.off(STORE_CHANNEL_EVENT_NAME, forwardStore);
unsubscribeStore?.();
unsubscribeStatusStore?.();
unsubscribeTestProviderStore?.();
child?.kill();
child = null;
};
store.subscribe('FATAL_ERROR', killChild);
const exit = (code = 0) => {
killChild();
eventQueue.length = 0;
@ -82,35 +83,30 @@ const bootTestRunner = async (channel: Channel) => {
}
});
channel.on(STORE_CHANNEL_EVENT_NAME, forwardStore);
unsubscribeStore = store.subscribe(forwardUniversalStoreEvent(STORE_CHANNEL_EVENT_NAME));
unsubscribeStatusStore = internal_universalStatusStore.subscribe(
forwardUniversalStoreEvent(STATUS_STORE_CHANNEL_EVENT_NAME)
);
unsubscribeTestProviderStore = internal_universalTestProviderStore.subscribe(
forwardUniversalStoreEvent(TEST_PROVIDER_STORE_CHANNEL_EVENT_NAME)
);
child.on('message', (result: any) => {
if (result.type === 'ready') {
child.on('message', (event: any) => {
if (event.type === 'ready') {
// Resend events that triggered (during) the boot sequence, now that Vitest is ready
while (eventQueue.length) {
const { type, args } = eventQueue.shift()!;
child?.send({ type, args, from: 'server' });
}
// Forward all events from the channel to the child process
channel.on(TESTING_MODULE_RUN_REQUEST, forwardRun);
channel.on(TESTING_MODULE_CANCEL_TEST_RUN_REQUEST, forwardCancel);
resolve();
} else if (result.type === 'error') {
killChild();
log(result.message);
log(result.error);
// eslint-disable-next-line local-rules/no-uncategorized-errors
const error = new Error(`${result.message}\n${result.error}`);
// Reject if the child process reports an error before it's ready
if (!ready) {
reject(error);
} else {
reportFatalError(error);
}
} else if (event.type === 'uncaught-error') {
store.send({
type: 'FATAL_ERROR',
payload: event.payload,
});
reject();
} else {
channel.emit(result.type, ...result.args);
channel.emit(event.type, ...event.args);
}
});
});
@ -126,20 +122,36 @@ const bootTestRunner = async (channel: Channel) => {
)
);
await Promise.race([startChildProcess(), timeout]).catch((e) => {
reportFatalError(e);
await Promise.race([startChildProcess(), timeout]).catch((error) => {
store.send({
type: 'FATAL_ERROR',
payload: {
message: 'Failed to start test runner process',
error: {
message: error.message,
name: error.name,
stack: error.stack,
cause: error.cause,
},
},
});
eventQueue.length = 0;
throw e;
throw error;
});
};
export const runTestRunner = async (channel: Channel, initEvent?: string, initArgs?: any[]) => {
export const runTestRunner = async (
channel: Channel,
store: Store,
initEvent?: string,
initArgs?: any[]
) => {
if (!ready && initEvent) {
eventQueue.push({ type: initEvent, args: initArgs });
}
if (!child) {
ready = false;
await bootTestRunner(channel);
await bootTestRunner(channel, store);
ready = true;
}
};

View File

@ -3,7 +3,7 @@ import type { ResolvedCoverageOptions } from 'vitest/node';
import type { ReportNode, Visitor } from 'istanbul-lib-report';
import { ReportBase } from 'istanbul-lib-report';
import { type Details, TEST_PROVIDER_ID } from '../constants';
import type { StoreState } from '../types';
import type { TestManager } from './test-manager';
export type StorybookCoverageReporterOptions = {
@ -26,15 +26,15 @@ export default class StorybookCoverageReporter extends ReportBase implements Par
if (!node.isRoot()) {
return;
}
const coverageSummary = node.getCoverageSummary(false);
const rawCoverageSummary = node.getCoverageSummary(false);
const percentage = Math.round(coverageSummary.data.statements.pct);
const percentage = Math.round(rawCoverageSummary.data.statements.pct);
// Fallback to Vitest's default watermarks https://vitest.dev/config/#coverage-watermarks
const [lowWatermark = 50, highWatermark = 80] =
this.#coverageOptions?.watermarks?.statements ?? [];
const coverageDetails: Details['coverageSummary'] = {
const coverageSummary: StoreState['currentRun']['coverageSummary'] = {
percentage,
status:
percentage < lowWatermark
@ -43,11 +43,6 @@ export default class StorybookCoverageReporter extends ReportBase implements Par
? 'warning'
: 'positive',
};
this.#testManager.sendProgressReport({
providerId: TEST_PROVIDER_ID,
details: {
coverageSummary: coverageDetails,
},
});
this.#testManager.onCoverageCollected(coverageSummary);
}
}

View File

@ -1,242 +1,60 @@
import type { TaskState } from 'vitest';
import type { Vitest } from 'vitest/node';
import * as vitestNode from 'vitest/node';
import type { SerializedError } from 'vitest';
import type { TestCase, TestModule, Vitest } from 'vitest/node';
import { type Reporter } from 'vitest/reporters';
import type {
TestingModuleProgressReportPayload,
TestingModuleProgressReportProgress,
} from 'storybook/internal/core-events';
import type { Suite } from '@vitest/runner';
import { throttle } from 'es-toolkit';
import { satisfies } from 'semver';
import type { TaskMeta } from '@vitest/runner';
import type { Report } from 'storybook/preview-api';
import { dedent } from 'ts-dedent';
import { TEST_PROVIDER_ID } from '../constants';
import type { VitestError } from '../types';
import type { TestManager } from './test-manager';
export type TestStatus = 'passed' | 'failed' | 'warning' | 'pending' | 'skipped';
export type TestResultResult =
| {
status: Extract<TestStatus, 'passed' | 'pending'>;
storyId: string;
testRunId: string;
duration: number;
reports: Report[];
}
| {
status: Extract<TestStatus, 'failed' | 'warning'>;
storyId: string;
duration: number;
testRunId: string;
failureMessages: string[];
reports: Report[];
};
export type TestResult = {
results: TestResultResult[];
startTime: number;
endTime: number;
status: Extract<TestStatus, 'passed' | 'failed' | 'warning'>;
message?: string;
};
const statusMap: Record<TaskState, TestStatus> = {
fail: 'failed',
only: 'pending',
pass: 'passed',
run: 'pending',
skip: 'skipped',
todo: 'skipped',
queued: 'pending',
};
const vitestVersion = vitestNode.version;
const isVitest3OrLater = vitestVersion
? satisfies(vitestVersion, '>=3.0.0-beta.3', { includePrerelease: true })
: false;
interface VitestError extends Error {
VITEST_TEST_PATH?: string;
VITEST_TEST_NAME?: string;
}
const getErrorOrigin = (error: VitestError): string => {
const parts: string[] = [];
if (error.VITEST_TEST_PATH) {
parts.push(
dedent`
\nThis error originated in "${error.VITEST_TEST_PATH}". It doesn't mean the error was thrown inside the file itself, but while it was running.
`
);
}
if (error.VITEST_TEST_NAME) {
parts.push(
dedent`
The latest test that might've caused the error is "${error.VITEST_TEST_NAME}".
It might mean one of the following:
- The error was thrown, while Vitest was running this test.
- If the error occurred after the test had been completed, this was the last documented test before it was thrown.
`
);
}
return parts.join('\n');
};
export class StorybookReporter implements Reporter {
start = 0;
ctx!: Vitest;
sendReport: (payload: TestingModuleProgressReportPayload) => void;
constructor(public testManager: TestManager) {
this.sendReport = throttle((payload) => this.testManager.sendProgressReport(payload), 1000);
}
constructor(public testManager: TestManager) {}
onInit(ctx: Vitest) {
this.ctx = ctx;
this.start = Date.now();
}
async getProgressReport(finishedAt?: number) {
// TODO
// We can theoretically avoid the `@vitest/runner` dependency by copying over the necessary
// functions from the `@vitest/runner` package. It is not complex and does not have
// any significant dependencies.
const { getTests } = await import('@vitest/runner/utils');
onTestCaseResult(testCase: TestCase) {
const { storyId, reports } = testCase.meta() as TaskMeta &
Partial<{ storyId: string; reports: Report[] }>;
const files = this.ctx.state.getFiles();
const fileTests = getTests(files).filter((t) => t.mode === 'run' || t.mode === 'only');
const testResult = testCase.result();
this.testManager.onTestCaseResult({
storyId,
testResult,
reports,
});
}
// The total number of tests reported by Vitest is dynamic and can change during the run, so we
// use `storyCountForCurrentRun` instead, based on the list of stories provided in the run request.
const numTotalTests = finishedAt
? fileTests.length
: Math.max(fileTests.length, this.testManager.vitestManager.storyCountForCurrentRun);
const numFailedTests = fileTests.filter((t) => t.result?.state === 'fail').length;
const numPassedTests = fileTests.filter((t) => t.result?.state === 'pass').length;
const numPendingTests = fileTests.filter((t) => t.result?.state === 'run').length;
const testResults: TestResult[] = files.map((file) => {
const tests = getTests([file]);
let startTime = tests.reduce(
(prev, next) => Math.min(prev, next.result?.startTime ?? Number.POSITIVE_INFINITY),
Number.POSITIVE_INFINITY
);
if (startTime === Number.POSITIVE_INFINITY) {
startTime = this.start;
}
const endTime = tests.reduce(
(prev, next) =>
Math.max(prev, (next.result?.startTime ?? 0) + (next.result?.duration ?? 0)),
startTime
);
const results = tests.flatMap<TestResultResult>((t) => {
const ancestorTitles: string[] = [];
let iter: Suite | undefined = t.suite;
while (iter) {
ancestorTitles.push(iter.name);
iter = iter.suite;
}
ancestorTitles.reverse();
const status = statusMap[t.result?.state || t.mode] || 'skipped';
const storyId = (t.meta as any).storyId as string;
const reports =
((t.meta as any).reports as Report[])?.map((report) => ({
status: report.status,
type: report.type,
})) ?? [];
const duration = t.result?.duration || 0;
const testRunId = this.start.toString();
switch (status) {
case 'passed':
case 'pending':
return [{ status, storyId, duration, testRunId, reports } as TestResultResult];
case 'failed':
const failureMessages = t.result?.errors?.map((e) => e.stack || e.message) || [];
return [
{
status,
storyId,
duration,
failureMessages,
testRunId,
reports,
} as TestResultResult,
];
default:
return [];
}
});
const hasFailedTests = tests.some((t) => t.result?.state === 'fail');
async onTestRunEnd(
testModules: readonly TestModule[],
unhandledErrors: readonly SerializedError[]
) {
const totalTestCount = testModules.flatMap((t) =>
Array.from(t.children.allTests('passed')).concat(Array.from(t.children.allTests('failed')))
).length;
const testModulesErrors = testModules.flatMap((t) => t.errors());
const serializedErrors = unhandledErrors.concat(testModulesErrors).map((e) => {
return {
results,
startTime,
endTime,
status: file.result?.state === 'fail' || hasFailedTests ? 'failed' : 'passed',
message: file.result?.errors?.[0]?.stack || file.result?.errors?.[0]?.message,
...e,
name: e.name,
message: e.message,
stack: e.stack?.replace(e.message, ''),
cause: e.cause,
};
});
this.testManager.onTestRunEnd({
totalTestCount,
unhandledErrors: serializedErrors as unknown as VitestError[],
});
return {
cancellable: !finishedAt,
progress: {
numFailedTests,
numPassedTests,
numPendingTests,
numTotalTests,
startedAt: this.start,
finishedAt,
percentageCompleted: finishedAt
? 100
: numTotalTests
? ((numPassedTests + numFailedTests) / numTotalTests) * 100
: 0,
} as TestingModuleProgressReportProgress,
details: {
testResults,
},
};
this.clearVitestState();
}
async onTaskUpdate() {
try {
this.sendReport({
providerId: TEST_PROVIDER_ID,
status: 'pending',
...(await this.getProgressReport()),
});
} catch (e) {
this.sendReport({
providerId: TEST_PROVIDER_ID,
status: 'failed',
error:
e instanceof Error
? { name: 'Failed to gather test results', message: e.message, stack: e.stack }
: { name: 'Failed to gather test results', message: String(e) },
});
}
}
// TODO
// Clearing the whole internal state of Vitest might be too aggressive
// Essentially, we want to reset the calculated total number of tests and the
// test results when a new test run starts, so that the getProgressReport
// method can calculate the correct values
// TODO: Clearing the whole internal state of Vitest might be too aggressive
async clearVitestState() {
this.ctx.state.filesMap.clear();
this.ctx.state.pathsSet.clear();
@ -244,72 +62,4 @@ export class StorybookReporter implements Reporter {
this.ctx.state.errorsSet.clear();
this.ctx.state.processTimeoutCauses.clear();
}
async onFinished() {
const unhandledErrors = this.ctx.state.getUnhandledErrors();
unhandledErrors.forEach((e: unknown) => {
const error = e as VitestError;
const origin = getErrorOrigin(error);
if (origin) {
error.message = `${error.message}\n${origin}`;
error.stack = `${error.stack}\n${origin}`;
}
});
const isCancelled = isVitest3OrLater
? this.testManager.vitestManager.isCancelling
: // @ts-expect-error isCancelling is private in Vitest 3.
this.ctx.isCancelling;
const report = await this.getProgressReport(Date.now());
const testSuiteFailures = report.details.testResults.filter(
(t) => t.status === 'failed' && t.results.length === 0
);
const reducedTestSuiteFailures = new Set<string | undefined>();
testSuiteFailures.forEach((t) => {
reducedTestSuiteFailures.add(t.message);
});
if (isCancelled) {
this.sendReport({
providerId: TEST_PROVIDER_ID,
status: 'cancelled',
...report,
});
} else if (reducedTestSuiteFailures.size > 0 || unhandledErrors.length > 0) {
const error =
reducedTestSuiteFailures.size > 0
? {
name: `${reducedTestSuiteFailures.size} component ${reducedTestSuiteFailures.size === 1 ? 'test' : 'tests'} failed`,
message: Array.from(reducedTestSuiteFailures).reduce(
(acc, curr) => `${acc}\n${curr}`,
''
)!,
}
: {
name: `${unhandledErrors.length} unhandled error${unhandledErrors?.length > 1 ? 's' : ''}`,
message: unhandledErrors
.map((e, index) => `[${index}]: ${(e as any).stack || (e as any).message}`)
.join('\n\n----------\n\n'),
};
this.sendReport({
providerId: TEST_PROVIDER_ID,
status: 'failed',
details: report.details,
progress: report.progress,
error,
});
} else {
this.sendReport({
providerId: TEST_PROVIDER_ID,
status: 'success',
...report,
});
}
this.clearVitestState();
}
}

View File

@ -3,12 +3,17 @@ import { createVitest as actualCreateVitest } from 'vitest/node';
import { Channel, type ChannelTransport } from 'storybook/internal/channels';
import { experimental_MockUniversalStore } from 'storybook/internal/core-server';
import type { StoryIndex } from 'storybook/internal/types';
import type {
StatusStoreByTypeId,
StoryIndex,
TestProviderStoreById,
} from 'storybook/internal/types';
import path from 'pathe';
import { TEST_PROVIDER_ID, storeOptions } from '../constants';
import { TestManager } from './test-manager';
import { STATUS_TYPE_ID_A11Y, STATUS_TYPE_ID_COMPONENT_TEST, storeOptions } from '../constants';
import type { StoreEvent, StoreState } from '../types';
import { TestManager, type TestManagerOptions } from './test-manager';
const setTestNamePattern = vi.hoisted(() => vi.fn());
const vitest = vi.hoisted(() => ({
@ -16,9 +21,9 @@ const vitest = vi.hoisted(() => ({
init: vi.fn(),
close: vi.fn(),
onCancel: vi.fn(),
runFiles: vi.fn(),
runTestSpecifications: vi.fn(),
cancelCurrentRun: vi.fn(),
globTestSpecs: vi.fn(),
globTestSpecifications: vi.fn(),
getModuleProjects: vi.fn(() => []),
setGlobalTestNamePattern: setTestNamePattern,
vite: {
@ -31,6 +36,9 @@ const vitest = vi.hoisted(() => ({
invalidateModule: vi.fn(),
},
},
config: {
coverage: { enabled: false },
},
}));
vi.mock('vitest/node', async (importOriginal) => ({
@ -41,6 +49,38 @@ const createVitest = vi.mocked(actualCreateVitest);
const transport = { setHandler: vi.fn(), send: vi.fn() } satisfies ChannelTransport;
const mockChannel = new Channel({ transport });
const mockStore = new experimental_MockUniversalStore<StoreState, StoreEvent>(
{
...storeOptions,
initialState: { ...storeOptions.initialState, indexUrl: 'http://localhost:6006/index.json' },
},
vi
);
const mockComponentTestStatusStore: StatusStoreByTypeId = {
set: vi.fn(),
getAll: vi.fn(),
onAllStatusChange: vi.fn(),
onSelect: vi.fn(),
unset: vi.fn(),
typeId: STATUS_TYPE_ID_COMPONENT_TEST,
};
const mockA11yStatusStore: StatusStoreByTypeId = {
set: vi.fn(),
getAll: vi.fn(),
onAllStatusChange: vi.fn(),
onSelect: vi.fn(),
unset: vi.fn(),
typeId: STATUS_TYPE_ID_A11Y,
};
const mockTestProviderStore: TestProviderStoreById = {
getState: vi.fn(),
setState: vi.fn(),
settingsChanged: vi.fn(),
onRunAll: vi.fn(),
onClearAll: vi.fn(),
runWithState: vi.fn((callback) => callback()),
testProviderId: 'test-provider-id',
};
const tests = [
{
@ -80,7 +120,11 @@ global.fetch = vi.fn().mockResolvedValue({
),
});
const options: ConstructorParameters<typeof TestManager>[2] = {
const options: TestManagerOptions = {
store: mockStore,
componentTestStatusStore: mockComponentTestStatusStore,
a11yStatusStore: mockA11yStatusStore,
testProviderStore: mockTestProviderStore,
onError: (message, error) => {
throw error;
},
@ -89,150 +133,94 @@ const options: ConstructorParameters<typeof TestManager>[2] = {
describe('TestManager', () => {
it('should create a vitest instance', async () => {
new TestManager(mockChannel, new experimental_MockUniversalStore(storeOptions, vi), options);
new TestManager(options);
await vi.waitFor(() => {
expect(createVitest).toHaveBeenCalled();
});
});
it('should call onReady callback', async () => {
new TestManager(mockChannel, new experimental_MockUniversalStore(storeOptions, vi), options);
new TestManager(options);
await vi.waitFor(() => {
expect(options.onReady).toHaveBeenCalled();
});
});
it('TestManager.start should start vitest and resolve when ready', async () => {
const testManager = await TestManager.start(
mockChannel,
new experimental_MockUniversalStore(storeOptions, vi),
options
);
const testManager = await TestManager.start(options);
expect(testManager).toBeInstanceOf(TestManager);
expect(createVitest).toHaveBeenCalled();
});
it('should handle watch mode request', async () => {
const testManager = await TestManager.start(
mockChannel,
new experimental_MockUniversalStore(storeOptions, vi),
options
);
expect(createVitest).toHaveBeenCalledTimes(1);
await testManager.handleWatchModeRequest(true);
expect(createVitest).toHaveBeenCalledTimes(1); // shouldn't restart vitest
});
it('should handle run request', async () => {
vitest.globTestSpecs.mockImplementation(() => tests);
const testManager = await TestManager.start(
mockChannel,
new experimental_MockUniversalStore(storeOptions, vi),
options
);
vitest.globTestSpecifications.mockImplementation(() => tests);
const testManager = await TestManager.start(options);
expect(createVitest).toHaveBeenCalledTimes(1);
await testManager.handleRunRequest({
providerId: TEST_PROVIDER_ID,
indexUrl: 'http://localhost:6006/index.json',
await testManager.handleTriggerRunEvent({
type: 'TRIGGER_RUN',
payload: {
triggeredBy: 'global',
},
});
expect(createVitest).toHaveBeenCalledTimes(1);
expect(vitest.runFiles).toHaveBeenCalledWith(tests, true);
expect(vitest.runTestSpecifications).toHaveBeenCalledWith(tests, true);
});
it('should filter tests', async () => {
vitest.globTestSpecs.mockImplementation(() => tests);
const testManager = await TestManager.start(
mockChannel,
new experimental_MockUniversalStore(storeOptions, vi),
options
);
vitest.globTestSpecifications.mockImplementation(() => tests);
const testManager = await TestManager.start(options);
await testManager.handleRunRequest({
providerId: TEST_PROVIDER_ID,
indexUrl: 'http://localhost:6006/index.json',
storyIds: [],
});
expect(vitest.runFiles).toHaveBeenCalledWith([], true);
await testManager.handleRunRequest({
providerId: TEST_PROVIDER_ID,
indexUrl: 'http://localhost:6006/index.json',
storyIds: ['story--one'],
await testManager.handleTriggerRunEvent({
type: 'TRIGGER_RUN',
payload: {
storyIds: ['story--one'],
triggeredBy: 'global',
},
});
expect(setTestNamePattern).toHaveBeenCalledWith(/^One$/);
expect(vitest.runFiles).toHaveBeenCalledWith(tests.slice(0, 1), true);
expect(vitest.runTestSpecifications).toHaveBeenCalledWith(tests.slice(0, 1), true);
});
it('should handle coverage toggling', async () => {
const testManager = await TestManager.start(
mockChannel,
new experimental_MockUniversalStore(storeOptions, vi),
options
);
expect(createVitest).toHaveBeenCalledTimes(1);
createVitest.mockClear();
await testManager.handleConfigChange(
{
coverage: true,
a11y: false,
},
{
coverage: false,
a11y: false,
}
);
expect(createVitest).toHaveBeenCalledTimes(1);
createVitest.mockClear();
await testManager.handleConfigChange(
{
coverage: false,
a11y: false,
},
{
coverage: true,
a11y: false,
}
);
expect(createVitest).toHaveBeenCalledTimes(1);
});
it('should temporarily disable coverage on focused tests', async () => {
vitest.globTestSpecs.mockImplementation(() => tests);
const mockStore = new experimental_MockUniversalStore(storeOptions, vi);
const testManager = await TestManager.start(mockChannel, mockStore, options);
it('should restart Vitest before a test run if coverage is enabled', async () => {
const testManager = await TestManager.start(options);
expect(createVitest).toHaveBeenCalledTimes(1);
createVitest.mockClear();
mockStore.setState((s) => ({ ...s, config: { coverage: true, a11y: false } }));
await vi.waitFor(() => {
expect(createVitest).toHaveBeenCalledTimes(1);
await testManager.handleTriggerRunEvent({
type: 'TRIGGER_RUN',
payload: {
triggeredBy: 'global',
},
});
expect(createVitest).toHaveBeenCalledTimes(1);
expect(createVitest).toHaveBeenCalledWith(
'test',
expect.objectContaining({
coverage: expect.objectContaining({ enabled: true }),
})
);
});
it('should not restart with coverage enabled Vitest before a focused test run', async () => {
const testManager = await TestManager.start(options);
expect(createVitest).toHaveBeenCalledTimes(1);
createVitest.mockClear();
await testManager.handleRunRequest({
providerId: TEST_PROVIDER_ID,
indexUrl: 'http://localhost:6006/index.json',
storyIds: ['button--primary', 'button--secondary'],
mockStore.setState((s) => ({ ...s, config: { coverage: true, a11y: false } }));
await testManager.handleTriggerRunEvent({
type: 'TRIGGER_RUN',
payload: {
storyIds: ['story--one'],
triggeredBy: 'global',
},
});
// expect vitest to be restarted twice, without and with coverage
expect(createVitest).toHaveBeenCalledTimes(2);
expect(vitest.runFiles).toHaveBeenCalledWith([], true);
createVitest.mockClear();
await testManager.handleRunRequest({
providerId: TEST_PROVIDER_ID,
indexUrl: 'http://localhost:6006/index.json',
});
// don't expect vitest to be restarted, as we're running all tests
expect(createVitest).not.toHaveBeenCalled();
expect(vitest.runFiles).toHaveBeenCalledWith(tests, true);
});
});

View File

@ -1,156 +1,295 @@
import type { Channel } from 'storybook/internal/channels';
import {
TESTING_MODULE_CANCEL_TEST_RUN_REQUEST,
TESTING_MODULE_PROGRESS_REPORT,
TESTING_MODULE_RUN_REQUEST,
type TestingModuleCancelTestRunRequestPayload,
type TestingModuleProgressReportPayload,
type TestingModuleRunRequestPayload,
} from 'storybook/internal/core-events';
import type { TestResult, TestState } from 'vitest/dist/node.js';
import type { experimental_UniversalStore } from 'storybook/internal/core-server';
import type {
StatusStoreByTypeId,
StatusValue,
TestProviderStoreById,
} from 'storybook/internal/types';
import { isEqual } from 'es-toolkit';
import { throttle } from 'es-toolkit';
import type { Report } from 'storybook/preview-api';
import { type StoreState, TEST_PROVIDER_ID } from '../constants';
import { STATUS_TYPE_ID_A11Y, STATUS_TYPE_ID_COMPONENT_TEST, storeOptions } from '../constants';
import type { RunTrigger, StoreEvent, StoreState, TriggerRunEvent, VitestError } from '../types';
import { errorToErrorLike } from '../utils';
import { VitestManager } from './vitest-manager';
export type TestManagerOptions = {
store: experimental_UniversalStore<StoreState, StoreEvent>;
componentTestStatusStore: StatusStoreByTypeId;
a11yStatusStore: StatusStoreByTypeId;
testProviderStore: TestProviderStoreById;
onError?: (message: string, error: Error) => void;
onReady?: () => void;
};
const testStateToStatusValueMap: Record<TestState | 'warning', StatusValue> = {
pending: 'status-value:pending',
passed: 'status-value:success',
warning: 'status-value:warning',
failed: 'status-value:error',
skipped: 'status-value:unknown',
};
export class TestManager {
vitestManager: VitestManager;
public store: TestManagerOptions['store'];
selectedStoryCountForLastRun = 0;
public vitestManager: VitestManager;
private componentTestStatusStore: TestManagerOptions['componentTestStatusStore'];
private a11yStatusStore: TestManagerOptions['a11yStatusStore'];
private testProviderStore: TestManagerOptions['testProviderStore'];
private onReady?: TestManagerOptions['onReady'];
private batchedTestCaseResults: {
storyId: string;
testResult: TestResult;
reports?: Report[];
}[] = [];
constructor(options: TestManagerOptions) {
this.store = options.store;
this.componentTestStatusStore = options.componentTestStatusStore;
this.a11yStatusStore = options.a11yStatusStore;
this.testProviderStore = options.testProviderStore;
this.onReady = options.onReady;
constructor(
private channel: Channel,
public store: experimental_UniversalStore<StoreState>,
private options: {
onError?: (message: string, error: Error) => void;
onReady?: () => void;
} = {}
) {
this.vitestManager = new VitestManager(this);
this.channel.on(TESTING_MODULE_RUN_REQUEST, this.handleRunRequest.bind(this));
this.channel.on(TESTING_MODULE_CANCEL_TEST_RUN_REQUEST, this.handleCancelRequest.bind(this));
this.store.subscribe('TRIGGER_RUN', this.handleTriggerRunEvent.bind(this));
this.store.subscribe('CANCEL_RUN', this.handleCancelEvent.bind(this));
this.store.onStateChange((state, previousState) => {
if (!isEqual(state.config, previousState.config)) {
this.handleConfigChange(state.config, previousState.config);
}
if (state.watching !== previousState.watching) {
this.handleWatchModeRequest(state.watching);
}
});
this.vitestManager.startVitest().then(() => options.onReady?.());
this.store
.untilReady()
.then(() =>
this.vitestManager.startVitest({ coverage: this.store.getState().config.coverage })
)
.then(() => this.onReady?.())
.catch((e) => {
this.reportFatalError('Failed to start Vitest', e);
});
}
async handleConfigChange(config: StoreState['config'], previousConfig: StoreState['config']) {
process.env.VITEST_STORYBOOK_CONFIG = JSON.stringify(config);
if (config.coverage !== previousConfig.coverage) {
try {
await this.vitestManager.restartVitest({
coverage: config.coverage,
});
} catch (e) {
this.reportFatalError('Failed to change coverage configuration', e);
}
}
}
async handleWatchModeRequest(watching: boolean) {
const coverage = this.store.getState().config.coverage ?? false;
if (coverage) {
try {
if (watching) {
// if watch mode is toggled on and coverage is already enabled, restart vitest without coverage to automatically disable it
await this.vitestManager.restartVitest({ coverage: false });
} else {
// if watch mode is toggled off and coverage is already enabled, restart vitest with coverage to automatically re-enable it
await this.vitestManager.restartVitest({ coverage });
async handleTriggerRunEvent(event: TriggerRunEvent) {
await this.runTestsWithState({
storyIds: event.payload.storyIds,
triggeredBy: event.payload.triggeredBy,
callback: async () => {
try {
await this.vitestManager.vitestRestartPromise;
await this.vitestManager.runTests(event.payload);
} catch (err) {
this.reportFatalError('Failed to run tests', err);
throw err;
}
} catch (e) {
this.reportFatalError('Failed to change watch mode while coverage was enabled', e);
}
}
},
});
}
async handleRunRequest(payload: TestingModuleRunRequestPayload) {
async handleCancelEvent() {
try {
if (payload.providerId !== TEST_PROVIDER_ID) {
return;
}
const state = this.store.getState();
/*
If we're only running a subset of stories, we have to temporarily disable coverage,
as a coverage report for a subset of stories is not useful.
*/
const temporarilyDisableCoverage =
state.config.coverage && !state.watching && (payload.storyIds ?? []).length > 0;
if (temporarilyDisableCoverage) {
await this.vitestManager.restartVitest({
coverage: false,
});
} else {
await this.vitestManager.vitestRestartPromise;
}
this.selectedStoryCountForLastRun = payload.storyIds?.length ?? 0;
await this.vitestManager.runTests(payload);
if (temporarilyDisableCoverage) {
// Re-enable coverage if it was temporarily disabled because of a subset of stories was run
await this.vitestManager.restartVitest({ coverage: state?.config.coverage });
}
} catch (e) {
this.reportFatalError('Failed to run tests', e);
}
}
async handleCancelRequest(payload: TestingModuleCancelTestRunRequestPayload) {
try {
if (payload.providerId !== TEST_PROVIDER_ID) {
return;
}
this.store.setState((s) => ({
...s,
cancelling: true,
}));
await this.vitestManager.cancelCurrentRun();
} catch (e) {
this.reportFatalError('Failed to cancel tests', e);
} catch (err) {
this.reportFatalError('Failed to cancel tests', err);
} finally {
this.store.setState((s) => ({
...s,
cancelling: false,
}));
}
}
async sendProgressReport(payload: TestingModuleProgressReportPayload) {
this.channel.emit(TESTING_MODULE_PROGRESS_REPORT, {
...payload,
details: { ...payload.details, selectedStoryCount: this.selectedStoryCountForLastRun },
async runTestsWithState({
storyIds,
triggeredBy,
callback,
}: {
storyIds?: string[];
triggeredBy: RunTrigger;
callback: () => Promise<void>;
}) {
this.componentTestStatusStore.unset(storyIds);
this.a11yStatusStore.unset(storyIds);
this.store.setState((s) => ({
...s,
currentRun: {
...storeOptions.initialState.currentRun,
triggeredBy,
startedAt: Date.now(),
storyIds: storyIds,
config: s.config,
},
}));
// set the config at the start of a test run,
// so that changing the config during the test run does not affect the currently running test run
process.env.VITEST_STORYBOOK_CONFIG = JSON.stringify(this.store.getState().config);
await this.testProviderStore.runWithState(async () => {
await callback();
this.store.send({
type: 'TEST_RUN_COMPLETED',
payload: this.store.getState().currentRun,
});
if (this.store.getState().currentRun.unhandledErrors.length > 0) {
throw new Error('Tests completed but there are unhandled errors');
}
});
}
onTestModuleCollected(collectedTestCount: number) {
this.store.setState((s) => ({
...s,
currentRun: {
...s.currentRun,
totalTestCount: (s.currentRun.totalTestCount ?? 0) + collectedTestCount,
},
}));
}
onTestCaseResult(result: { storyId?: string; testResult: TestResult; reports?: Report[] }) {
const { storyId, testResult, reports } = result;
if (!storyId) {
return;
}
this.batchedTestCaseResults.push({ storyId, testResult, reports });
this.throttledFlushTestCaseResults();
}
/**
* Throttled function to process batched test case results.
*
* This function:
*
* 1. Takes all batched test case results and clears the batch
* 2. Updates the store state with new test counts (component tests and a11y tests)
* 3. Adjusts the totalTestCount if more tests were run than initially anticipated
* 4. Creates status objects for component tests and updates the component test status store
* 5. Creates status objects for a11y tests (if any) and updates the a11y status store
*
* The throttling (500ms) is necessary as the channel would otherwise get overwhelmed with events,
* eventually causing the manager and dev server to lose connection.
*/
throttledFlushTestCaseResults = throttle(() => {
const testCaseResultsToFlush = this.batchedTestCaseResults;
this.batchedTestCaseResults = [];
this.store.setState((s) => {
let { success: ctSuccess, error: ctError } = s.currentRun.componentTestCount;
let { success: a11ySuccess, warning: a11yWarning, error: a11yError } = s.currentRun.a11yCount;
testCaseResultsToFlush.forEach(({ testResult, reports }) => {
if (testResult.state === 'passed') {
ctSuccess++;
} else if (testResult.state === 'failed') {
ctError++;
}
reports
?.filter((r) => r.type === 'a11y')
.forEach((report) => {
if (report.status === 'passed') {
a11ySuccess++;
} else if (report.status === 'warning') {
a11yWarning++;
} else if (report.status === 'failed') {
a11yError++;
}
});
});
const finishedTestCount = ctSuccess + ctError;
return {
...s,
currentRun: {
...s.currentRun,
componentTestCount: { success: ctSuccess, error: ctError },
a11yCount: { success: a11ySuccess, warning: a11yWarning, error: a11yError },
// in some cases successes and errors can exceed the anticipated totalTestCount
// e.g. when testing more tests than the stories we know about upfront
// in those cases, we set the totalTestCount to the sum of successes and errors
totalTestCount:
finishedTestCount > (s.currentRun.totalTestCount ?? 0)
? finishedTestCount
: s.currentRun.totalTestCount,
},
};
});
const status = 'status' in payload ? payload.status : undefined;
const progress = 'progress' in payload ? payload.progress : undefined;
if (
((status === 'success' || status === 'cancelled') && progress?.finishedAt) ||
status === 'failed'
) {
// reset the count when a test run is fully finished
this.selectedStoryCountForLastRun = 0;
const componentTestStatuses = testCaseResultsToFlush.map(({ storyId, testResult }) => ({
storyId,
typeId: STATUS_TYPE_ID_COMPONENT_TEST,
value: testStateToStatusValueMap[testResult.state],
title: 'Component tests',
description: testResult.errors?.map((error) => error.stack || error.message).join('\n') ?? '',
sidebarContextMenu: false,
}));
this.componentTestStatusStore.set(componentTestStatuses);
const a11yStatuses = testCaseResultsToFlush
.flatMap(({ storyId, reports }) =>
reports
?.filter((r) => r.type === 'a11y')
.map((a11yReport) => ({
storyId,
typeId: STATUS_TYPE_ID_A11Y,
value: testStateToStatusValueMap[a11yReport.status],
title: 'Accessibility tests',
description: '',
sidebarContextMenu: false,
}))
)
.filter((a11yStatus) => a11yStatus !== undefined);
if (a11yStatuses.length > 0) {
this.a11yStatusStore.set(a11yStatuses);
}
}, 500);
onTestRunEnd(endResult: { totalTestCount: number; unhandledErrors: VitestError[] }) {
this.store.setState((s) => ({
...s,
currentRun: {
...s.currentRun,
// when the test run is finished, we can set the totalTestCount to the actual number of tests run
// this number can be lower than the total number of tests we anticipated upfront
// e.g. when some tests where skipped without us knowing about it upfront
totalTestCount: endResult.totalTestCount,
unhandledErrors: endResult.unhandledErrors,
finishedAt: Date.now(),
},
}));
}
onCoverageCollected(coverageSummary: StoreState['currentRun']['coverageSummary']) {
this.store.setState((s) => ({
...s,
currentRun: { ...s.currentRun, coverageSummary },
}));
}
async reportFatalError(message: string, error: Error | any) {
this.options.onError?.(message, error);
await this.store.untilReady();
this.store.send({
type: 'FATAL_ERROR',
payload: {
message,
error: errorToErrorLike(error),
},
});
}
static async start(
channel: Channel,
store: experimental_UniversalStore<StoreState>,
options: typeof TestManager.prototype.options = {}
) {
static async start(options: TestManagerOptions) {
return new Promise<TestManager>((resolve) => {
const testManager = new TestManager(channel, store, {
const testManager = new TestManager({
...options,
onReady: () => {
resolve(testManager);

View File

@ -4,24 +4,26 @@ import type {
CoverageOptions,
ResolvedCoverageOptions,
TestProject,
TestRunResult,
TestSpecification,
Vitest,
WorkspaceProject,
} from 'vitest/node';
import * as vitestNode from 'vitest/node';
import { resolvePathInStorybookCache } from 'storybook/internal/common';
import type { TestingModuleRunRequestPayload } from 'storybook/internal/core-events';
import type { DocsIndexEntry, StoryIndex, StoryIndexEntry } from 'storybook/internal/types';
import type {
DocsIndexEntry,
StoryId,
StoryIndex,
StoryIndexEntry,
} from 'storybook/internal/types';
import { findUp } from 'find-up';
import path, { dirname, join, normalize } from 'pathe';
import { satisfies } from 'semver';
import slash from 'slash';
import { COVERAGE_DIRECTORY } from '../constants';
import { log } from '../logger';
import type { TriggerRunEvent } from '../types';
import type { StorybookCoverageReporterOptions } from './coverage-reporter';
import { StorybookReporter } from './reporter';
import type { TestManager } from './test-manager';
@ -37,11 +39,6 @@ type TagsFilter = {
const packageDir = dirname(require.resolve('@storybook/addon-test/package.json'));
const vitestVersion = vitestNode.version;
const isVitest3OrLater = vitestVersion
? satisfies(vitestVersion, '>=3.0.0-beta.3', { includePrerelease: true })
: false;
// We have to tell Vitest that it runs as part of Storybook
process.env.VITEST_STORYBOOK = 'true';
@ -52,12 +49,8 @@ export class VitestManager {
vitestRestartPromise: Promise<void> | null = null;
storyCountForCurrentRun: number = 0;
runningPromise: Promise<any> | null = null;
isCancelling = false;
constructor(private testManager: TestManager) {}
async startVitest({ coverage = false } = {}) {
@ -147,19 +140,8 @@ export class VitestManager {
return this.vitestRestartPromise;
}
private setGlobalTestNamePattern(pattern: string | RegExp) {
if (isVitest3OrLater) {
this.vitest!.setGlobalTestNamePattern(pattern);
} else {
// @ts-expect-error vitest.configOverride is a Vitest < 3 API.
this.vitest!.configOverride.testNamePattern = pattern;
}
}
private resetGlobalTestNamePattern() {
if (this.vitest) {
this.setGlobalTestNamePattern('');
}
this.vitest?.setGlobalTestNamePattern('');
}
private updateLastChanged(filepath: string) {
@ -176,7 +158,13 @@ export class VitestManager {
});
}
private async fetchStories(indexUrl: string, requestStoryIds?: string[]) {
private async fetchStories(requestStoryIds?: string[]) {
const indexUrl = this.testManager.store.getState().indexUrl;
if (!indexUrl) {
throw new Error(
'Tried to fetch stories to test, but the index URL was not set in the store yet.'
);
}
try {
const index = (await Promise.race([
fetch(indexUrl).then((res) => res.json()),
@ -209,35 +197,26 @@ export class VitestManager {
return true;
}
private get vite() {
// TODO: vitest.server is a Vitest < 3.0.0 API. Remove as soon as we don't support < 3.0.0 anymore.
return isVitest3OrLater ? this.vitest?.vite : this.vitest?.server;
}
async runTests(runPayload: TriggerRunEvent['payload']) {
const { watching, config } = this.testManager.store.getState();
const coverageShouldBeEnabled =
config.coverage && !watching && (runPayload?.storyIds?.length ?? 0) === 0;
const currentCoverage = this.vitest?.config.coverage?.enabled;
async runFiles(specifications: TestSpecification[], allTestsRun?: boolean) {
this.isCancelling = false;
const runTest: (
specifications: TestSpecification[],
allTestsRun?: boolean | undefined
// @ts-expect-error vitest.runFiles is a Vitest < 3.0.0 API. Remove as soon as we don't support < 3.0.0 anymore.
) => Promise<TestRunResult> = this.vitest!.runFiles ?? this.vitest!.runTestSpecifications;
this.runningPromise = runTest.call(this.vitest, specifications, allTestsRun);
await this.runningPromise;
this.runningPromise = null;
}
async runTests(requestPayload: TestingModuleRunRequestPayload) {
if (!this.vitest) {
await this.startVitest();
await this.startVitest({ coverage: coverageShouldBeEnabled });
} else if (currentCoverage !== coverageShouldBeEnabled) {
await this.vitestRestartPromise;
await this.restartVitest({ coverage: coverageShouldBeEnabled });
} else {
await this.vitestRestartPromise;
}
this.resetGlobalTestNamePattern();
const stories = await this.fetchStories(requestPayload.indexUrl, requestPayload.storyIds);
const stories = await this.fetchStories(runPayload?.storyIds);
const vitestTestSpecs = await this.getStorybookTestSpecs();
const isSingleStoryRun = requestPayload.storyIds?.length === 1;
const isSingleStoryRun = runPayload.storyIds?.length === 1;
const { filteredTestFiles, totalTestCount } = vitestTestSpecs.reduce(
(acc, spec) => {
@ -268,23 +247,27 @@ export class VitestManager {
);
await this.cancelCurrentRun();
this.storyCountForCurrentRun = totalTestCount;
this.testManager.store.setState((s) => ({
...s,
currentRun: {
...s.currentRun,
totalTestCount,
},
}));
if (isSingleStoryRun) {
const storyName = stories[0].name;
const regex = new RegExp(`^${storyName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`);
this.setGlobalTestNamePattern(regex);
this.vitest!.setGlobalTestNamePattern(regex);
}
await this.runFiles(filteredTestFiles, true);
await this.vitest!.runTestSpecifications(filteredTestFiles, true);
this.resetGlobalTestNamePattern();
}
async cancelCurrentRun() {
this.isCancelling = true;
await this.vitest?.cancelCurrentRun('keyboard-input');
await this.runningPromise;
this.isCancelling = false;
}
async closeVitest() {
@ -292,33 +275,34 @@ export class VitestManager {
}
async getStorybookTestSpecs() {
const globTestSpecs = (await this.vitest?.globTestSpecs()) ?? [];
const globTestSpecifications = (await this.vitest?.globTestSpecifications()) ?? [];
return (
globTestSpecs.filter((workspaceSpec) => this.isStorybookProject(workspaceSpec.project)) ?? []
globTestSpecifications.filter((workspaceSpec) =>
this.isStorybookProject(workspaceSpec.project)
) ?? []
);
}
private async getTestDependencies(spec: TestSpecification, deps = new Set<string>()) {
private async getTestDependencies(spec: TestSpecification) {
const deps = new Set<string>();
const addImports = async (project: TestProject, filepath: string) => {
if (deps.has(filepath)) {
return;
}
deps.add(filepath);
// TODO: Remove project.server once we don't support Vitest < 3.0.0 anymore
const server = isVitest3OrLater ? project.vite : project.server;
const mod = project.vite.moduleGraph.getModuleById(filepath);
const transformed =
mod?.ssrTransformResult || (await project.vite.transformRequest(filepath));
const mod = server.moduleGraph.getModuleById(filepath);
// @ts-expect-error project.vitenode is a Vitest < 3 API.
const viteNode = isVitest3OrLater ? project.vite : project.vitenode;
const transformed = mod?.ssrTransformResult || (await viteNode.transformRequest(filepath));
if (!transformed) {
return;
}
const dependencies = [...(transformed.deps || []), ...(transformed.dynamicDeps || [])];
await Promise.all(
dependencies.map(async (dep) => {
const idPath = await server.pluginContainer.resolveId(dep, filepath, {
const idPath = await project.vite.pluginContainer.resolveId(dep, filepath, {
ssr: true,
});
const fsPath = idPath && !idPath.external && idPath.id.split('?')[0];
@ -334,11 +318,7 @@ export class VitestManager {
);
};
await addImports(
// @ts-expect-error spec.project.workspaceProject is a Vitest < 3 API.
isVitest3OrLater ? spec.project : spec.project.workspaceProject,
spec.moduleId
);
await addImports(spec.project, spec.moduleId);
deps.delete(spec.moduleId);
return deps;
@ -350,11 +330,7 @@ export class VitestManager {
}
this.resetGlobalTestNamePattern();
const globTestSpecs: (filters?: string[] | undefined) => Promise<TestSpecification[]> =
// TODO: vitest.globTestSpecs is a Vitest < 3.0.0 API.
isVitest3OrLater ? this.vitest.globTestSpecifications : this.vitest.globTestSpecs;
const globTestFiles = await globTestSpecs.call(this.vitest);
const globTestFiles = await this.vitest.globTestSpecifications();
const testGraphs = await Promise.all(
globTestFiles
@ -372,11 +348,27 @@ export class VitestManager {
}
}
if (triggerAffectedTests.length) {
await this.vitest.cancelCurrentRun('keyboard-input');
await this.runningPromise;
await this.runFiles(triggerAffectedTests, false);
}
const stories = this.testManager.store.getState().indexUrl ? await this.fetchStories() : [];
const affectedStoryIds = triggerAffectedTests
.map((spec) =>
stories
.filter((story) => join(process.cwd(), story.importPath) === spec.moduleId)
.map((story) => story.id)
)
.flat();
await this.testManager.runTestsWithState({
storyIds: affectedStoryIds,
triggeredBy: 'watch',
callback: async () => {
if (triggerAffectedTests.length) {
await this.vitest!.cancelCurrentRun('keyboard-input');
await this.runningPromise;
await this.vitest!.runTestSpecifications(triggerAffectedTests, false);
}
},
});
}
async runAffectedTestsAfterChange(file: string) {
@ -390,14 +382,13 @@ export class VitestManager {
return;
}
this.storyCountForCurrentRun = 0;
await this.runAffectedTests(file);
}
async registerVitestConfigListener() {
this.vite?.watcher.on('change', async (file) => {
this.vitest!.vite.watcher.on('change', async (file) => {
file = normalize(file);
const isConfig = file === this.vite?.config.configFile;
const isConfig = file === this.vitest?.vite?.config.configFile;
if (isConfig) {
log('Restarting Vitest due to config change');
await this.closeVitest();
@ -408,11 +399,10 @@ export class VitestManager {
async setupWatchers() {
this.resetGlobalTestNamePattern();
const server = this.vite;
server?.watcher.removeAllListeners('change');
server?.watcher.removeAllListeners('add');
server?.watcher.on('change', this.runAffectedTestsAfterChange.bind(this));
server?.watcher.on('add', this.runAffectedTestsAfterChange.bind(this));
this.vitest!.vite.watcher.removeAllListeners('change');
this.vitest!.vite.watcher.removeAllListeners('add');
this.vitest!.vite.watcher.on('change', this.runAffectedTestsAfterChange.bind(this));
this.vitest!.vite.watcher.on('add', this.runAffectedTestsAfterChange.bind(this));
this.registerVitestConfigListener();
}

View File

@ -3,15 +3,26 @@ import process from 'node:process';
import { Channel } from 'storybook/internal/channels';
import type { StoreState } from '../constants';
import { storeOptions } from '../constants';
import {
ADDON_ID,
STATUS_TYPE_ID_A11Y,
STATUS_TYPE_ID_COMPONENT_TEST,
storeOptions,
} from '../constants';
import type { ErrorLike, FatalErrorEvent, StoreEvent, StoreState } from '../types';
import { TestManager } from './test-manager';
const require = createRequire(import.meta.url);
// we need to require core-server here, because its ESM output is not valid
// eslint-disable-next-line @typescript-eslint/naming-convention
const { experimental_UniversalStore } = require('storybook/internal/core-server') as {
const {
experimental_UniversalStore: UniversalStore,
experimental_getStatusStore: getStatusStore,
experimental_getTestProviderStore: getTestProviderStore,
} = require('storybook/internal/core-server') as {
experimental_UniversalStore: typeof import('storybook/internal/core-server').experimental_UniversalStore;
experimental_getStatusStore: typeof import('storybook/internal/core-server').experimental_getStatusStore;
experimental_getTestProviderStore: typeof import('storybook/internal/core-server').experimental_getTestProviderStore;
};
const channel: Channel = new Channel({
@ -27,15 +38,15 @@ const channel: Channel = new Channel({
});
// eslint-disable-next-line no-underscore-dangle
(experimental_UniversalStore as any).__prepare(
channel,
experimental_UniversalStore.Environment.SERVER
);
(UniversalStore as any).__prepare(channel, UniversalStore.Environment.SERVER);
new TestManager(channel, experimental_UniversalStore.create<StoreState>(storeOptions), {
onError: (message, error) => {
process.send?.({ type: 'error', message, error: error.stack ?? error });
},
const store = UniversalStore.create<StoreState, StoreEvent>(storeOptions);
new TestManager({
store,
componentTestStatusStore: getStatusStore(STATUS_TYPE_ID_COMPONENT_TEST),
a11yStatusStore: getStatusStore(STATUS_TYPE_ID_A11Y),
testProviderStore: getTestProviderStore(ADDON_ID),
onReady: () => {
process.send?.({ type: 'ready' });
},
@ -46,16 +57,38 @@ const exit = (code = 0) => {
process.exit(code);
};
const createUnhandledErrorHandler = (message: string) => async (error: ErrorLike) => {
try {
const payload: FatalErrorEvent['payload'] = {
message,
error: {
message: error.message,
name: error.name,
stack: error.stack,
cause: error.cause as ErrorLike,
},
};
// Node.js will exit immediately in these situations, so we can't send an event via the universal store
// because the process will exit before the event is sent.
// we're sending it manually instead, so the parent process can forward it to the store.
process.send?.({
type: 'uncaught-error',
payload,
});
} finally {
exit(1);
}
};
process.on(
'uncaughtException',
createUnhandledErrorHandler('Uncaught exception in the test runner process')
);
process.on(
'unhandledRejection',
createUnhandledErrorHandler('Unhandled rejection in the test runner process')
);
process.on('exit', exit);
process.on('SIGINT', () => exit(0));
process.on('SIGTERM', () => exit(0));
process.on('uncaughtException', (err) => {
process.send?.({ type: 'error', message: 'Uncaught exception', error: err.stack });
exit(1);
});
process.on('unhandledRejection', (reason) => {
process.send?.({ type: 'error', message: 'Unhandled rejection', error: String(reason) });
exit(1);
});

View File

@ -2,30 +2,33 @@ import { readFileSync } from 'node:fs';
import { mkdir } from 'node:fs/promises';
import type { Channel } from 'storybook/internal/channels';
import { getFrameworkName, resolvePathInStorybookCache } from 'storybook/internal/common';
import {
TESTING_MODULE_CRASH_REPORT,
TESTING_MODULE_PROGRESS_REPORT,
TESTING_MODULE_RUN_REQUEST,
type TestingModuleCrashReportPayload,
type TestingModuleProgressReportPayload,
} from 'storybook/internal/core-events';
import { experimental_UniversalStore } from 'storybook/internal/core-server';
createFileSystemCache,
getFrameworkName,
resolvePathInStorybookCache,
} from 'storybook/internal/common';
import {
experimental_UniversalStore,
experimental_getTestProviderStore,
} from 'storybook/internal/core-server';
import { cleanPaths, oneWayHash, sanitizeError, telemetry } from 'storybook/internal/telemetry';
import type { Options, PresetPropertyFn, StoryId } from 'storybook/internal/types';
import { isEqual } from 'es-toolkit';
import picocolors from 'picocolors';
import { dedent } from 'ts-dedent';
import {
ADDON_ID,
COVERAGE_DIRECTORY,
STORE_CHANNEL_EVENT_NAME,
STORYBOOK_ADDON_TEST_CHANNEL,
type StoreState,
TEST_PROVIDER_ID,
storeOptions,
} from './constants';
import { log } from './logger';
import { runTestRunner } from './node/boot-test-runner';
import type { CachedState, ErrorLike, StoreState } from './types';
import type { StoreEvent } from './types';
type Event = {
type: 'test-discrepancy';
@ -43,11 +46,6 @@ export const experimental_serverChannel = async (channel: Channel, options: Opti
const builderName = typeof core?.builder === 'string' ? core.builder : core?.builder?.name;
const framework = await getFrameworkName(options);
const store = experimental_UniversalStore.create<StoreState>({
...storeOptions,
leader: true,
});
// Only boot the test runner if the builder is vite, else just provide interactions functionality
if (!builderName?.includes('vite')) {
if (framework.includes('nextjs')) {
@ -60,22 +58,107 @@ export const experimental_serverChannel = async (channel: Channel, options: Opti
return channel;
}
const execute =
(eventName: string) =>
(...args: any[]) => {
if (args[0]?.providerId === TEST_PROVIDER_ID) {
runTestRunner(channel, eventName, args);
}
};
const fsCache = createFileSystemCache({
basePath: resolvePathInStorybookCache(ADDON_ID.replace('/', '-')),
ns: 'storybook',
ttl: 14 * 24 * 60 * 60 * 1000, // 14 days
});
const cachedState: CachedState = await fsCache.get<CachedState>('state', {
config: storeOptions.initialState.config,
watching: storeOptions.initialState.watching,
});
channel.on(TESTING_MODULE_RUN_REQUEST, execute(TESTING_MODULE_RUN_REQUEST));
store.onStateChange((state) => {
if (state.watching) {
runTestRunner(channel);
const store = experimental_UniversalStore.create<StoreState, StoreEvent>({
...storeOptions,
initialState: {
...storeOptions.initialState,
...cachedState,
},
leader: true,
});
store.onStateChange((state, previousState) => {
const selectCachedState = (s: StoreState): CachedState => ({
config: s.config,
watching: s.watching,
});
if (!isEqual(selectCachedState(state), selectCachedState(previousState))) {
fsCache.set('state', selectCachedState(state));
}
});
if (cachedState.watching) {
runTestRunner(channel, store);
}
const testProviderStore = experimental_getTestProviderStore(ADDON_ID);
store.subscribe('TRIGGER_RUN', (event, eventInfo) => {
testProviderStore.setState('test-provider-state:running');
store.setState((s) => ({
...s,
fatalError: undefined,
}));
runTestRunner(channel, store, STORE_CHANNEL_EVENT_NAME, [{ event, eventInfo }]);
});
store.subscribe('TOGGLE_WATCHING', (event, eventInfo) => {
store.setState((s) => ({
...s,
watching: event.payload.to,
currentRun: {
...s.currentRun,
// when enabling watch mode, clear the coverage summary too
...(event.payload.to && {
coverageSummary: undefined,
}),
},
}));
if (event.payload.to) {
runTestRunner(channel, store, STORE_CHANNEL_EVENT_NAME, [{ event, eventInfo }]);
}
});
store.subscribe('FATAL_ERROR', (event) => {
const { message, error } = event.payload;
const name = error.name || 'Error';
log(`${name}: ${message}`);
if (error.stack) {
log(error.stack);
}
function logErrorWithCauses(err: ErrorLike) {
if (!err) {
return;
}
log(`Caused by: ${err.name ?? 'Error'}: ${err.message}`);
if (err.stack) {
log(err.stack);
}
if (err.cause) {
logErrorWithCauses(err.cause);
}
}
if (error.cause) {
logErrorWithCauses(error.cause);
}
store.setState((s) => ({
...s,
fatalError: {
message,
error,
},
}));
testProviderStore.setState('test-provider-state:crashed');
});
testProviderStore.onClearAll(() => {
store.setState((s) => ({
...s,
currentRun: { ...s.currentRun, coverageSummary: undefined, unhandledErrors: [] },
}));
});
if (!core.disableTelemetry) {
const enableCrashReports = core.enableCrashReports || options.enableCrashReports;
const packageJsonPath = require.resolve('@storybook/addon-test/package.json');
const { version: addonVersion } = JSON.parse(
@ -83,7 +166,6 @@ export const experimental_serverChannel = async (channel: Channel, options: Opti
);
channel.on(STORYBOOK_ADDON_TEST_CHANNEL, (event: Event) => {
// @ts-expect-error This telemetry is not a core one, so we don't have official types for it (similar to onboarding addon)
telemetry('addon-test', {
...event,
payload: {
@ -94,66 +176,38 @@ export const experimental_serverChannel = async (channel: Channel, options: Opti
});
});
store.onStateChange(async (state, previous) => {
if (state.watching && !previous.watching) {
await telemetry('testing-module-watch-mode', {
provider: TEST_PROVIDER_ID,
watchMode: state.watching,
});
}
});
channel.on(
TESTING_MODULE_PROGRESS_REPORT,
async (payload: TestingModuleProgressReportPayload) => {
if (payload.providerId !== TEST_PROVIDER_ID) {
return;
}
const status = 'status' in payload ? payload.status : undefined;
const progress = 'progress' in payload ? payload.progress : undefined;
const error = 'error' in payload ? payload.error : undefined;
const config = store.getState().config;
if ((status === 'success' || status === 'cancelled') && progress?.finishedAt) {
await telemetry('testing-module-completed-report', {
provider: TEST_PROVIDER_ID,
status,
config,
duration: progress?.finishedAt - progress?.startedAt,
numTotalTests: progress?.numTotalTests,
numFailedTests: progress?.numFailedTests,
numPassedTests: progress?.numPassedTests,
numSelectedStories: payload.details?.selectedStoryCount ?? 0,
});
}
if (status === 'failed') {
await telemetry('testing-module-completed-report', {
provider: TEST_PROVIDER_ID,
status,
config,
...(options.enableCrashReports && {
error: error && sanitizeError(error),
}),
numSelectedStories: payload.details?.selectedStoryCount ?? 0,
});
}
}
);
channel.on(TESTING_MODULE_CRASH_REPORT, async (payload: TestingModuleCrashReportPayload) => {
if (payload.providerId !== TEST_PROVIDER_ID) {
return;
}
await telemetry('testing-module-crash-report', {
provider: payload.providerId,
...(options.enableCrashReports && {
error: cleanPaths(payload.error.message),
}),
store.subscribe('TOGGLE_WATCHING', async (event) => {
await telemetry('addon-test', {
watchMode: event.payload.to,
addonVersion,
});
});
store.subscribe('TEST_RUN_COMPLETED', async (event) => {
const { unhandledErrors, startedAt, finishedAt, storyIds, ...currentRun } = event.payload;
await telemetry('addon-test', {
...currentRun,
duration: (finishedAt ?? 0) - (startedAt ?? 0),
selectedStoryCount: storyIds?.length ?? 0,
unhandledErrorCount: unhandledErrors.length,
...(enableCrashReports &&
unhandledErrors.length > 0 && {
unhandledErrors: unhandledErrors.map((error) => {
const { stacks, ...errorWithoutStacks } = error;
return sanitizeError(errorWithoutStacks);
}),
}),
addonVersion,
});
});
if (enableCrashReports) {
store.subscribe('FATAL_ERROR', async (event) => {
await telemetry('addon-test', {
fatalError: cleanPaths(event.payload.error.message),
addonVersion,
});
});
}
}
return channel;

View File

@ -1,3 +1,7 @@
import type { experimental_UniversalStore } from 'storybook/internal/core-server';
import type { StoryId } from 'storybook/internal/types';
import type { API_HashEntry } from 'storybook/internal/types';
export interface TestParameters {
/**
* Test addon configuration
@ -12,3 +16,104 @@ export interface TestParameters {
throwPlayFunctionExceptions?: boolean;
};
}
export interface VitestError extends Error {
VITEST_TEST_PATH?: string;
VITEST_TEST_NAME?: string;
stacks?: Array<{
line: number;
column: number;
file: string;
method: string;
}>;
}
export type ErrorLike = {
message: string;
name?: string;
stack?: string;
cause?: ErrorLike;
};
export type RunTrigger = 'run-all' | 'global' | 'watch' | Extract<API_HashEntry['type'], string>;
export type StoreState = {
config: {
coverage: boolean;
a11y: boolean;
};
watching: boolean;
cancelling: boolean;
// TODO: Avoid needing to do a fetch request server-side to retrieve the index
// e.g. http://localhost:6006/index.json
indexUrl: string | undefined;
fatalError:
| {
message: string | undefined;
error: ErrorLike;
}
| undefined;
currentRun: {
triggeredBy: RunTrigger | undefined;
config: StoreState['config'];
componentTestCount: {
success: number;
error: number;
};
a11yCount: {
success: number;
warning: number;
error: number;
};
totalTestCount: number | undefined;
storyIds: StoryId[] | undefined;
startedAt: number | undefined;
finishedAt: number | undefined;
unhandledErrors: VitestError[];
coverageSummary:
| {
status: 'positive' | 'warning' | 'negative' | 'unknown';
percentage: number;
}
| undefined;
};
};
export type CachedState = Pick<StoreState, 'config' | 'watching'>;
export type TriggerRunEvent = {
type: 'TRIGGER_RUN';
payload: {
storyIds?: string[] | undefined;
triggeredBy: RunTrigger;
};
};
export type CancelRunEvent = {
type: 'CANCEL_RUN';
};
export type ToggleWatchingEvent = {
type: 'TOGGLE_WATCHING';
payload: {
to: boolean;
};
};
export type FatalErrorEvent = {
type: 'FATAL_ERROR';
payload: {
message: string;
error: ErrorLike;
};
};
export type TestRunCompletedEvent = {
type: 'TEST_RUN_COMPLETED';
payload: StoreState['currentRun'];
};
export type StoreEvent =
| TriggerRunEvent
| CancelRunEvent
| FatalErrorEvent
| ToggleWatchingEvent
| TestRunCompletedEvent;
export type Store = ReturnType<typeof experimental_UniversalStore.create<StoreState, StoreEvent>>;

View File

@ -0,0 +1,115 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type {
StatusTypeId,
StatusValue,
StatusesByStoryIdAndTypeId,
StoryId,
TestProviderState,
} from 'storybook/internal/types';
import { store, testProviderStore } from '#manager-store';
import { isEqual } from 'es-toolkit';
import {
type API,
experimental_useStatusStore,
experimental_useTestProviderStore,
experimental_useUniversalStore,
} from 'storybook/manager-api';
import { ADDON_ID, STATUS_TYPE_ID_A11Y, STATUS_TYPE_ID_COMPONENT_TEST } from './constants';
import type { StoreState } from './types';
export type StatusValueToStoryIds = Record<StatusValue, StoryId[]>;
const statusValueToStoryIds = (
allStatuses: StatusesByStoryIdAndTypeId,
typeId: StatusTypeId,
storyIds?: StoryId[]
) => {
const statusValueToStoryIdsMap: StatusValueToStoryIds = {
'status-value:pending': [],
'status-value:success': [],
'status-value:error': [],
'status-value:warning': [],
'status-value:unknown': [],
};
const stories = storyIds
? storyIds.map((storyId) => allStatuses[storyId]).filter(Boolean)
: Object.values(allStatuses);
stories.forEach((statusByTypeId) => {
const status = statusByTypeId[typeId];
if (!status) {
return;
}
statusValueToStoryIdsMap[status.value].push(status.storyId);
});
return statusValueToStoryIdsMap;
};
export const useTestProvider = (
api: API,
entryId?: string
): {
storeState: StoreState;
setStoreState: (typeof store)['setState'];
testProviderState: TestProviderState;
componentTestStatusValueToStoryIds: StatusValueToStoryIds;
a11yStatusValueToStoryIds: StatusValueToStoryIds;
isSettingsUpdated: boolean;
} => {
const testProviderState = experimental_useTestProviderStore((s) => s[ADDON_ID]);
const [storeState, setStoreState] = experimental_useUniversalStore(store);
// this follows the same behavior for the green border around the whole testing module in TestingModule.tsx
const [isSettingsUpdated, setIsSettingsUpdated] = useState(false);
const settingsUpdatedTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
const unsubscribe = store.onStateChange((state, previousState) => {
if (!isEqual(state.config, previousState.config)) {
testProviderStore.settingsChanged();
setIsSettingsUpdated(true);
clearTimeout(settingsUpdatedTimeoutRef.current);
settingsUpdatedTimeoutRef.current = setTimeout(() => {
setIsSettingsUpdated(false);
}, 1000);
}
});
return () => {
unsubscribe();
clearTimeout(settingsUpdatedTimeoutRef.current);
};
}, []);
// TODO: does this overmemo, if the index changes, would that trigger a re-calculation of storyIds?
const storyIds = useMemo(
() => (entryId ? api.findAllLeafStoryIds(entryId) : undefined),
[entryId, api]
);
const componentTestStatusSelector = useCallback(
(allStatuses: StatusesByStoryIdAndTypeId) =>
statusValueToStoryIds(allStatuses, STATUS_TYPE_ID_COMPONENT_TEST, storyIds),
[storyIds]
);
const componentTestStatusValueToStoryIds = experimental_useStatusStore(
componentTestStatusSelector
);
const a11yStatusValueToStoryIdsSelector = useCallback(
(allStatuses: StatusesByStoryIdAndTypeId) =>
statusValueToStoryIds(allStatuses, STATUS_TYPE_ID_A11Y, storyIds),
[storyIds]
);
const a11yStatusValueToStoryIds = experimental_useStatusStore(a11yStatusValueToStoryIdsSelector);
return {
storeState,
setStoreState,
testProviderState,
componentTestStatusValueToStoryIds,
a11yStatusValueToStoryIds,
isSettingsUpdated,
};
};

View File

@ -1,5 +1,7 @@
import type { StorybookConfig } from 'storybook/internal/types';
import type { ErrorLike } from './types';
export function getAddonNames(mainConfig: StorybookConfig): string[] {
const addons = mainConfig.addons || [];
const addonList = addons.map((addon) => {
@ -15,3 +17,12 @@ export function getAddonNames(mainConfig: StorybookConfig): string[] {
return addonList.filter((item): item is NonNullable<typeof item> => item != null);
}
export function errorToErrorLike(error: Error): ErrorLike {
return {
message: error.message,
name: error.name,
stack: error.stack,
cause: error.cause && error.cause instanceof Error ? errorToErrorLike(error.cause) : undefined,
};
}

View File

@ -22,9 +22,9 @@
"sideEffects": false,
"type": "module",
"imports": {
"#manager-status-store": {
"storybook": "./src/manager/status-store.mock.ts",
"default": "./src/manager/status-store.ts"
"#manager-stores": {
"storybook": "./src/manager/manager-stores.mock.ts",
"default": "./src/manager/manager-stores.ts"
},
"#utils": {
"storybook": "./template/stories/utils.mock.ts",

View File

@ -1,14 +1,16 @@
import React from 'react';
import { Badge, Spaced } from 'storybook/internal/components';
import { Badge } from 'storybook/internal/components';
import { STORY_CHANGED } from 'storybook/internal/core-events';
import { addons, types, useAddonState, useChannel } from 'storybook/manager-api';
import { addons, types, useAddonState, useChannel, useStorybookApi } from 'storybook/manager-api';
import { ADDON_ID, CLEAR_ID, EVENT_ID, PANEL_ID, PARAM_KEY } from './constants';
import ActionLogger from './containers/ActionLogger';
function Title() {
const api = useStorybookApi();
const selectedPanel = api.getSelectedPanel();
const [{ count }, setCount] = useAddonState(ADDON_ID, { count: 0 });
useChannel({
@ -23,14 +25,17 @@ function Title() {
},
});
const suffix = count === 0 ? '' : <Badge status="neutral">{count}</Badge>;
const suffix =
count === 0 ? null : (
<Badge compact status={selectedPanel === PANEL_ID ? 'active' : 'neutral'}>
{count}
</Badge>
);
return (
<div>
<Spaced col={1}>
<span style={{ display: 'inline-block', verticalAlign: 'middle' }}>Actions</span>
{suffix}
</Spaced>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span>Actions</span>
{suffix}
</div>
);
}

View File

@ -2,8 +2,7 @@ import { dirname, join } from 'node:path';
import { temporaryDirectory, versions } from 'storybook/internal/common';
import type { JsPackageManager } from 'storybook/internal/common';
import type { SupportedFrameworks } from 'storybook/internal/types';
import type { SupportedRenderers } from 'storybook/internal/types';
import type { SupportedFrameworks, SupportedRenderers } from 'storybook/internal/types';
import downloadTarballDefault from '@ndelangen/get-tarball';
import getNpmTarballUrlDefault from 'get-npm-tarball-url';

View File

@ -1,24 +1,30 @@
import React from 'react';
import { Badge, Spaced } from 'storybook/internal/components';
import { Badge } from 'storybook/internal/components';
import { useAddonState } from 'storybook/manager-api';
import { useAddonState, useStorybookApi } from 'storybook/manager-api';
import { ADDON_ID } from '../constants';
import { ADDON_ID, PANEL_ID } from '../constants';
export function PanelTitle() {
const api = useStorybookApi();
const selectedPanel = api.getSelectedPanel();
const [addonState = {}] = useAddonState(ADDON_ID);
const { hasException, interactionsCount } = addonState as any;
return (
<div>
<Spaced col={1}>
<span style={{ display: 'inline-block', verticalAlign: 'middle' }}>Component tests</span>
{interactionsCount && !hasException ? (
<Badge status="neutral">{interactionsCount}</Badge>
) : null}
{hasException ? <Badge status="negative">{interactionsCount}</Badge> : null}
</Spaced>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span>Component tests</span>
{interactionsCount && !hasException ? (
<Badge compact status={selectedPanel === PANEL_ID ? 'active' : 'neutral'}>
{interactionsCount}
</Badge>
) : null}
{hasException ? (
<Badge compact status={selectedPanel === PANEL_ID ? 'active' : 'negative'}>
{interactionsCount}
</Badge>
) : null}
</div>
);
}

View File

@ -10,8 +10,13 @@ export default meta;
type Story = StoryObj<typeof meta>;
export const Default = { args: { children: 'Default' } } satisfies Story;
export const Active = { args: { status: 'active', children: 'Active' } } satisfies Story;
export const Positive = { args: { status: 'positive', children: 'Positive' } } satisfies Story;
export const Negative = { args: { status: 'negative', children: 'Negative' } } satisfies Story;
export const Neutral = { args: { status: 'neutral', children: 'Neutral' } } satisfies Story;
export const Warning = { args: { status: 'warning', children: 'Warning' } } satisfies Story;
export const Critical = { args: { status: 'critical', children: 'Critical' } } satisfies Story;
export const Compact = {
args: { compact: true, status: 'neutral', children: '12' },
} satisfies Story;

View File

@ -4,14 +4,16 @@ import { transparentize } from 'polished';
import { styled } from 'storybook/theming';
const BadgeWrapper = styled.div<BadgeProps>(
({ theme }) => ({
display: 'inline-block',
fontSize: 11,
lineHeight: '12px',
alignSelf: 'center',
padding: '4px 10px',
borderRadius: '3em',
({ theme, compact }) => ({
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: theme.typography.size.s1,
fontWeight: theme.typography.weight.bold,
lineHeight: '12px',
minWidth: 20,
borderRadius: 20,
padding: compact ? '4px 7px' : '4px 10px',
}),
{
svg: {
@ -56,7 +58,7 @@ const BadgeWrapper = styled.div<BadgeProps>(
case 'neutral': {
return {
color: theme.textMutedColor,
background: theme.background.app,
background: theme.base === 'light' ? theme.background.app : theme.barBg,
boxShadow: `inset 0 0 0 1px ${transparentize(0.8, theme.textMutedColor)}`,
};
}
@ -70,6 +72,13 @@ const BadgeWrapper = styled.div<BadgeProps>(
: 'none',
};
}
case 'active': {
return {
color: theme.color.secondary,
background: theme.background.hoverable,
boxShadow: `inset 0 0 0 1px ${transparentize(0.9, theme.color.secondary)}`,
};
}
default: {
return {};
}
@ -78,7 +87,8 @@ const BadgeWrapper = styled.div<BadgeProps>(
);
export interface BadgeProps {
status?: 'positive' | 'negative' | 'neutral' | 'warning' | 'critical';
compact?: boolean;
status?: 'positive' | 'negative' | 'neutral' | 'warning' | 'critical' | 'active';
children?: React.ReactNode;
}

View File

@ -36,8 +36,6 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
if (asChild) {
Comp = Slot;
}
const localVariant = variant;
const localSize = size;
const [isAnimating, setIsAnimating] = useState(false);
@ -65,8 +63,8 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
<StyledButton
as={Comp}
ref={ref}
variant={localVariant}
size={localSize}
variant={variant}
size={size}
padding={padding}
disabled={disabled}
active={active}

View File

@ -18,7 +18,7 @@ export const Side = styled.div<SideProps>(
whiteSpace: 'nowrap',
flexBasis: 'auto',
marginLeft: 3,
marginRight: 3,
marginRight: 10,
},
({ scrollable }) => (scrollable ? { flexShrink: 0 } : {}),
({ left }) =>
@ -32,10 +32,7 @@ export const Side = styled.div<SideProps>(
({ right }) =>
right
? {
marginLeft: 30,
'& > *': {
marginRight: 4,
},
gap: 6,
}
: {}
);

View File

@ -1,6 +1,6 @@
import React from 'react';
import { AddonPanel, Badge, Spaced } from 'storybook/internal/components';
import { AddonPanel, Badge } from 'storybook/internal/components';
import type {
ResponseData,
SaveStoryRequestPayload,
@ -12,25 +12,36 @@ import type { Args } from 'storybook/internal/csf';
import { FailedIcon, PassedIcon } from '@storybook/icons';
import { dequal as deepEqual } from 'dequal';
import { addons, experimental_requestResponse, types, useArgTypes } from 'storybook/manager-api';
import {
addons,
experimental_requestResponse,
types,
useArgTypes,
useStorybookApi,
} from 'storybook/manager-api';
import { color } from 'storybook/theming';
import { ControlsPanel } from './components/ControlsPanel';
import { ADDON_ID, PARAM_KEY } from './constants';
function Title() {
const api = useStorybookApi();
const selectedPanel = api.getSelectedPanel();
const rows = useArgTypes();
const controlsCount = Object.values(rows).filter(
(argType) => argType?.control && !argType?.table?.disable
).length;
const suffix = controlsCount === 0 ? '' : <Badge status="neutral">{controlsCount}</Badge>;
const suffix =
controlsCount === 0 ? null : (
<Badge compact status={selectedPanel === ADDON_ID ? 'active' : 'neutral'}>
{controlsCount}
</Badge>
);
return (
<div>
<Spaced col={1}>
<span style={{ display: 'inline-block', verticalAlign: 'middle' }}>Controls</span>
{suffix}
</Spaced>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span>Controls</span>
{suffix}
</div>
);
}

View File

@ -17,8 +17,10 @@ export { MockUniversalStore as experimental_MockUniversalStore } from '../shared
export {
getStatusStoreByTypeId as experimental_getStatusStore,
fullStatusStore as internal_fullStatusStore,
universalStatusStore as internal_universalStatusStore,
} from './stores/status';
export {
getTestProviderStoreById as experimental_getTestProviderStore,
fullTestProviderStore as internal_fullTestProviderStore,
universalTestProviderStore as internal_universalTestProviderStore,
} from './stores/test-provider';

View File

@ -3,21 +3,18 @@ import { UNIVERSAL_STATUS_STORE_OPTIONS } from '../../shared/status-store';
import { UniversalStore } from '../../shared/universal-store';
const statusStore = createStatusStore({
universalStatusStore:
universalStatusStore: UniversalStore.create({
...UNIVERSAL_STATUS_STORE_OPTIONS,
/*
This is a temporary workaround, to ensure that the store is not created in the
vitest sub-process in addon-test, even though it imports from core-server
If it was created in the sub-process, it would try to connect to the leader in the dev server
before it was ready.
This will be fixed when we do the planned UniversalStore v0.2.
This is a temporary workaround, to ensure that the store is not created in the
vitest sub-process in addon-test, even though it imports from core-server
If it was created in the sub-process, it would try to connect to the leader in the dev server
before it was ready.
This will be fixed when we do the planned UniversalStore v0.2.
*/
process.env.VITEST !== 'true'
? UniversalStore.create({
...UNIVERSAL_STATUS_STORE_OPTIONS,
leader: true,
})
: ({} as any),
leader: process.env.VITEST !== 'true',
}),
environment: 'server',
});
export const { fullStatusStore, getStatusStoreByTypeId } = statusStore;
export const { fullStatusStore, getStatusStoreByTypeId, universalStatusStore } = statusStore;

View File

@ -3,20 +3,18 @@ import { UNIVERSAL_TEST_PROVIDER_STORE_OPTIONS } from '../../shared/test-provide
import { UniversalStore } from '../../shared/universal-store';
const testProviderStore = createTestProviderStore({
universalTestProviderStore:
universalTestProviderStore: UniversalStore.create({
...UNIVERSAL_TEST_PROVIDER_STORE_OPTIONS,
/*
This is a temporary workaround, to ensure that the store is not created in the
vitest sub-process in addon-test, even though it imports from core-server
If it was created in the sub-process, it would try to connect to the leader in the dev server
before it was ready.
This will be fixed when we do the planned UniversalStore v0.2.
*/
process.env.VITEST !== 'true'
? UniversalStore.create({
...UNIVERSAL_TEST_PROVIDER_STORE_OPTIONS,
leader: true,
})
: ({} as any),
This is a temporary workaround, to ensure that the store is not created in the
vitest sub-process in addon-test, even though it imports from core-server
If it was created in the sub-process, it would try to connect to the leader in the dev server
before it was ready.
This will be fixed when we do the planned UniversalStore v0.2.
*/
leader: process.env.VITEST !== 'true',
}),
});
export const { fullTestProviderStore, getTestProviderStoreById } = testProviderStore;
export const { fullTestProviderStore, getTestProviderStoreById, universalTestProviderStore } =
testProviderStore;

View File

@ -0,0 +1,18 @@
export * from './root';
export { UniversalStore as experimental_UniversalStore } from '../shared/universal-store';
export { useUniversalStore as experimental_useUniversalStore } from '../shared/universal-store/use-universal-store-manager';
export { MockUniversalStore as experimental_MockUniversalStore } from '../shared/universal-store/mock';
export {
getStatusStoreByTypeId as experimental_getStatusStore,
useStatusStore as experimental_useStatusStore,
fullStatusStore as internal_fullStatusStore,
universalStatusStore as internal_universalStatusStore,
} from './stores/__mocks__/status';
export {
getTestProviderStoreById as experimental_getTestProviderStore,
useTestProviderStore as experimental_useTestProviderStore,
fullTestProviderStore as internal_fullTestProviderStore,
universalTestProviderStore as internal_universalTestProviderStore,
} from './stores/__mocks__/test-provider';

View File

@ -1 +1,18 @@
export * from './root';
export { UniversalStore as experimental_UniversalStore } from '../shared/universal-store';
export { useUniversalStore as experimental_useUniversalStore } from '../shared/universal-store/use-universal-store-manager';
export { MockUniversalStore as experimental_MockUniversalStore } from '../shared/universal-store/mock';
export {
getStatusStoreByTypeId as experimental_getStatusStore,
useStatusStore as experimental_useStatusStore,
fullStatusStore as internal_fullStatusStore,
universalStatusStore as internal_universalStatusStore,
} from './stores/status';
export {
getTestProviderStoreById as experimental_getTestProviderStore,
useTestProviderStore as experimental_useTestProviderStore,
fullTestProviderStore as internal_fullTestProviderStore,
universalTestProviderStore as internal_universalTestProviderStore,
} from './stores/test-provider';

View File

@ -41,7 +41,6 @@ import type {
StoryName,
StoryPreparedPayload,
} from 'storybook/internal/types';
import type { StatusByTypeId } from 'storybook/internal/types';
import { global } from '@storybook/global';
@ -220,6 +219,14 @@ export interface SubAPI {
* @returns {StoryId} The ID of the leaf story, or null if no leaf story was found.
*/
findLeafStoryId(index: API_IndexHash, storyId: StoryId): StoryId;
/**
* Finds all the leaf story IDs for the given entry ID in the given index.
*
* @param {StoryId} entryId - The ID of the entry to find the leaf story IDs for.
* @returns {StoryId[]} The IDs of all the leaf stories, or an empty array if no leaf stories were
* found.
*/
findAllLeafStoryIds(entryId: string): StoryId[];
/**
* Finds the ID of the sibling story in the given direction for the given story ID in the given
* story index.
@ -476,6 +483,25 @@ export const init: ModuleFn<SubAPI, SubState> = ({
findLeafStoryId(index, storyId) {
return api.findLeafEntry(index, storyId)?.id;
},
findAllLeafStoryIds(entryId) {
const { index } = store.getState();
if (!index) {
return [];
}
const findChildEntriesRecursively = (currentEntryId: StoryId, results: StoryId[] = []) => {
const node = index[currentEntryId];
if (!node) {
return results;
}
if (node.type === 'story') {
results.push(node.id);
} else if ('children' in node) {
node.children.forEach((childId) => findChildEntriesRecursively(childId, results));
}
return results;
};
return findChildEntriesRecursively(entryId, []);
},
findSiblingStoryId(storyId, index, direction, toSiblingGroup): any {
if (toSiblingGroup) {
const lookupList = getComponentLookupList(index);

View File

@ -98,7 +98,11 @@ export const init: ModuleFn = ({ store }) => {
if (versioned && current?.version && latest?.version) {
const versionDiff = semver.diff(latest.version, current.version);
const isLatestDocs = versionDiff === 'patch' || versionDiff === null;
const isLatestDocs =
versionDiff === 'patch' ||
versionDiff === null ||
// assume latest version when current version is a 0.0.0 canary
semver.satisfies(current.version, '0.0.0', { includePrerelease: true });
if (!isLatestDocs) {
url += `${semver.major(current.version)}.${semver.minor(current.version)}/`;

View File

@ -487,10 +487,6 @@ export function useArgTypes(): ArgTypes {
return (current?.type === 'story' && current.argTypes) || {};
}
export { UniversalStore as experimental_UniversalStore } from '../shared/universal-store';
export { useUniversalStore as experimental_useUniversalStore } from '../shared/universal-store/use-universal-store-manager';
export { MockUniversalStore as experimental_MockUniversalStore } from '../shared/universal-store/mock';
export { addons } from './lib/addons';
// We need to rename this so it's not compiled to a straight re-export
@ -501,14 +497,3 @@ export { typesX as types };
/* deprecated */
export { mockChannel, type Addon, type AddonStore } from './lib/addons';
export {
getStatusStoreByTypeId as experimental_getStatusStore,
useStatusStore as experimental_useStatusStore,
fullStatusStore as internal_fullStatusStore,
} from './stores/status';
export {
getTestProviderStoreById as experimental_getTestProviderStore,
useTestProviderStore as experimental_useTestProviderStore,
fullTestProviderStore as internal_fullTestProviderStore,
} from './stores/test-provider';

View File

@ -1,7 +1,7 @@
import { createStatusStore } from '../../../shared/status-store';
import { UNIVERSAL_STATUS_STORE_OPTIONS } from '../../../shared/status-store';
import { useUniversalStore } from '../../../shared/universal-store/use-universal-store-manager';
import { experimental_MockUniversalStore } from '../../root';
import { experimental_MockUniversalStore } from '../../index.mock';
const mockStatusStore = createStatusStore({
universalStatusStore: new experimental_MockUniversalStore(UNIVERSAL_STATUS_STORE_OPTIONS),
@ -9,4 +9,5 @@ const mockStatusStore = createStatusStore({
environment: 'manager',
});
export const { fullStatusStore, getStatusStoreByTypeId, useStatusStore } = mockStatusStore;
export const { fullStatusStore, getStatusStoreByTypeId, useStatusStore, universalStatusStore } =
mockStatusStore;

View File

@ -1,7 +1,7 @@
import { createTestProviderStore } from '../../../shared/test-provider-store';
import { UNIVERSAL_TEST_PROVIDER_STORE_OPTIONS } from '../../../shared/test-provider-store';
import { useUniversalStore } from '../../../shared/universal-store/use-universal-store-manager';
import { experimental_MockUniversalStore } from '../../root';
import { experimental_MockUniversalStore } from '../../index.mock';
const mockTestProviderStore = createTestProviderStore({
universalTestProviderStore: new experimental_MockUniversalStore(
@ -10,5 +10,9 @@ const mockTestProviderStore = createTestProviderStore({
useUniversalStore,
});
export const { fullTestProviderStore, getTestProviderStoreById, useTestProviderStore } =
mockTestProviderStore;
export const {
fullTestProviderStore,
getTestProviderStoreById,
useTestProviderStore,
universalTestProviderStore,
} = mockTestProviderStore;

View File

@ -12,4 +12,5 @@ const statusStore = createStatusStore({
environment: 'manager',
});
export const { fullStatusStore, getStatusStoreByTypeId, useStatusStore } = statusStore;
export const { fullStatusStore, getStatusStoreByTypeId, useStatusStore, universalStatusStore } =
statusStore;

View File

@ -11,5 +11,9 @@ const testProviderStore = createTestProviderStore({
useUniversalStore,
});
export const { fullTestProviderStore, getTestProviderStoreById, useTestProviderStore } =
testProviderStore;
export const {
fullTestProviderStore,
getTestProviderStoreById,
useTestProviderStore,
universalTestProviderStore,
} = testProviderStore;

View File

@ -15,7 +15,7 @@ import {
STORY_SPECIFIED,
UPDATE_STORY_ARGS,
} from 'storybook/internal/core-events';
import { type API_StoryEntry, StatusValue } from 'storybook/internal/types';
import { type API_StoryEntry } from 'storybook/internal/types';
import { global } from '@storybook/global';
@ -946,6 +946,33 @@ describe('stories API', () => {
expect(result).toBe('b-c--1');
});
});
describe('findAllLeafStoryIds', () => {
it('work for a leaf story', () => {
const initialState = { path: '/story/a--1', storyId: 'a--1', viewMode: 'story' };
const moduleArgs = createMockModuleArgs({ initialState });
const { api } = initStories(moduleArgs as unknown as ModuleArgs);
api.setIndex({ v: 5, entries: navigationEntries });
const result = api.findAllLeafStoryIds('a--1');
expect(result).toEqual(['a--1']);
});
it('work for an entry with children', () => {
const initialState = {
path: '/story/group-a/component-a',
storyId: 'component-a--story-1',
viewMode: 'story',
};
const moduleArgs = createMockModuleArgs({ initialState });
const { api } = initStories(moduleArgs as unknown as ModuleArgs);
api.setIndex({
v: 5,
entries: mockEntries,
});
const result = api.findAllLeafStoryIds('component-a');
expect(result).toEqual(['component-a--story-1', 'component-a--story-2']);
});
});
describe('jumpToComponent', () => {
it('works forward', () => {
const initialState = { path: '/story/a--1', storyId: 'a--1', viewMode: 'story' };

View File

@ -8,7 +8,7 @@ import type { IndexHash } from 'storybook/manager-api';
import { ManagerContext } from 'storybook/manager-api';
import { expect, fn, userEvent, within } from 'storybook/test';
import { internal_fullStatusStore } from '../../status-store.mock';
import { internal_fullStatusStore } from '../../manager-stores.mock';
import { LayoutProvider } from '../layout/LayoutProvider';
import { standardData as standardHeaderData } from './Heading.stories';
import { IconSymbols } from './IconSymbols';

View File

@ -7,6 +7,8 @@ import type { Meta, StoryObj } from '@storybook/react-vite';
import { type API, ManagerContext } from 'storybook/manager-api';
import { expect, fireEvent, fn, waitFor, within } from 'storybook/test';
import type { TestProviders } from '../../../core-events';
import type { TestProviderStateByProviderId } from '../../../shared/test-provider-store';
import { SidebarBottomBase } from './SidebarBottom';
const DynamicHeightDemo: FC = () => {
@ -42,22 +44,6 @@ const managerContext: any = {
autodocs: 'tag',
docsMode: false,
},
testProviders: {
'component-tests': {
type: Addon_TypesEnum.experimental_TEST_PROVIDER,
id: 'component-tests',
title: () => 'Component tests',
description: () => 'Ran 2 seconds ago',
runnable: true,
},
'visual-tests': {
type: Addon_TypesEnum.experimental_TEST_PROVIDER,
id: 'visual-tests',
title: () => 'Visual tests',
description: () => 'Not run',
runnable: true,
},
},
},
api: {
on: fn().mockName('api::on'),
@ -66,6 +52,38 @@ const managerContext: any = {
},
};
const registeredTestProviders: TestProviders = {
'component-tests': {
type: Addon_TypesEnum.experimental_TEST_PROVIDER,
id: 'component-tests',
name: 'Component tests',
render: () => <div>Component tests</div>,
runnable: true,
details: {},
cancellable: true,
cancelling: false,
running: false,
failed: false,
crashed: false,
},
'visual-tests': {
type: Addon_TypesEnum.experimental_TEST_PROVIDER,
id: 'visual-tests',
name: 'Visual tests',
render: () => <div>Visual tests</div>,
runnable: true,
details: {},
cancellable: true,
cancelling: false,
running: false,
failed: false,
crashed: false,
},
};
const testProviderStates: TestProviderStateByProviderId = {
'component-tests': 'test-provider-state:succeeded',
'visual-tests': 'test-provider-state:pending',
};
const meta = {
component: SidebarBottomBase,
title: 'Sidebar/SidebarBottom',
@ -85,6 +103,9 @@ const meta = {
getChannel: fn(),
getElements: fn(() => ({})),
} as any as API,
onRunAll: fn(),
registeredTestProviders,
testProviderStates,
},
parameters: {
layout: 'fullscreen',
@ -123,28 +144,23 @@ export const Both: Story = {
};
export const DynamicHeight: Story = {
decorators: [
(storyFn) => (
<ManagerContext.Provider
value={{
...managerContext,
state: {
...managerContext.state,
testProviders: {
custom: {
type: Addon_TypesEnum.experimental_TEST_PROVIDER,
id: 'custom',
render: () => <DynamicHeightDemo />,
runnable: true,
},
},
},
}}
>
{storyFn()}
</ManagerContext.Provider>
),
],
args: {
registeredTestProviders: {
'dynamic-height': {
type: Addon_TypesEnum.experimental_TEST_PROVIDER,
id: 'dynamic-height',
name: 'Dynamic height',
render: () => <DynamicHeightDemo />,
runnable: true,
details: {},
cancellable: true,
cancelling: false,
running: false,
failed: false,
crashed: false,
},
},
},
play: async ({ canvasElement }) => {
const screen = await within(canvasElement);

View File

@ -3,15 +3,22 @@ import React, { Fragment, useEffect, useLayoutEffect, useRef, useState } from 'r
import {
TESTING_MODULE_CRASH_REPORT,
TESTING_MODULE_PROGRESS_REPORT,
type TestProviders,
type TestingModuleCrashReportPayload,
type TestingModuleProgressReportPayload,
} from 'storybook/internal/core-events';
import { type API_FilterFunction } from 'storybook/internal/types';
import { experimental_useStatusStore, internal_fullStatusStore } from '#manager-status-store';
import {
experimental_useStatusStore,
experimental_useTestProviderStore,
internal_fullStatusStore,
internal_fullTestProviderStore,
} from '#manager-stores';
import { type API, type State, useStorybookApi, useStorybookState } from 'storybook/manager-api';
import { styled } from 'storybook/theming';
import type { TestProviderStateByProviderId } from '../../../shared/test-provider-store';
import { NotificationList } from '../notifications/NotificationList';
import { TestingModule } from './TestingModule';
@ -81,6 +88,9 @@ interface SidebarBottomProps {
warningCount: number;
hasStatuses: boolean;
isDevelopment?: boolean;
testProviderStates: TestProviderStateByProviderId;
registeredTestProviders: TestProviders;
onRunAll: () => void;
}
export const SidebarBottomBase = ({
@ -90,12 +100,14 @@ export const SidebarBottomBase = ({
warningCount,
hasStatuses,
isDevelopment,
testProviderStates,
registeredTestProviders,
onRunAll,
}: SidebarBottomProps) => {
const spacerRef = useRef<HTMLDivElement | null>(null);
const wrapperRef = useRef<HTMLDivElement | null>(null);
const [warningsActive, setWarningsActive] = useState(false);
const [errorsActive, setErrorsActive] = useState(false);
const { testProviders } = useStorybookState();
useEffect(() => {
if (spacerRef.current && wrapperRef.current) {
@ -144,10 +156,14 @@ export const SidebarBottomBase = ({
api.off(TESTING_MODULE_CRASH_REPORT, onCrashReport);
api.off(TESTING_MODULE_PROGRESS_REPORT, onProgressReport);
};
}, [api, testProviders]);
}, [api, registeredTestProviders]);
const testProvidersArray = Object.values(testProviders || {});
if (!warningCount && !errorCount && !testProvidersArray.length && !notifications.length) {
if (
!warningCount &&
!errorCount &&
Object.values(registeredTestProviders).length === 0 &&
notifications.length === 0
) {
return null;
}
@ -159,9 +175,14 @@ export const SidebarBottomBase = ({
{isDevelopment && (
<TestingModule
{...{
testProviders: testProvidersArray,
registeredTestProviders,
testProviderStates,
onRunAll,
hasStatuses,
clearStatuses: internal_fullStatusStore.unset,
clearStatuses: () => {
internal_fullStatusStore.unset();
internal_fullTestProviderStore.clearAll();
},
errorCount,
errorsActive,
setErrorsActive,
@ -178,7 +199,7 @@ export const SidebarBottomBase = ({
export const SidebarBottom = ({ isDevelopment }: { isDevelopment?: boolean }) => {
const api = useStorybookApi();
const { notifications } = useStorybookState();
const { notifications, testProviders: registeredTestProviders } = useStorybookState();
const { hasStatuses, errorCount, warningCount } = experimental_useStatusStore((statuses) => {
return Object.values(statuses).reduce(
(result, storyStatuses) => {
@ -198,6 +219,8 @@ export const SidebarBottom = ({ isDevelopment }: { isDevelopment?: boolean }) =>
);
});
const testProviderStates = experimental_useTestProviderStore();
return (
<SidebarBottomBase
api={api}
@ -206,6 +229,9 @@ export const SidebarBottom = ({ isDevelopment }: { isDevelopment?: boolean }) =>
errorCount={errorCount}
warningCount={warningCount}
isDevelopment={isDevelopment}
testProviderStates={testProviderStates}
registeredTestProviders={registeredTestProviders}
onRunAll={internal_fullTestProviderStore.runAll}
/>
);
};

View File

@ -2,6 +2,7 @@ import React from 'react';
import type { Listener } from 'storybook/internal/channels';
import { type TestProviders } from 'storybook/internal/core-events';
import type { TestProviderStateByProviderId } from 'storybook/internal/types';
import { Addon_TypesEnum } from 'storybook/internal/types';
import type { Meta, StoryObj } from '@storybook/react-vite';
@ -10,6 +11,7 @@ import { ManagerContext, mockChannel } from 'storybook/manager-api';
import { fireEvent, fn } from 'storybook/test';
import { styled } from 'storybook/theming';
import { internal_fullTestProviderStore } from '../../manager-stores.mock';
import { TestingModule } from './TestingModule';
const TestProvider = styled.div({
@ -26,33 +28,37 @@ const baseState = {
crashed: false,
};
const testProviders: TestProviders[keyof TestProviders][] = [
{
const registeredTestProviders: TestProviders = {
'component-tests': {
type: Addon_TypesEnum.experimental_TEST_PROVIDER,
id: 'component-tests',
name: 'Component tests',
title: () => 'Component tests',
description: () => 'Ran 2 seconds ago',
render: () => <TestProvider>Component tests</TestProvider>,
runnable: true,
...baseState,
},
{
'visual-tests': {
type: Addon_TypesEnum.experimental_TEST_PROVIDER,
id: 'visual-tests',
name: 'Visual tests',
title: () => 'Visual tests',
description: () => 'Not run',
render: () => <TestProvider>Visual tests</TestProvider>,
runnable: true,
...baseState,
},
{
linting: {
type: Addon_TypesEnum.experimental_TEST_PROVIDER,
id: 'linting',
name: 'Linting',
render: () => <TestProvider>Custom render function</TestProvider>,
render: () => <TestProvider>Linting</TestProvider>,
...baseState,
},
];
};
const testProviderStates: TestProviderStateByProviderId = {
'component-tests': 'test-provider-state:pending',
'visual-tests': 'test-provider-state:pending',
linting: 'test-provider-state:pending',
};
const channel = mockChannel();
const managerContext: any = {
@ -71,9 +77,11 @@ const meta = {
component: TestingModule,
title: 'Sidebar/TestingModule',
args: {
testProviders,
registeredTestProviders,
testProviderStates,
hasStatuses: false,
clearStatuses: fn(),
onRunAll: fn(),
errorCount: 0,
errorsActive: false,
setErrorsActive: fn(),
@ -154,89 +162,58 @@ export const CollapsedStatuses: Story = {
export const Running: Story = {
args: {
testProviders: [{ ...testProviders[0], running: true }, ...testProviders.slice(1)],
testProviderStates: {
...testProviderStates,
'component-tests': 'test-provider-state:running',
},
},
play: Expanded.play,
};
export const RunningAll: Story = {
args: {
testProviders: testProviders.map((tp) => ({ ...tp, running: !!tp.runnable })),
},
play: Expanded.play,
};
export const RunningWithStatuses: Story = {
export const RunningWithErrors: Story = {
args: {
...Statuses.args,
testProviders: [{ ...testProviders[0], running: true }, ...testProviders.slice(1)],
...Running.args,
},
play: Expanded.play,
};
export const CollapsedRunning: Story = {
args: RunningAll.args,
args: Running.args,
};
export const Cancellable: Story = {
export const CollapsedRunningWithErrors: Story = {
args: {
testProviders: [
{ ...testProviders[0], running: true, cancellable: true },
...testProviders.slice(1),
],
...RunningWithErrors.args,
},
play: Expanded.play,
};
export const Cancelling: Story = {
args: {
testProviders: [
{ ...testProviders[0], running: true, cancellable: true, cancelling: true },
...testProviders.slice(1),
],
},
play: Expanded.play,
};
export const Failing: Story = {
args: {
testProviders: [
{ ...testProviders[0], failed: true, running: true },
...testProviders.slice(1),
],
},
play: Expanded.play,
};
export const Failed: Story = {
args: {
testProviders: [{ ...testProviders[0], failed: true }, ...testProviders.slice(1)],
},
play: Expanded.play,
};
export const Crashed: Story = {
args: {
testProviders: [
{
...testProviders[0],
render: () => (
<TestProvider>
Component tests didn't complete
<br />
Problems!
</TestProvider>
),
crashed: true,
},
...testProviders.slice(1),
],
testProviderStates: {
...testProviderStates,
'component-tests': 'test-provider-state:crashed',
},
},
play: Expanded.play,
};
export const NoTestProvider: Story = {
args: {
testProviders: [],
export const SettingsUpdated: Story = {
play: async (playContext) => {
await Expanded.play!(playContext);
internal_fullTestProviderStore.settingsChanged();
},
};
export const NoTestProvider: Story = {
args: {
registeredTestProviders: {},
},
};
export const NoTestProviderWithStatuses: Story = {
args: {
...Statuses.args,
registeredTestProviders: {},
},
};

View File

@ -6,9 +6,10 @@ import { type TestProviders } from 'storybook/internal/core-events';
import { ChevronSmallUpIcon, PlayAllHollowIcon, SweepIcon } from '@storybook/icons';
import { useStorybookApi } from 'storybook/manager-api';
import { internal_fullTestProviderStore } from '#manager-stores';
import { keyframes, styled } from 'storybook/theming';
import type { TestProviderStateByProviderId } from '../../../shared/test-provider-store';
import { LegacyRender } from './LegacyRender';
const DEFAULT_HEIGHT = 500;
@ -27,7 +28,8 @@ const Outline = styled.div<{
crashed: boolean;
failed: boolean;
running: boolean;
}>(({ crashed, failed, running, theme }) => ({
updated: boolean;
}>(({ crashed, failed, running, updated, theme }) => ({
position: 'relative',
lineHeight: '16px',
width: '100%',
@ -36,7 +38,7 @@ const Outline = styled.div<{
backgroundColor: `var(--sb-sidebar-bottom-card-background, ${theme.background.content})`,
borderRadius:
`var(--sb-sidebar-bottom-card-border-radius, ${theme.appBorderRadius + 1}px)` as any,
boxShadow: `inset 0 0 0 1px ${crashed && !running ? theme.color.negative : theme.appBorderColor}, var(--sb-sidebar-bottom-card-box-shadow, 0 1px 2px 0 rgba(0, 0, 0, 0.05), 0px -5px 20px 10px ${theme.background.app})`,
boxShadow: `inset 0 0 0 1px ${crashed && !running ? theme.color.negative : updated ? theme.color.positive : theme.appBorderColor}, var(--sb-sidebar-bottom-card-box-shadow, 0 1px 2px 0 rgba(0, 0, 0, 0.05), 0px -5px 20px 10px ${theme.background.app})`,
transition: 'box-shadow 1s',
'&:after': {
@ -160,9 +162,11 @@ const TestProvider = styled.div(({ theme }) => ({
}));
interface TestingModuleProps {
testProviders: TestProviders[keyof TestProviders][];
registeredTestProviders: TestProviders;
testProviderStates: TestProviderStateByProviderId;
hasStatuses: boolean;
clearStatuses: () => void;
onRunAll: () => void;
errorCount: number;
errorsActive: boolean;
setErrorsActive: (active: boolean) => void;
@ -172,9 +176,11 @@ interface TestingModuleProps {
}
export const TestingModule = ({
testProviders,
registeredTestProviders,
testProviderStates,
hasStatuses,
clearStatuses,
onRunAll,
errorCount,
errorsActive,
setErrorsActive,
@ -182,13 +188,27 @@ export const TestingModule = ({
warningsActive,
setWarningsActive,
}: TestingModuleProps) => {
const api = useStorybookApi();
const timeoutRef = useRef<null | ReturnType<typeof setTimeout>>(null);
const contentRef = useRef<HTMLDivElement>(null);
const [maxHeight, setMaxHeight] = useState(DEFAULT_HEIGHT);
const [isCollapsed, setCollapsed] = useState(true);
const [isChangingCollapse, setChangingCollapse] = useState(false);
const [isUpdated, setIsUpdated] = useState(false);
const settingsUpdatedTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
const unsubscribe = internal_fullTestProviderStore.onSettingsChanged(() => {
setIsUpdated(true);
clearTimeout(settingsUpdatedTimeoutRef.current);
settingsUpdatedTimeoutRef.current = setTimeout(() => {
setIsUpdated(false);
}, 1000);
});
return () => {
unsubscribe();
clearTimeout(settingsUpdatedTimeoutRef.current);
};
}, []);
useEffect(() => {
if (contentRef.current) {
@ -220,10 +240,13 @@ export const TestingModule = ({
}, 250);
}, []);
const isRunning = testProviders.some((tp) => tp.running);
const isCrashed = testProviders.some((tp) => tp.crashed);
const isFailed = testProviders.some((tp) => tp.failed);
const hasTestProviders = testProviders.length > 0;
const isRunning = Object.values(testProviderStates).some(
(testProviderState) => testProviderState === 'test-provider-state:running'
);
const isCrashed = Object.values(testProviderStates).some(
(testProviderState) => testProviderState === 'test-provider-state:crashed'
);
const hasTestProviders = Object.values(registeredTestProviders).length > 0;
if (!hasTestProviders && (!errorCount || !warningCount)) {
return null;
@ -234,7 +257,8 @@ export const TestingModule = ({
id="storybook-testing-module"
running={isRunning}
crashed={isCrashed}
failed={isFailed || errorCount > 0}
failed={errorCount > 0}
updated={isUpdated}
>
<Card>
{hasTestProviders && (
@ -247,7 +271,7 @@ export const TestingModule = ({
}}
>
<Content ref={contentRef}>
{testProviders.map((state) => {
{Object.values(registeredTestProviders).map((state) => {
const { render: Render } = state;
return (
<TestProvider key={state.id} data-module-id={state.id}>
@ -273,9 +297,7 @@ export const TestingModule = ({
padding="small"
onClick={(e: SyntheticEvent) => {
e.stopPropagation();
testProviders
.filter((state) => !state.running && state.runnable)
.forEach(({ id }) => api.runTestProvider(id));
onRunAll();
}}
disabled={isRunning}
>
@ -364,7 +386,15 @@ export const TestingModule = ({
{hasStatuses && (
<WithTooltip
hasChrome={false}
tooltip={<TooltipNote note="Clear all statuses" />}
tooltip={
<TooltipNote
note={
isRunning
? "Can't clear statuses while tests are running"
: 'Clear all statuses'
}
/>
}
trigger="hover"
>
<IconButton
@ -374,7 +404,12 @@ export const TestingModule = ({
e.stopPropagation();
clearStatuses();
}}
aria-label="Clear all statuses"
disabled={isRunning}
aria-label={
isRunning
? "Can't clear statuses while tests are running"
: 'Clear all statuses'
}
>
<SweepIcon />
</IconButton>

View File

@ -20,11 +20,9 @@ import {
SyncIcon,
} from '@storybook/icons';
import { internal_fullStatusStore as fullStatusStore } from '#manager-stores';
import { darken, lighten } from 'polished';
import {
internal_fullStatusStore as fullStatusStore,
useStorybookApi,
} from 'storybook/manager-api';
import { useStorybookApi } from 'storybook/manager-api';
import type {
API,
ComponentEntry,

View File

@ -325,6 +325,8 @@ export default {
'experimental_useUniversalStore',
'internal_fullStatusStore',
'internal_fullTestProviderStore',
'internal_universalStatusStore',
'internal_universalTestProviderStore',
'isMacLike',
'isShortcutTaken',
'keyToSymbol',
@ -705,6 +707,8 @@ export default {
'experimental_useUniversalStore',
'internal_fullStatusStore',
'internal_fullTestProviderStore',
'internal_universalStatusStore',
'internal_universalTestProviderStore',
'isMacLike',
'isShortcutTaken',
'keyToSymbol',

View File

@ -11,6 +11,12 @@ import {
createStatusStore,
} from '../shared/status-store';
import { UNIVERSAL_STATUS_STORE_OPTIONS } from '../shared/status-store';
import type {
TestProviderStateByProviderId,
TestProviderStoreEvent,
} from '../shared/test-provider-store';
import { UNIVERSAL_TEST_PROVIDER_STORE_OPTIONS } from '../shared/test-provider-store';
import { createTestProviderStore } from '../shared/test-provider-store';
import type { UniversalStore } from '../shared/universal-store';
export const {
@ -25,3 +31,15 @@ export const {
useUniversalStore: experimental_useUniversalStore,
environment: 'manager',
});
export const {
fullTestProviderStore: internal_fullTestProviderStore,
getTestProviderStoreById: experimental_getTestProviderStore,
useTestProviderStore: experimental_useTestProviderStore,
} = createTestProviderStore({
universalTestProviderStore: new experimental_MockUniversalStore(
UNIVERSAL_TEST_PROVIDER_STORE_OPTIONS,
testUtils
) as unknown as UniversalStore<TestProviderStateByProviderId, TestProviderStoreEvent>,
useUniversalStore: experimental_useUniversalStore,
});

View File

@ -2,4 +2,7 @@ export {
internal_fullStatusStore,
experimental_getStatusStore,
experimental_useStatusStore,
internal_fullTestProviderStore,
experimental_getTestProviderStore,
experimental_useTestProviderStore,
} from 'storybook/manager-api';

View File

@ -76,6 +76,7 @@ export function createStatusStore(params: {
}): {
getStatusStoreByTypeId: (typeId: StatusTypeId) => StatusStoreByTypeId;
fullStatusStore: FullStatusStore;
universalStatusStore: UniversalStore<StatusesByStoryIdAndTypeId, StatusStoreEvent>;
};
export function createStatusStore(params: {
universalStatusStore: UniversalStore<StatusesByStoryIdAndTypeId, StatusStoreEvent>;
@ -85,6 +86,7 @@ export function createStatusStore(params: {
getStatusStoreByTypeId: (typeId: StatusTypeId) => StatusStoreByTypeId;
fullStatusStore: FullStatusStore;
useStatusStore: UseStatusStore;
universalStatusStore: UniversalStore<StatusesByStoryIdAndTypeId, StatusStoreEvent>;
};
export function createStatusStore({
universalStatusStore,
@ -98,6 +100,7 @@ export function createStatusStore({
getStatusStoreByTypeId: (typeId: StatusTypeId) => StatusStoreByTypeId;
fullStatusStore: FullStatusStore;
useStatusStore?: UseStatusStore;
universalStatusStore: UniversalStore<StatusesByStoryIdAndTypeId, StatusStoreEvent>;
} {
const fullStatusStore: FullStatusStore = {
getAll() {
@ -214,12 +217,13 @@ export function createStatusStore({
});
if (!useUniversalStore) {
return { getStatusStoreByTypeId, fullStatusStore };
return { getStatusStoreByTypeId, fullStatusStore, universalStatusStore };
}
return {
getStatusStoreByTypeId,
fullStatusStore,
universalStatusStore,
useStatusStore: <T = StatusesByStoryIdAndTypeId>(
selector?: (statuses: StatusesByStoryIdAndTypeId) => T
) => useUniversalStore(universalStatusStore, selector as any)[0] as T,

View File

@ -75,7 +75,7 @@ describe('testProviderStore', () => {
describe('getTestProviderStoreById', () => {
describe('getState', () => {
it('should set initial provider state to pending', () => {
it('should initially return pending state for new provider', () => {
// Arrange - create empty test provider store
const { getTestProviderStoreById, fullTestProviderStore } = createTestProviderStore({
universalTestProviderStore: new MockUniversalStore<
@ -89,12 +89,6 @@ describe('testProviderStore', () => {
// Assert - verify initial state is pending
expect(store.getState()).toBe('test-provider-state:pending');
// Assert - verify provider was added to full state
const fullState = fullTestProviderStore.getFullState();
expect(fullState).toEqual({
'provider-1': 'test-provider-state:pending',
});
});
it('should return current state for existing provider', () => {
@ -172,7 +166,9 @@ describe('testProviderStore', () => {
store.runWithState(gatedSuccessCallback);
// Assert - verify running state
expect(store.getState()).toBe('test-provider-state:running');
await vi.waitFor(() => {
expect(store.getState()).toBe('test-provider-state:running');
});
// Act - complete execution
runningGate!();
@ -206,7 +202,9 @@ describe('testProviderStore', () => {
store.runWithState(gatedErrorCallback);
// Assert - verify running state
expect(store.getState()).toBe('test-provider-state:running');
await vi.waitFor(() => {
expect(store.getState()).toBe('test-provider-state:running');
});
// Act - trigger error
runningGate!();
@ -219,7 +217,7 @@ describe('testProviderStore', () => {
});
describe('onRunAll', () => {
it('should register and call listener when runAll is triggered', () => {
it('should register and call listener when runAll is triggered', async () => {
// Arrange - create store and setup listener
const mockUniversalStore = new MockUniversalStore<
TestProviderStateByProviderId,
@ -241,7 +239,9 @@ describe('testProviderStore', () => {
fullTestProviderStore.runAll();
// Assert - verify listener was called
expect(listener).toHaveBeenCalledTimes(1);
await vi.waitFor(() => {
expect(listener).toHaveBeenCalledTimes(1);
});
// Act - unsubscribe and trigger again
unsubscribe();
@ -253,7 +253,7 @@ describe('testProviderStore', () => {
});
describe('onClearAll', () => {
it('should register and call listener when clearAll is triggered', () => {
it('should register and call listener when clearAll is triggered', async () => {
// Arrange - create store and setup listener
const mockUniversalStore = new MockUniversalStore<
TestProviderStateByProviderId,
@ -275,7 +275,9 @@ describe('testProviderStore', () => {
fullTestProviderStore.clearAll();
// Assert - verify listener was called
expect(listener).toHaveBeenCalledTimes(1);
await vi.waitFor(() => {
expect(listener).toHaveBeenCalledTimes(1);
});
// Act - unsubscribe and trigger again
unsubscribe();
@ -287,7 +289,7 @@ describe('testProviderStore', () => {
});
describe('settingsChanged', () => {
it('should register and call listener when settingsChanged is triggered', () => {
it('should register and call listener when settingsChanged is triggered', async () => {
// Arrange - create store and setup listener
const mockUniversalStore = new MockUniversalStore<
TestProviderStateByProviderId,
@ -309,7 +311,9 @@ describe('testProviderStore', () => {
store.settingsChanged();
// Assert - verify listener was called
expect(listener).toHaveBeenCalledTimes(1);
await vi.waitFor(() => {
expect(listener).toHaveBeenCalledTimes(1);
});
// Act - unsubscribe and trigger again
unsubscribe();

View File

@ -378,6 +378,7 @@ export function createTestProviderStore(params: {
}): {
getTestProviderStoreById: (testProviderId: TestProviderId) => TestProviderStoreById;
fullTestProviderStore: FullTestProviderStore;
universalTestProviderStore: UniversalStore<TestProviderStateByProviderId, TestProviderStoreEvent>;
};
export function createTestProviderStore(params: {
universalTestProviderStore: UniversalStore<TestProviderStateByProviderId, TestProviderStoreEvent>;
@ -385,6 +386,7 @@ export function createTestProviderStore(params: {
}): {
getTestProviderStoreById: (testProviderId: TestProviderId) => TestProviderStoreById;
fullTestProviderStore: FullTestProviderStore;
universalTestProviderStore: UniversalStore<TestProviderStateByProviderId, TestProviderStoreEvent>;
useTestProviderStore: UseTestProviderStore;
};
export function createTestProviderStore({
@ -396,7 +398,9 @@ export function createTestProviderStore({
}) {
const baseStore: BaseTestProviderStore = {
settingsChanged: () => {
universalTestProviderStore.send({ type: 'settings-changed' });
universalTestProviderStore.untilReady().then(() => {
universalTestProviderStore.send({ type: 'settings-changed' });
});
},
onRunAll: (listener) => universalTestProviderStore.subscribe('run-all', listener),
onClearAll: (listener) => universalTestProviderStore.subscribe('clear-all', listener),
@ -408,22 +412,27 @@ export function createTestProviderStore({
setFullState: universalTestProviderStore.setState,
onSettingsChanged: (listener) =>
universalTestProviderStore.subscribe('settings-changed', listener),
runAll: () => universalTestProviderStore.send({ type: 'run-all' }),
clearAll: () => universalTestProviderStore.send({ type: 'clear-all' }),
runAll: async () => {
await universalTestProviderStore.untilReady();
universalTestProviderStore.send({ type: 'run-all' });
},
clearAll: async () => {
await universalTestProviderStore.untilReady();
universalTestProviderStore.send({ type: 'clear-all' });
},
};
const getTestProviderStoreById = (testProviderId: string): TestProviderStoreById => {
const getStateForTestProvider = () => universalTestProviderStore.getState()[testProviderId];
const getStateForTestProvider = () =>
universalTestProviderStore.getState()[testProviderId] ?? 'test-provider-state:pending';
const setStateForTestProvider = (state: TestProviderState) => {
universalTestProviderStore.setState((currentState) => ({
...currentState,
[testProviderId]: state,
}));
universalTestProviderStore.untilReady().then(() => {
universalTestProviderStore.setState((currentState) => ({
...currentState,
[testProviderId]: state,
}));
});
};
// Initialize the state to 'pending' if it doesn't exist yet
if (!getStateForTestProvider()) {
setStateForTestProvider('test-provider-state:pending');
}
return {
...baseStore,
testProviderId,
@ -445,6 +454,7 @@ export function createTestProviderStore({
return {
getTestProviderStoreById,
fullTestProviderStore,
universalTestProviderStore,
useTestProviderStore: <T = TestProviderStateByProviderId>(
selector?: (testProviders: TestProviderStateByProviderId) => T
) => useUniversalStore(universalTestProviderStore, selector as any)[0] as T,
@ -454,5 +464,6 @@ export function createTestProviderStore({
return {
getTestProviderStoreById,
fullTestProviderStore,
universalTestProviderStore,
};
}

View File

@ -1,4 +1,3 @@
import { isEqual } from 'es-toolkit';
import { dedent } from 'ts-dedent';
import { instances } from './instances';
@ -324,6 +323,7 @@ export class UniversalStore<
this.environment = environmentOverrides?.environment ?? UniversalStore.preparation.environment;
if (this.channel && this.environment) {
UniversalStore.preparation.resolve({ channel: this.channel, environment: this.environment });
this.prepareThis({ channel: this.channel, environment: this.environment });
} else {
UniversalStore.preparation.promise.then(this.prepareThis);
@ -600,6 +600,7 @@ export class UniversalStore<
responseEvent,
});
this.emitToChannel(responseEvent, { actor: this.actor });
this.emitToListeners(responseEvent, { actor: this.actor });
break;
case UniversalStore.InternalEventType.LEADER_CREATED:
// if a leader receives a LEADER_CREATED event it should not forward it,

View File

@ -24,7 +24,8 @@ export type EventType =
| 'create-new-story-file-search'
| 'testing-module-watch-mode'
| 'testing-module-completed-report'
| 'testing-module-crash-report';
| 'testing-module-crash-report'
| 'addon-test';
export interface Dependency {
version: string | undefined;

View File

@ -15,3 +15,4 @@ export * from './modules/frameworks';
export * from './modules/renderers';
export * from './modules/status';
export * from './modules/test-provider';
export * from './modules/universal-store';

View File

@ -1,6 +1,7 @@
export type {
TestProviderId,
TestProviderState,
TestProviderStateByProviderId,
TestProviderStoreById,
UseTestProviderStore,
} from '../../shared/test-provider-store';

View File

@ -0,0 +1,5 @@
import type { UniversalStore } from '../../shared/universal-store';
import type { MockUniversalStore } from '../../shared/universal-store/mock';
import type { Actor, Event, EventInfo, StoreOptions } from '../../shared/universal-store/types';
export type { UniversalStore, MockUniversalStore, Actor, Event, EventInfo, StoreOptions };

View File

@ -6696,10 +6696,10 @@ __metadata:
typescript: "npm:^5.7.3"
vitest: "npm:^3.0.9"
peerDependencies:
"@vitest/browser": ^2.1.1 || ^3.0.0
"@vitest/runner": ^2.1.1 || ^3.0.0
"@vitest/browser": ^3.0.0
"@vitest/runner": ^3.0.0
storybook: "workspace:^"
vitest: ^2.1.1 || ^3.0.0
vitest: ^3.0.0
peerDependenciesMeta:
"@vitest/browser":
optional: true

View File

@ -90,8 +90,6 @@ test.describe("component testing", () => {
const testingModuleDescription = await page.locator('#testing-module-description');
await expect(testingModuleDescription).toContainText('Not run');
const runTestsButton = await page.getByLabel('Start test run')
await runTestsButton.click();
@ -149,8 +147,6 @@ test.describe("component testing", () => {
const testingModuleDescription = await page.locator('#testing-module-description');
await expect(testingModuleDescription).toContainText('Not run');
const runTestsButton = await page.getByLabel('Start test run')
const watchModeButton = await page.getByLabel('Enable watch mode')
await expect(runTestsButton).toBeEnabled();
@ -323,7 +319,7 @@ test.describe("component testing", () => {
// Assert - Only one test is running and reported
await expect(sidebarContextMenu.locator('#testing-module-description')).toContainText('Ran 1 test', { timeout: 30000 });
await expect(sidebarContextMenu.getByLabel('status: passed')).toHaveCount(1);
await expect(sidebarContextMenu.getByLabel('Component tests passed')).toHaveCount(1);
await page.click('body');
await expect(page.locator('#storybook-explorer-menu').getByRole('status', { name: 'Test status: success' })).toHaveCount(1);
});
@ -352,8 +348,8 @@ test.describe("component testing", () => {
await sidebarContextMenu.getByLabel('Start test run').click();
// Assert - Tests are running and errors are reported
const errorLink = page.locator('#testing-module-description a');
await expect(errorLink).toContainText('2 unhandled errors', { timeout: 30000 });
const errorLink = page.locator('#storybook-testing-module #testing-module-description a');
await expect(errorLink).toContainText('View full error', { timeout: 30000 });
await errorLink.click();
await expect(page.locator('pre')).toContainText('I THREW AN UNHANDLED ERROR!');
@ -391,7 +387,7 @@ test.describe("component testing", () => {
await expect(sidebarContextMenu.locator('#testing-module-description')).toContainText('Ran 8 tests', { timeout: 30000 });
// Assert - Failing test shows as a failed status
await expect(sidebarContextMenu.getByText('1 story with errors')).toBeVisible();
await expect(sidebarContextMenu.getByLabel('status: failed')).toHaveCount(1);
await expect(sidebarContextMenu.getByLabel('Component tests failed')).toHaveCount(1);
await page.click('body');
await expect(page.locator('#storybook-explorer-menu').getByRole('status', { name: 'Test status: success' })).toHaveCount(7);
@ -424,7 +420,7 @@ test.describe("component testing", () => {
await expect(sidebarContextMenu.locator('#testing-module-description')).toContainText('Ran 10 test', { timeout: 30000 });
// Assert - 1 failing test shows as a failed status
await expect(sidebarContextMenu.getByText('2 stories with errors')).toBeVisible();
await expect(sidebarContextMenu.getByLabel('status: failed')).toHaveCount(1);
await expect(sidebarContextMenu.getByLabel('Component tests failed')).toHaveCount(1);
await page.click('body');
await expect(page.locator('#storybook-explorer-menu').getByRole('status', { name: 'Test status: success' })).toHaveCount(7);
@ -479,4 +475,13 @@ test.describe("component testing", () => {
expect(sbPercentage).toBeGreaterThanOrEqual(0);
expect(sbPercentage).toBeLessThanOrEqual(100);
});
test.fixme("should still collect statuses even when the browser is closed", () => {
});
test.fixme("should have correct status count globally and in context menus", () => {
});
test.fixme("should open the correct component test and a11y panels when clicking on statuses", () => {
});
});

File diff suppressed because it is too large Load Diff