From ee3fe71c2c3c5cb987f40c8d545b5b01e4b45c6b Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Thu, 23 Jun 2022 22:46:07 +0200 Subject: [PATCH 01/45] Add step function and support for deeply nested interactions --- addons/interactions/src/Panel.tsx | 13 ++++---- .../src/components/Interaction.stories.tsx | 2 +- .../src/components/Interaction.tsx | 12 ++++---- .../src/components/MethodCall.tsx | 23 ++++++++++++-- addons/interactions/src/mocks/index.ts | 12 ++++++-- lib/instrumenter/src/instrumenter.test.ts | 30 +++++++++---------- lib/instrumenter/src/instrumenter.ts | 30 +++++++++---------- lib/instrumenter/src/types.ts | 8 ++--- lib/preview-web/package.json | 1 + lib/preview-web/src/StoryRender.ts | 11 +++++-- yarn.lock | 1 + 11 files changed, 90 insertions(+), 53 deletions(-) diff --git a/addons/interactions/src/Panel.tsx b/addons/interactions/src/Panel.tsx index 960687f98956..bc4ce66aa464 100644 --- a/addons/interactions/src/Panel.tsx +++ b/addons/interactions/src/Panel.tsx @@ -43,16 +43,19 @@ export const getInteractions = ({ const callsById = new Map(); const childCallMap = new Map(); return log - .filter(({ callId, parentId }) => { - if (!parentId) return true; - childCallMap.set(parentId, (childCallMap.get(parentId) || []).concat(callId)); - return !collapsed.has(parentId); + .filter(({ callId, ancestors }) => { + let visible = true; + ancestors.forEach((ancestor) => { + if (collapsed.has(ancestor)) visible = false; + childCallMap.set(ancestor, (childCallMap.get(ancestor) || []).concat(callId)); + }); + return visible; }) .map(({ callId, status }) => ({ ...calls.get(callId), status } as Call)) .map((call) => { const status = call.status === CallStates.ERROR && - callsById.get(call.parentId)?.status === CallStates.ACTIVE + callsById.get(call.ancestors.slice(-1)[0])?.status === CallStates.ACTIVE ? CallStates.ACTIVE : call.status; callsById.set(call.id, { ...call, status }); diff --git a/addons/interactions/src/components/Interaction.stories.tsx b/addons/interactions/src/components/Interaction.stories.tsx index 3836267b0803..8ebecd5f383a 100644 --- a/addons/interactions/src/components/Interaction.stories.tsx +++ b/addons/interactions/src/components/Interaction.stories.tsx @@ -45,7 +45,7 @@ export const Done: Story = { export const WithParent: Story = { args: { - call: { ...getCalls(CallStates.DONE).slice(-1)[0], parentId: 'parent-id' }, + call: { ...getCalls(CallStates.DONE).slice(-1)[0], ancestors: ['parent-id'] }, }, }; diff --git a/addons/interactions/src/components/Interaction.tsx b/addons/interactions/src/components/Interaction.tsx index 609aa3e18569..12d48bfe82f0 100644 --- a/addons/interactions/src/components/Interaction.tsx +++ b/addons/interactions/src/components/Interaction.tsx @@ -32,7 +32,7 @@ const RowContainer = styled('div', { ? transparentize(0.93, theme.color.negative) : theme.background.warning, }), - paddingLeft: call.parentId ? 20 : 0, + paddingLeft: call.ancestors.length * 20, }), ({ theme, call, pausedAt }) => pausedAt === call.id && { @@ -56,9 +56,9 @@ const RowContainer = styled('div', { } ); -const RowHeader = styled.div<{ disabled: boolean }>(({ theme, disabled }) => ({ +const RowHeader = styled.div<{ isInteractive: boolean }>(({ theme, isInteractive }) => ({ display: 'flex', - '&:hover': disabled ? {} : { background: theme.background.hoverable }, + '&:hover': isInteractive ? {} : { background: theme.background.hoverable }, })); const RowLabel = styled('button', { shouldForwardProp: (prop) => !['call'].includes(prop) })< @@ -144,13 +144,15 @@ export const Interaction = ({ pausedAt?: Call['id']; }) => { const [isHovered, setIsHovered] = React.useState(false); + const isInteractive = !controlStates.goto || !call.interceptable || !!call.ancestors.length; + return ( - + controls.goto(call.id)} - disabled={!controlStates.goto || !call.interceptable || !!call.parentId} + disabled={isInteractive} onMouseEnter={() => controlStates.goto && setIsHovered(true)} onMouseLeave={() => controlStates.goto && setIsHovered(false)} > diff --git a/addons/interactions/src/components/MethodCall.tsx b/addons/interactions/src/components/MethodCall.tsx index e9a98602e783..bd4ecd8a3174 100644 --- a/addons/interactions/src/components/MethodCall.tsx +++ b/addons/interactions/src/components/MethodCall.tsx @@ -1,6 +1,6 @@ import { ObjectInspector } from '@devtools-ds/object-inspector'; import { Call, CallRef, ElementRef } from '@storybook/instrumenter'; -import { useTheme } from '@storybook/theming'; +import { color, useTheme } from '@storybook/theming'; import React, { Fragment, ReactElement } from 'react'; const colorsLight = { @@ -383,6 +383,22 @@ export const OtherNode = ({ value }: { value: any }) => { return {stringify(value)}; }; +export const StepNode = ({ label }: { label: string }) => { + const colors = useThemeColors(); + const { typography } = useTheme(); + return ( + + {label} + + ); +}; + export const MethodCall = ({ call, callsById, @@ -393,7 +409,9 @@ export const MethodCall = ({ // Call might be undefined during initial render, can be safely ignored. if (!call) return null; - const colors = useThemeColors(); + if (call.method === 'step' && call.path.length === 0) { + return ; + } const path = call.path.flatMap((elem, index) => { // eslint-disable-next-line no-underscore-dangle @@ -416,6 +434,7 @@ export const MethodCall = ({ : [node]; }); + const colors = useThemeColors(); return ( <> {path} diff --git a/addons/interactions/src/mocks/index.ts b/addons/interactions/src/mocks/index.ts index d42507545332..61600cccb4c0 100644 --- a/addons/interactions/src/mocks/index.ts +++ b/addons/interactions/src/mocks/index.ts @@ -6,6 +6,7 @@ export const getCalls = (finalStatus: CallStates) => { id: 'story--id [3] within', storyId: 'story--id', cursor: 3, + ancestors: [], path: [], method: 'within', args: [{ __element__: { localName: 'div', id: 'root' } }], @@ -17,6 +18,7 @@ export const getCalls = (finalStatus: CallStates) => { id: 'story--id [4] findByText', storyId: 'story--id', cursor: 4, + ancestors: [], path: [{ __callId__: 'story--id [3] within' }], method: 'findByText', args: ['Click'], @@ -28,6 +30,7 @@ export const getCalls = (finalStatus: CallStates) => { id: 'story--id [5] click', storyId: 'story--id', cursor: 5, + ancestors: [], path: ['userEvent'], method: 'click', args: [{ __element__: { localName: 'button', innerText: 'Click' } }], @@ -39,6 +42,7 @@ export const getCalls = (finalStatus: CallStates) => { id: 'story--id [6] waitFor', storyId: 'story--id', cursor: 6, + ancestors: [], path: [], method: 'waitFor', args: [{ __function__: { name: '' } }], @@ -48,9 +52,9 @@ export const getCalls = (finalStatus: CallStates) => { }, { id: 'story--id [6] waitFor [0] expect', - parentId: 'story--id [6] waitFor', storyId: 'story--id', cursor: 1, + ancestors: ['story--id [6] waitFor'], path: [], method: 'expect', args: [{ __function__: { name: 'handleSubmit' } }], @@ -60,9 +64,9 @@ export const getCalls = (finalStatus: CallStates) => { }, { id: 'story--id [6] waitFor [1] stringMatching', - parentId: 'story--id [6] waitFor', storyId: 'story--id', cursor: 2, + ancestors: ['story--id [6] waitFor'], path: ['expect'], method: 'stringMatching', args: [{ __regexp__: { flags: 'gi', source: '([A-Z])w+' } }], @@ -72,9 +76,9 @@ export const getCalls = (finalStatus: CallStates) => { }, { id: 'story--id [6] waitFor [2] toHaveBeenCalledWith', - parentId: 'story--id [6] waitFor', 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 }], @@ -86,6 +90,7 @@ export const getCalls = (finalStatus: CallStates) => { id: 'story--id [7] expect', storyId: 'story--id', cursor: 7, + ancestors: [], path: [], method: 'expect', args: [{ __function__: { name: 'handleReset' } }], @@ -97,6 +102,7 @@ export const getCalls = (finalStatus: CallStates) => { id: 'story--id [8] toHaveBeenCalled', storyId: 'story--id', cursor: 8, + ancestors: [], path: [{ __callId__: 'story--id [7] expect' }, 'not'], method: 'toHaveBeenCalled', args: [], diff --git a/lib/instrumenter/src/instrumenter.test.ts b/lib/instrumenter/src/instrumenter.test.ts index 5c8025c03dc9..277be46a0167 100644 --- a/lib/instrumenter/src/instrumenter.test.ts +++ b/lib/instrumenter/src/instrumenter.test.ts @@ -140,7 +140,7 @@ describe('Instrumenter', () => { method: 'fn', interceptable: false, status: 'done', - parentId: undefined, + ancestors: [], }) ); }); @@ -216,28 +216,28 @@ describe('Instrumenter', () => { }); fn5(); expect(callSpy).toHaveBeenCalledWith( - expect.objectContaining({ id: 'kind--story [0] fn1', parentId: undefined }) + expect.objectContaining({ id: 'kind--story [0] fn1', ancestors: [] }) ); expect(callSpy).toHaveBeenCalledWith( expect.objectContaining({ id: 'kind--story [0] fn1 [0] fn2', - parentId: 'kind--story [0] fn1', + ancestors: ['kind--story [0] fn1'], }) ); expect(callSpy).toHaveBeenCalledWith( expect.objectContaining({ id: 'kind--story [0] fn1 [0] fn2 [0] fn3', - parentId: 'kind--story [0] fn1 [0] fn2', + ancestors: ['kind--story [0] fn1 [0] fn2'], }) ); expect(callSpy).toHaveBeenCalledWith( expect.objectContaining({ id: 'kind--story [0] fn1 [1] fn4', - parentId: 'kind--story [0] fn1', + ancestors: ['kind--story [0] fn1'], }) ); expect(callSpy).toHaveBeenCalledWith( - expect.objectContaining({ id: 'kind--story [1] fn5', parentId: undefined }) + expect.objectContaining({ id: 'kind--story [1] fn5', ancestors: [] }) ); }); @@ -247,16 +247,16 @@ describe('Instrumenter', () => { await fn1(() => fn2()); await fn3(); expect(callSpy).toHaveBeenCalledWith( - expect.objectContaining({ id: 'kind--story [0] fn1', parentId: undefined }) + expect.objectContaining({ id: 'kind--story [0] fn1', ancestors: [] }) ); expect(callSpy).toHaveBeenCalledWith( expect.objectContaining({ id: 'kind--story [0] fn1 [0] fn2', - parentId: 'kind--story [0] fn1', + ancestors: ['kind--story [0] fn1'], }) ); expect(callSpy).toHaveBeenCalledWith( - expect.objectContaining({ id: 'kind--story [1] fn3', parentId: undefined }) + expect.objectContaining({ id: 'kind--story [1] fn3', ancestors: [] }) ); }); @@ -294,8 +294,8 @@ describe('Instrumenter', () => { expect(syncSpy).toHaveBeenCalledWith( expect.objectContaining({ logItems: [ - { callId: 'kind--story [2] fn2', status: 'done' }, - { callId: 'kind--story [3] fn', status: 'done' }, + { callId: 'kind--story [2] fn2', status: 'done', ancestors: [] }, + { callId: 'kind--story [3] fn', status: 'done', ancestors: [] }, ], }) ); @@ -388,8 +388,8 @@ describe('Instrumenter', () => { expect(syncSpy).toHaveBeenCalledWith( expect.objectContaining({ logItems: [ - { callId: 'kind--story [0] fn1', status: 'done' }, - { callId: 'kind--story [1] fn2', status: 'done' }, + { callId: 'kind--story [0] fn1', status: 'done', ancestors: [] }, + { callId: 'kind--story [1] fn2', status: 'done', ancestors: [] }, ], }) ); @@ -405,11 +405,11 @@ describe('Instrumenter', () => { expect(syncSpy).toHaveBeenCalledWith( expect.objectContaining({ logItems: [ - { callId: 'kind--story [0] fn1', status: 'done' }, + { callId: 'kind--story [0] fn1', status: 'done', ancestors: [] }, { callId: 'kind--story [0] fn1 [0] fn2', status: 'done', - parentId: 'kind--story [0] fn1', + ancestors: ['kind--story [0] fn1'], }, ], }) diff --git a/lib/instrumenter/src/instrumenter.ts b/lib/instrumenter/src/instrumenter.ts index 71170dcfc278..16562248790b 100644 --- a/lib/instrumenter/src/instrumenter.ts +++ b/lib/instrumenter/src/instrumenter.ts @@ -69,7 +69,7 @@ const getInitialState = (): State => ({ shadowCalls: [], callRefsByResult: new Map(), chainedCallIds: new Set(), - parentId: undefined, + ancestors: [], playUntil: undefined, resolvers: {}, syncTimeout: undefined, @@ -177,7 +177,7 @@ export class Instrumenter { playUntil || shadowCalls .slice(0, firstRowIndex) - .filter((call) => call.interceptable && !call.parentId) + .filter((call) => call.interceptable && !call.ancestors.length) .slice(-1)[0]?.id, }; }); @@ -187,7 +187,7 @@ export class Instrumenter { }; const back = ({ storyId }: { storyId: string }) => { - const log = this.getLog(storyId).filter((call) => !call.parentId); + const log = this.getLog(storyId).filter((call) => !call.ancestors.length); const last = log.reduceRight((res, item, index) => { if (res >= 0 || item.status === CallStates.WAITING) return res; return index; @@ -275,7 +275,7 @@ export class Instrumenter { } }); if ((call.interceptable || call.exception) && !seen.has(call.id)) { - acc.unshift({ callId: call.id, status: call.status, parentId: call.parentId }); + acc.unshift({ callId: call.id, status: call.status, ancestors: call.ancestors }); seen.add(call.id); } return acc; @@ -332,13 +332,13 @@ export class Instrumenter { track(method: string, fn: Function, args: any[], options: Options) { const storyId: StoryId = args?.[0]?.__storyId__ || global.window.__STORYBOOK_PREVIEW__?.urlStore?.selection?.storyId; - const { cursor, parentId } = this.getState(storyId); + const { cursor, ancestors } = this.getState(storyId); this.setState(storyId, { cursor: cursor + 1 }); - const id = `${parentId || storyId} [${cursor}] ${method}`; + const id = `${ancestors.slice(-1)[0] || storyId} [${cursor}] ${method}`; const { path = [], intercept = false, retain = false } = options; const interceptable = typeof intercept === 'function' ? intercept(method, path) : intercept; - const call: Call = { id, parentId, storyId, cursor, path, method, args, interceptable, retain }; - const interceptOrInvoke = interceptable && !parentId ? this.intercept : this.invoke; + const call = { id, cursor, storyId, ancestors, path, method, args, interceptable, retain }; + const interceptOrInvoke = interceptable && !ancestors.length ? this.intercept : this.invoke; const result = interceptOrInvoke.call(this, fn, call, options); return this.instrument(result, { ...options, mutate: true, path: [{ __callId__: call.id }] }); } @@ -449,7 +449,7 @@ export class Instrumenter { })); // Exceptions inside callbacks should bubble up to the parent call. - if (call.parentId) { + if (call.ancestors.length) { Object.defineProperty(e, 'callId', { value: call.id }); throw e; } @@ -481,15 +481,15 @@ export class Instrumenter { if (typeof arg !== 'function' || Object.keys(arg).length) return arg; return (...args: any) => { - // Set the cursor and parentId for calls that happen inside the callback. - const { cursor, parentId } = this.getState(call.storyId); - this.setState(call.storyId, { cursor: 0, parentId: call.id }); - const restore = () => this.setState(call.storyId, { cursor, parentId }); + // Set the cursor and ancestors for calls that happen inside the callback. + const { cursor, ancestors } = this.getState(call.storyId); + this.setState(call.storyId, { cursor: 0, ancestors: [...ancestors, call.id] }); + const restore = () => this.setState(call.storyId, { cursor, ancestors }); // Invoke the actual callback function. const res = arg(...args); - // Reset cursor and parentId to their original values before we entered the callback. + // Reset cursor and ancestors to their original values before we entered the callback. if (res instanceof Promise) res.then(restore, restore); else restore(); @@ -553,7 +553,7 @@ export class Instrumenter { const { isLocked, isPlaying } = this.getState(storyId); const logItems: LogItem[] = this.getLog(storyId); const pausedAt = logItems - .filter(({ parentId }) => !parentId) + .filter(({ ancestors }) => !ancestors.length) .find((item) => item.status === CallStates.WAITING)?.callId; const hasActive = logItems.some((item) => item.status === CallStates.ACTIVE); diff --git a/lib/instrumenter/src/types.ts b/lib/instrumenter/src/types.ts index e9cca7003547..2b9edab63de4 100644 --- a/lib/instrumenter/src/types.ts +++ b/lib/instrumenter/src/types.ts @@ -2,9 +2,9 @@ import type { StoryId } from '@storybook/addons'; export interface Call { id: string; - parentId?: Call['id']; - storyId: StoryId; cursor: number; + storyId: StoryId; + ancestors: Call['id'][]; path: Array; method: string; args: any[]; @@ -52,7 +52,7 @@ export interface ControlStates { export interface LogItem { callId: Call['id']; status: Call['status']; - parentId?: Call['id']; + ancestors: Call['id'][]; } export interface Payload { @@ -70,7 +70,7 @@ export interface State { shadowCalls: Call[]; callRefsByResult: Map; chainedCallIds: Set; - parentId?: Call['id']; + ancestors: Call['id'][]; playUntil?: Call['id']; resolvers: Record; syncTimeout: ReturnType; diff --git a/lib/preview-web/package.json b/lib/preview-web/package.json index bde7870db102..96808a2fc038 100644 --- a/lib/preview-web/package.json +++ b/lib/preview-web/package.json @@ -45,6 +45,7 @@ "@storybook/client-logger": "7.0.0-alpha.10", "@storybook/core-events": "7.0.0-alpha.10", "@storybook/csf": "0.0.2--canary.4566f4d.1", + "@storybook/instrumenter": "7.0.0-alpha.10", "@storybook/store": "7.0.0-alpha.10", "ansi-to-html": "^0.6.11", "core-js": "^3.8.2", diff --git a/lib/preview-web/src/StoryRender.ts b/lib/preview-web/src/StoryRender.ts index e49e89903abf..d4e9eea8ac68 100644 --- a/lib/preview-web/src/StoryRender.ts +++ b/lib/preview-web/src/StoryRender.ts @@ -20,6 +20,7 @@ import { STORY_RENDERED, PLAY_FUNCTION_THREW_EXCEPTION, } from '@storybook/core-events'; +import { instrument } from '@storybook/instrumenter'; const { AbortController } = global; @@ -242,9 +243,13 @@ export class StoryRender implements Render { - await playFunction(renderContext.storyContext); - }); + const { step } = instrument( + { step: async (label: string, callback: () => any) => callback() }, + { intercept: true } + ); + await this.runPhase(abortSignal, 'playing', async () => + playFunction({ ...renderContext.storyContext, step }) + ); await this.runPhase(abortSignal, 'played'); } catch (error) { logger.error(error); diff --git a/yarn.lock b/yarn.lock index c7eb673c6355..625b99d3e645 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9108,6 +9108,7 @@ __metadata: "@storybook/client-logger": 7.0.0-alpha.10 "@storybook/core-events": 7.0.0-alpha.10 "@storybook/csf": 0.0.2--canary.4566f4d.1 + "@storybook/instrumenter": 7.0.0-alpha.10 "@storybook/store": 7.0.0-alpha.10 ansi-to-html: ^0.6.11 core-js: ^3.8.2 From 9c427fe8a9c1bb4da80bcf9c74c3f269eb45d83a Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Thu, 23 Jun 2022 23:01:50 +0200 Subject: [PATCH 02/45] Fix bubbling multiple levels --- lib/instrumenter/src/instrumenter.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/instrumenter/src/instrumenter.ts b/lib/instrumenter/src/instrumenter.ts index 16562248790b..dc9a1f0b2a0e 100644 --- a/lib/instrumenter/src/instrumenter.ts +++ b/lib/instrumenter/src/instrumenter.ts @@ -450,7 +450,9 @@ export class Instrumenter { // Exceptions inside callbacks should bubble up to the parent call. if (call.ancestors.length) { - Object.defineProperty(e, 'callId', { value: call.id }); + if (!Object.prototype.hasOwnProperty.call(e, 'callId')) { + Object.defineProperty(e, 'callId', { value: call.id }); + } throw e; } From fc1d62d2d633a144e6cc38311b7b6b3a5fad3660 Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Fri, 24 Jun 2022 09:17:14 +0200 Subject: [PATCH 03/45] Refactor stories to use step function --- .../addon-interactions.stories.tsx | 104 ++++++++++++------ 1 file changed, 69 insertions(+), 35 deletions(-) diff --git a/addons/interactions/src/components/AccountForm/addon-interactions.stories.tsx b/addons/interactions/src/components/AccountForm/addon-interactions.stories.tsx index 3a4ad4e71704..2fb00e6feea5 100644 --- a/addons/interactions/src/components/AccountForm/addon-interactions.stories.tsx +++ b/addons/interactions/src/components/AccountForm/addon-interactions.stories.tsx @@ -1,3 +1,4 @@ +/* eslint-disable jest/no-standalone-expect */ import { Story as CSF2Story, Meta, ComponentStoryObj } from '@storybook/react'; import { expect } from '@storybook/jest'; import { @@ -120,38 +121,51 @@ export const Standard: CSF3Story = { args: { passwordVerification: false }, }; -export const StandardEmailFilled: CSF3Story = { +export const StandardEmailFilled = { ...Standard, - play: async ({ canvasElement }) => { + play: async ({ canvasElement, step }) => { const canvas = within(canvasElement); - await fireEvent.change(canvas.getByTestId('email'), { - target: { - value: 'michael@chromatic.com', - }, + + await step('Enter email', async () => { + await fireEvent.change(canvas.getByTestId('email'), { + target: { value: 'michael@chromatic.com' }, + }); }); }, }; -export const StandardEmailFailed: CSF3Story = { +export const StandardEmailFailed = { ...Standard, - play: async ({ args, canvasElement }) => { + play: async ({ args, canvasElement, step }) => { const canvas = within(canvasElement); - await userEvent.type(canvas.getByTestId('email'), 'gert@chromatic'); - await userEvent.type(canvas.getByTestId('password1'), 'supersecret'); - await userEvent.click(canvas.getByRole('button', { name: /create account/i })); + + await step('Enter email and password', async () => { + await userEvent.type(canvas.getByTestId('email'), 'gert@chromatic'); + await userEvent.type(canvas.getByTestId('password1'), 'supersecret'); + }); + + await step('Submit form', async () => { + await userEvent.click(canvas.getByRole('button', { name: /create account/i })); + }); await canvas.findByText('Please enter a correctly formatted email address'); await expect(args.onSubmit).not.toHaveBeenCalled(); }, }; -export const StandardEmailSuccess: CSF3Story = { +export const StandardEmailSuccess = { ...Standard, - play: async ({ args, canvasElement }) => { + play: async ({ args, canvasElement, step }) => { const canvas = within(canvasElement); - await userEvent.type(canvas.getByTestId('email'), 'michael@chromatic.com'); - await userEvent.type(canvas.getByTestId('password1'), 'testpasswordthatwontfail'); - await userEvent.click(canvas.getByTestId('submit')); + + await step('Enter email and password', async () => { + await userEvent.type(canvas.getByTestId('email'), 'michael@chromatic.com'); + await userEvent.type(canvas.getByTestId('password1'), 'testpasswordthatwontfail'); + }); + + await step('Submit form', async () => { + await userEvent.click(canvas.getByTestId('submit')); + }); await waitFor(async () => { await expect(args.onSubmit).toHaveBeenCalledTimes(1); @@ -163,17 +177,23 @@ export const StandardEmailSuccess: CSF3Story = { }, }; -export const StandardPasswordFailed: CSF3Story = { +export const StandardPasswordFailed = { ...Standard, play: async (context) => { const canvas = within(context.canvasElement); await StandardEmailFilled.play(context); - await userEvent.type(canvas.getByTestId('password1'), 'asdf'); - await userEvent.click(canvas.getByTestId('submit')); + + await context.step('Enter password', async () => { + await userEvent.type(canvas.getByTestId('password1'), 'asdf'); + }); + + await context.step('Submit form', async () => { + await userEvent.click(canvas.getByTestId('submit')); + }); }, }; -export const StandardFailHover: CSF3Story = { +export const StandardFailHover = { ...StandardPasswordFailed, play: async (context) => { const canvas = within(context.canvasElement); @@ -184,42 +204,56 @@ export const StandardFailHover: CSF3Story = { }, }; -export const Verification: CSF3Story = { +export const Verification = { args: { passwordVerification: true }, argTypes: { onSubmit: { action: 'clicked' } }, }; -export const VerificationPassword: CSF3Story = { +export const VerificationPassword = { ...Verification, play: async (context) => { const canvas = within(context.canvasElement); await StandardEmailFilled.play(context); - await userEvent.type(canvas.getByTestId('password1'), 'asdfasdf'); - await userEvent.click(canvas.getByTestId('submit')); + await context.step('Enter password', async () => { + await userEvent.type(canvas.getByTestId('password1'), 'asdfasdf'); + }); + await context.step('Submit form', async () => { + await userEvent.click(canvas.getByTestId('submit')); + }); }, }; -export const VerificationPasswordMismatch: CSF3Story = { +export const VerificationPasswordMismatch = { ...Verification, play: async (context) => { const canvas = within(context.canvasElement); await StandardEmailFilled.play(context); - await userEvent.type(canvas.getByTestId('password1'), 'asdfasdf'); - await userEvent.type(canvas.getByTestId('password2'), 'asdf1234'); - await userEvent.click(canvas.getByTestId('submit')); + await context.step('Enter passwords', async () => { + await userEvent.type(canvas.getByTestId('password1'), 'asdfasdf'); + await userEvent.type(canvas.getByTestId('password2'), 'asdf1234'); + }); + await context.step('Submit form', async () => { + await userEvent.click(canvas.getByTestId('submit')); + }); }, }; -export const VerificationSuccess: CSF3Story = { +export const VerificationSuccess = { ...Verification, play: async (context) => { const canvas = within(context.canvasElement); await StandardEmailFilled.play(context); - await new Promise((resolve) => setTimeout(resolve, 1000)); - await userEvent.type(canvas.getByTestId('password1'), 'helloyou', { delay: 50 }); - await new Promise((resolve) => setTimeout(resolve, 1000)); - await userEvent.type(canvas.getByTestId('password2'), 'helloyou', { delay: 50 }); - await new Promise((resolve) => setTimeout(resolve, 1000)); - await userEvent.click(canvas.getByTestId('submit')); + + await context.step('Enter passwords', async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + await userEvent.type(canvas.getByTestId('password1'), 'helloyou', { delay: 50 }); + await new Promise((resolve) => setTimeout(resolve, 1000)); + await userEvent.type(canvas.getByTestId('password2'), 'helloyou', { delay: 50 }); + }); + + await context.step('Submit form', async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + await userEvent.click(canvas.getByTestId('submit')); + }); }, }; From b2a18cf256eca1435b63def39bbf3d6f5f9ccd04 Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Fri, 24 Jun 2022 09:36:01 +0200 Subject: [PATCH 04/45] Pull example stories into their own file --- addons/interactions/src/Examples.stories.tsx | 126 ++++++++++++++++++ .../addon-interactions.stories.tsx | 101 +------------- 2 files changed, 128 insertions(+), 99 deletions(-) create mode 100644 addons/interactions/src/Examples.stories.tsx diff --git a/addons/interactions/src/Examples.stories.tsx b/addons/interactions/src/Examples.stories.tsx new file mode 100644 index 000000000000..79618730b304 --- /dev/null +++ b/addons/interactions/src/Examples.stories.tsx @@ -0,0 +1,126 @@ +/* eslint-disable jest/no-standalone-expect */ +import { Story, Meta } from '@storybook/react'; +import { expect } from '@storybook/jest'; +import { within, waitFor, userEvent, waitForElementToBeRemoved } from '@storybook/testing-library'; +import React from 'react'; + +export default { + title: 'Addons/Interactions/Examples', + parameters: { + layout: 'centered', + theme: 'light', + options: { selectedPanel: 'storybook/interactions/panel' }, + }, + argTypes: { + onSubmit: { action: true }, + }, +} as Meta; + +export const Assertions: Story = ({ onSubmit }) => ( + +); +Assertions.play = async ({ args, canvasElement }) => { + await userEvent.click(within(canvasElement).getByRole('button')); + await expect(args.onSubmit).toHaveBeenCalledWith(expect.stringMatching(/([A-Z])\w+/gi)); + await expect([{ name: 'John', age: 42 }]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'John' }), + expect.objectContaining({ age: 42 }), + ]) + ); +}; + +export const FindBy: Story = () => { + const [isLoading, setIsLoading] = React.useState(true); + React.useEffect(() => { + setTimeout(() => setIsLoading(false), 500); + }, []); + return isLoading ?
Loading...
: ; +}; +FindBy.play = async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByRole('button'); + await expect(true).toBe(true); +}; + +export const WaitFor: Story = ({ onSubmit }) => ( + +); +WaitFor.play = async ({ args, canvasElement }) => { + await userEvent.click(await within(canvasElement).findByText('Click')); + await waitFor(async () => { + await expect(args.onSubmit).toHaveBeenCalledWith(expect.stringMatching(/([A-Z])\w+/gi)); + await expect(true).toBe(true); + }); +}; + +export const WaitForElementToBeRemoved: Story = () => { + const [isLoading, setIsLoading] = React.useState(true); + React.useEffect(() => { + setTimeout(() => setIsLoading(false), 1500); + }, []); + return isLoading ?
Loading...
: ; +}; +WaitForElementToBeRemoved.play = async ({ canvasElement }) => { + const canvas = within(canvasElement); + await waitForElementToBeRemoved(await canvas.findByText('Loading...'), { timeout: 2000 }); + const button = await canvas.findByText('Loaded!'); + await expect(button).not.toBeNull(); +}; + +export const WithLoaders: Story = ({ onSubmit }, { loaded: { todo } }) => { + return ( + + ); +}; +WithLoaders.loaders = [ + async () => { + // long fake timeout + await new Promise((resolve) => setTimeout(resolve, 2000)); + + return { + todo: { + userId: 1, + id: 1, + title: 'delectus aut autem', + completed: false, + }, + }; + }, +]; +WithLoaders.play = async ({ args, canvasElement }) => { + const canvas = within(canvasElement); + const todoItem = await canvas.findByText('Todo: delectus aut autem'); + await userEvent.click(todoItem); + await expect(args.onSubmit).toHaveBeenCalledWith('delectus aut autem'); +}; + +export const WithSteps: Story = ({ onSubmit }) => ( + +); +WithSteps.play = async ({ args, canvasElement, step }) => { + step('Click button', async () => { + await userEvent.click(within(canvasElement).getByRole('button')); + + step('Verify submit', async () => { + await expect(args.onSubmit).toHaveBeenCalledWith(expect.stringMatching(/([A-Z])\w+/gi)); + }); + + step('Verify result', async () => { + await expect([{ name: 'John', age: 42 }]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'John' }), + expect.objectContaining({ age: 42 }), + ]) + ); + }); + }); +}; diff --git a/addons/interactions/src/components/AccountForm/addon-interactions.stories.tsx b/addons/interactions/src/components/AccountForm/addon-interactions.stories.tsx index 2fb00e6feea5..6d835dffa1b7 100644 --- a/addons/interactions/src/components/AccountForm/addon-interactions.stories.tsx +++ b/addons/interactions/src/components/AccountForm/addon-interactions.stories.tsx @@ -1,14 +1,7 @@ /* eslint-disable jest/no-standalone-expect */ -import { Story as CSF2Story, Meta, ComponentStoryObj } from '@storybook/react'; +import { Meta, ComponentStoryObj } from '@storybook/react'; import { expect } from '@storybook/jest'; -import { - within, - waitFor, - fireEvent, - userEvent, - waitForElementToBeRemoved, -} from '@storybook/testing-library'; -import React from 'react'; +import { within, waitFor, fireEvent, userEvent } from '@storybook/testing-library'; import { AccountForm } from './AccountForm'; @@ -27,96 +20,6 @@ export default { type CSF3Story = ComponentStoryObj; -export const Demo: CSF2Story = (args) => ( - -); - -Demo.play = async ({ args, canvasElement }) => { - await userEvent.click(within(canvasElement).getByRole('button')); - await expect(args.onSubmit).toHaveBeenCalledWith(expect.stringMatching(/([A-Z])\w+/gi)); - await expect([{ name: 'John', age: 42 }]).toEqual( - expect.arrayContaining([ - expect.objectContaining({ name: 'John' }), - expect.objectContaining({ age: 42 }), - ]) - ); -}; - -export const Exception = Demo.bind({}); -Exception.play = () => Demo.play(undefined as any); // deepscan-disable-line -Exception.parameters = { chromatic: { disableSnapshot: true } }; - -export const FindBy: CSF2Story = (args) => { - const [isLoading, setIsLoading] = React.useState(true); - React.useEffect(() => { - setTimeout(() => setIsLoading(false), 500); - }, []); - return isLoading ?
Loading...
: ; -}; -FindBy.play = async ({ canvasElement }) => { - const canvas = within(canvasElement); - await canvas.findByRole('button'); - await expect(true).toBe(true); -}; - -export const WaitFor: CSF2Story = (args) => ( - -); -WaitFor.play = async ({ args, canvasElement }) => { - await userEvent.click(await within(canvasElement).findByText('Click')); - await waitFor(async () => { - await expect(args.onSubmit).toHaveBeenCalledWith(expect.stringMatching(/([A-Z])\w+/gi)); - await expect(true).toBe(true); - }); -}; - -export const WaitForElementToBeRemoved: CSF2Story = () => { - const [isLoading, setIsLoading] = React.useState(true); - React.useEffect(() => { - setTimeout(() => setIsLoading(false), 1500); - }, []); - return isLoading ?
Loading...
: ; -}; -WaitForElementToBeRemoved.play = async ({ canvasElement }) => { - const canvas = within(canvasElement); - await waitForElementToBeRemoved(await canvas.findByText('Loading...'), { timeout: 2000 }); - const button = await canvas.findByText('Loaded!'); - await expect(button).not.toBeNull(); -}; - -export const WithLoaders: CSF2Story = (args, { loaded: { todo } }) => { - return ( - - ); -}; -WithLoaders.loaders = [ - async () => { - // long fake timeout - await new Promise((resolve) => setTimeout(resolve, 2000)); - - return { - todo: { - userId: 1, - id: 1, - title: 'delectus aut autem', - completed: false, - }, - }; - }, -]; -WithLoaders.play = async ({ args, canvasElement }) => { - const canvas = within(canvasElement); - const todoItem = await canvas.findByText('Todo: delectus aut autem'); - await userEvent.click(todoItem); - await expect(args.onSubmit).toHaveBeenCalledWith('delectus aut autem'); -}; - export const Standard: CSF3Story = { args: { passwordVerification: false }, }; From 884e410552607de4617b9626e26a2b9a78aa7ff0 Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Fri, 24 Jun 2022 09:59:26 +0200 Subject: [PATCH 05/45] Move examples to separate directory --- .../addon-interactions.stories.mdx | 33 ------------------- .../AccountFormInteractions.stories.mdx | 31 +++++++++++++++++ .../AccountFormInteractions.stories.tsx} | 4 +-- .../AccountFormInteractions.tsx} | 0 .../src/{ => examples}/Examples.stories.tsx | 0 5 files changed, 33 insertions(+), 35 deletions(-) delete mode 100644 addons/interactions/src/components/AccountForm/addon-interactions.stories.mdx create mode 100644 addons/interactions/src/examples/AccountFormInteractions/AccountFormInteractions.stories.mdx rename addons/interactions/src/{components/AccountForm/addon-interactions.stories.tsx => examples/AccountFormInteractions/AccountFormInteractions.stories.tsx} (97%) rename addons/interactions/src/{components/AccountForm/AccountForm.tsx => examples/AccountFormInteractions/AccountFormInteractions.tsx} (100%) rename addons/interactions/src/{ => examples}/Examples.stories.tsx (100%) diff --git a/addons/interactions/src/components/AccountForm/addon-interactions.stories.mdx b/addons/interactions/src/components/AccountForm/addon-interactions.stories.mdx deleted file mode 100644 index 1930000cc9a1..000000000000 --- a/addons/interactions/src/components/AccountForm/addon-interactions.stories.mdx +++ /dev/null @@ -1,33 +0,0 @@ -import { Meta, Canvas, Story } from '@storybook/addon-docs'; -import { expect } from '@storybook/jest'; -import { within, waitFor, fireEvent, userEvent } from '@storybook/testing-library'; - -import { AccountForm } from './AccountForm'; - - - -## AccountForm - - - { - const { args, canvasElement } = context - const canvas = within(canvasElement) - - await userEvent.type(canvas.getByTestId('email'), 'username@email.com') - await userEvent.type(canvas.getByTestId('password1'), 'thepassword') - await userEvent.click(canvas.getByRole('button', { name: /create account/i })) - expect(args.onSubmit).not.toHaveBeenCalled() - }}/> - diff --git a/addons/interactions/src/examples/AccountFormInteractions/AccountFormInteractions.stories.mdx b/addons/interactions/src/examples/AccountFormInteractions/AccountFormInteractions.stories.mdx new file mode 100644 index 000000000000..4c97235008e3 --- /dev/null +++ b/addons/interactions/src/examples/AccountFormInteractions/AccountFormInteractions.stories.mdx @@ -0,0 +1,31 @@ +import { Meta, Canvas, Story } from '@storybook/addon-docs'; +import { expect } from '@storybook/jest'; +import { within, waitFor, fireEvent, userEvent, screen } from '@storybook/testing-library'; + +import { AccountForm } from './AccountFormInteractions'; + + + +## AccountForm + + + { + await userEvent.type(screen.getByTestId('email'), 'username@email.com'); + await userEvent.type(screen.getByTestId('password1'), 'thepassword'); + await userEvent.click(screen.getByRole('button', { name: /create account/i })); + await expect(args.onSubmit).not.toHaveBeenCalled(); + }} + /> + diff --git a/addons/interactions/src/components/AccountForm/addon-interactions.stories.tsx b/addons/interactions/src/examples/AccountFormInteractions/AccountFormInteractions.stories.tsx similarity index 97% rename from addons/interactions/src/components/AccountForm/addon-interactions.stories.tsx rename to addons/interactions/src/examples/AccountFormInteractions/AccountFormInteractions.stories.tsx index 6d835dffa1b7..34f84115113c 100644 --- a/addons/interactions/src/components/AccountForm/addon-interactions.stories.tsx +++ b/addons/interactions/src/examples/AccountFormInteractions/AccountFormInteractions.stories.tsx @@ -3,10 +3,10 @@ import { Meta, ComponentStoryObj } from '@storybook/react'; import { expect } from '@storybook/jest'; import { within, waitFor, fireEvent, userEvent } from '@storybook/testing-library'; -import { AccountForm } from './AccountForm'; +import { AccountForm } from './AccountFormInteractions'; export default { - title: 'Addons/Interactions/AccountForm', + title: 'Addons/Interactions/Examples/AccountForm', component: AccountForm, parameters: { layout: 'centered', diff --git a/addons/interactions/src/components/AccountForm/AccountForm.tsx b/addons/interactions/src/examples/AccountFormInteractions/AccountFormInteractions.tsx similarity index 100% rename from addons/interactions/src/components/AccountForm/AccountForm.tsx rename to addons/interactions/src/examples/AccountFormInteractions/AccountFormInteractions.tsx diff --git a/addons/interactions/src/Examples.stories.tsx b/addons/interactions/src/examples/Examples.stories.tsx similarity index 100% rename from addons/interactions/src/Examples.stories.tsx rename to addons/interactions/src/examples/Examples.stories.tsx From 890d94a4e101af83785f3b8e698ed47463e4fc92 Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Fri, 24 Jun 2022 10:52:11 +0200 Subject: [PATCH 06/45] Stories for step function --- .../components/InteractionsPanel.stories.tsx | 2 +- .../src/components/MethodCall.stories.tsx | 21 ++++++++++++ addons/interactions/src/mocks/index.ts | 33 +++++++++++-------- 3 files changed, 42 insertions(+), 14 deletions(-) diff --git a/addons/interactions/src/components/InteractionsPanel.stories.tsx b/addons/interactions/src/components/InteractionsPanel.stories.tsx index 2d6e088674be..f46faeca93cd 100644 --- a/addons/interactions/src/components/InteractionsPanel.stories.tsx +++ b/addons/interactions/src/components/InteractionsPanel.stories.tsx @@ -120,7 +120,7 @@ export const Failed: Story = { }, }; -export const WithDebuggingDisabled: Story = { +export const NoDebugger: Story = { args: { controlStates: { ...SubnavStories.args.controlStates, debugger: false } }, }; diff --git a/addons/interactions/src/components/MethodCall.stories.tsx b/addons/interactions/src/components/MethodCall.stories.tsx index b65001b4e1cb..953a9e6adbb6 100644 --- a/addons/interactions/src/components/MethodCall.stories.tsx +++ b/addons/interactions/src/components/MethodCall.stories.tsx @@ -57,6 +57,7 @@ export const Args = () => ( /> + @@ -99,6 +100,7 @@ const calls: Call[] = [ { cursor: 0, id: '1', + ancestors: [], path: ['screen'], method: 'getByText', storyId: 'kind--story', @@ -109,6 +111,7 @@ const calls: Call[] = [ { cursor: 1, id: '2', + ancestors: [], path: ['userEvent'], method: 'click', storyId: 'kind--story', @@ -119,6 +122,7 @@ const calls: Call[] = [ { cursor: 2, id: '3', + ancestors: [], path: [], method: 'expect', storyId: 'kind--story', @@ -129,6 +133,7 @@ const calls: Call[] = [ { cursor: 3, id: '4', + ancestors: [], path: [{ __callId__: '3' }, 'not'], method: 'toBe', storyId: 'kind--story', @@ -139,6 +144,7 @@ const calls: Call[] = [ { cursor: 4, id: '5', + ancestors: [], path: ['jest'], method: 'fn', storyId: 'kind--story', @@ -149,6 +155,7 @@ const calls: Call[] = [ { cursor: 5, id: '6', + ancestors: [], path: [], method: 'expect', storyId: 'kind--story', @@ -159,6 +166,7 @@ const calls: Call[] = [ { cursor: 6, id: '7', + ancestors: [], path: ['expect'], method: 'stringMatching', storyId: 'kind--story', @@ -169,6 +177,7 @@ const calls: Call[] = [ { cursor: 7, id: '8', + ancestors: [], path: [{ __callId__: '6' }, 'not'], method: 'toHaveBeenCalledWith', storyId: 'kind--story', @@ -182,6 +191,17 @@ const calls: Call[] = [ 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) => { @@ -189,6 +209,7 @@ const callsById = calls.reduce((acc, call) => { return acc; }, new Map()); +export const Step = () => ; export const Simple = () => ; export const Nested = () => ; export const Chained = () => ; diff --git a/addons/interactions/src/mocks/index.ts b/addons/interactions/src/mocks/index.ts index 61600cccb4c0..14066c7a0149 100644 --- a/addons/interactions/src/mocks/index.ts +++ b/addons/interactions/src/mocks/index.ts @@ -3,11 +3,23 @@ import { CallStates, Call } from '@storybook/instrumenter'; export const getCalls = (finalStatus: CallStates) => { const calls: Call[] = [ { - id: 'story--id [3] within', + id: 'story--id [3] step', storyId: 'story--id', - cursor: 3, + 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, @@ -15,11 +27,11 @@ export const getCalls = (finalStatus: CallStates) => { status: CallStates.DONE, }, { - id: 'story--id [4] findByText', + id: 'story--id [3] step [2] findByText', storyId: 'story--id', cursor: 4, - ancestors: [], - path: [{ __callId__: 'story--id [3] within' }], + ancestors: ['story--id [3] step'], + path: [{ __callId__: 'story--id [3] step [1] within' }], method: 'findByText', args: ['Click'], interceptable: true, @@ -27,10 +39,10 @@ export const getCalls = (finalStatus: CallStates) => { status: CallStates.DONE, }, { - id: 'story--id [5] click', + id: 'story--id [3] step [3] click', storyId: 'story--id', cursor: 5, - ancestors: [], + ancestors: ['story--id [3] step'], path: ['userEvent'], method: 'click', args: [{ __element__: { localName: 'button', innerText: 'Click' } }], @@ -127,9 +139,4 @@ export const getCalls = (finalStatus: CallStates) => { export const getInteractions = (finalStatus: CallStates) => getCalls(finalStatus) .filter((call) => call.interceptable) - .map((call, _, calls) => ({ - ...call, - childCallIds: calls.filter((c) => c.parentId === call.id).map((c) => c.id), - isCollapsed: false, - toggleCollapsed: () => {}, - })); + .map((call) => ({ ...call, childCallIds: [], isCollapsed: false, toggleCollapsed: () => {} })); From b64677450b9e8f0371fe9309daef4a2fff066bff Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Fri, 24 Jun 2022 11:30:48 +0200 Subject: [PATCH 07/45] Document step function --- docs/essentials/interactions.md | 3 +-- ...torybook-interactions-play-function.js.mdx | 15 ++++++++----- ...torybook-interactions-step-function.js.mdx | 14 ++++++++++++ docs/writing-tests/interaction-testing.md | 20 +++++++++++++++++- .../storybook-addon-interactions-steps.png | Bin 0 -> 96863 bytes 5 files changed, 44 insertions(+), 8 deletions(-) create mode 100644 docs/snippets/common/storybook-interactions-step-function.js.mdx create mode 100644 docs/writing-tests/storybook-addon-interactions-steps.png diff --git a/docs/essentials/interactions.md b/docs/essentials/interactions.md index a46bcc13b11a..a5b47696596a 100644 --- a/docs/essentials/interactions.md +++ b/docs/essentials/interactions.md @@ -47,7 +47,6 @@ Next, update [`.storybook/main.js`](../configure/overview.md#configure-story-ren - Now when you run Storybook, the Interactions addon will be enabled. ![Storybook Interactions installed and registered](./addon-interactions-installed-registered.png) @@ -68,7 +67,7 @@ Make sure to import the Storybook wrappers for Jest and Testing Library rather t -The above example uses the `canvasElement` to scope your element queries to the current story. It's essential if you want your play functions to eventually be compatible with Storybook Docs, which renders multiple components on the same page. +The above example uses the `canvasElement` to scope your element queries to the current story. It's essential if you want your play functions to eventually be compatible with Storybook Docs, which renders multiple components on the same page. Additionally, the `step` function can be used to create labeled groups of interactions. While you can refer to the [Testing Library documentation](https://testing-library.com/docs/) for details on how to use it, there's an important detail that's different when using the Storybook wrapper: **method invocations must be `await`-ed**. It allows you to step back and forth through your interactions using the debugger. diff --git a/docs/snippets/common/storybook-interactions-play-function.js.mdx b/docs/snippets/common/storybook-interactions-play-function.js.mdx index b449defce278..7da126b7ca54 100644 --- a/docs/snippets/common/storybook-interactions-play-function.js.mdx +++ b/docs/snippets/common/storybook-interactions-play-function.js.mdx @@ -2,7 +2,7 @@ // MyForm.stories.js import { expect } from '@storybook/jest'; import { userEvent, waitFor, within } from '@storybook/testing-library'; -import { MyForm } from './MyForm' +import { MyForm } from './MyForm'; export default { title: 'MyForm', @@ -15,12 +15,17 @@ export default { const Template = (args) => ; const Submitted = Template.bind({}); -Submitted.play = async ({ args, canvasElement }) => { +Submitted.play = async ({ args, canvasElement, step }) => { const canvas = within(canvasElement); - await userEvent.type(canvas.getByTestId('email'), 'hi@example.com'); - await userEvent.type(canvas.getByTestId('password'), 'supersecret'); - await userEvent.click(canvas.getByRole('button')); + await step('Enter credentials', async () => { + await userEvent.type(canvas.getByTestId('email'), 'hi@example.com'); + await userEvent.type(canvas.getByTestId('password'), 'supersecret'); + }); + + await step('Submit form', async () => { + await userEvent.click(canvas.getByRole('button')); + }); await waitFor(() => expect(args.onSubmit).toHaveBeenCalled()); }; diff --git a/docs/snippets/common/storybook-interactions-step-function.js.mdx b/docs/snippets/common/storybook-interactions-step-function.js.mdx new file mode 100644 index 000000000000..7ef59f7d482c --- /dev/null +++ b/docs/snippets/common/storybook-interactions-step-function.js.mdx @@ -0,0 +1,14 @@ +```js +Submitted.play = async ({ args, canvasElement, step }) => { + const canvas = within(canvasElement); + + await step('Enter email and password', async () => { + await userEvent.type(canvas.getByTestId('email'), 'hi@example.com'); + await userEvent.type(canvas.getByTestId('password'), 'supersecret'); + }); + + await step('Submit form', async () => { + await userEvent.click(canvas.getByRole('button')); + }); +}; +``` diff --git a/docs/writing-tests/interaction-testing.md b/docs/writing-tests/interaction-testing.md index d4d74ea0ca48..9d13650d8c4a 100644 --- a/docs/writing-tests/interaction-testing.md +++ b/docs/writing-tests/interaction-testing.md @@ -81,7 +81,7 @@ Once the story loads in the UI, it simulates the user's behavior and verifies th /> -## API for user-events +### API for user-events Under the hood, Storybook’s interaction addon mirrors Testing Library’s [`user-events`](https://testing-library.com/docs/user-event/intro/) API. If you’re familiar with [Testing Library](https://testing-library.com/), you should be at home in Storybook. @@ -99,6 +99,24 @@ Below is an abridged API for user-event. For more, check out the [official user- | `type` | Writes text inside inputs, or textareas
`userEvent.type(await within(canvasElement).getByRole('my-input'),'Some text');` | | `unhover` | Unhovers out of element
`userEvent.unhover(await within(canvasElement).getByLabelText(/Example/i));` | +### Group interactions with the `step` function + +For complex flows, it can be worthwhile to group sets of related interactions together using the `step` function. This allows you to provide a custom label that describes a set of interactions: + + + + + + + +This will show your interactions nested in a collapsible group: + +![Interaction testing with labeled steps](./storybook-addon-interactions-steps.png) + ### Interactive debugger If you check your interactions panel, you'll see the step-by-step flow. It also offers a handy set of UI controls to pause, resume, rewind, and step through each interaction. diff --git a/docs/writing-tests/storybook-addon-interactions-steps.png b/docs/writing-tests/storybook-addon-interactions-steps.png new file mode 100644 index 0000000000000000000000000000000000000000..0438e58678d4c3fd86547324e9a428f91a323e09 GIT binary patch literal 96863 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%0AF+G5N8_5opPl^rx( zJLY{>=NLR%rDb+IV$MzkOh6RoI|tr(+?ONw#Tm0HcPx7jtSeuEZJFl@g2OW*^N!=k z$RW2hk8?8Hrn5>W!n;fp`8P4~9PqUjGb(v*wO?@;J@alG*(gKnH$HCeo?AAuWD*QV z<7Ea>H{JS&8<4wp&Cj3mEVRM5P(G9gj@`YQLmk`Y4bgs0m6EXA+01-$C-`axu#x^%+ zvKQGi@Ug>Prc9>@OkNA_MFKMZ;KJV)pNEr`ZZ)|{%x<`z-S*cQYr5>9{uWNVnr6|@-qrJtHa^Gz}Mp-{)t*MrAF%wU)Tx% z`Iy^IduMj-b7Y-g$hL5r?|7h@$ejrvdMTJE2)f`Fm*0jcY92oGJ{5|I+S-_$RfZ#n zc8Yk5{uDZF&Qa8wEy~D|aMe%g!=YR50J1`?T(fmKv;*h=<^weY&G=D&fDc4o*@epa z1G;R_TA)6S^jAmR?%kT#9X1%egJmr)Xpd!Ctds36F^rS`Wpu@0yLR{QNrB}oO5mS| z{lb*WAGJ`R;U;oJv6x3q0mtkms~`tH4IQWvymEyfWZbd~r5*^W@h)za zSh$@XoVNvk_jf<}S=-KGni*sw_9)HzY>qIk1EC*mYanOdPOiZNy!yqA{OZ`V zDV2eJCT>!C@VO+y?~?1;h4`Dru5)aDp{s-6Iywmcp#!P#F>r8>D~>%1>0g5bbfDjB z7V!f|QOVI%&TjpZ|9HLF(X z3e%IUCpCP@>72=1eGw>ofe~UkUC66)n}Mw!NfHSvjxFP7_HhQgBDL3k>rqk~=xs+7 z`%$ZYIhmV3|G9hBlt+Rfli8&tcljyxiuy0w#$4X4lSo8ICDsFntPso8d7mB`^>o&q znJCyN(w!0N9O5`Q5+!@<%aO;0Z3cAEv78=dW1J!>}Ny+y8&Y#M)orKu9 z66td(QylzpEIOTvnffGsEWVoR8+U*bRF?wb@~q5>-I2dP4rtD!|Euj=O`Pztu_8&U zr12K*qUwqA(EaYdchcr4xQKpt8S+9N5M!L~lP49YN`*mZ{ymE5!5x2!v#!=(iO>^T z8*9!TTyf=|c7ucExSVJADcp#*=jRYI80?t3e~vH5C)T%CF3I&zP<|ia2&XNPWvN=~ zm60=H2(N?}xv7qKDl(ICK`su~_?vJDnK~l1XNgAYE}wF4Tna)XL;BMWo{THol2nsM zh~H(`)AetDcPc(?duHtAW|y}b1`s+K?kes9kz~Y5gOeWEucF;IBVKipD3r%1NHb>O zGFZ;+<|hRk@|OOtHG|48LG>$!D^c`*nNU%nnzJEGVa7{=YR94TMz`Tc8aX=YmL!^P z9wn_-(kSCXiR7tPqlCYck0%MZ8*nFmmE~%J&~=@5dXlxTmd_id=Nn~WR8{%0J0oo| zx+!q;#c3ba{8u`?Q{!~#J%(2)d(|KWC1;sW$7G8=5FE?Qc?Tx0bfOTKHyfl0%~56X zHw*@c+vZ^CnXtp7RXJ9?$k{W3Sh61sAMgx+U>HOmAUvv4F{}S%*!cK~kGbmb$nqFj zwqQ#91`J4Hgfh#LBkUSD_WP?HVy$#0?=HaT?BA#QC#Mp7w`|uGOcOurI1=^8t2z7N z2{yqq;&=LMlwA^_1uEW^0BqaLI>&C^k$F08-tC>3!ZzMfV*uMP>%5&F;Xh0g38z)l zNXM`%!NBj&DJpPIN1zq2S<+TZuYVyrH_QC->yz+U1^B}L`hliZOPkwSG+r4mb5HmPDn`GaBjxE95-NUK1b zLJktxHpGqlyRlvk=l;V4L?$tfe2X>XQKR>nh482Py8u@!f$Lfv!88H+$}p+(-A*2F zi}txUz=SY=@>^pKTL$OaLZv!H*uC!8zs&0D$O-BQP3GH_V=+q{jFjIj7~dQgihA5ta+^ z`5=qGyWSxp<~j8+Id#9nk44k9-FKDFU+xU;jIgaSdl3+jBo0&W2URKoARJkLoteXMml6}UQW$90}Bu1dB50m#Q^ zlu7jGo+29O_}(d>1#2c+GE4KxP4E1+q0GVXyp_EnHB=@&g&rGVRR{cY54RIkw=0Gq z!gAzCIIv#N8|+G`x(YPHIAl&rdNWsa$kYZw-36SK;_9HKa5X*Fk=cqb!5XLfdcENc zf}bJSVPxA7z?~ngU@*^_eMBG-m<;FeIgJe8W^It|*C}ldE!MjFN02!Q=KT>&sm}jY zE! zOs`v9{?$Y6rwKVDDPt;s;wE>NuFq1X;D~u)5aP9yf9+G&iq~)cRuAbf#r}@{&F_tn zx}uoHcPg@2z3U(DYFfFFj4KE&s7nY96Uk*Ptu}zN?`a{(8Zc0xfswk^&e_=bxh%T7 z)sKz$CFg-zGucZO+e|uK?Bo~J9^q#K6c<)>Bv$S+1n$w1)o7spE`2iq+ioR0_3~aE zQa##ZnU){1io(|EQ3Gv%I$&oJw|6ZTV0CE`y!KwxMd!zRV>0E3(x|005!Z!dN#Ud_I-Vd3JQ9ksdoUC*q*&M$fEziCt5+K*eOfSlW@D%`T0r8FdBV~ zXS=onhnr)4;^Rl0F;I&ZP!WMg`*Lo2n^qIV90C;7$*EEG(D<%zuZh;^>Y7$R1q0W1 zUeh4wkbI}ZBEkm9K|?NzQCEBM7@XE;kcf{8xi z(~REA4>VUO4#8K;O(1!Ib}w!GF?k>&<;Y55e+NSmzWSO_oq!Kk(4`ZZHvhT=QTU5M zGJ#njGq=k61elJcMHoIVG%>!)P1Cc7d&KrEH@uqa%dhr4-8Ofi{GG2WFJGGxQ$KKe zA%H2}A9*stJZa}}8OYZbwi_Kgl=wKCOHk&3d%10#q0X1LzfvV1J2a~bp7ETE0xRA| z=3aJZHG%9No)0J}nf&K9RV4>qmZ?ETSp!~I7AHEhhV^+4Uf;Bre`m@_{pVLT?H9m&9{RrJziS>CJ``#k{Mz08NJkFu=#fl?s1O7*c(?xMu1c$4w&DtxLvmYFD1OvkF zb=QIfNT27uH%)sPrV4<%yd7f2IK|R#*gj2d7T{EC1C?#6Z_2&@4ze`6@ib@g6Z-&ph13eCL z766%NFj|+IZg*IQ#%EtYX$8mgdQHJ_wd2Bh5fb32zkRINZq4f;B$?TlZ@ zmFYgPJR|o+4CaN3<|DL0vjW_cU9Z6fw4_`y%$vl0Ou6jt-*(z@Shg3}=vg_%>Y(w2 zduMy~um)Ny<_yXW?6TcVCBSz@z5@Nt%grf{`qeXPn=*C5IcPpgPYb_dyTrZy<=1s_ zhJvfYh`HknkgnDOq^vEgQ2V(r47yq3ey)QD!Q+p9{afXV`MG4Pt9dJPO(r>N?N7s` zs1OFZS+?$w4!Qmv7rn(}Rs4Qan=lOmNj$DtB zWZhr|RtCYK8K}e@a+x7vbne#$w+{4M>e{A$c^E2^(j&!0>W`~W-j7kdkA*IMM00UF z2Eiv{CNN)>ZiV;Btj5&2=g!^zTo75i^}sj+w6j*!G&xP?Nsb!LcxIm2;kS z+b}EEs(@W0n4CO-42EKvRJNRkd~@A0H1E zX273b&f234fQ~)jgJ1S9eDvuUIN$oP>01x6EA0SeNszrf-9=Rn(b^MW(Z_=I{RL{B zpk5rYK02x#Q9Y#pry=BbR8Nsy0dBVs!CkOXA!Tt{J?UlC{FRY19w-8vC04)>>TjW8 z)L+=B7{Kk=OQC1cfF6K&-8%RrA?X=x$?4~q9tdA~yi^;McZBqotb72yYK}qtJxF6; z`9<8L9+^rJ=hmmToP~J?4Z902kdLYA`e`U(smnZk2fv5$ZKLu}WL|G1$joqhqZzC` zV}AHjzcOgr)nU=`!Tx7(QK&%29u7Lo(Rrx1lfRs#%h=X3LQXfO0Oz9tQX@;N+NZ{1 zcg?ZW-uraqobJ)VRRX9-yv&*jbIvE}uOd;PLhD-&(Nazhl-&8#( zdB_&3J039Q1vn_9iJMr5@$D=R3{BoPUQRX{am?|KC1A)!4y`i9T)Oow%Szdqbv)8= z67!uPBnjM94ag<@;-Ta|+N>tE*B?ig=aRP%4jY-o#cJIZ4w3Y78vOc~r7tkfA=Oh> z5x@0t*TUDfzvyCQHY}E$Afe=7lbb#_t}k!<{>N2%MHstupBPn6x9L-Ao0H=)QbN1p zD{;Y?7IRwjYAN1NNOvzUPRC;kOghbYAY5H36G;0hZY0ql3de8zO%GHjfAhiH_I63E zu};J03JbWNjjSkir7XRyzJm z-8c-h+>BaYt7~bGA<$1)^sE$qU9@4WBhz@gGBYmg{+8OztM@lE32vhl5R2&9;>^&- zYVdroY7DTlFW$c(%nLwt>u0he#65wX;yL4qI!#+^fe8p*lV5GI;W;9?1R?=ZxHy4BG{m~-RB!7Q^i-L5BSpt8rjw{Pp>E)~CTqohsz0JI_PL`kr{jQZxt zNIrFKD;tvficD_V%s{KAtt7&*IlP7P8ZKpV7PkbB(%vyYXU`UOYSqg9sg#B z9T9@k1buT75(Y>P?@#L2B-yob@iTiAMYY`9R;Y9C@YqQJ)n?}_)q?J;VKqEA z9_{j?x~~IvBrKS)`!P8BD_%R{^THA7_G;~+8Xm)ax|W>;K|SQ9z5Eotd|vhUmr|XC zTDq7x-TT{%TdB%2;%Bs$JnCw}L&Exz$XU74kqb>#U|Z_yS(%&)Xlh1qw_G}mc%j{- zk=9ksXR&!m`-mRuzmnMJ_5d<+sCOATKNuTeiH#&7YQLPvO{v+^7{LD_e@b=hFH^Z? z7>3Kf(*p-G*4r6OAQOQ^HCpCHVs^g8rCMw~^p!v#qlHcT_8CW-sdaB8jr^_JZM{!yt9JsNIB|TU%0NOXn98m8&3BwZ*Csf{CzPJ>OMGQle9bibDLixL(@UzT zYbaU#Epzj@4>Do$&Y}Ln(=cnuU{DPuDY#k!vk-l2g-F6&z_=;z|?)$ zLNaFuMt`zu)YQ>bmLVFM2ss!3EkR}49)ev-D~8y0P(seGBj#sr{bq z=F8|rI!1KKdb+clQ=sdI#JQ&@^k4448hRMPt3|8yu4+!2+`6x4CTRO$AWh1`AB=i? zCnfEmmg8^%n+fZ|iAs|%XOjhiaF2c*FO8>MC-)rDx@sGl(;D|HMC`95533&cdUIKs z&OtB9!N zPx-TX+bAF56EIPS*zy&jvJjjtAYSqlknsx6Fy2chSBkDnNQOfc5 z(~jLgUQjvf%>s`Q?M`NHTpnGXA2tzWk)LYkX@0{mh1^0U43iSeNel|<$fERYC!==6 zuyx8%Fr(yrn3ass6;O-cH$xCQ zT{vNTI<1~2M!O+N0_pna4lnSd#s=SeJPn!#zvkgHC2j3}v)g&V87Pcd>Y^k`*mH1G z?Vhbg3V-FASj>ucoBIP#h`7cP9GMw-<`%Vy#7_3{to7jA!$t@7)<@ldn5&Yr2duHW zY{{Q-gB9gRsRE1=akj+e1Kd)xnLmJ3UGmVnq`R-}xCMsDj%I*BP5||Rkrx6~Tq#+^ zG+ILrN9vd!-bQbvIZ9id}`{bLe+E zPujT8GfqE1=i&8en)6>d-|xr<5@Ndv_`X97Rp}29ZS_U5HL{mjB=dy&xFy#|%PTI- zCjw=Mbqvx?4M@rAV1l&FMz%Z-QAeSI^WyF(HrVcux$RDM_}UK5>l~OH`g48=3J#=; z+ClUg28x`_Br`&UWi8OdkQ%j(=%Tm)FhjT%lDjp+NJ*yWoS9c2yxbb>%oqw7uJB$> zQ9cK^Ml_VXz1*!`0>Z51U;VY4DMx>+KYcAhgPQCLT%+7m=%@J81&=z1$L^sLU4aYR z!ceGi(Mn1N>pFj)7)#oEb?i{=0W8f!8sOi_Jf8PDawuye{;we;(e}6lef7qum!eX_ zh(qUIw`CC01yvqvf3-0TyTcuNqXoV^G5SZ-Tq+C}c!|jueir(rZz1PCdmRv5E?D)Q zA_S!>(t}Me87oi+V;F&H`mHT9WiNxtXw;=%CEWFN&1%Y)j3^K?L4m z_S}#v7v)_-Q$g$}F)`XKr;TM+R>*Tnyz?cc6PaB!V{Mt|&4Oe~`F;SZX!18jB_Q9YKPf8+k@>CcI>>yvaE zc4lL=AVBRLjND1vqssq;LClf}4Ue-_Rf}0a>D{^|R0%q1&p$kEBv)MCsL@IAIwUD; z?E>S>mrNo0-p??Bkln}EncjGN{E>5&mpP)1w_dn{rDJg%`vXRaEr=_TWK?`&Od-B5tP>6BA(ToxH&+ zg42^fzf$pwsH$RbT+OQHi6`Lx>Pm9i!;D#SpN&JBTvzO^o~}JHo6CAx6L_S3_8lMh zM*Is+P4vPWCgzui!jZ2LsS;6@iLp;VEKAT9HO}zB&mXY(vZ>zO7 z`-@GX3!c8a6-_@Fj-LKIyr^M9vJ<)9Zx!4hT%Bz@RIRMo)T9omv6pXHq(8BYvpe)c zuXyF(+p)nR>~|#3KJU?6JRW{dC|67sewz=eSp><1!{xA!=`&_;-)re4`a^IM+=H?n z$$9z-goL7o7uNP-t)_~;dv4Hagqt$y2`+}+a2L7+pxRDV*+cD*hlafMtiwFARpEWz zJUCB4IK7iFc%%+jVH|Onq1R)t7I3=672ZuVo zex=AJrY7=P99tz7u8bBc&(gavYaP-1lMcQE zy~i^joy`1kO3O{RU^goHAb{C3BkAU^Wc^7I zo!lNlGMom&a82*<9q+K^M;PCiF$E8KdcF=_t_1^@1j-%Zqb1j1Q`$n@ZK1cvMS)5!@OP}U>YKI>~Px0%zxGC^3Vyc|N1z>XH=#k zb)#O4{6)*jCT+ZcU!1$5DLZH5Aa-Q)h12v?tA}%Hp<8Hs+@fA1HuD1E)I`q9)e7sE zwy_lXk;)~k>~E`1B?m-1`#pn!x4hPDK8QwMXDS>l4x%pnz(;n!+k1Ivpk_`hctBgSokL`XGupjv>BFQX;du)bi8e(GP#2eNY&B8~?3UDY&^tjUPOOZ_# zw2;b3hAz_+f$vqoWZs%P6ECA#$-Z$bB9VwR{FSU+legRMpYeHcErEA|T)=6pNkJNz%@QzC|bRmV$&KfwJ6s<)2&avvG7NN5G&D+EYtfE&V^~uM@F&c4bM^u zoi)2tC{A86c$~$=NcL2}eY0*R*xULuyf3a`u=w8O9?SEpD-EX4SjRa&u9blrbxao9 zj%e@?riu|K#W+aGv*RIYxC1yexlXzzbRkO&?-4`dH!3p(^M|5Nw)Ztiuf5FK-8WmV z3#=1gM97eLKA0wlo%!|e{+5Ikk>+=_J0W}mJMsdx_Z=*;jn@p@th z`VV{Wa)HziWnpcydtAGnP6o3Jhp*Esm44!n_aaP291T(PCJzU&kEgMO@K^uBN&&J! zzph^)%b-8HAN%u$Zf6KVvWAoYfDkLOws1!>*~re`g}K6yS{!}&HI)d_u&_+HokNij z&5RrL9+gLqN@IWeU~o7ZR#2u6ne5V|cm49U82IGIjoE0c=CB_uSxF_E63LiOs|2p_ z*zu`T^{KKGkFu5%Qg++vFNnY(G;Z zcDuzy>TOCVqEU0-)j}~WGB`iPj}xhPyWNne`AxNqgQv%WRFOK}2j1G$P5HO>)qr4!Plsz`gzwsC8s6R<3`oM&p zfgBy}d9ua$$Pe&c68HIRzbP(p06iXV+dU}Gs<_i1LCR=H&$Eygub zDzg2AhfqzU?P!`;caF?*wd$zEK{;BW$Fj4D6Uy}dB50Z4em96q9DN7<8(WO|D$!1z z%qfc3rI?4vCmmO0sr}D(%^4SIb-uTN9>K6j;L?kY&AX_+2l?*fK7))2XYf-N$8&>X z!4;Zwuh#QjjEL_=Am?LC*kYIj2L+m0+1*l&YeaCGvnHV*+<%)zb$7qL^==zn5Uc%}~YEzjK_? zFItUy^ZTPjlN}? zBBT>fEg4G8_5RrQVWks478Lt-ALZJjSEp!uO+r>i)O7r9*tIWxhU7rRv(imK;yh19 zr;eMy_md?p)%UE=1JU!xPkLS4aNolCm7CL%(CjLwoqJ4%zkSdi)<^4+5Gx22+v+`# z=oRb2CL-3@V47N_4|m7`<-V%HH*GPG;^h}NvkG566(c>8w$Y)P;ACCFtv*|gf_3um zy+vh;J07z<-lqN3@?Vf z8|I}Qo_^dQriW%6kkj@hJ-FOP6h!!5m0TU-$oAeKZ8Gxh+~=~D_=NZ9j@hU-Oppd> zU?~T_=}(0FpGmGMymCencH7xb!Zw~trX&y;4 z&med<%Gf=ed1Eg!mJzU$(DW3Xt(F9X(r;N6zjzK0WMs9O-eZ1a%FP=w{6; zWDG$ZX%$#{l2`gnXs)(N z$?HKlCY3Wr)aG`sHvxuSJ0>#F-S*;>7%T@3@JO>Yvec?!I?YeJ>|U5huM4d-d)ZlS zPb0fzZYUdcFiI?-3bN@6d9pn7vzxRC8S?+d8!+n`E&iC>1>uK(6Qv#8UwOR&j`Oa{ zvEe)+u8^j;E4SLKrgEaxcE+|&Xc6Oh@+OGCF`9%;*Bd8i*}8T``g>D1DS={*9rsPl zjMc;XbB{<*T=9Dd1Y8QY(FrpSu#Yot~O z7@L!dSJ+Ab4TvVK2op8Cr+5tD^RNp2Axu@Ad^`X`&YHH|1KsGg`l>OQk9u5mLmEBa!<-pGu}8OMv;sj0t_Ls8jL zC}CLRNaUpn5;;65yKN}^o<{}v)D(iy0^^64z4{Gqip!A(pu-!Zwc*?H&HZQ!adZTT z-g*_0oAxT z%a5n%lb=`}DbuqEafA?6TJ^ucf@WYJ@ny7A;UF^P5VhfHQODhDjpfzxCqUU=W#>nh z2^=rYGld1r4IM2&hL8{#bY_Kr-HUjU=cf0~k&5a#o+rAH|I_}K2g*bhVg;V>=M0Wb zrF)L3OX1`bX-sk5xZ_FHo6N9GRBuoSqF8odt3yoE#Jj$=ivkr-$LRa^?j4Gg7?(Vv z7q*8~Df+*=f60h=TSsvsq55<0IR8?gi!{2S>mARG3tY5-gu?s33WB zIFijZMpD&ak;2bhMJoyNAyjO>TJnkm_>9%XA9GApzGyGKX0S1UVlzk|3Rqxdt`FrfKq!bQoFM1oW3WmfTp>u zedLM&wVm?~X!~qE`paM3`Kg%*ds&%7)#o+L$rN^!azJVm`iW!*gjpUi@(jtRKOm%V zMXZE=mF(hVn678|u8z_6C!*$dk$#5B(8P0u>NZ@f0fe?j--&EN>~d?Iz5up_?jTz3 zq`ocNXGyS*R*{2+&rGl_`pu~LFXvp!SkPbOr)ZU{#*b`-!=DD|ymP5Z&z?)6yLY12 z%XHMCUT1OpDewx>x7J8R!$0;BA^yS<;?Lq4 zD>1&FF5|GmTI4DiEUb!ivX;{Wd7E}raXH<``AHjT9d{~2#8V5ylyF0d(>}fS*724K zzYT8(8ngo>Q3;m|Eb%h;9aHXBB}hWynNCdFw4XW4OO8fuE&{X z5MHX<;|4M-vrlI6Y&rB(;v>Zq8uSiT5Jj&eh8JaFK*!yHaq7 zZXeeMUhI%iCym;WFn`ePe>TYVo);7IAsP(PO{8iuf{t)q4f56NKD?{98ht@7PeYn0 z%4o_N6SZFJA!~?Y!;8UfpKv98v8SaXdgACz628cSkB3`7yxD|3+%HAk#;}yL#0kxa zD}IhF5-KxaZROeZLH`05;Q@lVOperud;jMtjRC_37=!-++L7IKjLnZ0f)WIw5I7~~QlG9N&@&uu3Mj)@OWGthkL5gFjy!3l~- zJvV))3#>OKmT11G)pozgc^4;r3wbN+LLaGH4w7UbA0y-_iCvcS49DC~#8ke2$e6G^$;=nUTVzUOUYy^D)A3k6(sb;-{4QiJg`&)t~jabiT z=fcQx!+-_c3-|?k)oZ1i0L1@kR71w!1?3=9m5PTvLKL|eDaZS}B+2?)gcu;)q^%+z z3~&7@1|*C&3RA?MnGu%rBg7#z`o}WssP*l*x$>Al?DCwcbs+W|1{xbb1loNO!Vr8~ z`7qMH1GYCsF7v*Ku^(z^Ne9Sq@p7W|+5sRdHyv~v>8#M)xJ1e>6n}*vh`%R(J4c$6iv_k{=IK+lu}9lB zYuUxft>oml^>EPVJe6){ z0P;m~adG?k(i*&76Q4<_rr5XBtvr-^JkbP82s2)&MGA_sVDc{r?`FRWX3w4fK2lB(m^6XuEbUwvDYLRTUa4vt(1t+KPi>5}qF zlP>zqu^t6{HD|YsdPAj&JyXkR)Sg6{=RnIx%Aa4o$Vls4Uvw{ZLJ#R3f<9f4Ykrjd zD&kV)<5{DgwrdWSUKqmog5Pr@=?Uvn?zPA*%A{BMIzp+af~_0ymPMxb(iWoWJq?7} zp2cHM{)u$?4>v^5DLTU6I`!6VM=ZIIru_UrT+^*ss~X%fj(al zBAGU1{E^OZ`vg-m@(RKp)Hn!e+?hS?UJxjYN@N>q6Ya24t+j<7$6_YKI&_u@~$-lDP2FPmVbB%>y4RTg;M z?&5|Bewz?(6@T7;KpxN$$lxjBoF^8;w*u!SAXJ3#9#U3Sa;J@#z z2dTJY>w&VB{@5|?eAt6F!ke#Pdzgh9 zK}vgvK&yxsPSJPvt?l$#rH!&i-vzpN(tMLw$Umft9u}ioJ%EdE#F8owLQIU!Y_VFH^m1rFr)BwsBmx{Go*UWFwtP zq3nC!*AU8|fi1!kAJ->3c*WD?A4iPOte%NHd-&Mr`#JS)GaFV?Fc$_&21bzhRw!Dc zgpRkjtq>XWPcNfektIumS>7RLq*d2CUrVVJv%Oq{I=X;;|F4AHF#jseH{orUQ-H1N z1#l<4f|?`VpyxD`5NPOy>3OkLJgo11>d|!a(rEI@aMmKXJmJ&l$33Hz<%F%PmcK~) z5RA=qA6!=|%EF0t)Q%U#?YirkXDA|*) zuw8QTPMby?JBVdtE2-DS{q^jljw1Xheeu$AZ&$(C15={|`Yv*OYS>pri z73bNYcG!jwoeZ6VhASpF7utbu=D{%cVq5zx(LQ?Y77i&5iWQgbqI@XL%Du-pAjZIZM+Km`c$zNmrv=)xq z>fEv$MOgYL25E|+zdU4Kw%!+;O}B>+`sZH=n3r15vNwb= zY?t$8F$qZmhZHm8*b*g6*__B&GAmKqWXmH9XXNfr(!?5)p|Qs=bEs}b4u09Nl!y>1V+DxTK7CTfi6SoQy+6a+B9$cCqVP$uDYyI%d zF4Zj9T{h1%bIRC`>P4c_J~b6U#4ui9H`gvU&3n(XZyyu8$_(?r!0;TH_f0k5ZfRRs zd=Y_oiKy?V$#MLO?ZBO{#dlWjHe~))N!W2Dwz;_$F?0BQ^(@>Q&8bS9YMQr_0quqP zRs-vg!N_{7*Q@3A*v-XeZASr|!Zz&Ph|v)CsaB$FRWaQ2tH{f3R6!N;)Ahnv7FL7j zH1Za=$SOX0PSR~e4E^ij2->W0_4Er~9<(oP>J+>ktCCk-ITm+mn}`_0%~Y1aO1gwDZT##rAgELmoZJ? zW8=5KrYyo2&yUwvE8kV=?y)_hEq}FLbdN5#=5r$w#1bogTgXOvxUXc5*?aLY&{0rL z_$6sWH&a(01aEw0>!ikRW*8LK<%^}1%>b8cO^32xL$ z8FNhksOH;0i$@WoWWZ{#p2&RblGKSbdhFvNkMb5=_gcM(=oH5aR0BnCuUy*QnK<~P zeeqd4g{ZL!2KRP?7K){(4m`IPXK%>rQgo1wKJB8{I6wd*^j5T-K?xZ0 z79vG@U(AyeToQ@>$w_T2yPX*|$@Kyz1 zP;Z4|tof*Yxi{^7cbV!{9hpXMSgjnD=A-KygNtAMe9~VHI}3lzXv}Z>Wa0#HtDQ`< z$OxzqzX-EV>nI3{$I|Imv2dt-ucn07xG_XBuzc}m-2dyFkN#Bb9IarX4Ty)@0(T6RIy@tz@amakI(;muH zUWPQwG@XKxw~bs_=|e3AioS&EvorW8nV&Jfk#P*a)f)w(`|n71Sf-Y#Xk#%_!I-4K zc##BmL>vn5FDT;qC5pJLzQDx6$=zS-6cUb+48|SOb1=)}c;;z%CF2TJOKDG?Um^qn zhSych=-w^|-ovp4uhRswF~?Z+81$W2AW~T(zu6mw#iHMIlecd5t=>*a1hW|uGpgJ? zBDEfYNE5pA1_p**oTRD@KU{*&f;l%Ygu)Mzo=n1Gx2;xfw6dsP=X}< zF0AIA6*>VF)BYg67=PSKjxX2$1bI)F z>B?SSI5os3dd(@;Q1qr@Sn{e5^EIM_Sr78bn_r-m0zTaT<>7C_7myyl%KT4Ecjo69 zFM^-=K;g@4UxMsM`qx&@W{6JU|Nr;r{Td=F^kG_=rt*LP-T(cc&bRz;(Gd`CZ(k>@ zOZ=xO=++#13c=XK&wa^VyY(jGb!n?7()9ra#*(VywhSlo_f;j1%F3ED7z(t@Ddi*Q zrE&s?y<|Nt{BxU5XI(3zZ7Y)JP@WyRU&~c=_oXH}Jo`Stb(+S#rbs}FghA{drFO#7Dt+rM4A1 z!>Io_aefFA=(~S;1pWtI#DK5@@AXNiKTPV~C@A5-ME>#!B-N+lu=~|)5TUR?Eg<+d z3ZxPJU$5IgCW_J;6|C?{u1V)#R@g5i@boW_z>q3IkdPg1hHmlyVI$p*0&EiUe{Yii zWn=xnu}S0zh>6|F(f+cH#3@Nphqorb24+x`8VQ_`{AFLh%LwE{{L31Gh35J9Zuwt4 z%)edA|8CU(b}9eE!v24`Nr>Shj;nnnfC3}LcBV!VP=wHfirT2HsY;`ak`8yTU#*A& zZd(ba-BHp&huBx5mQXP@UBv4pP>7)d>A1==N$zc-%TIqgcp|F6p))MX=y`H=es}=L zN4fxMy&2Fn$a)pru+pEb1!N7Lh(La9voL)Pk@daaBiPN)>yi288 z;Ynpt!2wUWY`tk$L|YK&PT<6SVt|*`;t?`ys{q!3WgkK5QzOrH+C(0kID>q-$R}!` zz=07!ceAb&28zw4~GtZDv~GF`HznT+uS!+BYClc zt{ZZo4cWG9ABf)c4CgAeyBQC3T)o<4{}FaWAQ2q&I9O>rws58;S`(eP+iry-+PSVM zST4T&;+xZwlycuD#yj_U580j=#^}AAbg$kxmgq=o*&aVcWEz8ds85{Soa7Kbl*ZL` z5mS)dWBI-gkTHP5VQoQ5&+Veu{t!p``v&@&z;!%xZ}$nGZyg6MWyA@2d}U-y1zX1SVYPymHV`L}WOTwQ%v)9|={h5oeqG zF}2{4wku6=&Fy8&ZRH_siX!T43FuR}`P2hZrfKhqr5A;fZe^&06JgGBrOvbTWqzsijeD4UI+oT*Gh6OS z8IB<9%dSke)jI-jkk$Gs>0gB=ZLJh#Bj(V!A9QtdxjS5-O6_sA-+7!VrPz)_|AK_m z^n;}$e*QTc1aJUF-Xox!jNTnc)(*sFdFRv5Gr|AGV@~w!S727P)K2rIeFKFcZ9J~k z@as)a{W}i^s9^`Vn5_VgUbFK-snhlMk9#xmL69cT<0v}Cq&LSUVsC*a2`8wvEHr4S zvAo8%Qx-Xnv7Ib$OPid4N$3Hoil)5dVUwpox66BAfueBTWUk(o^X-|VP>O;iYCa*0 zt_D;??($?iHMr3})x^;-AGGB6R9TJKPP%T4G7g>k?R=@!2hUpuY~n=TO;zF-0Qs}? zy_mLQAud_ZstLx0L;*UAE>8-6$5z&Cs$im$-QnDj_}~fLedIrw+FrO{+U6|aIj7ZP zzk1dzemkA9uRYTL90Ntww~co?N>uI zJdU)YTQF-h5`8G*byB75eLSY+h7wSjA_TXbEYB<<%kq~1M}gg^e5#LWA0|%V+2K9a z6Na0`8+F^$)n8a%X4iV^3r0z{e@+o_nPz_`YXEH~}Jl-&*-%4G%o(p;k?~0}mFHv>cEUE1=aGzTt zd6jiozW&@}PMIa3bf)3zpts4b(nu$6JFfP;lf}RMfK<`rJ zbWz?Tm{}F?&%455(k@F?H%P}X`+@G;yV?%|DF0xv$wra!1*l)F6~5YQ&+-on6CCnu zgS;pV7c7i#5$_T}OZ{zqdviTpr1^9mP~#jAw=65U#HlN68?`3@e?6Ih94IXt{r38J z;^cTJ0}da`c8duKqMwF73~Mg)xjxnsT9}JB@?qrQ;5ZSu`QM|-{|vTgkt_z&Vh6=; zXAO@^*BIwIx_A)oX>mBuJhbBoafVo-8{WzJMTRx>6mPwv2$|lLqL#RIBr> zb4x4j6R&xk+I+)_NeUta+r8%F%!m8bjYLiXxZc^gWW0T0DvoUBZri#- zucawQhMpJ!t%a<52!NR*Z>}$A#(XZ;>4ze5m0Xl=WI5q5tDGY?*N#twD@~{KE^hss zC&3gxO+$Zt%c`MLd-`gBvKLF9#HZ5TMx&~NYeFsk#p+Nr zzWUICFKZ6(LMOieDayFjL2PpLoNx4SlL{V(73fsvHNgzn>fJuY1Y^ls)C{>a#>+RD z28JD)YXsfI|GbLlRjkiweDEv`Z+SV9U^wfnf4Jz)@511K4dz&^bq%?e>yu=h2T+Cx zqHk_|O}a+1_uzzA%`Ey7!D%TIRwc8?LbEf&o{s?iWs5f)Fj3ZyFbNC_Ndzm^=FpFO zC#N}1T##SHXM^S`ADfTx4GK8XZ>b5Pa0Xl)T<9&&-VkH+6;EoI zf=!|&h7*u3=uzaGTh{3V`{rN+=pr7H;{4X>nstgBxi1Que6uY~KYNjQvlVN9{jOw{ zYxSiiFV6a+6&bJVGv7Si;Xb+Kf9{C?&E&S2c?9j6l|GiaMx`DgkOsitPb^w`-sl8A z%-?l9BC)23w}pK@hU@AyIs`o&G&Hncu}QI=T*MVPNt>h^JK&88GxL;hEAEiwkw3m5 z#kBmo10*A-115A^o++1al8M`h&3de*8hQsmg~R+L^QACHp%g%AklxA3$?bx{{a_UZ zqKnV~l+b({zTI4G+BqF~?bONIH=nOc^;&*gdU!xC{HENZp3VR!a86N}VRCbIo~3SQ zH4^bG{~U9)3f_RbKD=I%@_oI&*ae%ANn>wxZEqsD^6tc01~L)HwI3koS=7c?9{f)& z`-vgXvv)~K$e#UaHjwUF7G&NU$xBFOc;iegfw6PApGuv-J~n2Ism{^Y+bE@J4^!Y+ z-aAEFm%|iUEYaGt8M#le^Lh?MJ|J>_Z6kCxgTBTg@~X5wwkjx?>*86uO8_p80Qc73 z1XRO+JQ_Al1mrTFSA7Tzf>BB*6S25YAUY}CTnc@qc;Wq7itNyXvQD&W{=E`gp8BWH z5TS7kyIqQEgbSc|W7|e<&}sD?5?C2Jk5-w&^;sJ*ZY?KTq&b!C)Gd6`V$*5zbd4G@ z^&ncs{nw|}l?Ce26L%IFpF%>c78qL>EgAIjMf@1?VFXv*eUY zce*BMW&l->vhL;XXgi5>zWF!pmD=oxL9CDaoKYa;FL(ZzRL+0BrZ@(Y;3pFhU{;uP z;y#aOO&u-L>^1yRve>!cd&{DpuY{9%`cCsW#riIcUAPx6b21NJo}u?-iOlqBb@Y8=v*OxiB^YP7JD1ZylbtQ!1!>lOBO6>?dO}IK9w^2x?~I5XM-g?8n+{kh zwE*YI>AWnrwkTD5@pD{|jIUAV&R*uHtUUA`B)-YFZA+XN0kw}{=iT607;asDos+E7zWj6y0!Lpy^MFnxScB(=^`8iD>Zt8)&jh7{Uc4j!*Jx053MT6YI%@3s43b#NSrB_qSV8xw4 z*Fq{Ja`T}92ufne?7y{D>i-|Dp=X-zpwQ;Ik%&7q!05fz7Jlfym@yjB z#ZUSL$|;8llK6Y2!HMGyb9@DKWQR-i_`DXT%8VVNu>`Bq!QG%6aEIsW*Et`wp;Mft zHp^qh`W|%C*BB3K8F{bp98f19NY)bEZC)nnf9*E>9Wmg{qNLWABdWwZSVue8ilsH(x~E1pQkIHqyUfkdP5<<0`GZS@qV|)H^Ur2Y{}OT0xLSt0D}d7 zzBy?0=oc;JPlQkDKts1cr}FT$`Y zgT677+oyrgoU%}68~D(1uT(__5TkH?@4swYDt&~_*h+-_$cI)H>m+WC~>n?+PB z5Jp=VtSJElbG>69gu@9HQNZ1v&Es%2R^~J5$9md{fO~>fcsQ}#TQG6CU>z&=T=h40 z^Nl*(F~cW&?RYcgT{pij+P90p^X=VK^!o6fQ3) zFN0&zpR_U+CTa*o>DIvoG%ND)MM{k^QmAn}HM~6M%Ku+ylc+G^?@v5MwFkX6q?f@J zD-vijO@XV^i?xUed=DR>)`BTe%_C83)pzNzI2z$V9_qGyV|bk*R1gFr@S0`%nr=Xi9VY zkVSzvKJ*<^*TtWI4JAGYK(9UwRlfQQ>VTmMpf(MO@qc}p|Ho+kKimUIT4);1Uw}06 z0+3tF#Hq1 z2qYZ~0*b@`=Ue<&IB=f;pq-CI$YlNysDg#{R{^NoGeT4Ezf9nV1n{UI@G17cjQkxF zKmdy#2gOL-Cghd_o5v#o@^t`j?Ss0%Y{xTjvk!h5zrZ z^Z#_~_;Ujvx<*~&`qy2E@s2XWyfX}f0--{vVEV&q{KFk80l(pVV*58l^x4A<8QBkh z#eaj?Bkv48MMUp!29JMd@F=FPe>3K0Dj9b?SF2;ozna0iw4ng;;*(|1_yz!}lrS z-=KzACn$(00r>pR2ccU+eI&!rBU2I|i1-wWWL%c9s9#i_+&XS80Pu{JS|&8Y zrnF9m+-vKrBh=&h$>O9LN11C7)QYv+`^CBvi$T3cn&$>1qhaH>w?MWOU>nO}Pj{Ae z1l(5}a}?8MIL(#fhb3Q~`_{DrMekgY`KoZ;eCYw02W;6-*~Cn7*|3qk!aBOYs_!p& z?d%+w_EUOPm^z9i2DwsLB zS-psl?aADNb;K4g?`@sY{0?V3>x6}^O}Ew)uZqobaVS<_ih#PI&$$_+dS2+;qaoS* zIICF_YAsr;_5v}MS|dr~>VfCK>ninOFt@QClZuJ8?|$5?IbN?Sw;y3jL$S`OiYHz-WVj#(2}8~8QWzTEFhoug6ngmZU;Ej75V2?L41y+0B?zVUVKXo&{;x{D6p z;LBk9i|1%%H+?s=yA@t5rE1$EgXawd8*B4IyN%R$5F0K;unSLH`v6;M0Ml zfn8!)@dyKS%{d#M4~94^rMaBCf;@vIaBDi8^Bi1^ggllbWrbOZHwT`o z{|*d$LI~mD2txc{HFh`P71Zv-GD%s**x|G!rBhQx(3OVeFKAtoVGYGqr7>c^oD02w*RaL)xGN9@V^62sH$1y3KClg`aBeuq z9#3bJ8exCC8ibryYT+Pk*?uF33Sm4S*lM~mIERi{HtePw?PpanLUY&ncM=w4>2}j> zeADKa2?|pxL+=(i_aXFcrCmU=SdAjn8Ur;XB=>5;%ab{OFg?;HR3cS4HdV6G)S>f6 zL}AB}zjbG!$xN_ELDSRo}>hU8^57Fh`^Yi(Dx@yAPpA&Y(_`fj$;~qXJlnnSqOl#1lQEe6p)9 z@^Q>U|LQ|n2_uH!G@b#&lGaYF5)Wo~SA#X`@q7bV;$*6QYC&$>x=CumorYaeA@|yO zHvp+J1Y*^x$Oqy=hW5uHTwAQ`AHqUNpxk72vb+#c>g=*XdY)T>soT*$$%E@8?ez)D z{8@wT-|tz84|w(`+*bx~;YDN!SP;F@qfl61 zixYx(MKU%m_dUMd#epQ|sk5hX)GB#3y9%r?2sYl1I|ktPvwvFdv*mLgc&F(V?%~kj zH1#O)O5EUi`pNfqkO7VjyId{v?)s)q+6NM{@-1AqiW0#DefnBCAK$70bW7pE==JbY zS?pvby_sWmu(Yh4{sv15NL|?!qitHiI*kez%w|rqpk2mbJtIZp7yw zAJE!*2Z#7_7cRV(SAMh+$0`Mjyr9Og>jzDlUD@HHOm}q~4$bE>fN|s5COfuGJ~ter zolbqMV`s8b&EbcAcO2E{tFh{G{uSNQE6>_3ez%uD-TT;L4`s}EZZ#k(m|CBpFegwg zRCheCCtc?31C&>DK^HrtZ~)vu7#Pk@Ms|8)(H<(7S>_1$Z|$n>a$!#E+|+sb_75a@ zWAQTPq(2>b=;2*S9I1~XvppjvXv~MTHqcci!Y0K|v!rjIU%B23oyKXaPR$W7VX?-re<#B@3B zuW{fmQ5mQi+=tRMK1PAuBO=~3? ze_?$;!Kxb&HngeSl|6#nGe*nVYhm{rIm=FVut#%YD^56Zr02H~Rw5J1?L5(XhNM|K zIGjOts<6s(=vkV_YN9V&&0u8ik;~Jx_TRmzQ-t4o7dULA^^_%Z9Bi|cDULP$b%o~g}xa~6FKR5n@YC{w0YyO zNv@0{g+a%QQ(P0k%!}SMGLh-Re$MznCu`E_FtUyfF zs2FOTWVj+Uv8C{$277qrAS+E1H;tbq`*wQ!e5x*63wzUssGxuPjd+}`&~Fdjd@?;N z%$0HGB=sHWF<{NCRPC0M_Vf!gn`RYmsmBsBSF{HZ%lhc*<4E9|SK z=F+&TEs1XH6?tzx#82!6m^z-WMPlM$%DwkQrAf#z8TwY@OgBJz23p`lp094*x8%G~l|qG&%MksW{)$86=V|6<{5yO^Z4) ztL&;a`zM&MHKwd0 zO4L{AzYt3{wz&O>a;WvfJ8ES0)0Iu%S0h8%^m_*Q_2_H zA#BYNY%N!6Koy!A$Wa<3cq-PDT{m9Ok0!$gyG-&2Cf6~`O4M3?WPGYE^RGj{Xfe6B z4!+nn5c`s!lqBrCBg~|y*y@;g{b(!+ z-+na?-qy{hV7A_1Ik&k;6>#eyH*uHZO}r#Lu!ts=cJ9I%!+P$&HNPwAyD1c&%L|9G zo2Ml1sMMBK*L_+Wux$|&9Amb{Y7n|cuUG7L&l{2b78te?Jkc8?a%g(vzY-;;PF|RL z$ywKIsoovic6)k|Gw0hiK;V_DfAV{n!RR+5xpJBa?Sg%>rF*S(o2MGHf`rAyO8B*J zcSZZX+xbi9O@19OXoyC?j*>m&d{1SHOTOMZZWNW`cZ;(Eh`QNA;k(@)Vl8i5QTGA4 z%kQ6a?L$u)SViK8KIyp0e0=)hGb| zhFk`a>(jW(t`*ptOixuOhL#TO?k;ozs7tk6c{W13JrM z)Hr~c&CZHC0TfEwtnG8)QXxv)(N*1QQ%0dR?J+)gximWwZBc*Q%u<*kiB;lGS~HpU zYvW~Ef@Ob%I1mS3_5w9cTiw0szBg053#t|~TFuwNsiZ6WdATdT6PnKCN(VKE@d{=+ z0#0t(0w0gPC*Bb-lgf`>Eo@0`(rJZW?GJrf-quMbC?SUXOO{nVOdXk@o0aGcYp2h> zefKV!f53UW|KlJ<|Hto8#MLC<-CCA|F6F1G9U`@!SzL9mmlr3S?Hxe@-W|x=unizt zx^scGsW#J{6rRk*o_-5(1Vjgz*hp4;raayNzR3yeLJ;J1cfj*`8#oAo{Pi;~2b zR`gBK%9q+TZ9*KXj6SZ4oMdtV;SX8X1KzMWJRRZ2@kOG{PpsySwACKRRS znrDibrKP%Y;5-g08yacUrF6L22CGLqVgAVUn1>V)=UaCSZ z8SD3FBoKC(|0_-frBP{rB@TZ~l4;eYR7%#qz*;#48He5q8d=Nk4Z&SaZGu>2Hd##A z^wJD%R|+s{A$J#MMEBI)NRfUWX;hwPh%!p#TIKJMShs?cP!Ni@(ri%B{_M)vv{M|>4cHr(p{5 zTuM#DLGu*X4hfUKkJvVSB!0t%7dII=)t=s7t?(pC9kfki@S)Ne2tSY+z?<&A4sP33 z9U19coJIP^*y`lGe66*gM(Eq)!X}sw$y&-71*9z;aUlj^LdxGbOi$z_1G&XU_b&=> zB+;Yw7r9043u+(DQ=*c} z=M)n&go?Z;Kh7<nY#wcQC(G}5 zJTx|fQh~tJq%*YWj1A6L)ugDABH*T~|Jf3;#5~Uq&@;9zr)ln^k-u6ui!)qNfv15B z*x(%ih-xqUw>UvJTDHgFFwDFt53 z=$2A0xiE`$VErVo(2KTf-B#xXt>yEyE{XB9#utRPgN+8d&Qo1Y<^mBnHnedt_;tG;{z3SGnmDv zIXB4Zms|st;p0G!>`wEm_#XPQ9kFHc10zq=yS6#Jvp=adXReWeWMFi6jW5EAX>Rqs z`6FMgL)we-k-x1aq%}G~J7p(Dg!`rALi6WD@d61LNaFr}*wQ057F>#JvvB{elBfD5 z5F(^b0g@9#??As5lQfHR|1z)k5G@g~6@zyJPr)Y8Gp)sDKBmUiB(Kn$#3`?|y-n7> zG{L&IzYbX0V$j|NQ{AymNTMnvOSnJ~qRuQlmv2}0hHs?TP1=e~7agLNGj7-u)97Ch zBP5LhC0$zD31+dIyGgiv(1m62&fB`8gi6)n{`yLuqN-byyY_W&rj=Da@u~h_4^0L9 z0ykZwUZseVu#O4K1F-`rdy)53S8n-)gqFq0MVJqhR3BhnW8d_jB8Xc_Uog!*@jRNHSQZR`3v~Ui9L*!A3#&N`@xMKaUxd^7{YGpQF~?8Gta!tP zxNV!r03wBUAdhy?E@lC`rA}=lY-Qes)nXu+Sq=y^E+w zTxe3ksTJKW%9@mhM;Os3h);j~BD{bkf*9dh^bPTEp8Mwhptw4{nVWr(7ID#CN8( zD{WJ^hNe)ZTx|^T6I$R~Q71c&jf`1HeECLmGKFA3;F= zw@mL$iEA-paNt{`M7P+d8$suA)R=u?I$?L%ky`u}gfW-$UI*X;60O*Dq1$uMfYQ~z z#Bog0W07av=S>7m59yQ-?dJu({mI7U7`c&FaP?e;#Jm1=b5~56Uo=F z0sIJ{ip3jsy5z8GG-bfp&qP64FpcU`!gOXpE}(Z%3HbS%s*QPc^d_sJob)bRcd9Hm-non#984TIowy+BR*a_x@Ki@g)iYu5bZk8* zc<2X9mWzK?w$F)-)wx)>gIl~q29InWRuFwOb%Dz-gp@)Xn%&V7(>69tWC<;?BvkIp zB6G16GLEvKbH9aBNA2r=>RMyl_Xz?md=lub_pq#3zl$j}wA|cw$%b}Yy<2_&@z^c` z4?>8|=6?4hB@X*kW2Lwr;>qIc7;MH+;(dseokeVagu^UvUzI?P5x1P~bil(tQ`&{% z)Sq1U6(W6a0^{yZFnq-3GM+3S+f2j=ywLXqpH^3E{mWNaCMj*t!${FFfnSrm>}gfu zgox1&OVn=1LsM1HVB|0H`E7o|iR4QlMo9mrfq_!saI(+-;oL?7>*(^5Ag#8D^k}jH zGH--F%sTr~R=v5#bU$Bg@TdWY#)O3{slw5JtWZ<3d3m$T=m>MgW>7@Gy_h|byIZ(# z!|hi|f{^Dr_pqf`vZ~6hwE}O=%I2(!&3ny3>PRp-T%DC_-Ssrv=NU&NuWWjs zkP?m!u_940q$exc4hxTh*ffOb1V1kbS^JT0?ELJ3onq|cc%BmY>$*yLV8HWJ3MdcG z(xUPSwh$OF7AVy);XywQUoNe)1ZO=Z!O367bvJFu;V>ur^3G(AVR%K`bWN1>OFL=@ zpP&i_jU*$7K8yY468-gdEbG(dhTP-3f8%vZC4OTsl`}43TL(K_?o?@)JUB(h{M_e% z5;~zZ`mCNJBI({~MzTQSs8C|#W=t;+i2GaL5prYlm=vv>9}>-wdHVj7LHmA0(loFD zX>;R5Z+%p&k427K$w|3$F*d_JstD_-S>h&qSGB;8{KZPus{oSeqjw$Z_{G8#`H0PH z(*gfwzC7sS&lB7S7dZ<@x$g(o5pV68>~cqg0RSC(C^})vgGPq-r!uhpVr}OWy2U7) z;d7+|yCKc(Fp`tNQXzeZkhHnl(5D+tR}CAa_Z!4r zK)6a*^(`9ujP=~djy>(ZU+MWSy&q)8b_kJfGo$(PGRNiF1VqE4LL^Ki%~~ax)xmufd{g3`=#t zxsc8#N{YG7*}9}UI<8c2-Z%kYf1<>hmHb17l(Ik2B3sO(<&!SxXA1N-OC&yD(tAt8 zlX=xwu!O+H0Bx!szU`n0TP@D`z370-5%fhaXG)CmDozocoU6|rE|wIA9s?)O$!O= zBz%4MiM7M|<}KHGpPyf{(lH}GrcZP%Tr?e2qMto@I+OjSO1Cp}m2gXD9Z&KcLD{W& zwBFN+`@DDhrg43LAb^|fOA2q_vmh8BM$#}qft52k{{&7v?P`oK`QrDg`FMo1ch+P$ z%sD!A=C!zQC8B)F-b0xs!j<+8%AW@myF&tU?R|+qV_j_4KHQ4ja)jxa+?NS~F+$N% zaD%~z)ne)rytff=yTXPa>zgi0h9W`~RRb1uWp|;090D@j)x)uflYlYp;&t_Gd^Swc z95p^F=cmUCZJM5Dkk_1YWe%YSeUx#^8G|dPTieS5?NO{WCAB( zt`iI}mi$cz0|xJWOI0_ZN%o|ptz9L52%Tuq-dkA{OQZoq%Vj_z$YCuP!v3Pq^~!2` z{4KcQC7s0=NkmX`PQTKr+BOw%LX45@BXYn)8OT z8As&-S?BZwuOh$L&I%g^RPF>@I*}L%H7=>`{zR+eqgc1=E{aT9q0aHtks&UM+7@*> zb61UD5KlX2Q0^AzQN`mHn|Px+a^T6s(Gz9$hQtdX@K7#9yp#w(Y3BYQ@DRD(MDXkk zF;dgi=NPa&tN#!O`unL;X#b+Q?_=!-kxv#E#Bj!GjSjDw`t;yLV{$BuMh`_xeZ z_+Us6`v|ciI|0&d;t$Fo|K2(ASX+CRGt=uJN-}u3{=g20oEqbni}DRpKN9!kdx<6%TB?vJ_bFmgaM<{=JIy}Y1v@~@UQx#Em>7`(=H!wY>k zk`?OMl&Am5Dd-FKu!X$Y99$5}Nnz`+q6UI8#$z#QEu%1G+I~M(J-o16mxXT+$ z2Tm9F*r8UDrn%4r>|*GXHy)&`1ZS{bA;2}*iQIyk0|MwenQz{}(NRmMCZz;p!ytsX7X#u;Bal=Lg491CS>;+ppDz$m12FO`qnYdyE1NN9*X&^q{-9E zC`OqR-DK01ldmte$#iod1k!)RuKAaoL4*F4Iogwd}P;E^zremnSXKXmV#2^p`lr?W&-*8e===rDORMcOG5G zoIvD;-GIKvcw{IXP1H~g_`Fm*J`n|W$QkQg6Xi=xoH}4csAl?P&Ch!v7DH8CDj(4P z6ntXpP<(TO@za_{NE{86g2zNi%}9HQl3jDN;qn`t8PV~S z157)XWN#ZzmPvIhe&PHgDxK((cL4;S5_klk7I1Ezd_nZkmk&O!D#*8j!)B15tQOi+ zG(Uu0`5-l))4KM_G3<2eBA?V^eOD$w3ZFkm{laXLba8h}w=?ujr(1e!W5xp6hCl zfdrK5b6_5P;EFI+x0ky(-=G#S3slIH9XopZ?m|6(?_h{pQ2_x8R^32p&V`*Kn7hsH z-I-F29-wb)3`fN!wgdysPoFH^3@P;UB@C76s|EN-bt5dLmlFJ4wyklv)J-%GX0Y}cSKJEkl;4EV`aRx z=(6VcZVjEbG(YZ1m9!DQKkI!Ds0V6*dZ7KGdWr3rQ!(@;>5N-WIe61HwS|23)pFcY zPt@$P!Pf;8cqm;o0nqXC1onfVc=V`}d-GcIlYyh4uOMilue)bftp<&$vl|VqSZ48C zq-dh#%b%}Mo6_}%?{NnD!!57f8_4flL*EJKD^*_@=j#m42%@tB3F#q1f2Lmrb`Pdq zQkB_V$`w+@l*rLCb+|}kq67;?(8;7FVw=@{ISvYEm5h?KcFAA`)%)H)JI>zWkPM48 z+2Y={#-FZVKp2!x4;Z`E%oYp7qc&Qef>Ls0cFE3uK@FnNk#xgIba;^46**=*e< z;N1|yYV2!d<-I3Lp5;dE#PO>R|2Tpj7kMqcaq?Nff(coh12GDrDwdQD}U>*K$)jsL8hR2mQ9>PIqi9^tSxp2z7qzFg|JY)LNWSKZ%u`T-IdJ!V72?@Js!|153%v8DTX(s@$SvWZll&Hpc#q>?!d! z3YoaQv#DkViqrhJR_Sszz^F`>HsUb5hj0hoofTe9eD`)veqpnB85Jmis8zG?CO21@*k*ObJaSIJ9+|hFHZ8WF zO($nF^D_&JBk_-17A^ z9lzGDQam@XsF=bl^4H8=H1*;~THZC$W#guEmvJ7;ljW z@uQii+QFD9YH)vg_=>&<-43pr`R=s!3TwV&>h;n|vUSSszm&h@{t}h5aA!h&D>;X)Cx|Y9tct z3sAkh2=D<{Khr@dw0p1~N;YXM;hAeu?@;Ky%y_|(#N`HFGcyzhS$b{X75-?@%eqAI z^_*(H2AE~YyjTGTc&-Gp1$tnn2O)?JSA7puT7xk=TYEpmt>|@xaDyR@evoZTnL<91 zxM=?2{B@ve{3YwGVk_bHmRcKnVPU@5((N@S%#p#1U!_X!xr46uMtOZb$pLOZAWw~FYyP>z2PjTqTBFNY)`cdT8jQiyz!g2! zhBm-)W@l)Q2nnGlee=^Oe?mj$e`hWNHYPaq<*iLfwHEEmraoEV*n(->N5Q-VN4^k6 zR$#1+uY78y&~>i>)t}XGj=`kXl(gf)1_|PF0-*kMbZPozD2UK5-c0la21mnvoH_uS zhix*mF+y;P$tTN6r*NBNMh}+0og~5&hsVrUQt3o7KRRQ`j9O!#Y1jBn6^d0+KtxysoCAi{RE^7+wD1@PM6gl zIEkfi;b}&Z?ZM4}tqS{W8Iv|Ch`We#hyFOZsXdozIQgiskr8Szl%mkHbW@`KrN@4p z6V=+r0Z(BJwQBKw2AIU3T_K{i1KtN-fi0JPLUdFq3!aVp@znakG-X5qEY6{FC{GkD z@^i-fua2ts0!E+ZWB9c!DqTHCzi^eUcQ`8O+`%#hvgDJjWmDXbTx+($x}?bQV*lXe zl3%J!N0&f52O%XP8bf4@(%fIYa(;b7If6BZb_||xI=lL{DDHIowux19|Up3 z(L~NcvK<(5fW&p(WNdNKbk=y-Dt|4k5HBVCULe67NG|sPK)Aw`vg&sXz{xZD3n#1S zcODuMZM$3o2OF#5u=WxsNLui=+$tP5#Nd-e{z%jTTybDq?O+jt$FIk2sO8RN5@N zpUixVpi;`-;fx2Ed9m*#0zU(0tZ*ZT93lNsi3X{9@;gR!Q)~!8eHHsT+N&q!O46eS zLoy}qo&>g<0z4{Znnyg7nND$MRG);M>Wqne1sOjIVPK6>U-#LD!8D_BnG<;XUG}4K zUImd?)DuvPgDv}qv@)~!G0_`^e$lnBnQ4!j7@>G4<6*5^dP@T0UVW&s@Ia^5@+8e0OB8npt;#3gH=6KRCTr zn9DcgU+L-ImmpbLBDeu@4u?$-$_BrP`sgr{s*>!d9sI235EOLMX25<3UmT1!T922i zxHoZy+9$*Tl{G&BV6YK3lgXgQYsc@lTeZ#4gIe$BTN!`cyRUtB@3GzkZY!p!GprO) z$R`7nU}s8HV{YEPNW*-!zI6JR%XC#kfqW*FjkR^W^ei0O%TTu8gY)$ybs%xHsK!OJ z(;YKiGeJ1#IswamNTDH{u@XzkxYpaDDLoP7TVOQ$Nq+ z@o6K>+(k9ab!&RULq6LKSu+sKVQ^Do{2dc~Qh<~7XKRLjp?P#6$L)zRQsPEO5ax@Y zHt1uv{Sz&{?Z^7wc9muAd#?MnVqevqjW?vQ%RNP_Ls(Ln`~8LvV)I0>L(82iLTpCY_pG+N^qZ<=Th!AB z(N@##6X7t03b`csbO^uXm_yQm+ph>;%kRG&v|gRMND;qy)EsIGq1CoGvb`71ywzfW zmTss8W`p~`9YCjN2ap&olW(DR0>qac^+3PzW8!_Hy?XaTL(#Jj!+NqQfGf7uQr4@* zO%cYxT7G0AU|#Wy7wB(91a?vYZZ9)`RHgoNNh1L%sRAD%D3cmcUMWZK6*nq_(KAh+ z1q*(t5MRQiCe3>Sc3NkwSli|Nr-f$5k-Ta#6Az_GM5}6eVb>jiJPUgRbXg3MKI{v` z=HO$m(cHns@LR_F$XLGB?o(Q#P|kC#sxG_( z1vQ8h1BE<;yYJg&1&WSg*EPItxS&QNPRzd{V+Vemoxu^z_=b zNO5p2Q2eTiGf-{@8dqDpI}}q+X0^^UYYiN&UhvK*w!H!SBnFw5m5nmMwp|qT__Z*C zSAB7odIUI5@`Y-^qxmn|E0Er4L*zK6C_1&Q@?ml<&zGzN%gw*keLbWfikjX?s`8ld zV?SKTmI$0f%tOp={B76Zv&q?Kv$ua|#@m8F7C;Mre zbfT%FBbuZIyxUIhj5zwe@Sm~o#!s4e!;Ouwwr|GtPMX)~tjMk) z!`^kyaH_%X3v*KkPMmgw2GJec@Q+kQn?c^&-x2syu4zL^poWCG;q+)3htHKCh3R#! z<6Z%F&LgXcU-1||QB<1x0n4B_1erJBO#6Yg`MOTU!O5K9YM6^GT)7o)MyxQpxrdDZ zwnsYb8=0x0n(!8&Yj2kl|l& z`@Pho^W_VIgp}q!tYny6aE;w(>4WKv!!mh&ogtYoTHsT1sd{j@8;}j)o14|F?eV-^@4tU0SKRWVE%j-)g+El&a-fYoeC9YpQbwpzz|&k~VcS zWkXDM0JPFn7vRS`A@i`(`-8%@{=3#vS#KdO%kF@AT=KL<(2#7QzKjzsOkTU>{g~t? zsX>hj)S?46u>%;rjn`Vp*JVOZponEy5Cy2WebOX6^U)I);5FSwS85}2OUgKr43h&k zGNWS>U|k|h!t0}kc#ge+ZNmS=0-zrGwtST5soJW^kb`6-7=VjZ^oGi0@&+D>i3&I~ z^~FuBgyv|fBX`$hwD(p8E8OH>L)11Cz!)z5lP)6r1GGdSCT1dlg7-*zdVI$u7&D{PIidwWp862JU+MKxK~)htm(AR|Ps$rO9IJ4=k*! z^4Xo%gK$hQvIAQ?<-!WZ(7H+uZJ%HG8j}Z~?FgJ-qKIDv4JTy*ta^M*HjV$!K55VX zH@63-JR0&Mr_5fax8~xT{0x7r>KJOC#5if^A|bWwc)E-BVXX?GH=8D^0`Er9Eq<*K z*GfiAbHuGL^7Vf+uc`Gv6)S_Q+*u zO*|CoNyER-EHj{qmT4*WGpsxv-EBTCDvQO;x2K)x8fKb2yo(WlS%`_{y=ELgEmUM? zblN61O<xy^%QdmS>aGKY^7&DhPKPN7X5o(IG?7^kGP4CiPX#+o& z!&~mg1=sHe@#eI@8FUvLBy8G-O@TR0bh_ZlD!Dh{Jv;H2#&?+bC90Hr{=U`l+n|G* z(r)p1J|)GMLA;khWu%4iU$THH#R7C(`_TFe;8dakOkxd|Jo>ld`Oej|gH|3t{T8ry zN#I4S_NN*(vYRsQ#@!z702|$RdjA`!?}Dl>#YRUn$}q?QV;!V0@+kxsC|d1mR;{{@ z37``A9bE!@j$}Az^3qh5A1w&5nV>JlTs#g|+hG@giMMb0Rmf*nEDRTS-okE6ax!vj zeX6nPon!oc>+wBuksMG7UIH6gq~Y<>fOQIESb;D!4Dcs2Z4CpD?2Hzuo(U1eR!0?= zU#BzE-~h8g$M)-$YrCrnIvN0Xww-yJw+e7=Imsmq2rbb`(GSt?t1ay_{sb?Hgq#jGa!{!FuMFK`DFI80WVMbOMK|=Soe)l~g&= z=s&W+1y9&1JRzCn5D)$9P&AWKgO$R6Bq15c8OE%M9VvUM$?5}aInIav1r8ej9mCHP0-RG`<8jB|}s!~j4K@6SGKS}+|*#=WGt+%;;tM3Y;>*MkQcLb4be&7Bi@ zBna4c<|;GFWCN%FNS10*!lYuO+O*G`-3ks2;Ju=oRLr&u%lPimCT8_KR zI8)n>W7jEPorsmaQSE2jAz@+YA2guX%OVb}d>am^d4N!5-h0cb4AmEw2Ke^KOKF!# z+!pg_rTymP%;%SPNN?{Ery6RKwECV6AX50A_P6KjzjDH&TH5f=W_o+6IK8l;)1>$w z?l;rd)FP_rrJmKG_IKb#z6P$MP(P|g2KX^6_swTYzf8ABJAGf4H>4QAF>k+wdW_^_S|oX%yjOyX-It{ zBQ%+sP}=AD{rEsnwg2Q7$;oNM~f^i!me5BdCNb=bkkWQPEUHw5h6#Zf69 zRM=9GMZX~v^sI%{K-5QNhm0k98Wrxgvd;xSVw>AK-Kc_4Bn3zG`u-gH7-^m^EAFqj zujG;LtX4V${*xv<}1N54E>1dX1dUO#Wd;K%oq3?)e^3BR%He*(*>{ zuIy2I6vj|@#d@~n$c8f+`XTq8$*Jd;qHq|78hmuk=#QRf&)phQyBVz1--wR}5_Yn7 z+V+QOnLVnve*wISvU5dEoqQ*^c_g_cqJDF^0`9C~pp{mk9!F#GFQ!TG7d zj}W4MI}6Sz&qoZH2}o-1hdVn@^8Ux~Ef0 zB%6OYf07LvEs?qkwkB3^WM*|77WnXltu#7%KHgf>lpV(p^>R?wBL$0d*6!qYbc1(B zFKy4XPEMA3-q>^}1y0IIcYjWl>l=_)7MgKX+NVy9*x&7a1Y_BY*Y&jKuQCh~wyd11 zZD4_R*|@w`3SN9V^a(K6d{*~?^F{P;q2vgc>h5RFPZvZL^G;E+{B2uxPJ~#ZA%Z!S zsqiCTj%lL=_0e%gwgqmz``Fed^Go!7cxP)z$S(5Rq?gbEe6j;(^MP}#=IZnM%a*>_ z`3jTH2OT-P2h?SjxPz3-7-uAdvL9D!naBR#3Av(H)$HTK#9zb1)* z*5_wCn4{4YZ-P!1>a)AjDXl!8UPsz)c0QZ5qQ5;QelX*{_~_khB|kL>)Xd@4k3|8^ z+JJ2_$^C^CDpWA5a4n$(h-H!47mB*AixBZ20FYQ<1~)1&FGBMo!y zn8wEH#l|v)-p(5wg$X!wGmuiI{p~oK1l%~nA<(IB&e>jli}LmLe36u__{;X$+YKAr zn8OBA*GB5yz1cF4LQzTR$0;eQH&O)mT9ijhwo^YfHd)3QyOBFrO2cv1DrCYAaQOdx zy$Mjd7fWT_G>{BBFTV?J6z}8Q!FlW``XANR154(nq^qPLIst(n1m#bohDe531P_a- z-2UO_q}Z;B@I!>*$E9{l(P%gOP;=selr{n${E+HyY-W&w>zMug%yi{RZov>}YVl@w z7Y@AtMa<#c6g#u1=wZj!w})3_=iU4AMpEWWH=YIsJ4C=F1J-*&DKjG81$LFNfa8#z z8EF@8d$t~84|?&$kYauOeMVp|Psy)eQxi0cA=UZb=9imx&Z=)CRWz$it(x&wbaWH< zS=>X5Tbk~;$I{$7L~$XORwF*tM59 z+Hb~`OAvl{D3#ALL@&9de}DqExrOSHW;2*H&Z}v^kw)W#YubncWfE*z>Z*G6=hTd) z=k51Gh`HY5-Qx&-Hz24n2=j5vo;nN>F*QStPA{snG93Pr0q-V1>~i$E#(}yfEYc(A zuVS)qrGIBBXx_p}zPebK@Crmw_X`FvWHIyKJ8YZ#vF54kpUs}X2|cvHi-=6HSSfDEO3{{^=Gg!Q4?>GCyUmi#Y1A<>LG42e(TmL6ctrFJbS zsY5~q1)*VVCoS!{T#aR<-RqV*nG!A{NfgIBK&h?S z3-U9om=(wnM!(;P`C^BeDBJAg$@z?l>2ggsadFvlA^6-!zYpnh`oI<{$z#6z^>e-d zTKb%O#fay1Yz)`CwF((k!{y#UKlh&*t=X1|RRhEzSxXWd&)59zjA_Q4wCNkw9z1J| z+ff>%#=?a*cW( zbzd=8cK$$ZTtK2_a=kN zfiTLFh-P%;H=XHmO&)H9RY5G0T_Vg5_GdSF1l!X%UzSoNpC9TV#+|oXSw+lp!eobA zT~r+C~tsd2(~{fRoOS4J!s<>a*6Pt%x0C7t=N zMC2SS?rvsRWb2G`m^FRN{T06}r%|zFOrbctaq!FG8vPK^&Gue+Tk|myJM1qp6xzc3 zQhJMUs8wRwynH|iYu}52PPj#vCmfHg-Y?$^z?65pd7{o+F35ajN7efzB%bW?+4U*JY>&T|}cnbTror=ZB`}oC1buM-|(FUo#WAT!)Ope{##5ItO|! z)z+_*V#gScMYj@Bx8sLp1FH>!%-fGwYc}zoN~W@I2c*v1_NQwEOEtofKbK(ibMaTl zOC9Zjg_l`7;^80DUT-&i3|O4gMKZ7;uJ+^jzMl{v1zs;-4j;8keI787DmGEOw!9TS z0CQ?cG}`vuZLU*Ll|IDJXKTV`+P{EzK?Bq~aAJ#{D5~%7*la-Z8ok%Y5|h$v^Ic={ z4(yVTwEN$7&S$7I#8!IL#PaRCgtd7fJ_)+}3tHa0#(#LwF0BdJGY}~|9Y%YfGn#p$ za;D*zt9oK@ej*?Jlqn}Hd~vIG+X}J|B4W@TCP;>FJ?9ZVHsD`<9>u-P8(*4FXdgr- zNftXfC3md?4K%v0u1QvpywZ)oRzAe#-lL|IFFK`s|N{&4QbW@{ap zk0d?9l$N9PzQ|>Y)FIBtaut`7^2xcny~RtR#eThVvl;GCd|4;Y zK&$Hlz|YwQ;BAo+`I{n+x}x80lu8;AAX7X@0-`46Em&LW;{{}tx24*&2} z{szZGsm-BgzOig7y3*uNCr`4QKJgf5>BNtiorH=e*gT=IJFO1SuTh9uI5Q(S?o`-~ ztlIOIT4>-UAS2%d8aK12I>-+)J?eW6$CleNoc3N_S`Ch%NzeDaU7usp0s^{HSSkmt z_8OgLhUZJ-Bwb6l1=+{VP1{g0cW%i|sikC)QD4thFd1QVqcdF~qH; z>Y_Bh^`g<{o}M44IEtw$X*nG~wkJ8M1q(T$(=6~^sL&6=(UG<=SPL@=X3cXszH?5` zbQeG;t2fW`;j@FI=*nfV2FRGiDW7X~0T^Yl!_-~Lqm`;Vbj>jp#)2G8z2P@3xnW@B z-}4#Pn7qlCo6(~CO5W`KwLFZeNBgt(r5IMxa#K8pr7orLLvyNM{qPhshMB5^n#z+B zBR-A`!<9J?@C?g=K^+bh?U`upHiwg2=+E*-mIUWI$rZ)g(%u`wjiUdRpuhU#H9yZC z^o&>Ip%(C3=gA%Wi`^XKHC^*POqa+{=!O%Z53V&tt=OJ94VQ!EK<+>PywDgfKY@yx zjb!NQew>nH8y&t%MZYs`rjvWPAk@kQIi=v&;A1rfe%f-!`<@N0QN-J#xvz&$%e*~O#?mBIKqMg2o z?Hu=L0Ejti6U2F% z?Z4mD;9NMJ+~t4e3yX#?jxmJsf@%Tn@%47|A@}Fa>k(#GgCfgs9^!TorkLqgRk+My z)$Qkl=%%|P==XNiy;m_Qvh5?U>RnJ4=Ceg^)c<&b&fZh^^Q+u~nY>A%e}0bt<8K4k zJAmt_LQ_irx9k7;{r{}rf1aQJkNfAV7+_U%Tzcw`x% zp`;uL6%%ojg8!H6$UguO=h97E^Lzig@&Eba-zVCCZs0#F@Shd_8#9^z ToJ{=#_)=GfJgHKA5&pjbKod Date: Fri, 24 Jun 2022 11:50:09 +0200 Subject: [PATCH 08/45] Concrete type for playContext --- lib/preview-web/src/StoryRender.ts | 12 +++++++----- lib/store/src/types.ts | 1 + 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/lib/preview-web/src/StoryRender.ts b/lib/preview-web/src/StoryRender.ts index d4e9eea8ac68..cfcec1b1c3b3 100644 --- a/lib/preview-web/src/StoryRender.ts +++ b/lib/preview-web/src/StoryRender.ts @@ -62,6 +62,11 @@ export type RenderContextCallbacks = Pick< export const PREPARE_ABORTED = new Error('prepareAborted'); +const { step } = instrument( + { step: async (label: string, callback: () => Promise | void) => callback() }, + { intercept: true } +); + export type RenderType = 'story' | 'docs'; export interface Render { type: RenderType; @@ -227,6 +232,7 @@ export class StoryRender implements Render unboundStoryFn(renderStoryContext), unboundStoryFn, @@ -243,12 +249,8 @@ export class StoryRender implements Render any) => callback() }, - { intercept: true } - ); await this.runPhase(abortSignal, 'playing', async () => - playFunction({ ...renderContext.storyContext, step }) + playFunction(renderContext.playContext) ); await this.runPhase(abortSignal, 'played'); } catch (error) { diff --git a/lib/store/src/types.ts b/lib/store/src/types.ts index c84bdd451807..6b5570b5e60d 100644 --- a/lib/store/src/types.ts +++ b/lib/store/src/types.ts @@ -100,6 +100,7 @@ export declare type RenderContext void; showException: (err: Error) => void; forceRemount: boolean; + playContext: StoryContext & { step: () => Promise | void }; storyContext: StoryContext; storyFn: PartialStoryFn; unboundStoryFn: LegacyStoryFn; From 7c92060bb6765e1f52efac59071edd87c9ce6eb4 Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Fri, 24 Jun 2022 11:51:58 +0200 Subject: [PATCH 09/45] Drop needless async keyword --- lib/preview-web/src/StoryRender.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/preview-web/src/StoryRender.ts b/lib/preview-web/src/StoryRender.ts index cfcec1b1c3b3..66ab8618ef37 100644 --- a/lib/preview-web/src/StoryRender.ts +++ b/lib/preview-web/src/StoryRender.ts @@ -63,7 +63,7 @@ export type RenderContextCallbacks = Pick< export const PREPARE_ABORTED = new Error('prepareAborted'); const { step } = instrument( - { step: async (label: string, callback: () => Promise | void) => callback() }, + { step: (label: string, callback: () => Promise | void) => callback() }, { intercept: true } ); From 1b58c18662d9cddbf6291ddca40d3fdedcb9e893 Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Fri, 24 Jun 2022 11:55:32 +0200 Subject: [PATCH 10/45] Remove unused import --- addons/interactions/src/components/MethodCall.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addons/interactions/src/components/MethodCall.tsx b/addons/interactions/src/components/MethodCall.tsx index bd4ecd8a3174..c5e62c0caf69 100644 --- a/addons/interactions/src/components/MethodCall.tsx +++ b/addons/interactions/src/components/MethodCall.tsx @@ -1,6 +1,6 @@ import { ObjectInspector } from '@devtools-ds/object-inspector'; import { Call, CallRef, ElementRef } from '@storybook/instrumenter'; -import { color, useTheme } from '@storybook/theming'; +import { useTheme } from '@storybook/theming'; import React, { Fragment, ReactElement } from 'react'; const colorsLight = { From 5f5009e928bb091c4e01e682d3dd8f88a07caebb Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Fri, 24 Jun 2022 12:03:00 +0200 Subject: [PATCH 11/45] Fix step type definition --- lib/preview-web/src/Preview.tsx | 8 +++----- lib/preview-web/src/PreviewWeb.tsx | 4 +--- lib/preview-web/src/StoryRender.ts | 4 +++- lib/store/src/types.ts | 4 +++- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/lib/preview-web/src/Preview.tsx b/lib/preview-web/src/Preview.tsx index a179c0229399..e893e6d79124 100644 --- a/lib/preview-web/src/Preview.tsx +++ b/lib/preview-web/src/Preview.tsx @@ -26,13 +26,11 @@ import { RenderToDOM, } from '@storybook/store'; -import { StoryRender } from './StoryRender'; +import { MaybePromise, StoryRender } from './StoryRender'; import { DocsRender } from './DocsRender'; const { fetch } = global; -type MaybePromise = Promise | T; - const STORY_INDEX_PATH = './index.json'; export class Preview { @@ -114,7 +112,7 @@ export class Preview { Perhaps it needs to be upgraded for Storybook 6.4? - More info: https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#mainjs-framework-field + More info: https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#mainjs-framework-field `); } return projectAnnotations; @@ -341,7 +339,7 @@ export class Preview { // In v6 mode, if your preview.js throws, we never get a chance to initialize the preview // or store, and the error is simply logged to the browser console. This is the best we can do throw new Error(dedent`Failed to initialize Storybook. - + Do you have an error in your \`preview.js\`? Check your Storybook's browser console for errors.`); } diff --git a/lib/preview-web/src/PreviewWeb.tsx b/lib/preview-web/src/PreviewWeb.tsx index 8cf9ea0eb8ba..b677bc6a745d 100644 --- a/lib/preview-web/src/PreviewWeb.tsx +++ b/lib/preview-web/src/PreviewWeb.tsx @@ -35,7 +35,7 @@ import { Preview } from './Preview'; import { UrlStore } from './UrlStore'; import { WebView } from './WebView'; -import { PREPARE_ABORTED, Render, StoryRender } from './StoryRender'; +import { MaybePromise, PREPARE_ABORTED, Render, StoryRender } from './StoryRender'; import { DocsRender } from './DocsRender'; const { window: globalWindow } = global; @@ -45,8 +45,6 @@ function focusInInput(event: Event) { return /input|textarea/i.test(target.tagName) || target.getAttribute('contenteditable') !== null; } -type MaybePromise = Promise | T; - export class PreviewWeb extends Preview { urlStore: UrlStore; diff --git a/lib/preview-web/src/StoryRender.ts b/lib/preview-web/src/StoryRender.ts index 66ab8618ef37..aaddd813ae42 100644 --- a/lib/preview-web/src/StoryRender.ts +++ b/lib/preview-web/src/StoryRender.ts @@ -24,6 +24,8 @@ import { instrument } from '@storybook/instrumenter'; const { AbortController } = global; +export type MaybePromise = Promise | T; + export type RenderPhase = | 'preparing' | 'loading' @@ -63,7 +65,7 @@ export type RenderContextCallbacks = Pick< export const PREPARE_ABORTED = new Error('prepareAborted'); const { step } = instrument( - { step: (label: string, callback: () => Promise | void) => callback() }, + { step: (label: string, callback: () => MaybePromise) => callback() }, { intercept: true } ); diff --git a/lib/store/src/types.ts b/lib/store/src/types.ts index 6b5570b5e60d..6e5fa497a8e8 100644 --- a/lib/store/src/types.ts +++ b/lib/store/src/types.ts @@ -100,7 +100,9 @@ export declare type RenderContext void; showException: (err: Error) => void; forceRemount: boolean; - playContext: StoryContext & { step: () => Promise | void }; + playContext: StoryContext & { + step: (label: string, callback: () => MaybePromise) => MaybePromise; + }; storyContext: StoryContext; storyFn: PartialStoryFn; unboundStoryFn: LegacyStoryFn; From c2ce1702b21fed9aa5de74d0c4345f25e850bea3 Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Tue, 28 Jun 2022 11:25:47 +0200 Subject: [PATCH 12/45] Fix step function injection --- lib/preview-web/src/StoryRender.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/lib/preview-web/src/StoryRender.ts b/lib/preview-web/src/StoryRender.ts index aaddd813ae42..f4e064457c16 100644 --- a/lib/preview-web/src/StoryRender.ts +++ b/lib/preview-web/src/StoryRender.ts @@ -57,6 +57,14 @@ function serializeError(error: any) { } } +function createStepFunction() { + const { step } = instrument( + { step: (label: string, callback: () => MaybePromise) => callback() }, + { intercept: true } + ); + return step; +} + export type RenderContextCallbacks = Pick< RenderContext, 'showMain' | 'showError' | 'showException' @@ -64,11 +72,6 @@ export type RenderContextCallbacks = Pick< export const PREPARE_ABORTED = new Error('prepareAborted'); -const { step } = instrument( - { step: (label: string, callback: () => MaybePromise) => callback() }, - { intercept: true } -); - export type RenderType = 'story' | 'docs'; export interface Render { type: RenderType; @@ -90,6 +93,8 @@ export class StoryRender implements Render MaybePromise) => void; + private canvasElement?: HTMLElement; private notYetRendered = true; @@ -110,6 +115,7 @@ export class StoryRender implements Render ) { this.abortController = createController(); + this.stepFunction = createStepFunction(); // Allow short-circuiting preparing if we happen to already // have the story (this is used by docs mode) @@ -234,7 +240,7 @@ export class StoryRender implements Render unboundStoryFn(renderStoryContext), unboundStoryFn, From be40bcee0b3056a17bb053aa75360e43aea00dc0 Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Tue, 28 Jun 2022 11:27:27 +0200 Subject: [PATCH 13/45] Namespace events to storybook --- lib/instrumenter/src/instrumenter.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/instrumenter/src/instrumenter.ts b/lib/instrumenter/src/instrumenter.ts index dc9a1f0b2a0e..b7994b8f35c5 100644 --- a/lib/instrumenter/src/instrumenter.ts +++ b/lib/instrumenter/src/instrumenter.ts @@ -13,13 +13,13 @@ import global from 'global'; import { Call, CallRef, CallStates, ControlStates, LogItem, Options, State } from './types'; export const EVENTS = { - CALL: 'instrumenter/call', - SYNC: 'instrumenter/sync', - START: 'instrumenter/start', - BACK: 'instrumenter/back', - GOTO: 'instrumenter/goto', - NEXT: 'instrumenter/next', - END: 'instrumenter/end', + CALL: 'storybook/instrumenter/call', + SYNC: 'storybook/instrumenter/sync', + START: 'storybook/instrumenter/start', + BACK: 'storybook/instrumenter/back', + GOTO: 'storybook/instrumenter/goto', + NEXT: 'storybook/instrumenter/next', + END: 'storybook/instrumenter/end', }; type PatchedObj = { From 09a302bd22dcde79683024f3d1e49502c1748fa8 Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Tue, 28 Jun 2022 12:47:25 +0200 Subject: [PATCH 14/45] Fix tests --- lib/instrumenter/src/instrumenter.test.ts | 2 +- lib/preview-web/src/PreviewWeb.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/instrumenter/src/instrumenter.test.ts b/lib/instrumenter/src/instrumenter.test.ts index 277be46a0167..a0a1a0e19d22 100644 --- a/lib/instrumenter/src/instrumenter.test.ts +++ b/lib/instrumenter/src/instrumenter.test.ts @@ -227,7 +227,7 @@ describe('Instrumenter', () => { expect(callSpy).toHaveBeenCalledWith( expect.objectContaining({ id: 'kind--story [0] fn1 [0] fn2 [0] fn3', - ancestors: ['kind--story [0] fn1 [0] fn2'], + ancestors: ['kind--story [0] fn1', 'kind--story [0] fn1 [0] fn2'], }) ); expect(callSpy).toHaveBeenCalledWith( diff --git a/lib/preview-web/src/PreviewWeb.test.ts b/lib/preview-web/src/PreviewWeb.test.ts index ac18a122a429..cfa0ddf28870 100644 --- a/lib/preview-web/src/PreviewWeb.test.ts +++ b/lib/preview-web/src/PreviewWeb.test.ts @@ -521,7 +521,7 @@ describe('PreviewWeb', () => { Perhaps it needs to be upgraded for Storybook 6.4? - More info: https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#mainjs-framework-field ] + More info: https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#mainjs-framework-field] `); }); From 640072e9bdfd01904260fc2f3776d1cb6d778abc Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Fri, 8 Jul 2022 10:18:35 +0200 Subject: [PATCH 15/45] Pass playContext to step function --- lib/preview-web/src/StoryRender.ts | 28 +++++++++++++++++----------- lib/store/src/types.ts | 12 +++++++++--- 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/lib/preview-web/src/StoryRender.ts b/lib/preview-web/src/StoryRender.ts index f4e064457c16..83687a161723 100644 --- a/lib/preview-web/src/StoryRender.ts +++ b/lib/preview-web/src/StoryRender.ts @@ -9,6 +9,7 @@ import { import { Story, RenderContext, + PlayContext, StoryStore, RenderToDOM, TeardownRenderToDOM, @@ -48,6 +49,20 @@ function createController(): AbortController { } as AbortController; } +function createStepFunction(context: PlayContext) { + const { step } = instrument( + { step: (label: string, play: (context: PlayContext) => MaybePromise) => play(context) }, + { intercept: true } + ); + return step; +} + +function createPlayContext(storyContext: StoryContext): PlayContext { + const playContext = { ...storyContext } as any; + playContext.step = createStepFunction(playContext); + return playContext; +} + function serializeError(error: any) { try { const { name = 'Error', message = String(error), stack } = error; @@ -57,14 +72,6 @@ function serializeError(error: any) { } } -function createStepFunction() { - const { step } = instrument( - { step: (label: string, callback: () => MaybePromise) => callback() }, - { intercept: true } - ); - return step; -} - export type RenderContextCallbacks = Pick< RenderContext, 'showMain' | 'showError' | 'showException' @@ -93,7 +100,7 @@ export class StoryRender implements Render MaybePromise) => void; + private stepFunction?: PlayContext['step']; private canvasElement?: HTMLElement; @@ -115,7 +122,6 @@ export class StoryRender implements Render ) { this.abortController = createController(); - this.stepFunction = createStepFunction(); // Allow short-circuiting preparing if we happen to already // have the story (this is used by docs mode) @@ -240,7 +246,7 @@ export class StoryRender implements Render unboundStoryFn(renderStoryContext), unboundStoryFn, diff --git a/lib/store/src/types.ts b/lib/store/src/types.ts index db206b3e95af..998cbc1bf067 100644 --- a/lib/store/src/types.ts +++ b/lib/store/src/types.ts @@ -106,15 +106,21 @@ export type BoundStory = Story; }; +export declare type PlayContext = + StoryContext & { + step: ( + name: string, + play: (context: PlayContext) => MaybePromise + ) => MaybePromise; + }; + export declare type RenderContext = StoryIdentifier & { showMain: () => void; showError: (error: { title: string; description: string }) => void; showException: (err: Error) => void; forceRemount: boolean; - playContext: StoryContext & { - step: (label: string, callback: () => MaybePromise) => MaybePromise; - }; + playContext: PlayContext; storyContext: StoryContext; storyFn: PartialStoryFn; unboundStoryFn: LegacyStoryFn; From 26ec0e6e3d786404cb24ef089b3f9e71ad22fba8 Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Fri, 8 Jul 2022 10:19:48 +0200 Subject: [PATCH 16/45] Fix signature --- lib/preview-web/src/StoryRender.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/preview-web/src/StoryRender.ts b/lib/preview-web/src/StoryRender.ts index 83687a161723..32bff25cb611 100644 --- a/lib/preview-web/src/StoryRender.ts +++ b/lib/preview-web/src/StoryRender.ts @@ -51,7 +51,7 @@ function createController(): AbortController { function createStepFunction(context: PlayContext) { const { step } = instrument( - { step: (label: string, play: (context: PlayContext) => MaybePromise) => play(context) }, + { step: (name: string, play: (context: PlayContext) => MaybePromise) => play(context) }, { intercept: true } ); return step; From b6f9fe580e9250ccd0609799491fcc384856e2a5 Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Thu, 4 Aug 2022 14:00:09 +0200 Subject: [PATCH 17/45] Fix mock data --- code/addons/interactions/src/Panel.test.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/code/addons/interactions/src/Panel.test.ts b/code/addons/interactions/src/Panel.test.ts index 4e2903fd234e..60e3287d58ad 100644 --- a/code/addons/interactions/src/Panel.test.ts +++ b/code/addons/interactions/src/Panel.test.ts @@ -7,19 +7,22 @@ describe('Panel', () => { { 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', - parentId: 'story--id [6] waitFor', status: CallStates.DONE, + ancestors: ['story--id [6] waitFor'], }, ]; const calls = new Map( @@ -27,6 +30,7 @@ describe('Panel', () => { { id: 'story--id [0] action', storyId: 'story--id', + ancestors: [], cursor: 0, path: [], method: 'action', @@ -37,6 +41,7 @@ describe('Panel', () => { { id: 'story--id [1] action', storyId: 'story--id', + ancestors: [], cursor: 1, path: [], method: 'action', @@ -47,6 +52,7 @@ describe('Panel', () => { { id: 'story--id [2] action', storyId: 'story--id', + ancestors: [], cursor: 2, path: [], method: 'action', @@ -57,6 +63,7 @@ describe('Panel', () => { { id: 'story--id [3] within', storyId: 'story--id', + ancestors: [], cursor: 3, path: [], method: 'within', @@ -67,6 +74,7 @@ describe('Panel', () => { { id: 'story--id [4] findByText', storyId: 'story--id', + ancestors: [], cursor: 4, path: [{ __callId__: 'story--id [3] within' }], method: 'findByText', @@ -77,6 +85,7 @@ describe('Panel', () => { { id: 'story--id [5] click', storyId: 'story--id', + ancestors: [], cursor: 5, path: ['userEvent'], method: 'click', @@ -86,8 +95,8 @@ describe('Panel', () => { }, { id: 'story--id [6] waitFor [0] expect', - parentId: 'story--id [6] waitFor', storyId: 'story--id', + ancestors: ['story--id [6] waitFor'], cursor: 0, path: [], method: 'expect', @@ -97,8 +106,8 @@ describe('Panel', () => { }, { id: 'story--id [6] waitFor [1] stringMatching', - parentId: 'story--id [6] waitFor', storyId: 'story--id', + ancestors: ['story--id [6] waitFor'], cursor: 1, path: ['expect'], method: 'stringMatching', @@ -108,8 +117,8 @@ describe('Panel', () => { }, { id: 'story--id [6] waitFor [2] toHaveBeenCalledWith', - parentId: 'story--id [6] waitFor', storyId: 'story--id', + ancestors: ['story--id [6] waitFor'], cursor: 2, path: [{ __callId__: 'story--id [6] waitFor [0] expect' }], method: 'toHaveBeenCalledWith', @@ -120,6 +129,7 @@ describe('Panel', () => { { id: 'story--id [6] waitFor', storyId: 'story--id', + ancestors: [], cursor: 6, path: [], method: 'waitFor', From 931af52be97b0c2be86b4ec4217f017efd4bf63d Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Thu, 4 Aug 2022 14:36:51 +0200 Subject: [PATCH 18/45] Fix weird async behavior --- code/lib/preview-web/src/render/StoryRender.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/code/lib/preview-web/src/render/StoryRender.ts b/code/lib/preview-web/src/render/StoryRender.ts index 1f7682d3d82b..c2dd3df27ea8 100644 --- a/code/lib/preview-web/src/render/StoryRender.ts +++ b/code/lib/preview-web/src/render/StoryRender.ts @@ -256,9 +256,9 @@ export class StoryRender implements Render - playFunction(renderContext.playContext) - ); + await this.runPhase(abortSignal, 'playing', async () => { + await playFunction(renderContext.playContext); + }); await this.runPhase(abortSignal, 'played'); } catch (error) { logger.error(error); From 697c8ea064ecfd586b7363fa5b72ef8bf61cec96 Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Thu, 4 Aug 2022 17:06:02 +0200 Subject: [PATCH 19/45] Add default runStep function and export one from interactions addon --- code/addons/interactions/src/preset/preview.ts | 2 ++ .../testing-react/components/Button.stories.tsx | 6 ++++-- code/lib/store/src/csf/composeConfigs.ts | 10 ++++++++-- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/code/addons/interactions/src/preset/preview.ts b/code/addons/interactions/src/preset/preview.ts index 662b4f4e5af2..471826ebddd7 100644 --- a/code/addons/interactions/src/preset/preview.ts +++ b/code/addons/interactions/src/preset/preview.ts @@ -48,3 +48,5 @@ const addActionsFromArgTypes: ArgsEnhancer = ({ id, initialArgs }) addSpies(id, initialArgs); export const argsEnhancers = [addActionsFromArgTypes]; + +export const runStep = [(label: string, callback: Function, context: Object) => callback(context)]; diff --git a/code/examples/cra-ts-essentials/src/stories/testing-react/components/Button.stories.tsx b/code/examples/cra-ts-essentials/src/stories/testing-react/components/Button.stories.tsx index 7dc7cabc6a55..9c89d37b60d7 100644 --- a/code/examples/cra-ts-essentials/src/stories/testing-react/components/Button.stories.tsx +++ b/code/examples/cra-ts-essentials/src/stories/testing-react/components/Button.stories.tsx @@ -79,8 +79,10 @@ export const CSF3InputFieldFilled: CSF3Story = { render: () => { return ; }, - play: async ({ canvasElement }) => { + play: async ({ canvasElement, step }) => { const canvas = within(canvasElement); - await userEvent.type(canvas.getByTestId('input'), 'Hello world!'); + await step('foo', async () => { + await userEvent.type(canvas.getByTestId('input'), 'Hello world!'); + }); }, }; diff --git a/code/lib/store/src/csf/composeConfigs.ts b/code/lib/store/src/csf/composeConfigs.ts index dc2cc97c0cec..08308638e4a9 100644 --- a/code/lib/store/src/csf/composeConfigs.ts +++ b/code/lib/store/src/csf/composeConfigs.ts @@ -1,4 +1,4 @@ -import type { AnyFramework } from '@storybook/csf'; +import type { AnyFramework, StepLabel, PlayFunction, PlayFunctionContext } from '@storybook/csf'; import type { ModuleExports, WebProjectAnnotations } from '../types'; import { combineParameters } from '../parameters'; @@ -36,6 +36,12 @@ export function composeConfigs( moduleExportList: ModuleExports[] ): WebProjectAnnotations { const allArgTypeEnhancers = getArrayField(moduleExportList, 'argTypesEnhancers'); + const stepRunners = getArrayField(moduleExportList, 'runStep'); + const runStep = ( + label: StepLabel, + play: PlayFunction, + context: PlayFunctionContext + ) => play(context); return { parameters: combineParameters(...getField(moduleExportList, 'parameters')), @@ -53,6 +59,6 @@ export function composeConfigs( render: getSingletonField(moduleExportList, 'render'), renderToDOM: getSingletonField(moduleExportList, 'renderToDOM'), applyDecorators: getSingletonField(moduleExportList, 'applyDecorators'), - runStep: composeStepRunners(getArrayField(moduleExportList, 'runStep')), + runStep: composeStepRunners([runStep, ...stepRunners]), }; } From e05b07d00354e08f738c71f68f3bcb3c20ca0bb6 Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Thu, 4 Aug 2022 17:07:18 +0200 Subject: [PATCH 20/45] Use proper types --- code/addons/interactions/src/preset/preview.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/code/addons/interactions/src/preset/preview.ts b/code/addons/interactions/src/preset/preview.ts index 471826ebddd7..8b3faa6763f0 100644 --- a/code/addons/interactions/src/preset/preview.ts +++ b/code/addons/interactions/src/preset/preview.ts @@ -1,6 +1,12 @@ import { addons } from '@storybook/addons'; import { FORCE_REMOUNT, STORY_RENDER_PHASE_CHANGED } from '@storybook/core-events'; -import type { AnyFramework, ArgsEnhancer } from '@storybook/csf'; +import type { + AnyFramework, + ArgsEnhancer, + PlayFunction, + PlayFunctionContext, + StepLabel, +} from '@storybook/csf'; import { instrument } from '@storybook/instrumenter'; import { ModuleMocker } from 'jest-mock'; @@ -49,4 +55,6 @@ const addActionsFromArgTypes: ArgsEnhancer = ({ id, initialArgs }) export const argsEnhancers = [addActionsFromArgTypes]; -export const runStep = [(label: string, callback: Function, context: Object) => callback(context)]; +export const runStep = [ + (label: StepLabel, play: PlayFunction, context: PlayFunctionContext) => play(context), +]; From 0ba8a5efeb457f898823f18ba6032481ac07fae7 Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Fri, 5 Aug 2022 09:21:32 +0200 Subject: [PATCH 21/45] Instrument the step runner provided by the interactions addon --- code/addons/interactions/src/preset/preview.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/code/addons/interactions/src/preset/preview.ts b/code/addons/interactions/src/preset/preview.ts index 8b3faa6763f0..41eadcf626f5 100644 --- a/code/addons/interactions/src/preset/preview.ts +++ b/code/addons/interactions/src/preset/preview.ts @@ -55,6 +55,7 @@ const addActionsFromArgTypes: ArgsEnhancer = ({ id, initialArgs }) export const argsEnhancers = [addActionsFromArgTypes]; -export const runStep = [ - (label: StepLabel, play: PlayFunction, context: PlayFunctionContext) => play(context), -]; +export const { step: runStep } = instrument( + { step: (label: StepLabel, play: PlayFunction, context: PlayFunctionContext) => play(context) }, + { intercept: true } +); From 49098986eb94e6b0c39e924c245a889f4a53b772 Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Fri, 5 Aug 2022 09:22:11 +0200 Subject: [PATCH 22/45] Add addon-interactions to be able to see the labeled step --- code/examples/cra-ts-essentials/.storybook/main.ts | 1 + code/examples/cra-ts-essentials/package.json | 1 + 2 files changed, 2 insertions(+) diff --git a/code/examples/cra-ts-essentials/.storybook/main.ts b/code/examples/cra-ts-essentials/.storybook/main.ts index 01c95af66b35..8a4882ff3aa7 100644 --- a/code/examples/cra-ts-essentials/.storybook/main.ts +++ b/code/examples/cra-ts-essentials/.storybook/main.ts @@ -12,6 +12,7 @@ const mainConfig: StorybookConfig = { viewport: false, }, }, + '@storybook/addon-interactions', ], logLevel: 'debug', // add monorepo root as a valid directory to import modules from diff --git a/code/examples/cra-ts-essentials/package.json b/code/examples/cra-ts-essentials/package.json index a4809b8c0471..44b28f097d48 100644 --- a/code/examples/cra-ts-essentials/package.json +++ b/code/examples/cra-ts-essentials/package.json @@ -38,6 +38,7 @@ }, "devDependencies": { "@storybook/addon-essentials": "7.0.0-alpha.18", + "@storybook/addon-interactions": "7.0.0-alpha.18", "@storybook/addons": "7.0.0-alpha.18", "@storybook/builder-webpack5": "7.0.0-alpha.18", "@storybook/preset-create-react-app": "^4.1.0", From 226e21138eb39883cf61b82e93ae169c3cdd82d6 Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Fri, 5 Aug 2022 09:22:44 +0200 Subject: [PATCH 23/45] Better label --- .../src/stories/testing-react/components/Button.stories.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/code/examples/cra-ts-essentials/src/stories/testing-react/components/Button.stories.tsx b/code/examples/cra-ts-essentials/src/stories/testing-react/components/Button.stories.tsx index 9c89d37b60d7..d87e1e317baf 100644 --- a/code/examples/cra-ts-essentials/src/stories/testing-react/components/Button.stories.tsx +++ b/code/examples/cra-ts-essentials/src/stories/testing-react/components/Button.stories.tsx @@ -81,7 +81,7 @@ export const CSF3InputFieldFilled: CSF3Story = { }, play: async ({ canvasElement, step }) => { const canvas = within(canvasElement); - await step('foo', async () => { + await step('Step label', async () => { await userEvent.type(canvas.getByTestId('input'), 'Hello world!'); }); }, From 9b810225bf58599a0619d709f97ad36e6791e465 Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Fri, 5 Aug 2022 09:24:16 +0200 Subject: [PATCH 24/45] Get rid of dependency on instrumenter from preview-web --- code/lib/preview-web/package.json | 1 - code/lib/preview-web/src/render/StoryRender.ts | 18 +----------------- code/lib/store/src/types.ts | 1 - 3 files changed, 1 insertion(+), 19 deletions(-) diff --git a/code/lib/preview-web/package.json b/code/lib/preview-web/package.json index b78cbcb9611d..183ad41f69f3 100644 --- a/code/lib/preview-web/package.json +++ b/code/lib/preview-web/package.json @@ -47,7 +47,6 @@ "@storybook/client-logger": "7.0.0-alpha.18", "@storybook/core-events": "7.0.0-alpha.18", "@storybook/csf": "0.0.2--canary.0899bb7.0", - "@storybook/instrumenter": "7.0.0-alpha.18", "@storybook/store": "7.0.0-alpha.18", "ansi-to-html": "^0.6.11", "core-js": "^3.8.2", diff --git a/code/lib/preview-web/src/render/StoryRender.ts b/code/lib/preview-web/src/render/StoryRender.ts index c2dd3df27ea8..a13fdcb65082 100644 --- a/code/lib/preview-web/src/render/StoryRender.ts +++ b/code/lib/preview-web/src/render/StoryRender.ts @@ -21,7 +21,6 @@ import { STORY_RENDERED, PLAY_FUNCTION_THREW_EXCEPTION, } from '@storybook/core-events'; -import { instrument } from '@storybook/instrumenter'; import { Render, RenderType } from './Render'; const { AbortController } = global; @@ -50,20 +49,6 @@ function createController(): AbortController { } as AbortController; } -function createStepFunction(context: PlayContext) { - const { step } = instrument( - { step: (name: string, play: (context: PlayContext) => MaybePromise) => play(context) }, - { intercept: true } - ); - return step; -} - -function createPlayContext(storyContext: StoryContext): PlayContext { - const playContext = { ...storyContext } as any; - playContext.step = createStepFunction(playContext); - return playContext; -} - function serializeError(error: any) { try { const { name = 'Error', message = String(error), stack } = error; @@ -239,7 +224,6 @@ export class StoryRender implements Render unboundStoryFn(renderStoryContext), unboundStoryFn, @@ -257,7 +241,7 @@ export class StoryRender implements Render { - await playFunction(renderContext.playContext); + await playFunction(renderContext.storyContext); }); await this.runPhase(abortSignal, 'played'); } catch (error) { diff --git a/code/lib/store/src/types.ts b/code/lib/store/src/types.ts index 998cbc1bf067..a8082ff2365c 100644 --- a/code/lib/store/src/types.ts +++ b/code/lib/store/src/types.ts @@ -120,7 +120,6 @@ export declare type RenderContext void; showException: (err: Error) => void; forceRemount: boolean; - playContext: PlayContext; storyContext: StoryContext; storyFn: PartialStoryFn; unboundStoryFn: LegacyStoryFn; From 7538375952b6d579d761f54f27f49bfe2fd265c1 Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Fri, 5 Aug 2022 09:24:51 +0200 Subject: [PATCH 25/45] No need for explicit default implementation, composeStepRunners takes care of that --- code/lib/store/src/csf/composeConfigs.ts | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/code/lib/store/src/csf/composeConfigs.ts b/code/lib/store/src/csf/composeConfigs.ts index 08308638e4a9..f43aed164704 100644 --- a/code/lib/store/src/csf/composeConfigs.ts +++ b/code/lib/store/src/csf/composeConfigs.ts @@ -1,4 +1,4 @@ -import type { AnyFramework, StepLabel, PlayFunction, PlayFunctionContext } from '@storybook/csf'; +import type { AnyFramework } from '@storybook/csf'; import type { ModuleExports, WebProjectAnnotations } from '../types'; import { combineParameters } from '../parameters'; @@ -36,12 +36,7 @@ export function composeConfigs( moduleExportList: ModuleExports[] ): WebProjectAnnotations { const allArgTypeEnhancers = getArrayField(moduleExportList, 'argTypesEnhancers'); - const stepRunners = getArrayField(moduleExportList, 'runStep'); - const runStep = ( - label: StepLabel, - play: PlayFunction, - context: PlayFunctionContext - ) => play(context); + const stepRunners = getField(moduleExportList, 'runStep'); return { parameters: combineParameters(...getField(moduleExportList, 'parameters')), @@ -59,6 +54,6 @@ export function composeConfigs( render: getSingletonField(moduleExportList, 'render'), renderToDOM: getSingletonField(moduleExportList, 'renderToDOM'), applyDecorators: getSingletonField(moduleExportList, 'applyDecorators'), - runStep: composeStepRunners([runStep, ...stepRunners]), + runStep: composeStepRunners(stepRunners), }; } From 4c90cfd735ff8b36d02eefd2a3c43ef8d81e685a Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Fri, 5 Aug 2022 09:26:01 +0200 Subject: [PATCH 26/45] Update lockfile --- code/yarn.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/code/yarn.lock b/code/yarn.lock index ade6ce663fde..be862bd61762 100644 --- a/code/yarn.lock +++ b/code/yarn.lock @@ -8757,7 +8757,6 @@ __metadata: "@storybook/client-logger": 7.0.0-alpha.18 "@storybook/core-events": 7.0.0-alpha.18 "@storybook/csf": 0.0.2--canary.0899bb7.0 - "@storybook/instrumenter": 7.0.0-alpha.18 "@storybook/store": 7.0.0-alpha.18 ansi-to-html: ^0.6.11 core-js: ^3.8.2 @@ -17297,6 +17296,7 @@ __metadata: resolution: "cra-ts-essentials@workspace:examples/cra-ts-essentials" dependencies: "@storybook/addon-essentials": 7.0.0-alpha.18 + "@storybook/addon-interactions": 7.0.0-alpha.18 "@storybook/addons": 7.0.0-alpha.18 "@storybook/builder-webpack5": 7.0.0-alpha.18 "@storybook/components": 7.0.0-alpha.18 From 9319812bfa0aed5f521bd41081adbf71261301c3 Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Tue, 9 Aug 2022 14:43:12 +0200 Subject: [PATCH 27/45] Add missing await keywords --- code/addons/interactions/src/examples/Examples.stories.tsx | 6 +++--- code/lib/store/src/csf/prepareStory.test.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/code/addons/interactions/src/examples/Examples.stories.tsx b/code/addons/interactions/src/examples/Examples.stories.tsx index 79618730b304..d16c086e61ee 100644 --- a/code/addons/interactions/src/examples/Examples.stories.tsx +++ b/code/addons/interactions/src/examples/Examples.stories.tsx @@ -107,14 +107,14 @@ export const WithSteps: Story = ({ onSubmit }) => ( ); WithSteps.play = async ({ args, canvasElement, step }) => { - step('Click button', async () => { + await step('Click button', async () => { await userEvent.click(within(canvasElement).getByRole('button')); - step('Verify submit', async () => { + await step('Verify submit', async () => { await expect(args.onSubmit).toHaveBeenCalledWith(expect.stringMatching(/([A-Z])\w+/gi)); }); - step('Verify result', async () => { + await step('Verify result', async () => { await expect([{ name: 'John', age: 42 }]).toEqual( expect.arrayContaining([ expect.objectContaining({ name: 'John' }), diff --git a/code/lib/store/src/csf/prepareStory.test.ts b/code/lib/store/src/csf/prepareStory.test.ts index f943f61889a5..81ae1314eab3 100644 --- a/code/lib/store/src/csf/prepareStory.test.ts +++ b/code/lib/store/src/csf/prepareStory.test.ts @@ -641,7 +641,7 @@ describe('playFunction', () => { expect(context.step).toEqual(expect.any(Function)); }); const play = jest.fn(async ({ step }) => { - step('label', stepPlay); + await step('label', stepPlay); }); const runStep = jest.fn((label, p, c) => p(c)); const { playFunction } = prepareStory( From 64c950b9c251053cab7d34e0d72d88baaca912b3 Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Tue, 9 Aug 2022 14:50:14 +0200 Subject: [PATCH 28/45] Fix formatting in docs --- docs/.prettierrc | 8 +++++ ...torybook-interactions-play-function.js.mdx | 30 +++++++++---------- 2 files changed, 23 insertions(+), 15 deletions(-) create mode 100644 docs/.prettierrc diff --git a/docs/.prettierrc b/docs/.prettierrc new file mode 100644 index 000000000000..d033feea7ad5 --- /dev/null +++ b/docs/.prettierrc @@ -0,0 +1,8 @@ +{ + "printWidth": 100, + "tabWidth": 2, + "bracketSpacing": true, + "trailingComma": "es5", + "singleQuote": true, + "arrowParens": "always" +} diff --git a/docs/snippets/common/storybook-interactions-play-function.js.mdx b/docs/snippets/common/storybook-interactions-play-function.js.mdx index dfc5ff357e7f..dafb7dde0e51 100644 --- a/docs/snippets/common/storybook-interactions-play-function.js.mdx +++ b/docs/snippets/common/storybook-interactions-play-function.js.mdx @@ -1,39 +1,39 @@ ```js // MyForm.stories.js -import { expect } from "@storybook/jest" +import { expect } from '@storybook/jest'; -import { userEvent, waitFor, within } from "@storybook/testing-library" +import { userEvent, waitFor, within } from '@storybook/testing-library'; -import { MyForm } from "./MyForm" +import { MyForm } from './MyForm'; export default { /* 👇 The title prop is optional. * See https://storybook.js.org/docs/7.0/react/configure/overview#configure-story-loading * to learn how to generate automatic titles */ - title: "MyForm", + title: 'MyForm', component: MyForm, argTypes: { onSubmit: { action: true }, }, -} +}; export const Submitted = { play: async ({ args, canvasElement, step }) => { // Starts querying the component from its root element - const canvas = within(canvasElement) + const canvas = within(canvasElement); - await step("Enter credentials", async () => { - await userEvent.type(canvas.getByTestId("email"), "hi@example.com") - await userEvent.type(canvas.getByTestId("password"), "supersecret") - }) + await step('Enter credentials', async () => { + await userEvent.type(canvas.getByTestId('email'), 'hi@example.com'); + await userEvent.type(canvas.getByTestId('password'), 'supersecret'); + }); - await step("Submit form", async () => { - await userEvent.click(canvas.getByRole("button")) - }) + await step('Submit form', async () => { + await userEvent.click(canvas.getByRole('button')); + }); - await waitFor(() => expect(args.onSubmit).toHaveBeenCalled()) + await waitFor(() => expect(args.onSubmit).toHaveBeenCalled()); }, -} +}; ``` From 51d74a7800aa7852a324840666a782ece6d47b65 Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Tue, 9 Aug 2022 15:01:36 +0200 Subject: [PATCH 29/45] Move MaybePromise to Preview.ts --- code/lib/preview-web/src/Preview.tsx | 4 +++- code/lib/preview-web/src/PreviewWeb.tsx | 4 ++-- code/lib/preview-web/src/render/StoryRender.ts | 2 -- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/code/lib/preview-web/src/Preview.tsx b/code/lib/preview-web/src/Preview.tsx index 6b8b9d8f7a67..c17ca1fa131b 100644 --- a/code/lib/preview-web/src/Preview.tsx +++ b/code/lib/preview-web/src/Preview.tsx @@ -26,7 +26,7 @@ import { RenderToDOM, } from '@storybook/store'; -import { MaybePromise, StoryRender } from './render/StoryRender'; +import { StoryRender } from './render/StoryRender'; import { TemplateDocsRender } from './render/TemplateDocsRender'; import { StandaloneDocsRender } from './render/StandaloneDocsRender'; @@ -34,6 +34,8 @@ const { fetch } = global; const STORY_INDEX_PATH = './index.json'; +export type MaybePromise = Promise | T; + export class Preview { serverChannel?: Channel; diff --git a/code/lib/preview-web/src/PreviewWeb.tsx b/code/lib/preview-web/src/PreviewWeb.tsx index a89175ebac8e..3178ae7611fe 100644 --- a/code/lib/preview-web/src/PreviewWeb.tsx +++ b/code/lib/preview-web/src/PreviewWeb.tsx @@ -30,11 +30,11 @@ import type { WebProjectAnnotations, } from '@storybook/store'; -import { Preview } from './Preview'; +import { MaybePromise, Preview } from './Preview'; import { UrlStore } from './UrlStore'; import { WebView } from './WebView'; -import { MaybePromise, PREPARE_ABORTED, StoryRender } from './render/StoryRender'; +import { PREPARE_ABORTED, StoryRender } from './render/StoryRender'; import { TemplateDocsRender } from './render/TemplateDocsRender'; import { StandaloneDocsRender } from './render/StandaloneDocsRender'; diff --git a/code/lib/preview-web/src/render/StoryRender.ts b/code/lib/preview-web/src/render/StoryRender.ts index a13fdcb65082..a59c8e909aa6 100644 --- a/code/lib/preview-web/src/render/StoryRender.ts +++ b/code/lib/preview-web/src/render/StoryRender.ts @@ -25,8 +25,6 @@ import { Render, RenderType } from './Render'; const { AbortController } = global; -export type MaybePromise = Promise | T; - export type RenderPhase = | 'preparing' | 'loading' From f46a08ca7646c762517fd26fa05c57173984043b Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Tue, 9 Aug 2022 15:02:11 +0200 Subject: [PATCH 30/45] Drop unused stepFunction property and its type definition --- code/lib/preview-web/src/render/StoryRender.ts | 3 --- code/lib/store/src/types.ts | 8 -------- 2 files changed, 11 deletions(-) diff --git a/code/lib/preview-web/src/render/StoryRender.ts b/code/lib/preview-web/src/render/StoryRender.ts index a59c8e909aa6..70b6b8fd86a0 100644 --- a/code/lib/preview-web/src/render/StoryRender.ts +++ b/code/lib/preview-web/src/render/StoryRender.ts @@ -9,7 +9,6 @@ import { import { Story, RenderContext, - PlayContext, StoryStore, RenderToDOM, TeardownRenderToDOM, @@ -72,8 +71,6 @@ export class StoryRender implements Render = Story; }; -export declare type PlayContext = - StoryContext & { - step: ( - name: string, - play: (context: PlayContext) => MaybePromise - ) => MaybePromise; - }; - export declare type RenderContext = StoryIdentifier & { showMain: () => void; From 501b7e1b25b47bca55ca7b9ffea4683be21850df Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Wed, 10 Aug 2022 09:46:02 +0200 Subject: [PATCH 31/45] Always return chained promise so state will be restored before execution continues --- code/lib/instrumenter/src/instrumenter.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/code/lib/instrumenter/src/instrumenter.ts b/code/lib/instrumenter/src/instrumenter.ts index 1768e4486798..6488be15096a 100644 --- a/code/lib/instrumenter/src/instrumenter.ts +++ b/code/lib/instrumenter/src/instrumenter.ts @@ -502,9 +502,8 @@ export class Instrumenter { const res = arg(...args); // Reset cursor and ancestors to their original values before we entered the callback. - if (res instanceof Promise) res.then(restore, restore); - else restore(); - + if (res instanceof Promise) return res.then(restore, restore); + restore(); return res; }; }); From 118fe17652b327258a268550db676a8fbef0980c Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Wed, 10 Aug 2022 12:10:47 +0200 Subject: [PATCH 32/45] Add test for step runner composition --- code/lib/store/src/csf/composeConfigs.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/code/lib/store/src/csf/composeConfigs.test.ts b/code/lib/store/src/csf/composeConfigs.test.ts index ce35ddd4c444..97c920c7993c 100644 --- a/code/lib/store/src/csf/composeConfigs.test.ts +++ b/code/lib/store/src/csf/composeConfigs.test.ts @@ -157,4 +157,22 @@ describe('composeConfigs', () => { runStep: expect.any(Function), }); }); + + it('composes step runners', () => { + const fn = jest.fn(); + + const { runStep } = composeConfigs([ + { runStep: (label, play, context) => fn(`${label}1`, play(context)) }, + { runStep: (label, play, context) => fn(`${label}2`, play(context)) }, + { runStep: (label, play, context) => fn(`${label}3`, play(context)) }, + ]); + + // @ts-expect-error We don't care about the context value here + runStep('Label', () => {}, {}); + + expect(fn).toHaveBeenCalledTimes(3); + expect(fn).toHaveBeenNthCalledWith(1, 'Label3', expect.anything()); + expect(fn).toHaveBeenNthCalledWith(2, 'Label2', expect.anything()); + expect(fn).toHaveBeenNthCalledWith(3, 'Label1', expect.anything()); + }); }); From 7970c8952d9f3cb5d3c4e63e94b763fb4aca70ac Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Wed, 10 Aug 2022 13:14:26 +0200 Subject: [PATCH 33/45] Avoid step function in official-storybook for now --- .../AccountFormInteractions.stories.tsx | 80 ++++++------------- .../src/examples/Examples.stories.tsx | 43 +++++----- 2 files changed, 47 insertions(+), 76 deletions(-) diff --git a/code/addons/interactions/src/examples/AccountFormInteractions/AccountFormInteractions.stories.tsx b/code/addons/interactions/src/examples/AccountFormInteractions/AccountFormInteractions.stories.tsx index 34f84115113c..9235a28dee03 100644 --- a/code/addons/interactions/src/examples/AccountFormInteractions/AccountFormInteractions.stories.tsx +++ b/code/addons/interactions/src/examples/AccountFormInteractions/AccountFormInteractions.stories.tsx @@ -1,4 +1,3 @@ -/* eslint-disable jest/no-standalone-expect */ import { Meta, ComponentStoryObj } from '@storybook/react'; import { expect } from '@storybook/jest'; import { within, waitFor, fireEvent, userEvent } from '@storybook/testing-library'; @@ -26,30 +25,23 @@ export const Standard: CSF3Story = { export const StandardEmailFilled = { ...Standard, - play: async ({ canvasElement, step }) => { + play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await step('Enter email', async () => { - await fireEvent.change(canvas.getByTestId('email'), { - target: { value: 'michael@chromatic.com' }, - }); + await fireEvent.change(canvas.getByTestId('email'), { + target: { value: 'michael@chromatic.com' }, }); }, }; export const StandardEmailFailed = { ...Standard, - play: async ({ args, canvasElement, step }) => { + play: async ({ args, canvasElement }) => { const canvas = within(canvasElement); - await step('Enter email and password', async () => { - await userEvent.type(canvas.getByTestId('email'), 'gert@chromatic'); - await userEvent.type(canvas.getByTestId('password1'), 'supersecret'); - }); - - await step('Submit form', async () => { - await userEvent.click(canvas.getByRole('button', { name: /create account/i })); - }); + await userEvent.type(canvas.getByTestId('email'), 'gert@chromatic'); + await userEvent.type(canvas.getByTestId('password1'), 'supersecret'); + await userEvent.click(canvas.getByRole('button', { name: /create account/i })); await canvas.findByText('Please enter a correctly formatted email address'); await expect(args.onSubmit).not.toHaveBeenCalled(); @@ -58,17 +50,12 @@ export const StandardEmailFailed = { export const StandardEmailSuccess = { ...Standard, - play: async ({ args, canvasElement, step }) => { + play: async ({ args, canvasElement }) => { const canvas = within(canvasElement); - await step('Enter email and password', async () => { - await userEvent.type(canvas.getByTestId('email'), 'michael@chromatic.com'); - await userEvent.type(canvas.getByTestId('password1'), 'testpasswordthatwontfail'); - }); - - await step('Submit form', async () => { - await userEvent.click(canvas.getByTestId('submit')); - }); + await userEvent.type(canvas.getByTestId('email'), 'michael@chromatic.com'); + await userEvent.type(canvas.getByTestId('password1'), 'testpasswordthatwontfail'); + await userEvent.click(canvas.getByTestId('submit')); await waitFor(async () => { await expect(args.onSubmit).toHaveBeenCalledTimes(1); @@ -86,13 +73,8 @@ export const StandardPasswordFailed = { const canvas = within(context.canvasElement); await StandardEmailFilled.play(context); - await context.step('Enter password', async () => { - await userEvent.type(canvas.getByTestId('password1'), 'asdf'); - }); - - await context.step('Submit form', async () => { - await userEvent.click(canvas.getByTestId('submit')); - }); + await userEvent.type(canvas.getByTestId('password1'), 'asdf'); + await userEvent.click(canvas.getByTestId('submit')); }, }; @@ -117,12 +99,9 @@ export const VerificationPassword = { play: async (context) => { const canvas = within(context.canvasElement); await StandardEmailFilled.play(context); - await context.step('Enter password', async () => { - await userEvent.type(canvas.getByTestId('password1'), 'asdfasdf'); - }); - await context.step('Submit form', async () => { - await userEvent.click(canvas.getByTestId('submit')); - }); + + await userEvent.type(canvas.getByTestId('password1'), 'asdfasdf'); + await userEvent.click(canvas.getByTestId('submit')); }, }; @@ -131,13 +110,10 @@ export const VerificationPasswordMismatch = { play: async (context) => { const canvas = within(context.canvasElement); await StandardEmailFilled.play(context); - await context.step('Enter passwords', async () => { - await userEvent.type(canvas.getByTestId('password1'), 'asdfasdf'); - await userEvent.type(canvas.getByTestId('password2'), 'asdf1234'); - }); - await context.step('Submit form', async () => { - await userEvent.click(canvas.getByTestId('submit')); - }); + + await userEvent.type(canvas.getByTestId('password1'), 'asdfasdf'); + await userEvent.type(canvas.getByTestId('password2'), 'asdf1234'); + await userEvent.click(canvas.getByTestId('submit')); }, }; @@ -147,16 +123,12 @@ export const VerificationSuccess = { const canvas = within(context.canvasElement); await StandardEmailFilled.play(context); - await context.step('Enter passwords', async () => { - await new Promise((resolve) => setTimeout(resolve, 1000)); - await userEvent.type(canvas.getByTestId('password1'), 'helloyou', { delay: 50 }); - await new Promise((resolve) => setTimeout(resolve, 1000)); - await userEvent.type(canvas.getByTestId('password2'), 'helloyou', { delay: 50 }); - }); + await new Promise((resolve) => setTimeout(resolve, 1000)); + await userEvent.type(canvas.getByTestId('password1'), 'helloyou', { delay: 50 }); + await new Promise((resolve) => setTimeout(resolve, 1000)); + await userEvent.type(canvas.getByTestId('password2'), 'helloyou', { delay: 50 }); - await context.step('Submit form', async () => { - await new Promise((resolve) => setTimeout(resolve, 1000)); - await userEvent.click(canvas.getByTestId('submit')); - }); + await new Promise((resolve) => setTimeout(resolve, 1000)); + await userEvent.click(canvas.getByTestId('submit')); }, }; diff --git a/code/addons/interactions/src/examples/Examples.stories.tsx b/code/addons/interactions/src/examples/Examples.stories.tsx index d16c086e61ee..8085ce725d32 100644 --- a/code/addons/interactions/src/examples/Examples.stories.tsx +++ b/code/addons/interactions/src/examples/Examples.stories.tsx @@ -1,4 +1,3 @@ -/* eslint-disable jest/no-standalone-expect */ import { Story, Meta } from '@storybook/react'; import { expect } from '@storybook/jest'; import { within, waitFor, userEvent, waitForElementToBeRemoved } from '@storybook/testing-library'; @@ -101,26 +100,26 @@ WithLoaders.play = async ({ args, canvasElement }) => { await expect(args.onSubmit).toHaveBeenCalledWith('delectus aut autem'); }; -export const WithSteps: Story = ({ onSubmit }) => ( - -); -WithSteps.play = async ({ args, canvasElement, step }) => { - await step('Click button', async () => { - await userEvent.click(within(canvasElement).getByRole('button')); +// export const WithSteps: Story = ({ onSubmit }) => ( +// +// ); +// WithSteps.play = async ({ args, canvasElement, step }) => { +// await step('Click button', async () => { +// await userEvent.click(within(canvasElement).getByRole('button')); - await step('Verify submit', async () => { - await expect(args.onSubmit).toHaveBeenCalledWith(expect.stringMatching(/([A-Z])\w+/gi)); - }); +// await step('Verify submit', async () => { +// await expect(args.onSubmit).toHaveBeenCalledWith(expect.stringMatching(/([A-Z])\w+/gi)); +// }); - await step('Verify result', async () => { - await expect([{ name: 'John', age: 42 }]).toEqual( - expect.arrayContaining([ - expect.objectContaining({ name: 'John' }), - expect.objectContaining({ age: 42 }), - ]) - ); - }); - }); -}; +// await step('Verify result', async () => { +// await expect([{ name: 'John', age: 42 }]).toEqual( +// expect.arrayContaining([ +// expect.objectContaining({ name: 'John' }), +// expect.objectContaining({ age: 42 }), +// ]) +// ); +// }); +// }); +// }; From 24fb9394528ed2e7f4861bfe549237767b7a6133 Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Wed, 10 Aug 2022 15:55:56 +0200 Subject: [PATCH 34/45] Fix projectAnnotations --- code/lib/store/src/csf/testing-utils/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/code/lib/store/src/csf/testing-utils/index.ts b/code/lib/store/src/csf/testing-utils/index.ts index 6fddcbce6f5e..c87c7870b832 100644 --- a/code/lib/store/src/csf/testing-utils/index.ts +++ b/code/lib/store/src/csf/testing-utils/index.ts @@ -27,9 +27,9 @@ let GLOBAL_STORYBOOK_PROJECT_ANNOTATIONS = {}; export function setProjectAnnotations( projectAnnotations: ProjectAnnotations | ProjectAnnotations[] ) { - GLOBAL_STORYBOOK_PROJECT_ANNOTATIONS = Array.isArray(projectAnnotations) - ? composeConfigs(projectAnnotations) - : projectAnnotations; + GLOBAL_STORYBOOK_PROJECT_ANNOTATIONS = composeConfigs( + Array.isArray(projectAnnotations) ? projectAnnotations : [projectAnnotations] + ); } interface ComposeStory { From 5e56ec81c3e4850575322f57e60f021379cc8e4b Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Wed, 10 Aug 2022 15:56:27 +0200 Subject: [PATCH 35/45] Revert "Avoid step function in official-storybook for now" This reverts commit 7970c8952d9f3cb5d3c4e63e94b763fb4aca70ac. --- .../AccountFormInteractions.stories.tsx | 80 +++++++++++++------ .../src/examples/Examples.stories.tsx | 43 +++++----- 2 files changed, 76 insertions(+), 47 deletions(-) diff --git a/code/addons/interactions/src/examples/AccountFormInteractions/AccountFormInteractions.stories.tsx b/code/addons/interactions/src/examples/AccountFormInteractions/AccountFormInteractions.stories.tsx index 9235a28dee03..34f84115113c 100644 --- a/code/addons/interactions/src/examples/AccountFormInteractions/AccountFormInteractions.stories.tsx +++ b/code/addons/interactions/src/examples/AccountFormInteractions/AccountFormInteractions.stories.tsx @@ -1,3 +1,4 @@ +/* eslint-disable jest/no-standalone-expect */ import { Meta, ComponentStoryObj } from '@storybook/react'; import { expect } from '@storybook/jest'; import { within, waitFor, fireEvent, userEvent } from '@storybook/testing-library'; @@ -25,23 +26,30 @@ export const Standard: CSF3Story = { export const StandardEmailFilled = { ...Standard, - play: async ({ canvasElement }) => { + play: async ({ canvasElement, step }) => { const canvas = within(canvasElement); - await fireEvent.change(canvas.getByTestId('email'), { - target: { value: 'michael@chromatic.com' }, + await step('Enter email', async () => { + await fireEvent.change(canvas.getByTestId('email'), { + target: { value: 'michael@chromatic.com' }, + }); }); }, }; export const StandardEmailFailed = { ...Standard, - play: async ({ args, canvasElement }) => { + play: async ({ args, canvasElement, step }) => { const canvas = within(canvasElement); - await userEvent.type(canvas.getByTestId('email'), 'gert@chromatic'); - await userEvent.type(canvas.getByTestId('password1'), 'supersecret'); - await userEvent.click(canvas.getByRole('button', { name: /create account/i })); + await step('Enter email and password', async () => { + await userEvent.type(canvas.getByTestId('email'), 'gert@chromatic'); + await userEvent.type(canvas.getByTestId('password1'), 'supersecret'); + }); + + await step('Submit form', async () => { + await userEvent.click(canvas.getByRole('button', { name: /create account/i })); + }); await canvas.findByText('Please enter a correctly formatted email address'); await expect(args.onSubmit).not.toHaveBeenCalled(); @@ -50,12 +58,17 @@ export const StandardEmailFailed = { export const StandardEmailSuccess = { ...Standard, - play: async ({ args, canvasElement }) => { + play: async ({ args, canvasElement, step }) => { const canvas = within(canvasElement); - await userEvent.type(canvas.getByTestId('email'), 'michael@chromatic.com'); - await userEvent.type(canvas.getByTestId('password1'), 'testpasswordthatwontfail'); - await userEvent.click(canvas.getByTestId('submit')); + await step('Enter email and password', async () => { + await userEvent.type(canvas.getByTestId('email'), 'michael@chromatic.com'); + await userEvent.type(canvas.getByTestId('password1'), 'testpasswordthatwontfail'); + }); + + await step('Submit form', async () => { + await userEvent.click(canvas.getByTestId('submit')); + }); await waitFor(async () => { await expect(args.onSubmit).toHaveBeenCalledTimes(1); @@ -73,8 +86,13 @@ export const StandardPasswordFailed = { const canvas = within(context.canvasElement); await StandardEmailFilled.play(context); - await userEvent.type(canvas.getByTestId('password1'), 'asdf'); - await userEvent.click(canvas.getByTestId('submit')); + await context.step('Enter password', async () => { + await userEvent.type(canvas.getByTestId('password1'), 'asdf'); + }); + + await context.step('Submit form', async () => { + await userEvent.click(canvas.getByTestId('submit')); + }); }, }; @@ -99,9 +117,12 @@ export const VerificationPassword = { play: async (context) => { const canvas = within(context.canvasElement); await StandardEmailFilled.play(context); - - await userEvent.type(canvas.getByTestId('password1'), 'asdfasdf'); - await userEvent.click(canvas.getByTestId('submit')); + await context.step('Enter password', async () => { + await userEvent.type(canvas.getByTestId('password1'), 'asdfasdf'); + }); + await context.step('Submit form', async () => { + await userEvent.click(canvas.getByTestId('submit')); + }); }, }; @@ -110,10 +131,13 @@ export const VerificationPasswordMismatch = { play: async (context) => { const canvas = within(context.canvasElement); await StandardEmailFilled.play(context); - - await userEvent.type(canvas.getByTestId('password1'), 'asdfasdf'); - await userEvent.type(canvas.getByTestId('password2'), 'asdf1234'); - await userEvent.click(canvas.getByTestId('submit')); + await context.step('Enter passwords', async () => { + await userEvent.type(canvas.getByTestId('password1'), 'asdfasdf'); + await userEvent.type(canvas.getByTestId('password2'), 'asdf1234'); + }); + await context.step('Submit form', async () => { + await userEvent.click(canvas.getByTestId('submit')); + }); }, }; @@ -123,12 +147,16 @@ export const VerificationSuccess = { const canvas = within(context.canvasElement); await StandardEmailFilled.play(context); - await new Promise((resolve) => setTimeout(resolve, 1000)); - await userEvent.type(canvas.getByTestId('password1'), 'helloyou', { delay: 50 }); - await new Promise((resolve) => setTimeout(resolve, 1000)); - await userEvent.type(canvas.getByTestId('password2'), 'helloyou', { delay: 50 }); + await context.step('Enter passwords', async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + await userEvent.type(canvas.getByTestId('password1'), 'helloyou', { delay: 50 }); + await new Promise((resolve) => setTimeout(resolve, 1000)); + await userEvent.type(canvas.getByTestId('password2'), 'helloyou', { delay: 50 }); + }); - await new Promise((resolve) => setTimeout(resolve, 1000)); - await userEvent.click(canvas.getByTestId('submit')); + await context.step('Submit form', async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + await userEvent.click(canvas.getByTestId('submit')); + }); }, }; diff --git a/code/addons/interactions/src/examples/Examples.stories.tsx b/code/addons/interactions/src/examples/Examples.stories.tsx index 8085ce725d32..d16c086e61ee 100644 --- a/code/addons/interactions/src/examples/Examples.stories.tsx +++ b/code/addons/interactions/src/examples/Examples.stories.tsx @@ -1,3 +1,4 @@ +/* eslint-disable jest/no-standalone-expect */ import { Story, Meta } from '@storybook/react'; import { expect } from '@storybook/jest'; import { within, waitFor, userEvent, waitForElementToBeRemoved } from '@storybook/testing-library'; @@ -100,26 +101,26 @@ WithLoaders.play = async ({ args, canvasElement }) => { await expect(args.onSubmit).toHaveBeenCalledWith('delectus aut autem'); }; -// export const WithSteps: Story = ({ onSubmit }) => ( -// -// ); -// WithSteps.play = async ({ args, canvasElement, step }) => { -// await step('Click button', async () => { -// await userEvent.click(within(canvasElement).getByRole('button')); +export const WithSteps: Story = ({ onSubmit }) => ( + +); +WithSteps.play = async ({ args, canvasElement, step }) => { + await step('Click button', async () => { + await userEvent.click(within(canvasElement).getByRole('button')); -// await step('Verify submit', async () => { -// await expect(args.onSubmit).toHaveBeenCalledWith(expect.stringMatching(/([A-Z])\w+/gi)); -// }); + await step('Verify submit', async () => { + await expect(args.onSubmit).toHaveBeenCalledWith(expect.stringMatching(/([A-Z])\w+/gi)); + }); -// await step('Verify result', async () => { -// await expect([{ name: 'John', age: 42 }]).toEqual( -// expect.arrayContaining([ -// expect.objectContaining({ name: 'John' }), -// expect.objectContaining({ age: 42 }), -// ]) -// ); -// }); -// }); -// }; + await step('Verify result', async () => { + await expect([{ name: 'John', age: 42 }]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'John' }), + expect.objectContaining({ age: 42 }), + ]) + ); + }); + }); +}; From 2a70b8a9e2035bb7e7e67e9f342e9dcf85683957 Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Fri, 12 Aug 2022 22:21:22 +0200 Subject: [PATCH 36/45] Fix step function in StoryStore v6 --- .../preview/virtualModuleEntry.template.js | 4 ++++ code/lib/client-api/src/ClientApi.ts | 22 ++++++++++++++++--- code/lib/client-api/src/index.ts | 2 ++ code/lib/store/src/csf/index.ts | 1 + 4 files changed, 26 insertions(+), 3 deletions(-) diff --git a/code/lib/builder-webpack5/src/preview/virtualModuleEntry.template.js b/code/lib/builder-webpack5/src/preview/virtualModuleEntry.template.js index 1e3f319f27ce..b667a36248a0 100644 --- a/code/lib/builder-webpack5/src/preview/virtualModuleEntry.template.js +++ b/code/lib/builder-webpack5/src/preview/virtualModuleEntry.template.js @@ -5,6 +5,7 @@ import { addLoader, addArgs, addArgTypes, + addStepRunner, addArgsEnhancer, addArgTypesEnhancer, setGlobalRender, @@ -49,6 +50,9 @@ Object.keys(config).forEach((key) => { case 'renderToDOM': { return null; // This key is not handled directly in v6 mode. } + case 'runStep': { + return addStepRunner(value); + } default: { // eslint-disable-next-line prefer-template return console.log(key + ' was not supported :( !'); diff --git a/code/lib/client-api/src/ClientApi.ts b/code/lib/client-api/src/ClientApi.ts index 9f3b0d93a957..58d0090046f9 100644 --- a/code/lib/client-api/src/ClientApi.ts +++ b/code/lib/client-api/src/ClientApi.ts @@ -4,7 +4,7 @@ import deprecate from 'util-deprecate'; import { dedent } from 'ts-dedent'; import global from 'global'; import { logger } from '@storybook/client-logger'; -import { toId, sanitize } from '@storybook/csf'; +import { toId, sanitize, StepRunner } from '@storybook/csf'; import type { Args, ArgTypes, @@ -20,7 +20,12 @@ import type { GlobalTypes, LegacyStoryFn, } from '@storybook/csf'; -import { combineParameters, StoryStore, normalizeInputTypes } from '@storybook/store'; +import { + combineParameters, + composeStepRunners, + StoryStore, + normalizeInputTypes, +} from '@storybook/store'; import type { NormalizedComponentAnnotations, Path, ModuleImportFn } from '@storybook/store'; import type { ClientApiAddons, StoryApi } from '@storybook/addons'; @@ -68,7 +73,7 @@ const checkMethod = (method: string, deprecationWarning: boolean) => { if (global.FEATURES?.storyStoreV7) { throw new Error( dedent`You cannot use \`${method}\` with the new Story Store. - + ${warningAlternatives[method as keyof typeof warningAlternatives]}` ); } @@ -120,6 +125,11 @@ export const addArgTypesEnhancer = (enhancer: ArgTypesEnhancer) => singleton.addArgTypesEnhancer(enhancer); }; +export const addStepRunner = (stepRunner: StepRunner) => { + checkMethod('addStepRunner', false); + singleton.addStepRunner(stepRunner); +}; + export const getGlobalRender = () => { checkMethod('getGlobalRender', false); return singleton.facade.projectAnnotations.render; @@ -215,6 +225,12 @@ export class ClientApi { } }; + addStepRunner = (stepRunner: StepRunner) => { + this.facade.projectAnnotations.runStep = composeStepRunners( + [this.facade.projectAnnotations.runStep, stepRunner].filter(Boolean) + ); + }; + addLoader = (loader: LoaderFunction) => { this.facade.projectAnnotations.loaders.push(loader); }; diff --git a/code/lib/client-api/src/index.ts b/code/lib/client-api/src/index.ts index e1b780cc2d6b..9ac279239c2d 100644 --- a/code/lib/client-api/src/index.ts +++ b/code/lib/client-api/src/index.ts @@ -7,6 +7,7 @@ import { addArgTypes, addArgsEnhancer, addArgTypesEnhancer, + addStepRunner, setGlobalRender, } from './ClientApi'; @@ -24,6 +25,7 @@ export { addArgs, addArgTypes, addParameters, + addStepRunner, setGlobalRender, ClientApi, }; diff --git a/code/lib/store/src/csf/index.ts b/code/lib/store/src/csf/index.ts index 00cdb73c4794..4b3aa8fa8c97 100644 --- a/code/lib/store/src/csf/index.ts +++ b/code/lib/store/src/csf/index.ts @@ -6,4 +6,5 @@ export * from './normalizeComponentAnnotations'; export * from './normalizeProjectAnnotations'; export * from './getValuesFromArgTypes'; export * from './composeConfigs'; +export * from './stepRunners'; export * from './testing-utils'; From 6a47756bfaeb9cb8e1799c2b3139768ec6d0fe03 Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Fri, 12 Aug 2022 22:28:59 +0200 Subject: [PATCH 37/45] Load step runner in Storyshots --- .../storyshots-core/src/frameworks/Loader.ts | 1 + .../storyshots-core/src/frameworks/configure.ts | 14 ++++++++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/code/addons/storyshots/storyshots-core/src/frameworks/Loader.ts b/code/addons/storyshots/storyshots-core/src/frameworks/Loader.ts index 20ce598c423c..101f2e97735f 100644 --- a/code/addons/storyshots/storyshots-core/src/frameworks/Loader.ts +++ b/code/addons/storyshots/storyshots-core/src/frameworks/Loader.ts @@ -15,6 +15,7 @@ export interface ClientApi setAddon: ClientApiClass['setAddon']; addArgsEnhancer: ClientApiClass['addArgsEnhancer']; addArgTypesEnhancer: ClientApiClass['addArgTypesEnhancer']; + addStepRunner: ClientApiClass['addStepRunner']; raw: ClientApiClass['raw']; } diff --git a/code/addons/storyshots/storyshots-core/src/frameworks/configure.ts b/code/addons/storyshots/storyshots-core/src/frameworks/configure.ts index 670ecdfd63e6..ad64cdd91d69 100644 --- a/code/addons/storyshots/storyshots-core/src/frameworks/configure.ts +++ b/code/addons/storyshots/storyshots-core/src/frameworks/configure.ts @@ -113,8 +113,15 @@ function configure( if (preview) { // This is essentially the same code as lib/core/src/server/preview/virtualModuleEntry.template - const { parameters, decorators, globals, globalTypes, argsEnhancers, argTypesEnhancers } = - jest.requireActual(preview); + const { + parameters, + decorators, + globals, + globalTypes, + argsEnhancers, + argTypesEnhancers, + runStep, + } = jest.requireActual(preview); if (decorators) { decorators.forEach((decorator: DecoratorFunction) => @@ -124,6 +131,9 @@ function configure( if (parameters || globals || globalTypes) { storybook.addParameters({ ...parameters, globals, globalTypes }); } + if (runStep) { + storybook.addStepRunner(runStep); + } if (argsEnhancers) { argsEnhancers.forEach((enhancer: ArgsEnhancer) => storybook.addArgsEnhancer(enhancer as any) From d76bbba2311193c7bbf04a58b6cb408756d9ce14 Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Mon, 15 Aug 2022 16:07:19 +0200 Subject: [PATCH 38/45] Fix interactions count --- code/addons/interactions/src/Panel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/code/addons/interactions/src/Panel.tsx b/code/addons/interactions/src/Panel.tsx index bc4ce66aa464..8c56755a00f9 100644 --- a/code/addons/interactions/src/Panel.tsx +++ b/code/addons/interactions/src/Panel.tsx @@ -137,7 +137,7 @@ export const Panel: React.FC<{ active: boolean }> = (props) => { React.useEffect(() => { if (isPlaying || isRerunAnimating) return; - setInteractionsCount(interactions.length); + setInteractionsCount(interactions.filter(({ method }) => method !== 'step').length); }, [interactions, isPlaying, isRerunAnimating]); const controls = React.useMemo( From 60c6653da89b7c6b54a7a8d34a1f8c7b2f8545a2 Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Mon, 15 Aug 2022 16:53:19 +0200 Subject: [PATCH 39/45] Fix collapse to bust memoization --- code/addons/interactions/src/Panel.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/code/addons/interactions/src/Panel.tsx b/code/addons/interactions/src/Panel.tsx index 8c56755a00f9..8c5152fee16e 100644 --- a/code/addons/interactions/src/Panel.tsx +++ b/code/addons/interactions/src/Panel.tsx @@ -87,7 +87,8 @@ export const Panel: React.FC<{ active: boolean }> = (props) => { const [interactions, setInteractions] = React.useState([]); const [interactionsCount, setInteractionsCount] = React.useState(); - // Calls are tracked in a ref so we don't needlessly rerender. + // Log and calls are tracked in a ref so we don't needlessly rerender. + const log = React.useRef([]); const calls = React.useRef>>(new Map()); const setCall = ({ status, ...call }: Call) => calls.current.set(call.id, call); @@ -113,6 +114,7 @@ export const Panel: React.FC<{ active: boolean }> = (props) => { setInteractions( getInteractions({ log: payload.logItems, calls: calls.current, collapsed, setCollapsed }) ); + log.current = payload.logItems; }, [STORY_RENDER_PHASE_CHANGED]: (event) => { setStoryId(event.storyId); @@ -135,6 +137,12 @@ export const Panel: React.FC<{ active: boolean }> = (props) => { [collapsed] ); + React.useEffect(() => { + setInteractions( + getInteractions({ log: log.current, calls: calls.current, collapsed, setCollapsed }) + ); + }, [collapsed]); + React.useEffect(() => { if (isPlaying || isRerunAnimating) return; setInteractionsCount(interactions.filter(({ method }) => method !== 'step').length); From ff5e9e8d40b4e16758fc66ae3fe335361ca6b533 Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Mon, 15 Aug 2022 16:54:45 +0200 Subject: [PATCH 40/45] Nobody cares about the number of items to hide --- code/addons/interactions/src/components/Interaction.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/code/addons/interactions/src/components/Interaction.tsx b/code/addons/interactions/src/components/Interaction.tsx index 8888e092cd85..7ad25af69a32 100644 --- a/code/addons/interactions/src/components/Interaction.tsx +++ b/code/addons/interactions/src/components/Interaction.tsx @@ -165,11 +165,7 @@ export const Interaction = ({ {childCallIds?.length > 0 && ( - } + tooltip={} > From 18dea52f534078d05b4328bc0c73d23fa2695150 Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Tue, 16 Aug 2022 11:44:53 +0200 Subject: [PATCH 41/45] Fix interactions count with collapsed items --- code/addons/interactions/src/Panel.tsx | 10 +++++----- .../addons/interactions/src/components/Interaction.tsx | 4 ++++ .../interactions/src/components/InteractionsPanel.tsx | 2 ++ 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/code/addons/interactions/src/Panel.tsx b/code/addons/interactions/src/Panel.tsx index 8c5152fee16e..ce114dc9b05c 100644 --- a/code/addons/interactions/src/Panel.tsx +++ b/code/addons/interactions/src/Panel.tsx @@ -16,6 +16,7 @@ import { TabIcon, TabStatus } from './components/TabStatus'; interface Interaction extends Call { status: Call['status']; childCallIds: Call['id'][]; + isVisible: boolean; isCollapsed: boolean; toggleCollapsed: () => void; } @@ -43,15 +44,14 @@ export const getInteractions = ({ const callsById = new Map(); const childCallMap = new Map(); return log - .filter(({ callId, ancestors }) => { - let visible = true; + .map(({ callId, ancestors, status }) => { + let isVisible = true; ancestors.forEach((ancestor) => { - if (collapsed.has(ancestor)) visible = false; + if (collapsed.has(ancestor)) isVisible = false; childCallMap.set(ancestor, (childCallMap.get(ancestor) || []).concat(callId)); }); - return visible; + return { ...calls.get(callId), status, isVisible }; }) - .map(({ callId, status }) => ({ ...calls.get(callId), status } as Call)) .map((call) => { const status = call.status === CallStates.ERROR && diff --git a/code/addons/interactions/src/components/Interaction.tsx b/code/addons/interactions/src/components/Interaction.tsx index 7ad25af69a32..72f2f39c77d8 100644 --- a/code/addons/interactions/src/components/Interaction.tsx +++ b/code/addons/interactions/src/components/Interaction.tsx @@ -130,6 +130,7 @@ export const Interaction = ({ controls, controlStates, childCallIds, + isVisible, isCollapsed, toggleCollapsed, pausedAt, @@ -139,6 +140,7 @@ export const Interaction = ({ controls: Controls; controlStates: ControlStates; childCallIds?: Call['id'][]; + isVisible: boolean; isCollapsed: boolean; toggleCollapsed: () => void; pausedAt?: Call['id']; @@ -146,6 +148,8 @@ export const Interaction = ({ const [isHovered, setIsHovered] = React.useState(false); const isInteractive = !controlStates.goto || !call.interceptable || !!call.ancestors.length; + if (!isVisible) return null; + return ( diff --git a/code/addons/interactions/src/components/InteractionsPanel.tsx b/code/addons/interactions/src/components/InteractionsPanel.tsx index dee82be36ef4..f4f0e61f3299 100644 --- a/code/addons/interactions/src/components/InteractionsPanel.tsx +++ b/code/addons/interactions/src/components/InteractionsPanel.tsx @@ -23,6 +23,7 @@ interface InteractionsPanelProps { interactions: (Call & { status?: CallStates; childCallIds: Call['id'][]; + isVisible: boolean; isCollapsed: boolean; toggleCollapsed: () => void; })[]; @@ -118,6 +119,7 @@ export const InteractionsPanel: React.FC = React.memo( controls={controls} controlStates={controlStates} childCallIds={call.childCallIds} + isVisible={call.isVisible} isCollapsed={call.isCollapsed} toggleCollapsed={call.toggleCollapsed} pausedAt={pausedAt} From b3585b9043b585219e66c769fd40e1a87fc6ad56 Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Tue, 16 Aug 2022 11:45:02 +0200 Subject: [PATCH 42/45] Remove log statement --- code/addons/interactions/src/Panel.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/code/addons/interactions/src/Panel.tsx b/code/addons/interactions/src/Panel.tsx index ce114dc9b05c..b93bc0f2d9cb 100644 --- a/code/addons/interactions/src/Panel.tsx +++ b/code/addons/interactions/src/Panel.tsx @@ -129,7 +129,6 @@ export const Panel: React.FC<{ active: boolean }> = (props) => { setErrored(true); }, [PLAY_FUNCTION_THREW_EXCEPTION]: (e) => { - console.log('PLAY_FUNCTION_THREW_EXCEPTION'); if (e?.message !== IGNORED_EXCEPTION.message) setCaughtException(e); else setCaughtException(undefined); }, From cd0fe4e6b1127fa1b9702ec12c4064f44e29e6c3 Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Wed, 17 Aug 2022 12:37:50 +0200 Subject: [PATCH 43/45] Fix debugger behavior by ensuring we sync then jumping to the start --- code/lib/instrumenter/src/instrumenter.ts | 82 ++++++++++++----------- 1 file changed, 44 insertions(+), 38 deletions(-) diff --git a/code/lib/instrumenter/src/instrumenter.ts b/code/lib/instrumenter/src/instrumenter.ts index 6488be15096a..966e7881528e 100644 --- a/code/lib/instrumenter/src/instrumenter.ts +++ b/code/lib/instrumenter/src/instrumenter.ts @@ -110,7 +110,7 @@ export class Instrumenter { // Restore state from the parent window in case the iframe was reloaded. this.state = global.window.parent.__STORYBOOK_ADDON_INTERACTIONS_INSTRUMENTER_STATE__ || {}; - // When called from `start`, isDebugging will be true + // When called from `start`, isDebugging will be true. const resetState = ({ storyId, isPlaying = true, @@ -130,9 +130,7 @@ export class Instrumenter { isPlaying, isDebugging, }); - - // Don't sync while debugging, as it'll cause flicker. - if (!isDebugging) this.sync(storyId); + this.sync(storyId); }; // A forceRemount might be triggered for debugging (on `start`), or elsewhere in Storybook. @@ -171,6 +169,8 @@ export class Instrumenter { const start = ({ storyId, playUntil }: { storyId: string; playUntil?: Call['id'] }) => { if (!this.getState(storyId).isDebugging) { + // Move everything into shadowCalls (a "carbon copy") and mark them as "waiting", so we keep + // a record of the original calls which haven't yet been executed while stepping through. this.setState(storyId, ({ calls }) => ({ calls: [], shadowCalls: calls.map((call) => ({ ...call, status: CallStates.WAITING })), @@ -180,14 +180,13 @@ export class Instrumenter { const log = this.getLog(storyId); this.setState(storyId, ({ shadowCalls }) => { + if (playUntil || !log.length) return { playUntil }; const firstRowIndex = shadowCalls.findIndex((call) => call.id === log[0].callId); return { - playUntil: - playUntil || - shadowCalls - .slice(0, firstRowIndex) - .filter((call) => call.interceptable && !call.ancestors.length) - .slice(-1)[0]?.id, + playUntil: shadowCalls + .slice(0, firstRowIndex) + .filter((call) => call.interceptable && !call.ancestors.length) + .slice(-1)[0]?.id, }; }); @@ -539,10 +538,8 @@ export class Instrumenter { } } - // Sends the call info and log to the manager. - // Uses a 0ms debounce because this might get called many times in one tick. + // Sends the call info to the manager and synchronizes the log. update(call: Call) { - clearTimeout(this.getState(call.storyId).syncTimeout); this.channel.emit(EVENTS.CALL, call); this.setState(call.storyId, ({ calls }) => { // Omit earlier calls for the same ID, which may have been superceded by a later invocation. @@ -555,39 +552,48 @@ export class Instrumenter { calls: Object.values(callsById).sort((a, b) => a.id.localeCompare(b.id, undefined, { numeric: true }) ), - syncTimeout: setTimeout(() => this.sync(call.storyId), 0), }; }); + this.sync(call.storyId); } - sync(storyId: StoryId) { - const { isLocked, isPlaying } = this.getState(storyId); - const logItems: LogItem[] = this.getLog(storyId); - const pausedAt = logItems - .filter(({ ancestors }) => !ancestors.length) - .find((item) => item.status === CallStates.WAITING)?.callId; + // Builds a log of interceptable calls and control states and sends it to the manager. + // Uses a 0ms debounce because this might get called many times in one tick. + sync(storyId: string) { + const synchronize = () => { + const { isLocked, isPlaying } = this.getState(storyId); + const logItems: LogItem[] = this.getLog(storyId); + const pausedAt = logItems + .filter(({ ancestors }) => !ancestors.length) + .find((item) => item.status === CallStates.WAITING)?.callId; + + const hasActive = logItems.some((item) => item.status === CallStates.ACTIVE); + if (debuggerDisabled || isLocked || hasActive || logItems.length === 0) { + const payload: SyncPayload = { controlStates: controlsDisabled, logItems }; + this.channel.emit(EVENTS.SYNC, payload); + return; + } - const hasActive = logItems.some((item) => item.status === CallStates.ACTIVE); - if (debuggerDisabled || isLocked || hasActive || logItems.length === 0) { - const payload: SyncPayload = { controlStates: controlsDisabled, logItems }; - this.channel.emit(EVENTS.SYNC, payload); - return; - } + const hasPrevious = logItems.some((item) => + [CallStates.DONE, CallStates.ERROR].includes(item.status) + ); + const controlStates: ControlStates = { + debugger: true, + start: hasPrevious, + back: hasPrevious, + goto: true, + next: isPlaying, + end: isPlaying, + }; - const hasPrevious = logItems.some((item) => - [CallStates.DONE, CallStates.ERROR].includes(item.status) - ); - const controlStates: ControlStates = { - debugger: true, - start: hasPrevious, - back: hasPrevious, - goto: true, - next: isPlaying, - end: isPlaying, + const payload: SyncPayload = { controlStates, logItems, pausedAt }; + this.channel.emit(EVENTS.SYNC, payload); }; - const payload: SyncPayload = { controlStates, logItems, pausedAt }; - this.channel.emit(EVENTS.SYNC, payload); + this.setState(storyId, ({ syncTimeout }) => { + clearTimeout(syncTimeout); + return { syncTimeout: setTimeout(synchronize, 0) }; + }); } } From 5c0755fdfd68b78bb271e2e6a731a3c77a6c24de Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Wed, 17 Aug 2022 14:23:49 +0200 Subject: [PATCH 44/45] Flip boolean arg to have workable fallback --- code/addons/interactions/src/Panel.tsx | 10 +++++----- .../addons/interactions/src/components/Interaction.tsx | 6 +++--- .../interactions/src/components/InteractionsPanel.tsx | 4 ++-- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/code/addons/interactions/src/Panel.tsx b/code/addons/interactions/src/Panel.tsx index b93bc0f2d9cb..2585c506f4d7 100644 --- a/code/addons/interactions/src/Panel.tsx +++ b/code/addons/interactions/src/Panel.tsx @@ -16,7 +16,7 @@ import { TabIcon, TabStatus } from './components/TabStatus'; interface Interaction extends Call { status: Call['status']; childCallIds: Call['id'][]; - isVisible: boolean; + isHidden: boolean; isCollapsed: boolean; toggleCollapsed: () => void; } @@ -44,13 +44,13 @@ export const getInteractions = ({ const callsById = new Map(); const childCallMap = new Map(); return log - .map(({ callId, ancestors, status }) => { - let isVisible = true; + .map(({ callId, ancestors, status }) => { + let isHidden = false; ancestors.forEach((ancestor) => { - if (collapsed.has(ancestor)) isVisible = false; + if (collapsed.has(ancestor)) isHidden = true; childCallMap.set(ancestor, (childCallMap.get(ancestor) || []).concat(callId)); }); - return { ...calls.get(callId), status, isVisible }; + return { ...calls.get(callId), status, isHidden }; }) .map((call) => { const status = diff --git a/code/addons/interactions/src/components/Interaction.tsx b/code/addons/interactions/src/components/Interaction.tsx index 72f2f39c77d8..a982ad3d6fc3 100644 --- a/code/addons/interactions/src/components/Interaction.tsx +++ b/code/addons/interactions/src/components/Interaction.tsx @@ -130,7 +130,7 @@ export const Interaction = ({ controls, controlStates, childCallIds, - isVisible, + isHidden, isCollapsed, toggleCollapsed, pausedAt, @@ -140,7 +140,7 @@ export const Interaction = ({ controls: Controls; controlStates: ControlStates; childCallIds?: Call['id'][]; - isVisible: boolean; + isHidden: boolean; isCollapsed: boolean; toggleCollapsed: () => void; pausedAt?: Call['id']; @@ -148,7 +148,7 @@ export const Interaction = ({ const [isHovered, setIsHovered] = React.useState(false); const isInteractive = !controlStates.goto || !call.interceptable || !!call.ancestors.length; - if (!isVisible) return null; + if (isHidden) return null; return ( diff --git a/code/addons/interactions/src/components/InteractionsPanel.tsx b/code/addons/interactions/src/components/InteractionsPanel.tsx index f4f0e61f3299..71f8b83d882d 100644 --- a/code/addons/interactions/src/components/InteractionsPanel.tsx +++ b/code/addons/interactions/src/components/InteractionsPanel.tsx @@ -23,7 +23,7 @@ interface InteractionsPanelProps { interactions: (Call & { status?: CallStates; childCallIds: Call['id'][]; - isVisible: boolean; + isHidden: boolean; isCollapsed: boolean; toggleCollapsed: () => void; })[]; @@ -119,7 +119,7 @@ export const InteractionsPanel: React.FC = React.memo( controls={controls} controlStates={controlStates} childCallIds={call.childCallIds} - isVisible={call.isVisible} + isHidden={call.isHidden} isCollapsed={call.isCollapsed} toggleCollapsed={call.toggleCollapsed} pausedAt={pausedAt} From 9bd934ad2d3baa8462ae80ff76ab5979cd113ddf Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Wed, 17 Aug 2022 14:39:36 +0200 Subject: [PATCH 45/45] Update test --- code/addons/interactions/src/Panel.test.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/code/addons/interactions/src/Panel.test.ts b/code/addons/interactions/src/Panel.test.ts index 60e3287d58ad..06a1e7278120 100644 --- a/code/addons/interactions/src/Panel.test.ts +++ b/code/addons/interactions/src/Panel.test.ts @@ -148,6 +148,7 @@ describe('Panel', () => { ...calls.get('story--id [4] findByText'), status: CallStates.DONE, childCallIds: undefined, + isHidden: false, isCollapsed: false, toggleCollapsed: expect.any(Function), }, @@ -155,6 +156,7 @@ describe('Panel', () => { ...calls.get('story--id [5] click'), status: CallStates.DONE, childCallIds: undefined, + isHidden: false, isCollapsed: false, toggleCollapsed: expect.any(Function), }, @@ -162,6 +164,7 @@ describe('Panel', () => { ...calls.get('story--id [6] waitFor'), status: CallStates.DONE, childCallIds: ['story--id [6] waitFor [2] toHaveBeenCalledWith'], + isHidden: false, isCollapsed: false, toggleCollapsed: expect.any(Function), }, @@ -169,13 +172,14 @@ describe('Panel', () => { ...calls.get('story--id [6] waitFor [2] toHaveBeenCalledWith'), status: CallStates.DONE, childCallIds: undefined, + isHidden: false, isCollapsed: false, toggleCollapsed: expect.any(Function), }, ]); }); - it('omits calls for which the parent is collapsed', () => { + it('hides calls for which the parent is collapsed', () => { const withCollapsed = new Set(['story--id [6] waitFor']); expect(getInteractions({ log, calls, collapsed: withCollapsed, setCollapsed })).toEqual([ @@ -183,16 +187,25 @@ describe('Panel', () => { ...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, }), ]); });