Merge branch 'unified-ui-testing' into testing-module-ui

This commit is contained in:
Gert Hengeveld 2024-10-01 13:22:40 +02:00
commit 0c11df3d04
43 changed files with 3359 additions and 40 deletions

View File

@ -0,0 +1 @@
import './dist/manager';

View File

@ -6,7 +6,8 @@
"storybook-addons",
"addon-test",
"vitest",
"testing"
"testing",
"test"
],
"homepage": "https://github.com/storybookjs/storybook/tree/next/code/addons/test",
"bugs": {
@ -22,39 +23,43 @@
"url": "https://opencollective.com/storybook"
},
"license": "MIT",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
"import": "./dist/index.mjs",
"require": "./dist/index.js"
},
"./vitest-plugin": {
"types": "./dist/vitest-plugin/index.d.ts",
"import": "./dist/vitest-plugin/index.js",
"require": "./dist/vitest-plugin/index.cjs"
"import": "./dist/vitest-plugin/index.mjs",
"require": "./dist/vitest-plugin/index.js"
},
"./internal/global-setup": {
"types": "./dist/vitest-plugin/global-setup.d.ts",
"import": "./dist/vitest-plugin/global-setup.js",
"require": "./dist/vitest-plugin/global-setup.cjs"
"import": "./dist/vitest-plugin/global-setup.mjs",
"require": "./dist/vitest-plugin/global-setup.js"
},
"./internal/setup-file": {
"types": "./dist/vitest-plugin/setup-file.d.ts",
"import": "./dist/vitest-plugin/setup-file.js"
"import": "./dist/vitest-plugin/setup-file.mjs"
},
"./internal/test-utils": {
"types": "./dist/vitest-plugin/test-utils.d.ts",
"import": "./dist/vitest-plugin/test-utils.js",
"require": "./dist/vitest-plugin/test-utils.cjs"
"import": "./dist/vitest-plugin/test-utils.mjs",
"require": "./dist/vitest-plugin/test-utils.js"
},
"./preview": {
"types": "./dist/preview.d.ts",
"import": "./dist/preview.mjs",
"require": "./dist/preview.js"
},
"./manager": "./dist/manager.js",
"./preset": "./dist/preset.cjs",
"./postinstall": "./dist/postinstall.cjs",
"./preset": "./dist/preset.js",
"./postinstall": "./dist/postinstall.js",
"./package.json": "./package.json"
},
"main": "dist/index.cjs",
"module": "dist/index.js",
"main": "dist/index.js",
"module": "dist/index.mjs",
"types": "dist/index.d.ts",
"files": [
"dist/**/*",
@ -71,22 +76,32 @@
},
"dependencies": {
"@storybook/csf": "^0.1.11",
"@storybook/global": "^5.0.0",
"@storybook/icons": "^1.2.12",
"chalk": "^5.3.0"
"@storybook/instrumenter": "workspace:*",
"@storybook/test": "workspace:*",
"polished": "^4.2.2",
"ts-dedent": "^2.2.0"
},
"devDependencies": {
"@devtools-ds/object-inspector": "^1.1.2",
"@storybook/icons": "^1.2.12",
"@types/node": "^22.0.0",
"@types/semver": "^7",
"@vitest/browser": "^2.1.1",
"@vitest/runner": "^2.1.1",
"ansi-to-html": "^0.7.2",
"boxen": "^8.0.1",
"execa": "^8.0.1",
"find-up": "^7.0.0",
"formik": "^2.2.9",
"lodash": "^4.17.21",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"semver": "^7.6.3",
"tinyrainbow": "^1.2.0",
"ts-dedent": "^2.2.0",
"typescript": "^5.3.2",
"vitest": "^2.1.1"
},
"peerDependencies": {
@ -107,6 +122,9 @@
"managerEntries": [
"./src/manager.tsx"
],
"previewEntries": [
"./src/preview.ts"
],
"nodeEntries": [
"./src/preset.ts",
"./src/vitest-plugin/index.ts",
@ -114,5 +132,12 @@
"./src/postinstall.ts",
"./src/node/vitest.ts"
]
},
"storybook": {
"displayName": "Test",
"unsupportedFrameworks": [
"react-native"
],
"icon": "https://user-images.githubusercontent.com/263385/101991666-479cc600-3c7c-11eb-837b-be4e5ffa1bb8.png"
}
}

View File

@ -0,0 +1,10 @@
const { checkActionsLoaded } = require('./dist/preset');
function previewAnnotations(entry = [], options) {
checkActionsLoaded(options.configDir);
return entry;
}
module.exports = {
previewAnnotations,
};

View File

@ -0,0 +1 @@
export * from './dist/preview';

View File

@ -0,0 +1,292 @@
// @vitest-environment happy-dom
import { describe, expect, it } from 'vitest';
import { type Call, CallStates, type LogItem } from '@storybook/instrumenter';
import { getInteractions } from './Panel';
describe('Panel', () => {
describe('getInteractions', () => {
const log: LogItem[] = [
{
callId: 'story--id [4] findByText',
status: CallStates.DONE,
ancestors: [],
},
{
callId: 'story--id [5] click',
status: CallStates.DONE,
ancestors: [],
},
{
callId: 'story--id [6] waitFor',
status: CallStates.DONE,
ancestors: [],
},
{
callId: 'story--id [6] waitFor [2] toHaveBeenCalledWith',
status: CallStates.DONE,
ancestors: ['story--id [6] waitFor'],
},
];
const calls = new Map<Call['id'], Call>(
[
{
id: 'story--id [0] action',
storyId: 'story--id',
ancestors: [],
cursor: 0,
path: [],
method: 'action',
args: [{ __function__: { name: 'onSubmit' } }],
interceptable: false,
retain: true,
},
{
id: 'story--id [1] action',
storyId: 'story--id',
ancestors: [],
cursor: 1,
path: [],
method: 'action',
args: [{ __function__: { name: 'onTransactionStart' } }],
interceptable: false,
retain: true,
},
{
id: 'story--id [2] action',
storyId: 'story--id',
ancestors: [],
cursor: 2,
path: [],
method: 'action',
args: [{ __function__: { name: 'onTransactionEnd' } }],
interceptable: false,
retain: true,
},
{
id: 'story--id [3] within',
storyId: 'story--id',
ancestors: [],
cursor: 3,
path: [],
method: 'within',
args: [{ __element__: { localName: 'div', id: 'root', innerText: 'Click' } }],
interceptable: false,
retain: false,
},
{
id: 'story--id [4] findByText',
storyId: 'story--id',
ancestors: [],
cursor: 4,
path: [{ __callId__: 'story--id [3] within' }],
method: 'findByText',
args: ['Click'],
interceptable: true,
retain: false,
},
{
id: 'story--id [5] click',
storyId: 'story--id',
ancestors: [],
cursor: 5,
path: ['userEvent'],
method: 'click',
args: [{ __element__: { localName: 'button', innerText: 'Click' } }],
interceptable: true,
retain: false,
},
{
id: 'story--id [6] waitFor [0] expect',
storyId: 'story--id',
ancestors: ['story--id [6] waitFor'],
cursor: 0,
path: [],
method: 'expect',
args: [{ __callId__: 'story--id [0] action', retain: true }],
interceptable: true,
retain: false,
},
{
id: 'story--id [6] waitFor [1] stringMatching',
storyId: 'story--id',
ancestors: ['story--id [6] waitFor'],
cursor: 1,
path: ['expect'],
method: 'stringMatching',
args: [{ __regexp__: { flags: 'gi', source: '([A-Z])\\w+' } }],
interceptable: false,
retain: false,
},
{
id: 'story--id [6] waitFor [2] toHaveBeenCalledWith',
storyId: 'story--id',
ancestors: ['story--id [6] waitFor'],
cursor: 2,
path: [{ __callId__: 'story--id [6] waitFor [0] expect' }],
method: 'toHaveBeenCalledWith',
args: [{ __callId__: 'story--id [6] waitFor [1] stringMatching', retain: false }],
interceptable: true,
retain: false,
},
{
id: 'story--id [6] waitFor',
storyId: 'story--id',
ancestors: [],
cursor: 6,
path: [],
method: 'waitFor',
args: [{ __function__: { name: '' } }],
interceptable: true,
retain: false,
},
].map((v) => [v.id, v])
);
const collapsed = new Set<Call['id']>();
const setCollapsed = () => {};
it('returns list of interactions', () => {
expect(getInteractions({ log, calls, collapsed, setCollapsed })).toEqual([
{
...calls.get('story--id [4] findByText'),
status: CallStates.DONE,
childCallIds: undefined,
isHidden: false,
isCollapsed: false,
toggleCollapsed: expect.any(Function),
},
{
...calls.get('story--id [5] click'),
status: CallStates.DONE,
childCallIds: undefined,
isHidden: false,
isCollapsed: false,
toggleCollapsed: expect.any(Function),
},
{
...calls.get('story--id [6] waitFor'),
status: CallStates.DONE,
childCallIds: ['story--id [6] waitFor [2] toHaveBeenCalledWith'],
isHidden: false,
isCollapsed: false,
toggleCollapsed: expect.any(Function),
},
{
...calls.get('story--id [6] waitFor [2] toHaveBeenCalledWith'),
status: CallStates.DONE,
childCallIds: undefined,
isHidden: false,
isCollapsed: false,
toggleCollapsed: expect.any(Function),
},
]);
});
it('hides calls for which the parent is collapsed', () => {
const withCollapsed = new Set<Call['id']>(['story--id [6] waitFor']);
expect(getInteractions({ log, calls, collapsed: withCollapsed, setCollapsed })).toEqual([
expect.objectContaining({
...calls.get('story--id [4] findByText'),
childCallIds: undefined,
isCollapsed: false,
isHidden: false,
}),
expect.objectContaining({
...calls.get('story--id [5] click'),
childCallIds: undefined,
isCollapsed: false,
isHidden: false,
}),
expect.objectContaining({
...calls.get('story--id [6] waitFor'),
childCallIds: ['story--id [6] waitFor [2] toHaveBeenCalledWith'],
isCollapsed: true,
isHidden: false,
}),
expect.objectContaining({
...calls.get('story--id [6] waitFor [2] toHaveBeenCalledWith'),
childCallIds: undefined,
isCollapsed: false,
isHidden: true,
}),
]);
});
it('uses status from log', () => {
const withError = log.slice(0, 3).concat({ ...log[3], status: CallStates.ERROR });
expect(getInteractions({ log: withError, calls, collapsed, setCollapsed })).toEqual([
expect.objectContaining({
id: 'story--id [4] findByText',
status: CallStates.DONE,
}),
expect.objectContaining({
id: 'story--id [5] click',
status: CallStates.DONE,
}),
expect.objectContaining({
id: 'story--id [6] waitFor',
status: CallStates.DONE,
}),
expect.objectContaining({
id: 'story--id [6] waitFor [2] toHaveBeenCalledWith',
status: CallStates.ERROR,
}),
]);
});
it('keeps status active for errored child calls while parent is active', () => {
const withActiveError = log.slice(0, 2).concat([
{ ...log[2], status: CallStates.ACTIVE },
{ ...log[3], status: CallStates.ERROR },
]);
expect(getInteractions({ log: withActiveError, calls, collapsed, setCollapsed })).toEqual([
expect.objectContaining({
id: 'story--id [4] findByText',
status: CallStates.DONE,
}),
expect.objectContaining({
id: 'story--id [5] click',
status: CallStates.DONE,
}),
expect.objectContaining({
id: 'story--id [6] waitFor',
status: CallStates.ACTIVE,
}),
expect.objectContaining({
id: 'story--id [6] waitFor [2] toHaveBeenCalledWith',
status: CallStates.ACTIVE, // not ERROR
}),
]);
});
it('does not override child status other than error for active parent', () => {
const withActiveWaiting = log.slice(0, 2).concat([
{ ...log[2], status: CallStates.ACTIVE },
{ ...log[3], status: CallStates.WAITING },
]);
expect(getInteractions({ log: withActiveWaiting, calls, collapsed, setCollapsed })).toEqual([
expect.objectContaining({
id: 'story--id [4] findByText',
status: CallStates.DONE,
}),
expect.objectContaining({
id: 'story--id [5] click',
status: CallStates.DONE,
}),
expect.objectContaining({
id: 'story--id [6] waitFor',
status: CallStates.ACTIVE,
}),
expect.objectContaining({
id: 'story--id [6] waitFor [2] toHaveBeenCalledWith',
status: CallStates.WAITING,
}),
]);
});
});
});

