mirror of
https://github.com/storybookjs/storybook.git
synced 2025-04-08 11:11:53 +08:00
Merge branch 'unified-ui-testing' into testing-module-ui
This commit is contained in:
commit
0c11df3d04
1
code/addons/test/manager.js
Normal file
1
code/addons/test/manager.js
Normal file
@ -0,0 +1 @@
|
||||
import './dist/manager';
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
10
code/addons/test/preset.js
Normal file
10
code/addons/test/preset.js
Normal file
@ -0,0 +1,10 @@
|
||||
const { checkActionsLoaded } = require('./dist/preset');
|
||||
|
||||
function previewAnnotations(entry = [], options) {
|
||||
checkActionsLoaded(options.configDir);
|
||||
return entry;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
previewAnnotations,
|
||||
};
|
1
code/addons/test/preview.js
Normal file
1
code/addons/test/preview.js
Normal file
@ -0,0 +1 @@
|
||||
export * from './dist/preview';
|
292
code/addons/test/src/Panel.test.ts
Normal file
292
code/addons/test/src/Panel.test.ts
Normal 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,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
261
code/addons/test/src/Panel.tsx
Normal file
261
code/addons/test/src/Panel.tsx
Normal 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>
|
||||
);
|
||||
});
|
69
code/addons/test/src/components/EmptyState.tsx
Normal file
69
code/addons/test/src/components/EmptyState.tsx
Normal 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'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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
63
code/addons/test/src/components/Interaction.stories.tsx
Normal file
63
code/addons/test/src/components/Interaction.stories.tsx
Normal 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();
|
||||
},
|
||||
};
|
208
code/addons/test/src/components/Interaction.tsx
Normal file
208
code/addons/test/src/components/Interaction.tsx
Normal 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>
|
||||
);
|
||||
};
|
136
code/addons/test/src/components/InteractionsPanel.stories.tsx
Normal file
136
code/addons/test/src/components/InteractionsPanel.stories.tsx
Normal 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')"),
|
||||
},
|
||||
};
|
176
code/addons/test/src/components/InteractionsPanel.tsx
Normal file
176
code/addons/test/src/components/InteractionsPanel.tsx
Normal 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}`;
|
||||
}
|
93
code/addons/test/src/components/List.tsx
Normal file
93
code/addons/test/src/components/List.tsx
Normal 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>
|
||||
);
|
113
code/addons/test/src/components/MatcherResult.stories.tsx
Normal file
113
code/addons/test/src/components/MatcherResult.stories.tsx
Normal 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
|
||||
`,
|
||||
},
|
||||
};
|
146
code/addons/test/src/components/MatcherResult.tsx
Normal file
146
code/addons/test/src/components/MatcherResult.tsx
Normal 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>
|
||||
);
|
||||
};
|
219
code/addons/test/src/components/MethodCall.stories.tsx
Normal file
219
code/addons/test/src/components/MethodCall.stories.tsx
Normal 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} />;
|
465
code/addons/test/src/components/MethodCall.tsx
Normal file
465
code/addons/test/src/components/MethodCall.tsx
Normal 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 }}>
|
||||
<
|
||||
</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 }}>
|
||||
>
|
||||
</span>
|
||||
{!id && classNames.length === 0 && innerText && (
|
||||
<>
|
||||
<span key={`${name}_text`}>{innerText}</span>
|
||||
<span key={`${name}_close_lt`} style={{ color: colors.muted }}>
|
||||
<
|
||||
</span>
|
||||
<span key={`${name}_close_tag`} style={{ color: colors.tag.name }}>
|
||||
/{name}
|
||||
</span>
|
||||
<span key={`${name}_close_gt`} style={{ color: colors.muted }}>
|
||||
>
|
||||
</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}`}>, </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>
|
||||
</>
|
||||
);
|
||||
};
|
25
code/addons/test/src/components/StatusBadge.stories.tsx
Normal file
25
code/addons/test/src/components/StatusBadge.stories.tsx
Normal 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 },
|
||||
};
|
45
code/addons/test/src/components/StatusBadge.tsx
Normal file
45
code/addons/test/src/components/StatusBadge.tsx
Normal 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>
|
||||
);
|
||||
};
|
24
code/addons/test/src/components/StatusIcon.stories.tsx
Normal file
24
code/addons/test/src/components/StatusIcon.stories.tsx
Normal 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 },
|
||||
};
|
46
code/addons/test/src/components/StatusIcon.tsx
Normal file
46
code/addons/test/src/components/StatusIcon.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
};
|
91
code/addons/test/src/components/Subnav.stories.tsx
Normal file
91
code/addons/test/src/components/Subnav.stories.tsx
Normal 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,
|
||||
},
|
||||
},
|
||||
};
|
193
code/addons/test/src/components/Subnav.tsx
Normal file
193
code/addons/test/src/components/Subnav.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -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';
|
||||
|
@ -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()}`);
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
148
code/addons/test/src/mocks/index.ts
Normal file
148
code/addons/test/src/mocks/index.ts
Normal 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: () => {},
|
||||
}));
|
@ -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);
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
},
|
||||
|
@ -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
|
||||
|
22
code/addons/test/src/preview.ts
Normal file
22
code/addons/test/src/preview.ts
Normal 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,
|
||||
};
|
20
code/addons/test/src/theme.ts
Normal file
20
code/addons/test/src/theme.ts
Normal 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;
|
39
code/addons/test/src/utils.ts
Normal file
39
code/addons/test/src/utils.ts
Normal 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);
|
||||
}
|
@ -27,7 +27,7 @@ describe('modifyErrorMessage', () => {
|
||||
|
||||
expect(task.result?.errors?.[0].message).toMatchInlineSnapshot(`
|
||||
"
|
||||
[34mClick to debug the error directly in Storybook: http://localhost:6006/?path=/story/my-story&addonPanel=storybook/interactions/panel[39m
|
||||
[34mClick to debug the error directly in Storybook: http://localhost:6006/?path=/story/my-story&addonPanel=storybook/test/panel[39m
|
||||
|
||||
Original error message"
|
||||
`);
|
||||
|
@ -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}`;
|
||||
}
|
||||
};
|
||||
|
134
code/addons/test/template/stories/basics.stories.ts
Normal file
134
code/addons/test/template/stories/basics.stories.ts
Normal 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 };
|
@ -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'));
|
||||
},
|
||||
};
|
@ -4,7 +4,8 @@
|
||||
"rootDir": "../../../",
|
||||
"module": "Preserve",
|
||||
"moduleResolution": "Bundler",
|
||||
"types": ["vitest"]
|
||||
"types": ["vitest"],
|
||||
"strict": false
|
||||
},
|
||||
"include": ["src/**/*", "./typings.d.ts"]
|
||||
}
|
||||
|
143
code/e2e-tests/addon-test.spec.ts
Normal file
143
code/e2e-tests/addon-test.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
@ -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) {
|
||||
|
@ -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
|
||||
|
@ -15,6 +15,7 @@ const projectJson = (name: string, framework: string, tags: string[]) => ({
|
||||
'test',
|
||||
'essentials',
|
||||
'interactions',
|
||||
'experimental-addon-test',
|
||||
'links',
|
||||
'onboarding',
|
||||
'blocks',
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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');
|
||||
|
Loading…
x
Reference in New Issue
Block a user