View File

@ -0,0 +1,261 @@
import type { Dispatch, SetStateAction } from 'react';
import React, { Fragment, memo, useEffect, useMemo, useRef, useState } from 'react';
import {
FORCE_REMOUNT,
PLAY_FUNCTION_THREW_EXCEPTION,
STORY_RENDER_PHASE_CHANGED,
STORY_THREW_EXCEPTION,
UNHANDLED_ERRORS_WHILE_PLAYING,
} from 'storybook/internal/core-events';
import { useAddonState, useChannel, useParameter } from 'storybook/internal/manager-api';
import { global } from '@storybook/global';
import { type Call, CallStates, EVENTS, type LogItem } from '@storybook/instrumenter';
import { InteractionsPanel } from './components/InteractionsPanel';
import { ADDON_ID } from './constants';
interface Interaction extends Call {
status: Call['status'];
childCallIds: Call['id'][];
isHidden: boolean;
isCollapsed: boolean;
toggleCollapsed: () => void;
}
const INITIAL_CONTROL_STATES = {
start: false,
back: false,
goto: false,
next: false,
end: false,
};
export const getInteractions = ({
log,
calls,
collapsed,
setCollapsed,
}: {
log: LogItem[];
calls: Map<Call['id'], Call>;
collapsed: Set<Call['id']>;
setCollapsed: Dispatch<SetStateAction<Set<string>>>;
}) => {
const callsById = new Map<Call['id'], Call>();
const childCallMap = new Map<Call['id'], Call['id'][]>();
return log
.map<Call & { isHidden: boolean }>(({ callId, ancestors, status }) => {
let isHidden = false;
ancestors.forEach((ancestor) => {
if (collapsed.has(ancestor)) {
isHidden = true;
}
childCallMap.set(ancestor, (childCallMap.get(ancestor) || []).concat(callId));
});
return { ...calls.get(callId), status, isHidden };
})
.map<Interaction>((call) => {
const status =
call.status === CallStates.ERROR &&
callsById.get(call.ancestors.slice(-1)[0])?.status === CallStates.ACTIVE
? CallStates.ACTIVE
: call.status;
callsById.set(call.id, { ...call, status });
return {
...call,
status,
childCallIds: childCallMap.get(call.id),
isCollapsed: collapsed.has(call.id),
toggleCollapsed: () =>
setCollapsed((ids) => {
if (ids.has(call.id)) {
ids.delete(call.id);
} else {
ids.add(call.id);
}
return new Set(ids);
}),
};
});
};
export const Panel = memo<{ storyId: string }>(function PanelMemoized({ storyId }) {
// shared state
const [addonState, set] = useAddonState(ADDON_ID, {
controlStates: INITIAL_CONTROL_STATES,
isErrored: false,
pausedAt: undefined,
interactions: [],
isPlaying: false,
hasException: false,
caughtException: undefined,
interactionsCount: 0,
unhandledErrors: undefined,
});
// local state
const [scrollTarget, setScrollTarget] = useState<HTMLElement | undefined>(undefined);
const [collapsed, setCollapsed] = useState<Set<Call['id']>>(new Set());
const {
controlStates = INITIAL_CONTROL_STATES,
isErrored = false,
pausedAt = undefined,
interactions = [],
isPlaying = false,
caughtException = undefined,
unhandledErrors = undefined,
} = addonState;
// Log and calls are tracked in a ref so we don't needlessly rerender.
const log = useRef<LogItem[]>([]);
const calls = useRef<Map<Call['id'], Omit<Call, 'status'>>>(new Map());
const setCall = ({ status, ...call }: Call) => calls.current.set(call.id, call);
const endRef = useRef();
useEffect(() => {
let observer: IntersectionObserver;
if (global.IntersectionObserver) {
observer = new global.IntersectionObserver(
([end]: any) => setScrollTarget(end.isIntersecting ? undefined : end.target),
{ root: global.document.querySelector('#panel-tab-content') }
);
if (endRef.current) {
observer.observe(endRef.current);
}
}
return () => observer?.disconnect();
}, []);
const emit = useChannel(
{
[EVENTS.CALL]: setCall,
[EVENTS.SYNC]: (payload) => {
set((s) => {
const list = getInteractions({
log: payload.logItems,
calls: calls.current,
collapsed,
setCollapsed,
});
return {
...s,
controlStates: payload.controlStates,
pausedAt: payload.pausedAt,
interactions: list,
interactionsCount: list.filter(({ method }) => method !== 'step').length,
};
});
log.current = payload.logItems;
},
[STORY_RENDER_PHASE_CHANGED]: (event) => {
if (event.newPhase === 'preparing') {
set({
controlStates: INITIAL_CONTROL_STATES,
isErrored: false,
pausedAt: undefined,
interactions: [],
isPlaying: false,
hasException: false,
caughtException: undefined,
interactionsCount: 0,
unhandledErrors: undefined,
});
return;
}
set((s) => {
const newState: typeof s = {
...s,
isPlaying: event.newPhase === 'playing',
pausedAt: undefined,
...(event.newPhase === 'rendering'
? {
isErrored: false,
caughtException: undefined,
}
: {}),
};
return newState;
});
},
[STORY_THREW_EXCEPTION]: () => {
set((s) => ({ ...s, isErrored: true, hasException: true }));
},
[PLAY_FUNCTION_THREW_EXCEPTION]: (e) => {
set((s) => ({ ...s, caughtException: e, hasException: true }));
},
[UNHANDLED_ERRORS_WHILE_PLAYING]: (e) => {
set((s) => ({ ...s, unhandledErrors: e, hasException: true }));
},
},
[collapsed]
);
useEffect(() => {
set((s) => {
const list = getInteractions({
log: log.current,
calls: calls.current,
collapsed,
setCollapsed,
});
return {
...s,
interactions: list,
interactionsCount: list.filter(({ method }) => method !== 'step').length,
};
});
}, [collapsed]);
const controls = useMemo(
() => ({
start: () => emit(EVENTS.START, { storyId }),
back: () => emit(EVENTS.BACK, { storyId }),
goto: (callId: string) => emit(EVENTS.GOTO, { storyId, callId }),
next: () => emit(EVENTS.NEXT, { storyId }),
end: () => emit(EVENTS.END, { storyId }),
rerun: () => {
emit(FORCE_REMOUNT, { storyId });
},
}),
[storyId]
);
const storyFilePath = useParameter('fileName', '');
const [fileName] = storyFilePath.toString().split('/').slice(-1);
const scrollToTarget = () => scrollTarget?.scrollIntoView({ behavior: 'smooth', block: 'end' });
const hasException =
!!caughtException ||
!!unhandledErrors ||
interactions.some((v) => v.status === CallStates.ERROR);
if (isErrored) {
return <Fragment key="component-tests" />;
}
return (
<Fragment key="component-tests">
<InteractionsPanel
calls={calls.current}
controls={controls}
controlStates={controlStates}
interactions={interactions}
fileName={fileName}
hasException={hasException}
caughtException={caughtException}
unhandledErrors={unhandledErrors}
isPlaying={isPlaying}
pausedAt={pausedAt}
endRef={endRef}
onScrollToEnd={scrollTarget && scrollToTarget}
/>
</Fragment>
);
});

View File

@ -0,0 +1,69 @@
import React, { useEffect, useState } from 'react';
import { EmptyTabContent, Link } from 'storybook/internal/components';
import { useStorybookApi } from 'storybook/internal/manager-api';
import { styled } from 'storybook/internal/theming';
import { DocumentIcon, VideoIcon } from '@storybook/icons';
import { DOCUMENTATION_LINK, TUTORIAL_VIDEO_LINK } from '../constants';
const Links = styled.div(({ theme }) => ({
display: 'flex',
fontSize: theme.typography.size.s2 - 1,
gap: 25,
}));
const Divider = styled.div(({ theme }) => ({
width: 1,
height: 16,
backgroundColor: theme.appBorderColor,
}));
export const Empty = () => {
const [isLoading, setIsLoading] = useState(true);
const api = useStorybookApi();
const docsUrl = api.getDocsUrl({
subpath: DOCUMENTATION_LINK,
versioned: true,
renderer: true,
});
// We are adding a small delay to avoid flickering when the story is loading.
// It takes a bit of time for the controls to appear, so we don't want
// to show the empty state for a split second.
useEffect(() => {
const load = setTimeout(() => {
setIsLoading(false);
}, 100);
return () => clearTimeout(load);
}, []);
if (isLoading) {
return null;
}
return (
<EmptyTabContent
title="Interaction testing"
description={
<>
Interaction tests allow you to verify the functional aspects of UIs. Write a play function
for your story and you&apos;ll see it run here.
</>
}
footer={
<Links>
<Link href={TUTORIAL_VIDEO_LINK} target="_blank" withArrow>
<VideoIcon /> Watch 8m video
</Link>
<Divider />
<Link href={docsUrl} target="_blank" withArrow>
<DocumentIcon /> Read docs
</Link>
</Links>
}
/>
);
};

View File

@ -0,0 +1,63 @@
import { CallStates } from '@storybook/instrumenter';
import type { Meta, StoryObj } from '@storybook/react';
import { expect, userEvent, within } from '@storybook/test';
import { getCalls } from '../mocks';
import { Interaction } from './Interaction';
import SubnavStories from './Subnav.stories';
type Story = StoryObj<typeof Interaction>;
export default {
title: 'Interaction',
component: Interaction,
args: {
callsById: new Map(getCalls(CallStates.DONE).map((call) => [call.id, call])),
controls: SubnavStories.args.controls,
controlStates: SubnavStories.args.controlStates,
},
} as Meta<typeof Interaction>;
export const Active: Story = {
args: {
call: getCalls(CallStates.ACTIVE).slice(-1)[0],
},
};
export const Waiting: Story = {
args: {
call: getCalls(CallStates.WAITING).slice(-1)[0],
},
};
export const Failed: Story = {
args: {
call: getCalls(CallStates.ERROR).slice(-1)[0],
},
};
export const Done: Story = {
args: {
call: getCalls(CallStates.DONE).slice(-1)[0],
},
};
export const WithParent: Story = {
args: {
call: { ...getCalls(CallStates.DONE).slice(-1)[0], ancestors: ['parent-id'] },
},
};
export const Disabled: Story = {
args: { ...Done.args, controlStates: { ...SubnavStories.args.controlStates, goto: false } },
};
export const Hovered: Story = {
...Done,
globals: { sb_theme: 'light' },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.hover(canvas.getByRole('button'));
await expect(canvas.getByTestId('icon-active')).toBeInTheDocument();
},
};

View File

@ -0,0 +1,208 @@
import * as React from 'react';
import { IconButton, TooltipNote, WithTooltip } from 'storybook/internal/components';
import { styled, typography } from 'storybook/internal/theming';
import { ListUnorderedIcon } from '@storybook/icons';
import { type Call, CallStates, type ControlStates } from '@storybook/instrumenter';
import { transparentize } from 'polished';
import { isChaiError, isJestError, useAnsiToHtmlFilter } from '../utils';
import type { Controls } from './InteractionsPanel';
import { MatcherResult } from './MatcherResult';
import { MethodCall } from './MethodCall';
import { StatusIcon } from './StatusIcon';
const MethodCallWrapper = styled.div(() => ({
fontFamily: typography.fonts.mono,
fontSize: typography.size.s1,
overflowWrap: 'break-word',
inlineSize: 'calc( 100% - 40px )',
}));
const RowContainer = styled('div', {
shouldForwardProp: (prop) => !['call', 'pausedAt'].includes(prop.toString()),
})<{ call: Call; pausedAt: Call['id'] }>(
({ theme, call }) => ({
position: 'relative',
display: 'flex',
flexDirection: 'column',
borderBottom: `1px solid ${theme.appBorderColor}`,
fontFamily: typography.fonts.base,
fontSize: 13,
...(call.status === CallStates.ERROR && {
backgroundColor:
theme.base === 'dark'
? transparentize(0.93, theme.color.negative)
: theme.background.warning,
}),
paddingLeft: call.ancestors.length * 20,
}),
({ theme, call, pausedAt }) =>
pausedAt === call.id && {
'&::before': {
content: '""',
position: 'absolute',
top: -5,
zIndex: 1,
borderTop: '4.5px solid transparent',
borderLeft: `7px solid ${theme.color.warning}`,
borderBottom: '4.5px solid transparent',
},
'&::after': {
content: '""',
position: 'absolute',
top: -1,
zIndex: 1,
width: '100%',
borderTop: `1.5px solid ${theme.color.warning}`,
},
}
);
const RowHeader = styled.div<{ isInteractive: boolean }>(({ theme, isInteractive }) => ({
display: 'flex',
'&:hover': isInteractive ? {} : { background: theme.background.hoverable },
}));
const RowLabel = styled('button', {
shouldForwardProp: (prop) => !['call'].includes(prop.toString()),
})<React.ButtonHTMLAttributes<HTMLButtonElement> & { call: Call }>(({ theme, disabled, call }) => ({
flex: 1,
display: 'grid',
background: 'none',
border: 0,
gridTemplateColumns: '15px 1fr',
alignItems: 'center',
minHeight: 40,
margin: 0,
padding: '8px 15px',
textAlign: 'start',
cursor: disabled || call.status === CallStates.ERROR ? 'default' : 'pointer',
'&:focus-visible': {
outline: 0,
boxShadow: `inset 3px 0 0 0 ${
call.status === CallStates.ERROR ? theme.color.warning : theme.color.secondary
}`,
background: call.status === CallStates.ERROR ? 'transparent' : theme.background.hoverable,
},
'& > div': {
opacity: call.status === CallStates.WAITING ? 0.5 : 1,
},
}));
const RowActions = styled.div({
padding: 6,
});
export const StyledIconButton = styled(IconButton as any)(({ theme }) => ({
color: theme.textMutedColor,
margin: '0 3px',
}));
const Note = styled(TooltipNote)(({ theme }) => ({
fontFamily: theme.typography.fonts.base,
}));
const RowMessage = styled('div')(({ theme }) => ({
padding: '8px 10px 8px 36px',
fontSize: typography.size.s1,
color: theme.color.defaultText,
pre: {
margin: 0,
padding: 0,
},
}));
export const Exception = ({ exception }: { exception: Call['exception'] }) => {
const filter = useAnsiToHtmlFilter();
if (isJestError(exception)) {
return <MatcherResult {...exception} />;
}
if (isChaiError(exception)) {
return (
<RowMessage>
<MatcherResult
message={`${exception.message}${exception.diff ? `\n\n${exception.diff}` : ''}`}
style={{ padding: 0 }}
/>
<p>See the full stack trace in the browser console.</p>
</RowMessage>
);
}
const paragraphs = exception.message.split('\n\n');
const more = paragraphs.length > 1;
return (
<RowMessage>
<pre dangerouslySetInnerHTML={{ __html: filter.toHtml(paragraphs[0]) }}></pre>
{more && <p>See the full stack trace in the browser console.</p>}
</RowMessage>
);
};
export const Interaction = ({
call,
callsById,
controls,
controlStates,
childCallIds,
isHidden,
isCollapsed,
toggleCollapsed,
pausedAt,
}: {
call: Call;
callsById: Map<Call['id'], Call>;
controls: Controls;
controlStates: ControlStates;
childCallIds?: Call['id'][];
isHidden: boolean;
isCollapsed: boolean;
toggleCollapsed: () => void;
pausedAt?: Call['id'];
}) => {
const [isHovered, setIsHovered] = React.useState(false);
const isInteractive = !controlStates.goto || !call.interceptable || !!call.ancestors.length;
if (isHidden) {
return null;
}
return (
<RowContainer call={call} pausedAt={pausedAt}>
<RowHeader isInteractive={isInteractive}>
<RowLabel
aria-label="Interaction step"
call={call}
onClick={() => controls.goto(call.id)}
disabled={isInteractive}
onMouseEnter={() => controlStates.goto && setIsHovered(true)}
onMouseLeave={() => controlStates.goto && setIsHovered(false)}
>
<StatusIcon status={isHovered ? CallStates.ACTIVE : call.status} />
<MethodCallWrapper style={{ marginLeft: 6, marginBottom: 1 }}>
<MethodCall call={call} callsById={callsById} />
</MethodCallWrapper>
</RowLabel>
<RowActions>
{childCallIds?.length > 0 && (
<WithTooltip
hasChrome={false}
tooltip={<Note note={`${isCollapsed ? 'Show' : 'Hide'} interactions`} />}
>
<StyledIconButton onClick={toggleCollapsed}>
<ListUnorderedIcon />
</StyledIconButton>
</WithTooltip>
)}
</RowActions>
</RowHeader>
{call.status === CallStates.ERROR && call.exception?.callId === call.id && (
<Exception exception={call.exception} />
)}
</RowContainer>
);
};

View File

@ -0,0 +1,136 @@
import React from 'react';
import { styled } from 'storybook/internal/theming';
import { CallStates } from '@storybook/instrumenter';
import type { Meta, StoryObj } from '@storybook/react';
import { expect, userEvent, waitFor, within } from '@storybook/test';
import { isChromatic } from '../../../../.storybook/isChromatic';
import { getCalls, getInteractions } from '../mocks';
import { InteractionsPanel } from './InteractionsPanel';
import SubnavStories from './Subnav.stories';
const StyledWrapper = styled.div(({ theme }) => ({
backgroundColor: theme.background.content,
color: theme.color.defaultText,
display: 'block',
height: '100%',
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
overflow: 'auto',
}));
const interactions = getInteractions(CallStates.DONE);
const meta = {
title: 'InteractionsPanel',
component: InteractionsPanel,
decorators: [
(Story: any) => (
<StyledWrapper id="panel-tab-content">
<Story />
</StyledWrapper>
),
],
parameters: { layout: 'fullscreen' },
args: {
calls: new Map(getCalls(CallStates.DONE).map((call) => [call.id, call])),
controls: SubnavStories.args.controls,
controlStates: SubnavStories.args.controlStates,
interactions,
fileName: 'addon-interactions.stories.tsx',
hasException: false,
isPlaying: false,
onScrollToEnd: () => {},
endRef: null,
// prop for the AddonPanel used as wrapper of Panel
active: true,
},
} as Meta<typeof InteractionsPanel>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Passing: Story = {
args: {
interactions: getInteractions(CallStates.DONE),
},
play: async ({ args, canvasElement }) => {
if (isChromatic()) {
return;
}
const canvas = within(canvasElement);
await waitFor(async () => {
await userEvent.click(canvas.getByLabelText('Go to start'));
await expect(args.controls.start).toHaveBeenCalled();
});
await waitFor(async () => {
await userEvent.click(canvas.getByLabelText('Go back'));
await expect(args.controls.back).toHaveBeenCalled();
});
await waitFor(async () => {
await userEvent.click(canvas.getByLabelText('Go forward'));
await expect(args.controls.next).not.toHaveBeenCalled();
});
await waitFor(async () => {
await userEvent.click(canvas.getByLabelText('Go to end'));
await expect(args.controls.end).not.toHaveBeenCalled();
});
await waitFor(async () => {
await userEvent.click(canvas.getByLabelText('Rerun'));
await expect(args.controls.rerun).toHaveBeenCalled();
});
},
};
export const Paused: Story = {
args: {
isPlaying: true,
interactions: getInteractions(CallStates.WAITING),
controlStates: {
start: false,
back: false,
goto: true,
next: true,
end: true,
},
pausedAt: interactions[interactions.length - 1].id,
},
};
export const Playing: Story = {
args: {
isPlaying: true,
interactions: getInteractions(CallStates.ACTIVE),
},
};
export const Failed: Story = {
args: {
hasException: true,
interactions: getInteractions(CallStates.ERROR),
},
};
// export const NoInteractions: Story = {
// args: {
// interactions: [],
// },
// };
export const CaughtException: Story = {
args: {
hasException: true,
interactions: [],
caughtException: new TypeError("Cannot read properties of undefined (reading 'args')"),
},
};

View File

@ -0,0 +1,176 @@
import * as React from 'react';
import { styled } from 'storybook/internal/theming';
import { type Call, CallStates, type ControlStates } from '@storybook/instrumenter';
import { transparentize } from 'polished';
import { isTestAssertionError, useAnsiToHtmlFilter } from '../utils';
import { Empty } from './EmptyState';
import { Interaction } from './Interaction';
import { Subnav } from './Subnav';
export interface Controls {
start: (args: any) => void;
back: (args: any) => void;
goto: (args: any) => void;
next: (args: any) => void;
end: (args: any) => void;
rerun: (args: any) => void;
}
interface InteractionsPanelProps {
controls: Controls;
controlStates: ControlStates;
interactions: (Call & {
status?: CallStates;
childCallIds: Call['id'][];
isHidden: boolean;
isCollapsed: boolean;
toggleCollapsed: () => void;
})[];
fileName?: string;
hasException?: boolean;
caughtException?: Error;
unhandledErrors?: SerializedError[];
isPlaying?: boolean;
pausedAt?: Call['id'];
calls: Map<string, any>;
endRef?: React.Ref<HTMLDivElement>;
onScrollToEnd?: () => void;
}
const Container = styled.div(({ theme }) => ({
height: '100%',
background: theme.background.content,
}));
const CaughtException = styled.div(({ theme }) => ({
borderBottom: `1px solid ${theme.appBorderColor}`,
backgroundColor:
theme.base === 'dark' ? transparentize(0.93, theme.color.negative) : theme.background.warning,
padding: 15,
fontSize: theme.typography.size.s2 - 1,
lineHeight: '19px',
}));
const CaughtExceptionCode = styled.code(({ theme }) => ({
margin: '0 1px',
padding: 3,
fontSize: theme.typography.size.s1 - 1,
lineHeight: 1,
verticalAlign: 'top',
background: 'rgba(0, 0, 0, 0.05)',
border: `1px solid ${theme.appBorderColor}`,
borderRadius: 3,
}));
const CaughtExceptionTitle = styled.div({
paddingBottom: 4,
fontWeight: 'bold',
});
const CaughtExceptionDescription = styled.p({
margin: 0,
padding: '0 0 20px',
});
const CaughtExceptionStack = styled.pre(({ theme }) => ({
margin: 0,
padding: 0,
'&:not(:last-child)': {
paddingBottom: 16,
},
fontSize: theme.typography.size.s1 - 1,
}));
export const InteractionsPanel: React.FC<InteractionsPanelProps> = React.memo(
function InteractionsPanel({
calls,
controls,
controlStates,
interactions,
fileName,
hasException,
caughtException,
unhandledErrors,
isPlaying,
pausedAt,
onScrollToEnd,
endRef,
}) {
const filter = useAnsiToHtmlFilter();
return (
<Container>
{(interactions.length > 0 || hasException) && (
<Subnav
controls={controls}
controlStates={controlStates}
status={
isPlaying ? CallStates.ACTIVE : hasException ? CallStates.ERROR : CallStates.DONE
}
storyFileName={fileName}
onScrollToEnd={onScrollToEnd}
/>
)}
<div aria-label="Interactions list">
{interactions.map((call) => (
<Interaction
key={call.id}
call={call}
callsById={calls}
controls={controls}
controlStates={controlStates}
childCallIds={call.childCallIds}
isHidden={call.isHidden}
isCollapsed={call.isCollapsed}
toggleCollapsed={call.toggleCollapsed}
pausedAt={pausedAt}
/>
))}
</div>
{caughtException && !isTestAssertionError(caughtException) && (
<CaughtException>
<CaughtExceptionTitle>
Caught exception in <CaughtExceptionCode>play</CaughtExceptionCode> function
</CaughtExceptionTitle>
<CaughtExceptionStack
data-chromatic="ignore"
dangerouslySetInnerHTML={{
__html: filter.toHtml(printSerializedError(caughtException)),
}}
></CaughtExceptionStack>
</CaughtException>
)}
{unhandledErrors && (
<CaughtException>
<CaughtExceptionTitle>Unhandled Errors</CaughtExceptionTitle>
<CaughtExceptionDescription>
Found {unhandledErrors.length} unhandled error{unhandledErrors.length > 1 ? 's' : ''}{' '}
while running the play function. This might cause false positive assertions. Resolve
unhandled errors or ignore unhandled errors with setting the
<CaughtExceptionCode>test.dangerouslyIgnoreUnhandledErrors</CaughtExceptionCode>{' '}
parameter to <CaughtExceptionCode>true</CaughtExceptionCode>.
</CaughtExceptionDescription>
{unhandledErrors.map((error, i) => (
<CaughtExceptionStack key={i} data-chromatic="ignore">
{printSerializedError(error)}
</CaughtExceptionStack>
))}
</CaughtException>
)}
<div ref={endRef} />
{!isPlaying && !caughtException && interactions.length === 0 && <Empty />}
</Container>
);
}
);
interface SerializedError {
name: string;
stack?: string;
message: string;
}
function printSerializedError(error: SerializedError) {
return error.stack || `${error.name}: ${error.message}`;
}

View File

@ -0,0 +1,93 @@
import React, { Fragment, useState } from 'react';
import { convert, styled, themes } from 'storybook/internal/theming';
import { ChevronSmallDownIcon } from '@storybook/icons';
const ListWrapper = styled.ul({
listStyle: 'none',
fontSize: 14,
padding: 0,
margin: 0,
});
const Wrapper = styled.div({
display: 'flex',
width: '100%',
borderBottom: `1px solid ${convert(themes.light).appBorderColor}`,
'&:hover': {
background: convert(themes.light).background.hoverable,
},
});
const Icon = styled(ChevronSmallDownIcon)({
color: convert(themes.light).textMutedColor,
marginRight: 10,
transition: 'transform 0.1s ease-in-out',
alignSelf: 'center',
display: 'inline-flex',
});
const HeaderBar = styled.div({
padding: convert(themes.light).layoutMargin,
paddingLeft: convert(themes.light).layoutMargin - 3,
background: 'none',
color: 'inherit',
textAlign: 'left',
cursor: 'pointer',
borderLeft: '3px solid transparent',
width: '100%',
'&:focus': {
outline: '0 none',
borderLeft: `3px solid ${convert(themes.light).color.secondary}`,
},
});
const Description = styled.div({
padding: convert(themes.light).layoutMargin,
marginBottom: convert(themes.light).layoutMargin,
fontStyle: 'italic',
});
type Item = {
title: string;
description: string;
};
interface ListItemProps {
item: Item;
}
export const ListItem: React.FC<ListItemProps> = ({ item }) => {
const [open, onToggle] = useState(false);
return (
<Fragment>
<Wrapper>
<HeaderBar onClick={() => onToggle(!open)} role="button">
<Icon
color={convert(themes.light).appBorderColor}
style={{
transform: `rotate(${open ? 0 : -90}deg)`,
}}
/>
{item.title}
</HeaderBar>
</Wrapper>
{open ? <Description>{item.description}</Description> : null}
</Fragment>
);
};
interface ListProps {
items: Item[];
}
export const List: React.FC<ListProps> = ({ items }) => (
<ListWrapper>
{items.map((item) => (
<ListItem key={item.title + item.description} item={item} />
))}
</ListWrapper>
);

View File

@ -0,0 +1,113 @@
import React from 'react';
import { styled } from 'storybook/internal/theming';
import { dedent } from 'ts-dedent';
import { MatcherResult } from './MatcherResult';
const StyledWrapper = styled.div(({ theme }) => ({
backgroundColor: theme.background.content,
padding: '12px 0',
boxShadow: `0 0 0 1px ${theme.appBorderColor}`,
color: theme.color.defaultText,
fontSize: 13,
}));
export default {
title: 'MatcherResult',
component: MatcherResult,
decorators: [
(Story: any) => (
<StyledWrapper>
<Story />
</StyledWrapper>
),
],
parameters: {
layout: 'fullscreen',
},
};
export const Expected = {
args: {
message: dedent`
expected last "spy" call to have been called with [ { (2) } ]
- Expected:
Array [
Object {
"email": "michael@chromatic.com",
"password": "testpasswordthatwontfail",
},
]
+ Received:
undefined
`,
},
};
export const ExpectedReceived = {
args: {
message: dedent`
expected last "spy" call to have been called with []
- Expected
+ Received
- Array []
+ Array [
+ Object {
+ "email": "michael@chromatic.com",
+ "password": "testpasswordthatwontfail",
+ },
+ ]
`,
},
};
export const ExpectedNumberOfCalls = {
args: {
message: dedent`
expected "spy" to not be called at all, but actually been called 1 times
Received:
1st spy call:
Array [
Object {
"email": "michael@chromatic.com",
"password": "testpasswordthatwontfail",
},
]
Number of calls: 1
`,
},
};
export const Diff = {
args: {
message: dedent`
expected "spy" to be called with arguments: [ { (2) } ]
Received:
1st spy call:
Array [
Object {
- "email": "michael@chromaui.com",
+ "email": "michael@chromatic.com",
"password": "testpasswordthatwontfail",
},
]
Number of calls: 1
`,
},
};

View File

@ -0,0 +1,146 @@
import React from 'react';
import { styled, typography } from 'storybook/internal/theming';
import { useAnsiToHtmlFilter } from '../utils';
import { Node } from './MethodCall';
const getParams = (line: string, fromIndex = 0): string => {
for (let i = fromIndex, depth = 1; i < line.length; i += 1) {
if (line[i] === '(') {
depth += 1;
} else if (line[i] === ')') {
depth -= 1;
}
if (depth === 0) {
return line.slice(fromIndex, i);
}
}
return '';
};
const parseValue = (value: string): any => {
try {
return value === 'undefined' ? undefined : JSON.parse(value);
} catch (e) {
return value;
}
};
const StyledExpected = styled.span(({ theme }) => ({
color: theme.base === 'light' ? theme.color.positiveText : theme.color.positive,
}));
const StyledReceived = styled.span(({ theme }) => ({
color: theme.base === 'light' ? theme.color.negativeText : theme.color.negative,
}));
export const Received = ({ value, parsed }: { value: any; parsed?: boolean }) =>
parsed ? (
<Node showObjectInspector value={value} style={{ color: '#D43900' }} />
) : (
<StyledReceived>{value}</StyledReceived>
);
export const Expected = ({ value, parsed }: { value: any; parsed?: boolean }) => {
if (parsed) {
if (typeof value === 'string' && value.startsWith('called with')) {
return <>{value}</>;
}
return <Node showObjectInspector value={value} style={{ color: '#16B242' }} />;
}
return <StyledExpected>{value}</StyledExpected>;
};
export const MatcherResult = ({
message,
style = {},
}: {
message: string;
style?: React.CSSProperties;
}) => {
const filter = useAnsiToHtmlFilter();
const lines = message.split('\n');
return (
<pre
style={{
margin: 0,
padding: '8px 10px 8px 36px',
fontSize: typography.size.s1,
...style,
}}
>
{lines.flatMap((line: string, index: number) => {
if (line.startsWith('expect(')) {
const received = getParams(line, 7);
const remainderIndex = received && 7 + received.length;
const matcher = received && line.slice(remainderIndex).match(/\.(to|last|nth)[A-Z]\w+\(/);
if (matcher) {
const expectedIndex = remainderIndex + matcher.index + matcher[0].length;
const expected = getParams(line, expectedIndex);
if (expected) {
return [
'expect(',
<Received key={`received_${received}`} value={received} />,
line.slice(remainderIndex, expectedIndex),
<Expected key={`expected_${expected}`} value={expected} />,
line.slice(expectedIndex + expected.length),
<br key={`br${index}`} />,
];
}
}
}
if (line.match(/^\s*- /)) {
return [<Expected key={line + index} value={line} />, <br key={`br${index}`} />];
}
if (line.match(/^\s*\+ /) || line.match(/^Received: $/)) {
return [<Received key={line + index} value={line} />, <br key={`br${index}`} />];
}
const [, assertionLabel, assertionValue] = line.match(/^(Expected|Received): (.*)$/) || [];
if (assertionLabel && assertionValue) {
return assertionLabel === 'Expected'
? [
'Expected: ',
<Expected key={line + index} value={parseValue(assertionValue)} parsed />,
<br key={`br${index}`} />,
]
: [
'Received: ',
<Received key={line + index} value={parseValue(assertionValue)} parsed />,
<br key={`br${index}`} />,
];
}
const [, prefix, numberOfCalls] =
line.match(/(Expected number|Received number|Number) of calls: (\d+)$/i) || [];
if (prefix && numberOfCalls) {
return [
`${prefix} of calls: `,
<Node key={line + index} value={Number(numberOfCalls)} />,
<br key={`br${index}`} />,
];
}
const [, receivedValue] = line.match(/^Received has value: (.+)$/) || [];
if (receivedValue) {
return [
'Received has value: ',
<Node key={line + index} value={parseValue(receivedValue)} />,
<br key={`br${index}`} />,
];
}
return [
<span
key={line + index}
dangerouslySetInnerHTML={{ __html: filter.toHtml(line) }}
></span>,
<br key={`br${index}`} />,
];
})}
</pre>
);
};

View File

@ -0,0 +1,219 @@
import React from 'react';
import { styled, typography } from 'storybook/internal/theming';
import type { Call } from '@storybook/instrumenter';
import { MethodCall, Node } from './MethodCall';
const StyledWrapper = styled.div(({ theme }) => ({
backgroundColor: theme.background.content,
padding: '20px',
boxShadow: `0 0 0 1px ${theme.appBorderColor}`,
color: theme.color.defaultText,
fontFamily: typography.fonts.mono,
fontSize: typography.size.s1,
}));
export default {
title: 'MethodCall',
component: MethodCall,
decorators: [
(Story: any) => (
<StyledWrapper>
<Story />
</StyledWrapper>
),
],
parameters: {
layout: 'fullscreen',
},
};
export const Args = () => (
<div style={{ display: 'inline-flex', flexDirection: 'column', gap: 10 }}>
<Node value={null} />
<Node value={undefined} />
<Node value="Hello world" />
<Node value="https://github.com/storybookjs/storybook/blob/next/README.md" />
<Node value="012345678901234567890123456789012345678901234567890123456789" />
{}
<Node value={true} />
<Node value={false} />
<Node value={12345} />
<Node value={['foo', 1, { hello: 'world' }]} />
<Node value={[...Array(23)].map((_, i) => i)} />
<Node value={{ hello: 'world' }} />
<Node value={{ hello: 'world', arr: [1, 2, 3], more: true }} />
<Node value={{ hello: 'world', arr: [1, 2, 3], more: true }} showObjectInspector />
<Node
value={{
hello: 'world',
arr: [1, 2, 3],
more: true,
regex: /regex/,
class: class DummyClass {},
fn: () => 123,
asyncFn: async () => 'hello',
}}
showObjectInspector
/>
<Node value={{ __class__: { name: 'FooBar' } }} />
<Node value={{ __function__: { name: 'goFaster' } }} />
<Node value={{ __function__: { name: '' } }} />
<Node value={{ __element__: { localName: 'hr' } }} />
<Node value={{ __element__: { localName: 'foo', prefix: 'x' } }} />
<Node value={{ __element__: { localName: 'div', id: 'foo' } }} />
<Node value={{ __element__: { localName: 'span', classNames: ['foo', 'bar'] } }} />
<Node value={{ __element__: { localName: 'button', innerText: 'Click me' } }} />
<Node
value={{ __date__: { value: new Date(Date.UTC(2012, 11, 20, 0, 0, 0)).toISOString() } }}
/>
<Node value={{ __date__: { value: new Date(1600000000000).toISOString() } }} />
<Node value={{ __date__: { value: new Date(1600000000123).toISOString() } }} />
<Node value={{ __error__: { name: 'EvalError', message: '' } }} />
<Node value={{ __error__: { name: 'SyntaxError', message: "Can't do that" } }} />
<Node
value={{
__error__: { name: 'TypeError', message: "Cannot read property 'foo' of undefined" },
}}
/>
<Node
value={{
__error__: { name: 'ReferenceError', message: 'Invalid left-hand side in assignment' },
}}
/>
<Node
value={{
__error__: {
name: 'Error',
message:
"XMLHttpRequest cannot load https://example.com. No 'Access-Control-Allow-Origin' header is present on the requested resource.",
},
}}
/>
<Node value={{ __regexp__: { flags: 'i', source: 'hello' } }} />
<Node value={{ __regexp__: { flags: '', source: 'src(.*)\\.js$' } }} />
<Node value={{ __symbol__: { description: '' } }} />
<Node value={{ __symbol__: { description: 'Hello world' } }} />
</div>
);
const calls: Call[] = [
{
cursor: 0,
id: '1',
ancestors: [],
path: ['screen'],
method: 'getByText',
storyId: 'kind--story',
args: ['Click'],
interceptable: false,
retain: false,
},
{
cursor: 1,
id: '2',
ancestors: [],
path: ['userEvent'],
method: 'click',
storyId: 'kind--story',
args: [{ __callId__: '1' }],
interceptable: true,
retain: false,
},
{
cursor: 2,
id: '3',
ancestors: [],
path: [],
method: 'expect',
storyId: 'kind--story',
args: [true],
interceptable: true,
retain: false,
},
{
cursor: 3,
id: '4',
ancestors: [],
path: [{ __callId__: '3' }, 'not'],
method: 'toBe',
storyId: 'kind--story',
args: [false],
interceptable: true,
retain: false,
},
{
cursor: 4,
id: '5',
ancestors: [],
path: ['jest'],
method: 'fn',
storyId: 'kind--story',
args: [{ __function__: { name: 'actionHandler' } }],
interceptable: false,
retain: false,
},
{
cursor: 5,
id: '6',
ancestors: [],
path: [],
method: 'expect',
storyId: 'kind--story',
args: [{ __callId__: '5' }],
interceptable: false,
retain: false,
},
{
cursor: 6,
id: '7',
ancestors: [],
path: ['expect'],
method: 'stringMatching',
storyId: 'kind--story',
args: [{ __regexp__: { flags: 'i', source: 'hello' } }],
interceptable: false,
retain: false,
},
{
cursor: 7,
id: '8',
ancestors: [],
path: [{ __callId__: '6' }, 'not'],
method: 'toHaveBeenCalledWith',
storyId: 'kind--story',
args: [
{ __callId__: '7' },
[
{ __error__: { name: 'Error', message: "Cannot read property 'foo' of undefined" } },
{ __symbol__: { description: 'Hello world' } },
],
],
interceptable: false,
retain: false,
},
{
cursor: 8,
id: '9',
ancestors: [],
path: [],
method: 'step',
storyId: 'kind--story',
args: ['Custom step label', { __function__: { name: '' } }],
interceptable: true,
retain: false,
},
];
const callsById = calls.reduce((acc, call) => {
acc.set(call.id, call);
return acc;
}, new Map<Call['id'], Call>());
export const Step = () => <MethodCall call={callsById.get('9')} callsById={callsById} />;
export const Simple = () => <MethodCall call={callsById.get('1')} callsById={callsById} />;
export const Nested = () => <MethodCall call={callsById.get('2')} callsById={callsById} />;
export const Chained = () => <MethodCall call={callsById.get('4')} callsById={callsById} />;
export const Complex = () => <MethodCall call={callsById.get('8')} callsById={callsById} />;

View File

@ -0,0 +1,465 @@
import type { ReactElement } from 'react';
import React, { Fragment } from 'react';
import { useTheme } from 'storybook/internal/theming';
import type { Call, CallRef, ElementRef } from '@storybook/instrumenter';
import { ObjectInspector } from '@devtools-ds/object-inspector';
const colorsLight = {
base: '#444',
nullish: '#7D99AA',
string: '#16B242',
number: '#5D40D0',
boolean: '#f41840',
objectkey: '#698394',
instance: '#A15C20',
function: '#EA7509',
muted: '#7D99AA',
tag: {
name: '#6F2CAC',
suffix: '#1F99E5',
},
date: '#459D9C',
error: {
name: '#D43900',
message: '#444',
},
regex: {
source: '#A15C20',
flags: '#EA7509',
},
meta: '#EA7509',
method: '#0271B6',
};
const colorsDark = {
base: '#eee',
nullish: '#aaa',
string: '#5FE584',
number: '#6ba5ff',
boolean: '#ff4191',
objectkey: '#accfe6',
instance: '#E3B551',
function: '#E3B551',
muted: '#aaa',
tag: {
name: '#f57bff',
suffix: '#8EB5FF',
},
date: '#70D4D3',
error: {
name: '#f40',
message: '#eee',
},
regex: {
source: '#FAD483',
flags: '#E3B551',
},
meta: '#FAD483',
method: '#5EC1FF',
};
const useThemeColors = () => {
const { base } = useTheme();
return base === 'dark' ? colorsDark : colorsLight;
};
const special = /[^A-Z0-9]/i;
const trimEnd = /[\s.,…]+$/gm;
const ellipsize = (string: string, maxlength: number): string => {
if (string.length <= maxlength) {
return string;
}
for (let i = maxlength - 1; i >= 0; i -= 1) {
if (special.test(string[i]) && i > 10) {
return `${string.slice(0, i).replace(trimEnd, '')}`;
}
}
return `${string.slice(0, maxlength).replace(trimEnd, '')}`;
};
const stringify = (value: any) => {
try {
return JSON.stringify(value, null, 1);
} catch (e) {
return String(value);
}
};
const interleave = (nodes: ReactElement[], separator: ReactElement) =>
nodes.flatMap((node, index) =>
index === nodes.length - 1
? [node]
: [node, React.cloneElement(separator, { key: `sep${index}` })]
);
export const Node = ({
value,
nested,
showObjectInspector,
callsById,
...props
}: {
value: any;
nested?: boolean;
/** Shows an object inspector instead of just printing the object. Only available for Objects */
showObjectInspector?: boolean;
callsById?: Map<Call['id'], Call>;
[props: string]: any;
}) => {
switch (true) {
case value === null:
return <NullNode {...props} />;
case value === undefined:
return <UndefinedNode {...props} />;
case Array.isArray(value):
return <ArrayNode {...props} value={value} callsById={callsById} />;
case typeof value === 'string':
return <StringNode {...props} value={value} />;
case typeof value === 'number':
return <NumberNode {...props} value={value} />;
case typeof value === 'boolean':
return <BooleanNode {...props} value={value} />;
/* eslint-disable no-underscore-dangle */
case Object.prototype.hasOwnProperty.call(value, '__date__'):
return <DateNode {...props} {...value.__date__} />;
case Object.prototype.hasOwnProperty.call(value, '__error__'):
return <ErrorNode {...props} {...value.__error__} />;
case Object.prototype.hasOwnProperty.call(value, '__regexp__'):
return <RegExpNode {...props} {...value.__regexp__} />;
case Object.prototype.hasOwnProperty.call(value, '__function__'):
return <FunctionNode {...props} {...value.__function__} />;
case Object.prototype.hasOwnProperty.call(value, '__symbol__'):
return <SymbolNode {...props} {...value.__symbol__} />;
case Object.prototype.hasOwnProperty.call(value, '__element__'):
return <ElementNode {...props} {...value.__element__} />;
case Object.prototype.hasOwnProperty.call(value, '__class__'):
return <ClassNode {...props} {...value.__class__} />;
case Object.prototype.hasOwnProperty.call(value, '__callId__'):
return <MethodCall call={callsById.get(value.__callId__)} callsById={callsById} />;
/* eslint-enable no-underscore-dangle */
case Object.prototype.toString.call(value) === '[object Object]':
return (
<ObjectNode
value={value}
showInspector={showObjectInspector}
callsById={callsById}
{...props}
/>
);
default:
return <OtherNode value={value} {...props} />;
}
};
export const NullNode = (props: object) => {
const colors = useThemeColors();
return (
<span style={{ color: colors.nullish }} {...props}>
null
</span>
);
};
export const UndefinedNode = (props: object) => {
const colors = useThemeColors();
return (
<span style={{ color: colors.nullish }} {...props}>
undefined
</span>
);
};
export const StringNode = ({ value, ...props }: { value: string }) => {
const colors = useThemeColors();
return (
<span style={{ color: colors.string }} {...props}>
{JSON.stringify(ellipsize(value, 50))}
</span>
);
};
export const NumberNode = ({ value, ...props }: { value: number }) => {
const colors = useThemeColors();
return (
<span style={{ color: colors.number }} {...props}>
{value}
</span>
);
};
export const BooleanNode = ({ value, ...props }: { value: boolean }) => {
const colors = useThemeColors();
return (
<span style={{ color: colors.boolean }} {...props}>
{String(value)}
</span>
);
};
export const ArrayNode = ({
value,
nested = false,
callsById,
}: {
value: any[];
nested?: boolean;
callsById?: Map<Call['id'], Call>;
}) => {
const colors = useThemeColors();
if (nested) {
return <span style={{ color: colors.base }}>[]</span>;
}
const nodes = value
.slice(0, 3)
.map((v, index) => (
<Node key={`${index}--${JSON.stringify(v)}`} value={v} nested callsById={callsById} />
));
const nodelist = interleave(nodes, <span>, </span>);
if (value.length <= 3) {
return <span style={{ color: colors.base }}>[{nodelist}]</span>;
}
return (
<span style={{ color: colors.base }}>
({value.length}) [{nodelist}, ]
</span>
);
};
export const ObjectNode = ({
showInspector,
value,
callsById,
nested = false,
}: {
showInspector?: boolean;
value: object;
nested?: boolean;
callsById?: Map<Call['id'], Call>;
}) => {
const isDarkMode = useTheme().base === 'dark';
const colors = useThemeColors();
if (showInspector) {
return (
<>
<ObjectInspector
id="interactions-object-inspector"
data={value}
includePrototypes={false}
colorScheme={isDarkMode ? 'dark' : 'light'}
/>
</>
);
}
if (nested) {
return <span style={{ color: colors.base }}>{'{…}'}</span>;
}
const nodelist = interleave(
Object.entries(value)
.slice(0, 2)
.map(([k, v]) => (
<Fragment key={k}>
<span style={{ color: colors.objectkey }}>{k}: </span>
<Node value={v} callsById={callsById} nested />
</Fragment>
)),
<span>, </span>
);
if (Object.keys(value).length <= 2) {
return (
<span style={{ color: colors.base }}>
{'{ '}
{nodelist}
{' }'}
</span>
);
}
return (
<span style={{ color: colors.base }}>
({Object.keys(value).length}) {'{ '}
{nodelist}
{', … }'}
</span>
);
};
export const ClassNode = ({ name }: { name: string }) => {
const colors = useThemeColors();
return <span style={{ color: colors.instance }}>{name}</span>;
};
export const FunctionNode = ({ name }: { name: string }) => {
const colors = useThemeColors();
return name ? (
<span style={{ color: colors.function }}>{name}</span>
) : (
<span style={{ color: colors.nullish, fontStyle: 'italic' }}>anonymous</span>
);
};
export const ElementNode = ({
prefix,
localName,
id,
classNames = [],
innerText,
}: ElementRef['__element__']) => {
const name = prefix ? `${prefix}:${localName}` : localName;
const colors = useThemeColors();
return (
<span style={{ wordBreak: 'keep-all' }}>
<span key={`${name}_lt`} style={{ color: colors.muted }}>
&lt;
</span>
<span key={`${name}_tag`} style={{ color: colors.tag.name }}>
{name}
</span>
<span key={`${name}_suffix`} style={{ color: colors.tag.suffix }}>
{id ? `#${id}` : classNames.reduce((acc, className) => `${acc}.${className}`, '')}
</span>
<span key={`${name}_gt`} style={{ color: colors.muted }}>
&gt;
</span>
{!id && classNames.length === 0 && innerText && (
<>
<span key={`${name}_text`}>{innerText}</span>
<span key={`${name}_close_lt`} style={{ color: colors.muted }}>
&lt;
</span>
<span key={`${name}_close_tag`} style={{ color: colors.tag.name }}>
/{name}
</span>
<span key={`${name}_close_gt`} style={{ color: colors.muted }}>
&gt;
</span>
</>
)}
</span>
);
};
export const DateNode = ({ value }: { value: string }) => {
const [date, time, ms] = value.split(/[T.Z]/);
const colors = useThemeColors();
return (
<span style={{ whiteSpace: 'nowrap', color: colors.date }}>
{date}
<span style={{ opacity: 0.7 }}>T</span>
{time === '00:00:00' ? <span style={{ opacity: 0.7 }}>{time}</span> : time}
{ms === '000' ? <span style={{ opacity: 0.7 }}>.{ms}</span> : `.${ms}`}
<span style={{ opacity: 0.7 }}>Z</span>
</span>
);
};
export const ErrorNode = ({ name, message }: { name: string; message: string }) => {
const colors = useThemeColors();
return (
<span style={{ color: colors.error.name }}>
{name}
{message && ': '}
{message && (
<span style={{ color: colors.error.message }} title={message.length > 50 ? message : ''}>
{ellipsize(message, 50)}
</span>
)}
</span>
);
};
export const RegExpNode = ({ flags, source }: { flags: string; source: string }) => {
const colors = useThemeColors();
return (
<span style={{ whiteSpace: 'nowrap', color: colors.regex.flags }}>
/<span style={{ color: colors.regex.source }}>{source}</span>/{flags}
</span>
);
};
export const SymbolNode = ({ description }: { description: string }) => {
const colors = useThemeColors();
return (
<span style={{ whiteSpace: 'nowrap', color: colors.instance }}>
Symbol(
{description && <span style={{ color: colors.meta }}>"{description}"</span>})
</span>
);
};
export const OtherNode = ({ value }: { value: any }) => {
const colors = useThemeColors();
return <span style={{ color: colors.meta }}>{stringify(value)}</span>;
};
export const StepNode = ({ label }: { label: string }) => {
const colors = useThemeColors();
const { typography } = useTheme();
return (
<span
style={{
color: colors.base,
fontFamily: typography.fonts.base,
fontSize: typography.size.s2 - 1,
}}
>
{label}
</span>
);
};
export const MethodCall = ({
call,
callsById,
}: {
call?: Call;
callsById: Map<Call['id'], Call>;
}) => {
// Call might be undefined during initial render, can be safely ignored.
if (!call) {
return null;
}
if (call.method === 'step' && call.path.length === 0) {
return <StepNode label={call.args[0]} />;
}
const path = call.path.flatMap((elem, index) => {
// eslint-disable-next-line no-underscore-dangle
const callId = (elem as CallRef).__callId__;
return [
callId ? (
<MethodCall key={`elem${index}`} call={callsById.get(callId)} callsById={callsById} />
) : (
<span key={`elem${index}`}>{elem as any}</span>
),
<wbr key={`wbr${index}`} />,
<span key={`dot${index}`}>.</span>,
];
});
const args = call.args.flatMap((arg, index, array) => {
const node = <Node key={`node${index}`} value={arg} callsById={callsById} />;
return index < array.length - 1
? [node, <span key={`comma${index}`}>,&nbsp;</span>, <wbr key={`wbr${index}`} />]
: [node];
});
const colors = useThemeColors();
return (
<>
<span style={{ color: colors.base }}>{path}</span>
<span style={{ color: colors.method }}>{call.method}</span>
<span style={{ color: colors.base }}>
(<wbr />
{args}
<wbr />)
</span>
</>
);
};

View File

@ -0,0 +1,25 @@
import { CallStates } from '@storybook/instrumenter';
import { StatusBadge } from './StatusBadge';
export default {
title: 'StatusBadge',
component: StatusBadge,
parameters: { layout: 'padded' },
};
export const Pass = {
args: { status: CallStates.DONE },
};
export const Active = {
args: { status: CallStates.ACTIVE },
};
export const Waiting = {
args: { status: CallStates.WAITING },
};
export const Fail = {
args: { status: CallStates.ERROR },
};

View File

@ -0,0 +1,45 @@
import React from 'react';
import { styled, typography } from 'storybook/internal/theming';
import { type Call, CallStates } from '@storybook/instrumenter';
export interface StatusBadgeProps {
status: Call['status'];
}
const StyledBadge = styled.div<StatusBadgeProps>(({ theme, status }) => {
const backgroundColor = {
[CallStates.DONE]: theme.color.positive,
[CallStates.ERROR]: theme.color.negative,
[CallStates.ACTIVE]: theme.color.warning,
[CallStates.WAITING]: theme.color.warning,
}[status];
return {
padding: '4px 6px 4px 8px;',
borderRadius: '4px',
backgroundColor,
color: 'white',
fontFamily: typography.fonts.base,
textTransform: 'uppercase',
fontSize: typography.size.s1,
letterSpacing: 3,
fontWeight: typography.weight.bold,
width: 65,
textAlign: 'center',
};
});
export const StatusBadge: React.FC<StatusBadgeProps> = ({ status }) => {
const badgeText = {
[CallStates.DONE]: 'Pass',
[CallStates.ERROR]: 'Fail',
[CallStates.ACTIVE]: 'Runs',
[CallStates.WAITING]: 'Runs',
}[status];
return (
<StyledBadge aria-label="Status of the test run" status={status}>
{badgeText}
</StyledBadge>
);
};

View File

@ -0,0 +1,24 @@
import { CallStates } from '@storybook/instrumenter';
import { StatusIcon } from './StatusIcon';
export default {
title: 'StatusIcon',
component: StatusIcon,
};
export const Pending = {
args: { status: CallStates.WAITING },
};
export const Active = {
args: { status: CallStates.ACTIVE },
};
export const Error = {
args: { status: CallStates.ERROR },
};
export const Done = {
args: { status: CallStates.DONE },
};

View File

@ -0,0 +1,46 @@
import React from 'react';
import { styled, useTheme } from 'storybook/internal/theming';
import { CheckIcon, CircleIcon, PlayIcon, StopAltIcon } from '@storybook/icons';
import { type Call, CallStates } from '@storybook/instrumenter';
import { transparentize } from 'polished';
export interface StatusIconProps {
status: Call['status'];
}
const WarningContainer = styled.div({
width: 14,
height: 14,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
});
export const StatusIcon: React.FC<StatusIconProps> = ({ status }) => {
const theme = useTheme();
switch (status) {
case CallStates.DONE: {
return <CheckIcon color={theme.color.positive} data-testid="icon-done" />;
}
case CallStates.ERROR: {
return <StopAltIcon color={theme.color.negative} data-testid="icon-error" />;
}
case CallStates.ACTIVE: {
return <PlayIcon color={theme.color.secondary} data-testid="icon-active" />;
}
case CallStates.WAITING: {
return (
<WarningContainer data-testid="icon-waiting">
<CircleIcon color={transparentize(0.5, '#CCCCCC')} size={6} />
</WarningContainer>
);
}
default: {
return null;
}
}
};

View File

@ -0,0 +1,91 @@
import { CallStates } from '@storybook/instrumenter';
import { action } from '@storybook/addon-actions';
import { parameters } from '../preview';
import { Subnav } from './Subnav';
export default {
title: 'Subnav',
component: Subnav,
parameters: {
layout: 'fullscreen',
},
args: {
controls: {
start: action('start'),
back: action('back'),
goto: action('goto'),
next: action('next'),
end: action('end'),
rerun: action('rerun'),
},
controlStates: {
start: true,
back: true,
goto: true,
next: false,
end: false,
},
storyFileName: 'Subnav.stories.tsx',
hasNext: true,
hasPrevious: true,
},
};
export const Pass = {
args: {
status: CallStates.DONE,
},
};
export const Fail = {
args: {
status: CallStates.ERROR,
},
};
export const Runs = {
args: {
status: CallStates.WAITING,
},
};
export const AtStart = {
args: {
status: CallStates.WAITING,
controlStates: {
start: false,
back: false,
goto: true,
next: true,
end: true,
},
},
};
export const Midway = {
args: {
status: CallStates.WAITING,
controlStates: {
start: true,
back: true,
goto: true,
next: true,
end: true,
},
},
};
export const Locked = {
args: {
status: CallStates.ACTIVE,
controlStates: {
start: false,
back: false,
goto: false,
next: false,
end: false,
},
},
};

View File

@ -0,0 +1,193 @@
import type { ComponentProps } from 'react';
import React from 'react';
import {
Bar,
Button,
IconButton,
P,
Separator,
TooltipNote,
WithTooltip,
} from 'storybook/internal/components';
import { styled } from 'storybook/internal/theming';
import {
FastForwardIcon,
PlayBackIcon,
PlayNextIcon,
RewindIcon,
SyncIcon,
} from '@storybook/icons';
import type { Call, ControlStates } from '@storybook/instrumenter';
import { CallStates } from '@storybook/instrumenter';
import type { Controls } from './InteractionsPanel';
import { StatusBadge } from './StatusBadge';
const SubnavWrapper = styled.div(({ theme }) => ({
background: theme.background.app,
borderBottom: `1px solid ${theme.appBorderColor}`,
position: 'sticky',
top: 0,
zIndex: 1,
}));
const StyledSubnav = styled.nav(({ theme }) => ({
height: 40,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
paddingLeft: 15,
}));
export interface SubnavProps {
controls: Controls;
controlStates: ControlStates;
status: Call['status'];
storyFileName?: string;
onScrollToEnd?: () => void;
}
const StyledButton = styled(Button)(({ theme }) => ({
borderRadius: 4,
padding: 6,
color: theme.textMutedColor,
'&:not(:disabled)': {
'&:hover,&:focus-visible': {
color: theme.color.secondary,
},
},
}));
const Note = styled(TooltipNote)(({ theme }) => ({
fontFamily: theme.typography.fonts.base,
}));
export const StyledIconButton = styled(IconButton as any)(({ theme }) => ({
color: theme.textMutedColor,
margin: '0 3px',
}));
const StyledSeparator = styled(Separator)({
marginTop: 0,
});
const StyledLocation = styled(P)(({ theme }) => ({
color: theme.textMutedColor,
justifyContent: 'flex-end',
textAlign: 'right',
whiteSpace: 'nowrap',
marginTop: 'auto',
marginBottom: 1,
paddingRight: 15,
fontSize: 13,
}));
const Group = styled.div({
display: 'flex',
alignItems: 'center',
});
const RewindButton = styled(StyledIconButton)({
marginLeft: 9,
});
const JumpToEndButton = styled(StyledButton)({
marginLeft: 9,
marginRight: 9,
marginBottom: 1,
lineHeight: '12px',
});
interface AnimatedButtonProps {
animating?: boolean;
}
const RerunButton = styled(StyledIconButton)<
AnimatedButtonProps & ComponentProps<typeof StyledIconButton>
>(({ theme, animating, disabled }) => ({
opacity: disabled ? 0.5 : 1,
svg: {
animation: animating && `${theme.animation.rotate360} 200ms ease-out`,
},
}));
export const Subnav: React.FC<SubnavProps> = ({
controls,
controlStates,
status,
storyFileName,
onScrollToEnd,
}) => {
const buttonText = status === CallStates.ERROR ? 'Scroll to error' : 'Scroll to end';
return (
<SubnavWrapper>
<Bar>
<StyledSubnav>
<Group>
<StatusBadge status={status} />
<JumpToEndButton onClick={onScrollToEnd} disabled={!onScrollToEnd}>
{buttonText}
</JumpToEndButton>
<StyledSeparator />
<WithTooltip trigger="hover" hasChrome={false} tooltip={<Note note="Go to start" />}>
<RewindButton
aria-label="Go to start"
onClick={controls.start}
disabled={!controlStates.start}
>
<RewindIcon />
</RewindButton>
</WithTooltip>
<WithTooltip trigger="hover" hasChrome={false} tooltip={<Note note="Go back" />}>
<StyledIconButton
aria-label="Go back"
onClick={controls.back}
disabled={!controlStates.back}
>
<PlayBackIcon />
</StyledIconButton>
</WithTooltip>
<WithTooltip trigger="hover" hasChrome={false} tooltip={<Note note="Go forward" />}>
<StyledIconButton
aria-label="Go forward"
onClick={controls.next}
disabled={!controlStates.next}
>
<PlayNextIcon />
</StyledIconButton>
</WithTooltip>
<WithTooltip trigger="hover" hasChrome={false} tooltip={<Note note="Go to end" />}>
<StyledIconButton
aria-label="Go to end"
onClick={controls.end}
disabled={!controlStates.end}
>
<FastForwardIcon />
</StyledIconButton>
</WithTooltip>
<WithTooltip trigger="hover" hasChrome={false} tooltip={<Note note="Rerun" />}>
<RerunButton aria-label="Rerun" onClick={controls.rerun}>
<SyncIcon />
</RerunButton>
</WithTooltip>
</Group>
{storyFileName && (
<Group>
<StyledLocation>{storyFileName}</StyledLocation>
</Group>
)}
</StyledSubnav>
</Bar>
</SubnavWrapper>
);
};

View File

@ -1,2 +1,6 @@
export const ADDON_ID = 'storybook/test';
export const TEST_PROVIDER_ID = `${ADDON_ID}/test-provider`;
export const PANEL_ID = `${ADDON_ID}/panel`;
export const TUTORIAL_VIDEO_LINK = 'https://youtu.be/Waht9qq7AoA';
export const DOCUMENTATION_LINK = 'writing-tests/interaction-testing';

View File

@ -1,7 +1,7 @@
import chalk from 'chalk';
import c from 'tinyrainbow';
import { ADDON_ID } from './constants';
export const log = (message: any) => {
console.log(`${chalk.magenta(ADDON_ID)}: ${message.toString().trim()}`);
console.log(`${c.magenta(ADDON_ID)}: ${message.toString().trim()}`);
};

View File

@ -1,19 +1,58 @@
import React from 'react';
import React, { useCallback } from 'react';
import { addons } from 'storybook/internal/manager-api';
import { AddonPanel, Badge, Spaced } from 'storybook/internal/components';
import type { Combo } from 'storybook/internal/manager-api';
import { Consumer, addons, types, useAddonState } from 'storybook/internal/manager-api';
import { Addon_TypesEnum } from 'storybook/internal/types';
import { PointerHandIcon } from '@storybook/icons';
import { ADDON_ID, TEST_PROVIDER_ID } from './constants';
import { Panel } from './Panel';
import { ADDON_ID, PANEL_ID, TEST_PROVIDER_ID } from './constants';
addons.register(ADDON_ID, () => {
function Title() {
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>
);
}
addons.register(ADDON_ID, (api) => {
addons.add(TEST_PROVIDER_ID, {
type: Addon_TypesEnum.experimental_TEST_PROVIDER,
icon: <PointerHandIcon />,
title: 'Component tests',
title: 'Component Tests',
description: () => 'Not yet run',
runnable: true,
watchable: true,
});
addons.add(PANEL_ID, {
type: types.PANEL,
title: Title,
match: ({ viewMode }) => viewMode === 'story',
render: ({ active }) => {
const newLocal = useCallback(({ state }: Combo) => {
return {
storyId: state.storyId,
};
}, []);
return (
<AddonPanel active={active}>
<Consumer filter={newLocal}>{({ storyId }) => <Panel storyId={storyId} />}</Consumer>
</AddonPanel>
);
},
});
});

View File

@ -0,0 +1,148 @@
import { type Call, CallStates } from '@storybook/instrumenter';
export const getCalls = (finalStatus: CallStates) => {
const calls: Call[] = [
{
id: 'story--id [3] step',
storyId: 'story--id',
cursor: 1,
ancestors: [],
path: [],
method: 'step',
args: ['Click button', { __function__: { name: '' } }],
interceptable: true,
retain: false,
status: CallStates.DONE,
},
{
id: 'story--id [3] step [1] within',
storyId: 'story--id',
cursor: 3,
ancestors: ['story--id [3] step'],
path: [],
method: 'within',
args: [{ __element__: { localName: 'div', id: 'root' } }],
interceptable: false,
retain: false,
status: CallStates.DONE,
},
{
id: 'story--id [3] step [2] findByText',
storyId: 'story--id',
cursor: 4,
ancestors: ['story--id [3] step'],
path: [{ __callId__: 'story--id [3] step [1] within' }],
method: 'findByText',
args: ['Click'],
interceptable: true,
retain: false,
status: CallStates.DONE,
},
{
id: 'story--id [3] step [3] click',
storyId: 'story--id',
cursor: 5,
ancestors: ['story--id [3] step'],
path: ['userEvent'],
method: 'click',
args: [{ __element__: { localName: 'button', innerText: 'Click' } }],
interceptable: true,
retain: false,
status: CallStates.DONE,
},
{
id: 'story--id [6] waitFor',
storyId: 'story--id',
cursor: 6,
ancestors: [],
path: [],
method: 'waitFor',
args: [{ __function__: { name: '' } }],
interceptable: true,
retain: false,
status: CallStates.DONE,
},
{
id: 'story--id [6] waitFor [0] expect',
storyId: 'story--id',
cursor: 1,
ancestors: ['story--id [6] waitFor'],
path: [],
method: 'expect',
args: [{ __function__: { name: 'handleSubmit' } }],
interceptable: false,
retain: false,
status: CallStates.DONE,
},
{
id: 'story--id [6] waitFor [1] stringMatching',
storyId: 'story--id',
cursor: 2,
ancestors: ['story--id [6] waitFor'],
path: ['expect'],
method: 'stringMatching',
args: [{ __regexp__: { flags: 'gi', source: '([A-Z])w+' } }],
interceptable: false,
retain: false,
status: CallStates.DONE,
},
{
id: 'story--id [6] waitFor [2] toHaveBeenCalledWith',
storyId: 'story--id',
cursor: 3,
ancestors: ['story--id [6] waitFor'],
path: [{ __callId__: 'story--id [6] waitFor [0] expect' }],
method: 'toHaveBeenCalledWith',
args: [{ __callId__: 'story--id [6] waitFor [1] stringMatching', retain: false }],
interceptable: true,
retain: false,
status: CallStates.DONE,
},
{
id: 'story--id [7] expect',
storyId: 'story--id',
cursor: 7,
ancestors: [],
path: [],
method: 'expect',
args: [{ __function__: { name: 'handleReset' } }],
interceptable: false,
retain: false,
status: CallStates.DONE,
},
{
id: 'story--id [8] toHaveBeenCalled',
storyId: 'story--id',
cursor: 8,
ancestors: [],
path: [{ __callId__: 'story--id [7] expect' }, 'not'],
method: 'toHaveBeenCalled',
args: [],
interceptable: true,
retain: false,
status: finalStatus,
},
];
if (finalStatus === CallStates.ERROR) {
calls[calls.length - 1].exception = {
name: 'Error',
stack: '',
message: 'Oops!',
callId: calls[calls.length - 1].id,
};
}
return calls;
};
export const getInteractions = (finalStatus: CallStates) =>
getCalls(finalStatus)
.filter((call) => call.interceptable)
.map((call) => ({
...call,
childCallIds: [],
isCollapsed: false,
isHidden: false,
toggleCollapsed: () => {},
}));

View File

@ -17,6 +17,7 @@ import type { Suite } from '@vitest/runner';
// functions from the `@vitest/runner` package. It is not complex and does not have
// any significant dependencies.
import { getTests } from '@vitest/runner/utils';
// @ts-expect-error we will very soon replace this library with es-toolkit
import throttle from 'lodash/throttle.js';
import { TEST_PROVIDER_ID } from '../constants';
@ -47,6 +48,7 @@ export class StorybookReporter implements Reporter {
sendReport: (payload: TestingModuleRunProgressPayload) => void;
constructor(private testManager: TestManager) {
// @ts-expect-error we will very soon replace this library with es-toolkit
this.sendReport = throttle((payload) => this.testManager.sendProgressReport(payload), 200);
}

View File

@ -31,6 +31,7 @@ export class VitestManager {
// find a way to just show errors and warnings for example
// Otherwise it might be hard for the user to discover Storybook related logs
reporters: ['default', new StorybookReporter(this.testManager)],
// @ts-expect-error we just want to disable coverage, not specify a provider
coverage: {
enabled: false,
},

View File

@ -1,4 +1,7 @@
import { isAbsolute, join } from 'node:path';
import type { Channel } from 'storybook/internal/channels';
import { checkAddonOrder, serverRequire } from 'storybook/internal/common';
import {
TESTING_MODULE_RUN_ALL_REQUEST,
TESTING_MODULE_RUN_REQUEST,
@ -8,8 +11,32 @@ import type { Options } from 'storybook/internal/types';
import { bootTestRunner } from './node/boot-test-runner';
export const checkActionsLoaded = (configDir: string) => {
checkAddonOrder({
before: {
name: '@storybook/addon-actions',
inEssentials: true,
},
after: {
name: '@storybook/experimental-addon-test',
inEssentials: false,
},
configFile: isAbsolute(configDir)
? join(configDir, 'main')
: join(process.cwd(), configDir, 'main'),
getConfig: (configFile) => serverRequire(configFile),
});
};
// eslint-disable-next-line @typescript-eslint/naming-convention
export const experimental_serverChannel = async (channel: Channel, options: Options) => {
const core = await options.presets.apply('core');
const builderName = typeof core?.builder === 'string' ? core.builder : core?.builder?.name;
// Only boot the test runner if the builder is vite, else just provide interactions functionality
if (!builderName?.includes('vite')) {
return channel;
}
let booting = false;
let booted = false;
const start =
@ -40,8 +67,3 @@ export const experimental_serverChannel = async (channel: Channel, options: Opti
return channel;
};
// TODO:
// 1 - Do not boot Vitest on Storybook boot, but rather on the first test run
// 2 - Handle cases where Vitest is already booted, so we dont boot it again
// 3 - Upon crash, provide a notification to the user

View File

@ -0,0 +1,22 @@
import type { PlayFunction, StepLabel, StoryContext } from 'storybook/internal/types';
import { instrument } from '@storybook/instrumenter';
// This makes sure that storybook test loaders are always loaded when addon-interactions is used
// For 9.0 we want to merge storybook/test and addon-interactions into one addon.
import '@storybook/test';
export const { step: runStep } = instrument(
{
// It seems like the label is unused, but the instrumenter has access to it
// The context will be bounded later in StoryRender, so that the user can write just:
// await step("label", (context) => {
// // labeled step
// });
step: (label: StepLabel, play: PlayFunction, context: StoryContext) => play(context),
},
{ intercept: true }
);
export const parameters = {
throwPlayFunctionExceptions: false,
};

View File

@ -0,0 +1,20 @@
/** Interaction Testing Theme PLACEHOLDER until SB is updated <3 */
interface Colors {
pure?: {
gray?: any;
};
}
export const colors: Colors = {
pure: {
gray: {
500: '#CCCCCC',
},
},
};
export const theme = {
colors,
};
export default theme;

View File

@ -0,0 +1,39 @@
import { type StorybookTheme, useTheme } from 'storybook/internal/theming';
import Filter from 'ansi-to-html';
export function isTestAssertionError(error: unknown) {
return isChaiError(error) || isJestError(error);
}
export function isChaiError(error: unknown) {
return (
error &&
typeof error === 'object' &&
'name' in error &&
typeof error.name === 'string' &&
error.name === 'AssertionError'
);
}
export function isJestError(error: unknown) {
return (
error &&
typeof error === 'object' &&
'message' in error &&
typeof error.message === 'string' &&
error.message.startsWith('expect(')
);
}
export function createAnsiToHtmlFilter(theme: StorybookTheme) {
return new Filter({
fg: theme.color.defaultText,
bg: theme.background.content,
});
}
export function useAnsiToHtmlFilter() {
const theme = useTheme();
return createAnsiToHtmlFilter(theme);
}

View File

@ -27,7 +27,7 @@ describe('modifyErrorMessage', () => {
expect(task.result?.errors?.[0].message).toMatchInlineSnapshot(`
"
Click to debug the error directly in Storybook: http://localhost:6006/?path=/story/my-story&addonPanel=storybook/interactions/panel
Click to debug the error directly in Storybook: http://localhost:6006/?path=/story/my-story&addonPanel=storybook/test/panel
Original error message"
`);

View File

@ -30,7 +30,7 @@ export const modifyErrorMessage = ({ task }: { task: Task }) => {
) {
const currentError = task.result.errors[0];
const storybookUrl = import.meta.env.__STORYBOOK_URL__;
const storyUrl = `${storybookUrl}/?path=/story/${meta.storyId}&addonPanel=storybook/interactions/panel`;
const storyUrl = `${storybookUrl}/?path=/story/${meta.storyId}&addonPanel=storybook/test/panel`;
currentError.message = `\n\x1B[34mClick to debug the error directly in Storybook: ${storyUrl}\x1B[39m\n\n${currentError.message}`;
}
};

View File

@ -0,0 +1,134 @@
import { global as globalThis } from '@storybook/global';
import {
expect,
fireEvent,
fn,
userEvent,
waitFor,
waitForElementToBeRemoved,
within,
} from '@storybook/test';
export default {
component: globalThis.Components.Form,
args: {
onSuccess: fn(),
},
globals: {
sb_theme: 'light',
},
};
export const Validation = {
play: async (context) => {
const { args, canvasElement, step } = context;
const canvas = within(canvasElement);
await step('Submit', async () => fireEvent.click(canvas.getByRole('button')));
await expect(args.onSuccess).not.toHaveBeenCalled();
},
};
export const Type = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.type(canvas.getByTestId('value'), 'foobar');
},
};
export const Step = {
play: async ({ step }) => {
await step('Enter value', Type.play);
},
};
export const TypeAndClear = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.type(canvas.getByTestId('value'), 'initial value');
await userEvent.clear(canvas.getByTestId('value'));
await userEvent.type(canvas.getByTestId('value'), 'final value');
},
};
export const Callback = {
play: async ({ args, canvasElement, step }) => {
const canvas = within(canvasElement);
await step('Enter value', Type.play);
await step('Submit', async () => {
await fireEvent.click(canvas.getByRole('button'));
});
await expect(args.onSuccess).toHaveBeenCalled();
},
};
// NOTE: of course you can use `findByText()` to implicitly waitFor, but we want
// an explicit test here
export const SyncWaitFor = {
play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement);
await step('Submit form', Callback.play);
await waitFor(() => canvas.getByText('Completed!!'));
},
};
export const AsyncWaitFor = {
play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement);
await step('Submit form', Callback.play);
await waitFor(async () => canvas.getByText('Completed!!'));
},
};
export const WaitForElementToBeRemoved = {
play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement);
await step('SyncWaitFor play fn', SyncWaitFor.play);
await waitForElementToBeRemoved(() => canvas.queryByText('Completed!!'), {
timeout: 2000,
});
},
};
export const WithLoaders = {
loaders: [async () => new Promise((resolve) => setTimeout(resolve, 2000))],
play: async ({ step }) => {
await step('Submit form', Callback.play);
},
};
const UserEventSetup = {
play: async (context) => {
const { args, canvasElement, step } = context;
const user = userEvent.setup();
const canvas = within(canvasElement);
await step('Select and type on input using user-event v14 setup', async () => {
const input = canvas.getByRole('textbox');
await user.click(input);
await user.type(input, 'Typing ...');
});
await step('Tab and press enter on submit button', async () => {
await user.pointer([
{ keys: '[TouchA>]', target: canvas.getByRole('textbox') },
{ keys: '[/TouchA]' },
]);
const submitButton = await canvas.findByRole('button');
if (navigator.userAgent.toLowerCase().includes('firefox')) {
// user event has a few issues on firefox, therefore we do it differently
await fireEvent.click(submitButton);
} else {
await user.tab();
await user.keyboard('{enter}');
await expect(submitButton).toHaveFocus();
}
await expect(args.onSuccess).toHaveBeenCalled();
});
},
};
export { UserEventSetup };

View File

@ -0,0 +1,26 @@
import { global as globalThis } from '@storybook/global';
import { userEvent, within } from '@storybook/test';
export default {
component: globalThis.Components.Button,
args: {
label: 'Button',
// onClick: fn(), <-- this is intentionally missing to trigger an unhandled error
},
argTypes: {
onClick: { type: 'function' },
},
parameters: {
actions: { argTypesRegex: '^on[A-Z].*' },
chromatic: { disable: true },
},
tags: ['!test', '!vitest'],
};
export const Default = {
play: async (context) => {
const { args, canvasElement } = context;
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole('button'));
},
};

View File

@ -4,7 +4,8 @@
"rootDir": "../../../",
"module": "Preserve",
"moduleResolution": "Bundler",
"types": ["vitest"]
"types": ["vitest"],
"strict": false
},
"include": ["src/**/*", "./typings.d.ts"]
}

View File

@ -0,0 +1,143 @@
import { expect, test } from '@playwright/test';
import process from 'process';
import { SbPage } from './util';
const storybookUrl = process.env.STORYBOOK_URL || 'http://localhost:8001';
const templateName = process.env.STORYBOOK_TEMPLATE_NAME || '';
test.describe('addon-test', () => {
test.beforeEach(async ({ page }) => {
await page.goto(storybookUrl);
await new SbPage(page).waitUntilLoaded();
});
test('should have interactions', async ({ page }) => {
// templateName is e.g. 'vue-cli/default-js'
test.skip(
/^(lit)/i.test(`${templateName}`),
`Skipping ${templateName}, which does not support addon-test`
);
const sbPage = new SbPage(page);
await sbPage.navigateToStory('example/page', 'logged-in');
await sbPage.viewAddonPanel('Component Tests');
const welcome = sbPage.previewRoot().locator('.welcome');
await expect(welcome).toContainText('Welcome, Jane Doe!', { timeout: 50000 });
const interactionsTab = page.locator('#tabbutton-storybook-test-panel');
await expect(interactionsTab).toContainText(/(\d)/);
await expect(interactionsTab).toBeVisible();
const panel = sbPage.panelContent();
await expect(panel).toContainText(/Pass/);
await expect(panel).toContainText(/userEvent.click/);
await expect(panel).toBeVisible();
const done = panel.locator('[data-testid=icon-done]').nth(0);
await expect(done).toBeVisible();
});
test('should step through interactions', async ({ page, browserName }) => {
// templateName is e.g. 'vue-cli/default-js'
test.skip(
/^(lit)/i.test(`${templateName}`),
`Skipping ${templateName}, which does not support addon-test`
);
test.skip(
browserName === 'firefox',
`Skipping on FIreFox, which has trouble with "initial value"`
);
const sbPage = new SbPage(page);
await sbPage.deepLinkToStory(storybookUrl, 'addons/test/basics', 'type-and-clear');
await sbPage.viewAddonPanel('Component Tests');
// Test initial state - Interactions have run, count is correct and values are as expected
const formInput = sbPage.previewRoot().locator('#interaction-test-form input');
await expect(formInput).toHaveValue('final value', { timeout: 50000 });
const interactionsTab = page.locator('#tabbutton-storybook-test-panel');
await expect(interactionsTab.getByText('3')).toBeVisible();
await expect(interactionsTab).toBeVisible();
await expect(interactionsTab).toBeVisible();
const panel = sbPage.panelContent();
const runStatusBadge = panel.locator('[aria-label="Status of the test run"]');
await expect(runStatusBadge).toContainText(/Pass/);
await expect(panel).toContainText(/"initial value"/);
await expect(panel).toContainText(/clear/);
await expect(panel).toContainText(/"final value"/);
await expect(panel).toBeVisible();
// Test interactions debugger - Stepping through works, count is correct and values are as expected
const interactionsRow = panel.locator('[aria-label="Interaction step"]');
await interactionsRow.first().isVisible();
await expect(interactionsRow).toHaveCount(3);
const firstInteraction = interactionsRow.first();
await firstInteraction.click();
await expect(runStatusBadge).toContainText(/Runs/);
await expect(formInput).toHaveValue('initial value');
const goForwardBtn = panel.locator('[aria-label="Go forward"]');
await goForwardBtn.click();
await expect(formInput).toHaveValue('');
await goForwardBtn.click();
await expect(formInput).toHaveValue('final value');
await expect(runStatusBadge).toContainText(/Pass/);
// Test rerun state (from addon panel) - Interactions have rerun, count is correct and values are as expected
const rerunInteractionButton = panel.locator('[aria-label="Rerun"]');
await rerunInteractionButton.click();
await expect(formInput).toHaveValue('final value');
await interactionsRow.first().isVisible();
await interactionsRow.nth(1).isVisible();
await interactionsRow.nth(2).isVisible();
await expect(interactionsTab.getByText('3')).toBeVisible();
await expect(interactionsTab).toBeVisible();
await expect(interactionsTab.getByText('3')).toBeVisible();
// Test remount state (from toolbar) - Interactions have rerun, count is correct and values are as expected
const remountComponentButton = page.locator('[title="Remount component"]');
await remountComponentButton.click();
await interactionsRow.first().isVisible();
await interactionsRow.nth(1).isVisible();
await interactionsRow.nth(2).isVisible();
await expect(interactionsTab.getByText('3')).toBeVisible();
await expect(interactionsTab).toBeVisible();
await expect(interactionsTab).toBeVisible();
await expect(formInput).toHaveValue('final value');
});
test('should show unhandled errors', async ({ page }) => {
test.skip(
/^(lit)/i.test(`${templateName}`),
`Skipping ${templateName}, which does not support addon-test`
);
// We trigger the implicit action error here, but angular works a bit different with implicit actions.
test.skip(/^(angular)/i.test(`${templateName}`));
const sbPage = new SbPage(page);
await sbPage.deepLinkToStory(storybookUrl, 'addons/test/unhandled-errors', 'default');
await sbPage.viewAddonPanel('Component Tests');
const button = sbPage.previewRoot().locator('button');
await expect(button).toContainText('Button', { timeout: 50000 });
const panel = sbPage.panelContent();
await expect(panel).toContainText(/Fail/);
await expect(panel).toContainText(/Found 1 unhandled error/);
await expect(panel).toBeVisible();
});
});

View File

@ -120,7 +120,7 @@ export class SbPage {
}
panelContent() {
return this.page.locator('#storybook-panel-root #panel-tab-content');
return this.page.locator('#storybook-panel-root #panel-tab-content > div:not([hidden])');
}
async viewAddonPanel(name: string) {

View File

@ -6234,21 +6234,29 @@ __metadata:
version: 0.0.0-use.local
resolution: "@storybook/experimental-addon-test@workspace:addons/test"
dependencies:
"@devtools-ds/object-inspector": "npm:^1.1.2"
"@storybook/csf": "npm:^0.1.11"
"@storybook/global": "npm:^5.0.0"
"@storybook/icons": "npm:^1.2.12"
"@storybook/instrumenter": "workspace:*"
"@storybook/test": "workspace:*"
"@types/node": "npm:^22.0.0"
"@types/semver": "npm:^7"
"@vitest/browser": "npm:^2.1.1"
"@vitest/runner": "npm:^2.1.1"
ansi-to-html: "npm:^0.7.2"
boxen: "npm:^8.0.1"
chalk: "npm:^5.3.0"
execa: "npm:^8.0.1"
find-up: "npm:^7.0.0"
formik: "npm:^2.2.9"
lodash: "npm:^4.17.21"
polished: "npm:^4.2.2"
react: "npm:^18.2.0"
react-dom: "npm:^18.2.0"
semver: "npm:^7.6.3"
tinyrainbow: "npm:^1.2.0"
ts-dedent: "npm:^2.2.0"
typescript: "npm:^5.3.2"
vitest: "npm:^2.1.1"
peerDependencies:
"@vitest/browser": ^2.1.1

View File

@ -15,6 +15,7 @@ const projectJson = (name: string, framework: string, tags: string[]) => ({
'test',
'essentials',
'interactions',
'experimental-addon-test',
'links',
'onboarding',
'blocks',

View File

@ -663,6 +663,15 @@ export const addStories: Task['run'] = async (
cwd,
disableDocs,
});
await linkPackageStories(
await workspacePath('addon test package', '@storybook/experimental-addon-test'),
{
mainConfig,
cwd,
disableDocs,
}
);
}
const mainAddons = (mainConfig.getSafeFieldValue(['addons']) || []).reduce(
@ -783,6 +792,9 @@ export const extendMain: Task['run'] = async ({ template, sandboxDir, key }, { d
mainConfig.setFieldValue(['stories'], updatedStories);
}
const addons = mainConfig.getFieldValue(['addons']);
mainConfig.setFieldValue(['addons'], [...addons, '@storybook/experimental-addon-test']);
if (template.expected.builder === '@storybook/builder-vite') {
setSandboxViteFinal(mainConfig);
}

View File

@ -87,15 +87,10 @@ export const sandbox: Task = {
// The storybook package forwards some CLI commands to @storybook/cli with npx.
// Adding the dep makes sure that even npx will use the linked workspace version.
'@storybook/cli',
'@storybook/experimental-addon-test',
];
if (!details.template.skipTasks?.includes('vitest-integration')) {
extraDeps.push(
'happy-dom',
'vitest',
'playwright',
'@vitest/browser',
'@storybook/experimental-addon-test'
);
extraDeps.push('happy-dom', 'vitest', 'playwright', '@vitest/browser');
if (details.template.expected.framework.includes('nextjs')) {
extraDeps.push('@storybook/experimental-nextjs-vite', 'jsdom');