From 6ea5bd899fbc0c82699637aeb4eac20a61de9e73 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Thu, 4 Apr 2024 16:12:10 +0200 Subject: [PATCH 01/13] Make mocks reactive, and listen to them in addon actions --- code/addons/actions/src/loaders.ts | 38 +++++++++++----------------- code/lib/test/package.json | 1 + code/lib/test/src/index.ts | 4 ++- code/lib/test/src/spy.ts | 40 ++++++++++++++++++++++++++++-- code/yarn.lock | 1 + 5 files changed, 57 insertions(+), 27 deletions(-) diff --git a/code/addons/actions/src/loaders.ts b/code/addons/actions/src/loaders.ts index 3acfa9795eef..356fb51adecb 100644 --- a/code/addons/actions/src/loaders.ts +++ b/code/addons/actions/src/loaders.ts @@ -1,36 +1,26 @@ /* eslint-disable no-underscore-dangle */ import type { LoaderFunction } from '@storybook/types'; +import { global } from '@storybook/global'; +import type { onMockCalled as onMockCalledType } from '@storybook/test'; import { action } from './runtime'; -export const tinySpyInternalState = Symbol.for('tinyspy:spy'); +let subscribed = false; -const attachActionsToFunctionMocks: LoaderFunction = (context) => { +const logActionsWhenMockCalled: LoaderFunction = (context) => { const { - args, parameters: { actions }, } = context; if (actions?.disable) return; - Object.entries(args) - .filter( - ([, value]) => - typeof value === 'function' && '_isMockFunction' in value && value._isMockFunction - ) - .forEach(([key, value]) => { - // See this discussion for context: - // https://github.com/vitest-dev/vitest/pull/5352 - const previous = - value.getMockImplementation() ?? - (tinySpyInternalState in value ? value[tinySpyInternalState]?.getOriginal() : undefined); - if (previous?._actionAttached !== true && previous?.isAction !== true) { - const implementation = (...params: unknown[]) => { - action(key)(...params); - return previous?.(...params); - }; - implementation._actionAttached = true; - value.mockImplementation(implementation); - } - }); + if ( + !subscribed && + '__STORYBOOK_TEST_ON_MOCK_CALLED__' in global && + typeof global.__STORYBOOK_TEST_ON_MOCK_CALLED__ === 'function' + ) { + const onMockCalled = global.__STORYBOOK_TEST_ON_MOCK_CALLED__ as typeof onMockCalledType; + onMockCalled((mock, args) => action(mock.getMockName())(args)); + subscribed = true; + } }; -export const loaders: LoaderFunction[] = [attachActionsToFunctionMocks]; +export const loaders: LoaderFunction[] = [logActionsWhenMockCalled]; diff --git a/code/lib/test/package.json b/code/lib/test/package.json index 81c72b9980d5..73554c492350 100644 --- a/code/lib/test/package.json +++ b/code/lib/test/package.json @@ -56,6 +56,7 @@ "util": "^0.12.4" }, "devDependencies": { + "tinyspy": "^2.2.0", "ts-dedent": "^2.2.0", "type-fest": "~2.19", "typescript": "^5.3.2" diff --git a/code/lib/test/src/index.ts b/code/lib/test/src/index.ts index 7bd72666f341..7ef1f5f312dd 100644 --- a/code/lib/test/src/index.ts +++ b/code/lib/test/src/index.ts @@ -3,7 +3,7 @@ import { type LoaderFunction } from '@storybook/csf'; import chai from 'chai'; import { global } from '@storybook/global'; import { expect as rawExpect } from './expect'; -import { clearAllMocks, resetAllMocks, restoreAllMocks } from './spy'; +import { clearAllMocks, onMockCalled, resetAllMocks, restoreAllMocks } from './spy'; export * from './spy'; @@ -39,3 +39,5 @@ const resetAllMocksLoader: LoaderFunction = ({ parameters }) => { // We are using this as a default Storybook loader, when the test package is used. This avoids the need for optional peer dependency workarounds. // eslint-disable-next-line no-underscore-dangle (global as any).__STORYBOOK_TEST_LOADERS__ = [resetAllMocksLoader]; +// eslint-disable-next-line no-underscore-dangle +(global as any).__STORYBOOK_TEST_ON_MOCK_CALLED__ = onMockCalled; diff --git a/code/lib/test/src/spy.ts b/code/lib/test/src/spy.ts index 3208df77ae76..124a3477a29e 100644 --- a/code/lib/test/src/spy.ts +++ b/code/lib/test/src/spy.ts @@ -1,17 +1,53 @@ +/* eslint-disable @typescript-eslint/no-shadow */ +import type { Mock } from '@vitest/spy'; import { spyOn, isMockFunction, - fn, + fn as vitestFn, mocks, type MaybeMocked, type MaybeMockedDeep, type MaybePartiallyMocked, type MaybePartiallyMockedDeep, } from '@vitest/spy'; +import type { SpyInternalImpl } from 'tinyspy'; +import * as tinyspy from 'tinyspy'; export type * from '@vitest/spy'; -export { spyOn, isMockFunction, fn, mocks }; +export { spyOn, isMockFunction, mocks }; + +type Listener = (mock: Mock, args: unknown[]) => void; +let listeners: Listener[] = []; + +export function onMockCalled(callback: Listener): () => void { + listeners = [...listeners, callback]; + return () => { + listeners = listeners.filter((listener) => listener !== callback); + }; +} + +export function fn(): Mock; +export function fn( + implementation: (...args: TArgs) => R +): Mock; +export function fn(implementation?: (...args: TArgs) => R) { + const mock = implementation ? vitestFn(implementation) : vitestFn(); + const reactive = reactiveMock(mock); + const originalMockImplementation = reactive.mockImplementation.bind(null); + reactive.mockImplementation = (fn) => reactiveMock(originalMockImplementation(fn)); + return reactive; +} + +function reactiveMock(mock: Mock) { + const state = tinyspy.getInternalState(mock as unknown as SpyInternalImpl); + const impl = state.impl?.bind(null); + state.willCall((...args) => { + listeners.forEach((listener) => listener(mock, args)); + impl?.(...args); + }); + return mock; +} /** * Calls [`.mockClear()`](https://vitest.dev/api/mock#mockclear) on every mocked function. This will only empty `.mock` state, it will not reset implementation. diff --git a/code/yarn.lock b/code/yarn.lock index 472dd503b241..e2aa602d6f4c 100644 --- a/code/yarn.lock +++ b/code/yarn.lock @@ -6667,6 +6667,7 @@ __metadata: "@vitest/expect": "npm:1.3.1" "@vitest/spy": "npm:^1.3.1" chai: "npm:^4.4.1" + tinyspy: "npm:^2.2.0" ts-dedent: "npm:^2.2.0" type-fest: "npm:~2.19" typescript: "npm:^5.3.2" From d2ad4a011cb8d9230388c40ae1d6ef9d975ecd5a Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Thu, 4 Apr 2024 16:34:04 +0200 Subject: [PATCH 02/13] Add test --- code/lib/test/src/spy.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 code/lib/test/src/spy.test.ts diff --git a/code/lib/test/src/spy.test.ts b/code/lib/test/src/spy.test.ts new file mode 100644 index 000000000000..54c287dcaf01 --- /dev/null +++ b/code/lib/test/src/spy.test.ts @@ -0,0 +1,15 @@ +import { it, vi, expect, beforeEach } from 'vitest'; +import { fn, onMockCalled } from './spy'; + +const vitestSpy = vi.fn(); + +beforeEach(() => { + const unsubscribe = onMockCalled(vitestSpy); + return () => unsubscribe(); +}); + +it('mocks are reactive', () => { + const storybookSpy = fn(); + storybookSpy(1); + expect(vitestSpy).toHaveBeenCalledWith(storybookSpy, [1]); +}); From 7b5f532662677021c283dcbe7de74f135ceee866 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Thu, 4 Apr 2024 16:37:51 +0200 Subject: [PATCH 03/13] Fix bug --- code/lib/test/src/spy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/code/lib/test/src/spy.ts b/code/lib/test/src/spy.ts index 124a3477a29e..51742262b1e4 100644 --- a/code/lib/test/src/spy.ts +++ b/code/lib/test/src/spy.ts @@ -44,7 +44,7 @@ function reactiveMock(mock: Mock) { const impl = state.impl?.bind(null); state.willCall((...args) => { listeners.forEach((listener) => listener(mock, args)); - impl?.(...args); + return impl?.(...args); }); return mock; } From afdfd038b2c0148c7bdc340ec79e45e9c6ce462d Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Thu, 4 Apr 2024 16:41:07 +0200 Subject: [PATCH 04/13] Change name --- code/addons/actions/src/loaders.ts | 10 +++++----- code/lib/test/src/index.ts | 4 ++-- code/lib/test/src/spy.test.ts | 4 ++-- code/lib/test/src/spy.ts | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/code/addons/actions/src/loaders.ts b/code/addons/actions/src/loaders.ts index 356fb51adecb..d49a048da231 100644 --- a/code/addons/actions/src/loaders.ts +++ b/code/addons/actions/src/loaders.ts @@ -1,7 +1,7 @@ /* eslint-disable no-underscore-dangle */ import type { LoaderFunction } from '@storybook/types'; import { global } from '@storybook/global'; -import type { onMockCalled as onMockCalledType } from '@storybook/test'; +import type { onMockCall as onMockCallType } from '@storybook/test'; import { action } from './runtime'; let subscribed = false; @@ -14,11 +14,11 @@ const logActionsWhenMockCalled: LoaderFunction = (context) => { if ( !subscribed && - '__STORYBOOK_TEST_ON_MOCK_CALLED__' in global && - typeof global.__STORYBOOK_TEST_ON_MOCK_CALLED__ === 'function' + '__STORYBOOK_TEST_ON_MOCK_CALL__' in global && + typeof global.__STORYBOOK_TEST_ON_MOCK_CALL__ === 'function' ) { - const onMockCalled = global.__STORYBOOK_TEST_ON_MOCK_CALLED__ as typeof onMockCalledType; - onMockCalled((mock, args) => action(mock.getMockName())(args)); + const onMockCall = global.__STORYBOOK_TEST_ON_MOCK_CALL__ as typeof onMockCallType; + onMockCall((mock, args) => action(mock.getMockName())(args)); subscribed = true; } }; diff --git a/code/lib/test/src/index.ts b/code/lib/test/src/index.ts index 7ef1f5f312dd..3ddf1fcd5687 100644 --- a/code/lib/test/src/index.ts +++ b/code/lib/test/src/index.ts @@ -3,7 +3,7 @@ import { type LoaderFunction } from '@storybook/csf'; import chai from 'chai'; import { global } from '@storybook/global'; import { expect as rawExpect } from './expect'; -import { clearAllMocks, onMockCalled, resetAllMocks, restoreAllMocks } from './spy'; +import { clearAllMocks, onMockCall, resetAllMocks, restoreAllMocks } from './spy'; export * from './spy'; @@ -40,4 +40,4 @@ const resetAllMocksLoader: LoaderFunction = ({ parameters }) => { // eslint-disable-next-line no-underscore-dangle (global as any).__STORYBOOK_TEST_LOADERS__ = [resetAllMocksLoader]; // eslint-disable-next-line no-underscore-dangle -(global as any).__STORYBOOK_TEST_ON_MOCK_CALLED__ = onMockCalled; +(global as any).__STORYBOOK_TEST_ON_MOCK_CALL__ = onMockCall; diff --git a/code/lib/test/src/spy.test.ts b/code/lib/test/src/spy.test.ts index 54c287dcaf01..5c3dcf13d924 100644 --- a/code/lib/test/src/spy.test.ts +++ b/code/lib/test/src/spy.test.ts @@ -1,10 +1,10 @@ import { it, vi, expect, beforeEach } from 'vitest'; -import { fn, onMockCalled } from './spy'; +import { fn, onMockCall } from './spy'; const vitestSpy = vi.fn(); beforeEach(() => { - const unsubscribe = onMockCalled(vitestSpy); + const unsubscribe = onMockCall(vitestSpy); return () => unsubscribe(); }); diff --git a/code/lib/test/src/spy.ts b/code/lib/test/src/spy.ts index 51742262b1e4..ce696dc832ff 100644 --- a/code/lib/test/src/spy.ts +++ b/code/lib/test/src/spy.ts @@ -20,7 +20,7 @@ export { spyOn, isMockFunction, mocks }; type Listener = (mock: Mock, args: unknown[]) => void; let listeners: Listener[] = []; -export function onMockCalled(callback: Listener): () => void { +export function onMockCall(callback: Listener): () => void { listeners = [...listeners, callback]; return () => { listeners = listeners.filter((listener) => listener !== callback); From 0dc9e5813c308f979787e7aeccd6439be417657e Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Thu, 4 Apr 2024 17:04:24 +0200 Subject: [PATCH 05/13] Also make spyOn reactive --- code/lib/test/src/spy.ts | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/code/lib/test/src/spy.ts b/code/lib/test/src/spy.ts index ce696dc832ff..a8fa2b935b1c 100644 --- a/code/lib/test/src/spy.ts +++ b/code/lib/test/src/spy.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-shadow */ -import type { Mock } from '@vitest/spy'; +import type { Mock, MockInstance } from '@vitest/spy'; import { - spyOn, + spyOn as vitestSpyOn, isMockFunction, fn as vitestFn, mocks, @@ -15,9 +15,9 @@ import * as tinyspy from 'tinyspy'; export type * from '@vitest/spy'; -export { spyOn, isMockFunction, mocks }; +export { isMockFunction, mocks }; -type Listener = (mock: Mock, args: unknown[]) => void; +type Listener = (mock: MockInstance, args: unknown[]) => void; let listeners: Listener[] = []; export function onMockCall(callback: Listener): () => void { @@ -27,19 +27,29 @@ export function onMockCall(callback: Listener): () => void { }; } +// @ts-expect-error TS is hard you know +export const spyOn: typeof vitestSpyOn = (...args) => { + const mock = vitestSpyOn(...(args as Parameters)); + return reactiveMock(mock); +}; + export function fn(): Mock; export function fn( implementation: (...args: TArgs) => R ): Mock; export function fn(implementation?: (...args: TArgs) => R) { const mock = implementation ? vitestFn(implementation) : vitestFn(); - const reactive = reactiveMock(mock); + return reactiveMock(mock); +} + +function reactiveMock(mock: MockInstance) { + const reactive = listenWhenCalled(mock); const originalMockImplementation = reactive.mockImplementation.bind(null); - reactive.mockImplementation = (fn) => reactiveMock(originalMockImplementation(fn)); + reactive.mockImplementation = (fn) => listenWhenCalled(originalMockImplementation(fn)); return reactive; } -function reactiveMock(mock: Mock) { +function listenWhenCalled(mock: MockInstance) { const state = tinyspy.getInternalState(mock as unknown as SpyInternalImpl); const impl = state.impl?.bind(null); state.willCall((...args) => { From 726a80a34a06700a64a8eafea135c3711dc14f4e Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Tue, 9 Apr 2024 17:37:49 +0200 Subject: [PATCH 06/13] Make sure test packages attaches name to arg spies --- code/addons/interactions/src/preview.ts | 53 +---------------------- code/lib/test/src/index.ts | 56 ++++++++++++++++++++++++- 2 files changed, 55 insertions(+), 54 deletions(-) diff --git a/code/addons/interactions/src/preview.ts b/code/addons/interactions/src/preview.ts index 6e70166629aa..5ef2520b6b71 100644 --- a/code/addons/interactions/src/preview.ts +++ b/code/addons/interactions/src/preview.ts @@ -1,11 +1,4 @@ -import type { - ArgsEnhancer, - PlayFunction, - PlayFunctionContext, - Renderer, - StepLabel, -} from '@storybook/types'; -import { fn, isMockFunction } from '@storybook/test'; +import type { PlayFunction, PlayFunctionContext, StepLabel } from '@storybook/types'; import { instrument } from '@storybook/instrumenter'; export const { step: runStep } = instrument( @@ -16,50 +9,6 @@ export const { step: runStep } = instrument( { intercept: true } ); -export const traverseArgs = (value: unknown, depth = 0, key?: string): unknown => { - // Make sure to not get in infinite loops with self referencing args - if (depth > 5) return value; - if (value == null) return value; - if (isMockFunction(value)) { - // Makes sure we get the arg name in the interactions panel - if (key) value.mockName(key); - return value; - } - - // wrap explicit actions in a spy - if ( - typeof value === 'function' && - 'isAction' in value && - value.isAction && - !('implicit' in value && value.implicit) - ) { - const mock = fn(value as any); - if (key) mock.mockName(key); - return mock; - } - - if (Array.isArray(value)) { - depth++; - return value.map((item) => traverseArgs(item, depth)); - } - - if (typeof value === 'object' && value.constructor === Object) { - depth++; - for (const [k, v] of Object.entries(value)) { - if (Object.getOwnPropertyDescriptor(value, k).writable) { - // We have to mutate the original object for this to survive HMR. - (value as Record)[k] = traverseArgs(v, depth, k); - } - } - return value; - } - return value; -}; - -const wrapActionsInSpyFns: ArgsEnhancer = ({ initialArgs }) => traverseArgs(initialArgs); - -export const argsEnhancers = [wrapActionsInSpyFns]; - export const parameters = { throwPlayFunctionExceptions: false, }; diff --git a/code/lib/test/src/index.ts b/code/lib/test/src/index.ts index 3ddf1fcd5687..1dcd78c457b8 100644 --- a/code/lib/test/src/index.ts +++ b/code/lib/test/src/index.ts @@ -3,7 +3,15 @@ import { type LoaderFunction } from '@storybook/csf'; import chai from 'chai'; import { global } from '@storybook/global'; import { expect as rawExpect } from './expect'; -import { clearAllMocks, onMockCall, resetAllMocks, restoreAllMocks } from './spy'; +import { + clearAllMocks, + fn, + isMockFunction, + onMockCall, + resetAllMocks, + restoreAllMocks, +} from './spy'; +import type { Renderer } from '@storybook/types'; export * from './spy'; @@ -36,8 +44,52 @@ const resetAllMocksLoader: LoaderFunction = ({ parameters }) => { } }; +export const traverseArgs = (value: unknown, depth = 0, key?: string): unknown => { + // Make sure to not get in infinite loops with self referencing args + if (depth > 5) return value; + if (value == null) return value; + if (isMockFunction(value)) { + // Makes sure we get the arg name in the interactions panel + if (key) value.mockName(key); + return value; + } + + // wrap explicit actions in a spy + if ( + typeof value === 'function' && + 'isAction' in value && + value.isAction && + !('implicit' in value && value.implicit) + ) { + const mock = fn(value as any); + if (key) mock.mockName(key); + return mock; + } + + if (Array.isArray(value)) { + depth++; + return value.map((item) => traverseArgs(item, depth)); + } + + if (typeof value === 'object' && value.constructor === Object) { + depth++; + for (const [k, v] of Object.entries(value)) { + if (Object.getOwnPropertyDescriptor(value, k)?.writable) { + // We have to mutate the original object for this to survive HMR. + (value as Record)[k] = traverseArgs(v, depth, k); + } + } + return value; + } + return value; +}; + +const nameSpiesAndWrapActionsInSpies: LoaderFunction = ({ initialArgs }) => { + traverseArgs(initialArgs); +}; + // We are using this as a default Storybook loader, when the test package is used. This avoids the need for optional peer dependency workarounds. // eslint-disable-next-line no-underscore-dangle -(global as any).__STORYBOOK_TEST_LOADERS__ = [resetAllMocksLoader]; +(global as any).__STORYBOOK_TEST_LOADERS__ = [resetAllMocksLoader, nameSpiesAndWrapActionsInSpies]; // eslint-disable-next-line no-underscore-dangle (global as any).__STORYBOOK_TEST_ON_MOCK_CALL__ = onMockCall; From 412b52583812369b9ec04f16087510cd04a6e9f9 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Tue, 9 Apr 2024 20:13:10 +0200 Subject: [PATCH 07/13] Move test --- code/addons/interactions/src/preview.test.ts | 42 -------------------- code/lib/test/src/index.test.ts | 42 +++++++++++++++++++- 2 files changed, 40 insertions(+), 44 deletions(-) delete mode 100644 code/addons/interactions/src/preview.test.ts diff --git a/code/addons/interactions/src/preview.test.ts b/code/addons/interactions/src/preview.test.ts deleted file mode 100644 index 5cfec9d19d03..000000000000 --- a/code/addons/interactions/src/preview.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, expect, test } from 'vitest'; -import { fn, isMockFunction } from '@storybook/test'; -import { action } from '@storybook/addon-actions'; - -import { traverseArgs } from './preview'; - -describe('traverseArgs', () => { - const args = { - deep: { - deeper: { - fnKey: fn(), - actionKey: action('name'), - }, - }, - arg2: Object.freeze({ frozen: true }), - }; - - expect(args.deep.deeper.fnKey.getMockName()).toEqual('spy'); - - const traversed = traverseArgs(args) as typeof args; - - test('The same structure is maintained', () => - expect(traversed).toEqual({ - deep: { - deeper: { - fnKey: args.deep.deeper.fnKey, - actionKey: args.deep.deeper.actionKey, - }, - }, - // We don't mutate frozen objects, but we do insert them back in the tree - arg2: args.arg2, - })); - - test('The mock name is mutated to be the arg key', () => - expect(traversed.deep.deeper.fnKey.getMockName()).toEqual('fnKey')); - - const actionFn = traversed.deep.deeper.actionKey; - - test('Actions are wrapped in a spy', () => expect(isMockFunction(actionFn)).toBeTruthy()); - test('The spy of the action is also matching the arg key ', () => - expect(isMockFunction(actionFn) && actionFn.getMockName()).toEqual('actionKey')); -}); diff --git a/code/lib/test/src/index.test.ts b/code/lib/test/src/index.test.ts index bed58592c1d6..70f1df02b0f1 100644 --- a/code/lib/test/src/index.test.ts +++ b/code/lib/test/src/index.test.ts @@ -1,8 +1,46 @@ -import { it } from 'vitest'; -import { expect, fn } from '@storybook/test'; +import { describe, it, test } from 'vitest'; +import { expect, fn, isMockFunction, traverseArgs } from '@storybook/test'; +import { action } from '@storybook/addon-actions/src'; it('storybook expect and fn can be used in vitest test', () => { const spy = fn(); spy(1); expect(spy).toHaveBeenCalledWith(1); }); + +describe('traverseArgs', () => { + const args = { + deep: { + deeper: { + fnKey: fn(), + actionKey: action('name'), + }, + }, + arg2: Object.freeze({ frozen: true }), + }; + + expect(args.deep.deeper.fnKey.getMockName()).toEqual('spy'); + + const traversed = traverseArgs(args) as typeof args; + + test('The same structure is maintained', () => + expect(traversed).toEqual({ + deep: { + deeper: { + fnKey: args.deep.deeper.fnKey, + actionKey: args.deep.deeper.actionKey, + }, + }, + // We don't mutate frozen objects, but we do insert them back in the tree + arg2: args.arg2, + })); + + test('The mock name is mutated to be the arg key', () => + expect(traversed.deep.deeper.fnKey.getMockName()).toEqual('fnKey')); + + const actionFn = traversed.deep.deeper.actionKey; + + test('Actions are wrapped in a spy', () => expect(isMockFunction(actionFn)).toBeTruthy()); + test('The spy of the action is also matching the arg key ', () => + expect(isMockFunction(actionFn) && actionFn.getMockName()).toEqual('actionKey')); +}); From f3d14529eda905eaf3c213dc99af5736b5398969 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Tue, 9 Apr 2024 20:22:49 +0200 Subject: [PATCH 08/13] Resolve review --- code/lib/test/src/spy.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/code/lib/test/src/spy.ts b/code/lib/test/src/spy.ts index a8fa2b935b1c..81f5c71eda71 100644 --- a/code/lib/test/src/spy.ts +++ b/code/lib/test/src/spy.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-shadow */ -import type { Mock, MockInstance } from '@vitest/spy'; +import type { MockInstance } from '@vitest/spy'; import { spyOn as vitestSpyOn, isMockFunction, @@ -27,20 +27,17 @@ export function onMockCall(callback: Listener): () => void { }; } -// @ts-expect-error TS is hard you know +// @ts-expect-error Make sure we export the exact same type as @vitest/spy export const spyOn: typeof vitestSpyOn = (...args) => { const mock = vitestSpyOn(...(args as Parameters)); return reactiveMock(mock); }; -export function fn(): Mock; -export function fn( - implementation: (...args: TArgs) => R -): Mock; -export function fn(implementation?: (...args: TArgs) => R) { +// @ts-expect-error Make sure we export the exact same type as @vitest/spy +export const fn: typeof vitestFn = (implementation) => { const mock = implementation ? vitestFn(implementation) : vitestFn(); return reactiveMock(mock); -} +}; function reactiveMock(mock: MockInstance) { const reactive = listenWhenCalled(mock); From a78474717c4fb52edbc8c3e8e6b84c1e5218eca9 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Tue, 9 Apr 2024 20:40:43 +0200 Subject: [PATCH 09/13] Fix test --- .../actions/template/stories/spies.stories.ts | 25 +++++++++++++++++++ code/e2e-tests/addon-actions.spec.ts | 22 ++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 code/addons/actions/template/stories/spies.stories.ts diff --git a/code/addons/actions/template/stories/spies.stories.ts b/code/addons/actions/template/stories/spies.stories.ts new file mode 100644 index 000000000000..dd1147305793 --- /dev/null +++ b/code/addons/actions/template/stories/spies.stories.ts @@ -0,0 +1,25 @@ +import { global as globalThis } from '@storybook/global'; +import { withActions } from '@storybook/addon-actions/decorator'; +import { spyOn } from '@storybook/test'; + +export default { + component: globalThis.Components.Button, + loaders() { + spyOn(console, 'log').mockName('console.log'); + }, + args: { + label: 'Button', + }, + parameters: { + chromatic: { disable: true }, + }, +}; + +export const ShowSpyOnInActions = { + args: { + onClick: () => { + console.log('first'); + console.log('second'); + }, + }, +}; diff --git a/code/e2e-tests/addon-actions.spec.ts b/code/e2e-tests/addon-actions.spec.ts index 3b93599e81ad..a67aa8599ce9 100644 --- a/code/e2e-tests/addon-actions.spec.ts +++ b/code/e2e-tests/addon-actions.spec.ts @@ -26,4 +26,26 @@ test.describe('addon-actions', () => { }); await expect(logItem).toBeVisible(); }); + + test('should show spies', async ({ page }) => { + test.skip( + templateName.includes('svelte') && templateName.includes('prerelease'), + 'Svelte 5 prerelase does not support automatic actions with our current example components yet' + ); + await page.goto(storybookUrl); + const sbPage = new SbPage(page); + sbPage.waitUntilLoaded(); + + await sbPage.navigateToStory('addons/actions/spies', 'show-spy-on-in-actions'); + + const root = sbPage.previewRoot(); + const button = root.locator('button', { hasText: 'Button' }); + await button.click(); + + await sbPage.viewAddonPanel('Actions'); + const logItem = await page.locator('#storybook-panel-root #panel-tab-content', { + hasText: 'console.log', + }); + await expect(logItem).toBeVisible(); + }); }); From bf1fe1062530544283257b6dc20ff8b32f51953a Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Wed, 10 Apr 2024 07:23:25 +0200 Subject: [PATCH 10/13] Fix test --- code/addons/actions/template/stories/spies.stories.ts | 1 - code/lib/test/src/index.test.ts | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/code/addons/actions/template/stories/spies.stories.ts b/code/addons/actions/template/stories/spies.stories.ts index dd1147305793..824494bda1c9 100644 --- a/code/addons/actions/template/stories/spies.stories.ts +++ b/code/addons/actions/template/stories/spies.stories.ts @@ -1,5 +1,4 @@ import { global as globalThis } from '@storybook/global'; -import { withActions } from '@storybook/addon-actions/decorator'; import { spyOn } from '@storybook/test'; export default { diff --git a/code/lib/test/src/index.test.ts b/code/lib/test/src/index.test.ts index 70f1df02b0f1..87f5b2206418 100644 --- a/code/lib/test/src/index.test.ts +++ b/code/lib/test/src/index.test.ts @@ -1,6 +1,6 @@ import { describe, it, test } from 'vitest'; import { expect, fn, isMockFunction, traverseArgs } from '@storybook/test'; -import { action } from '@storybook/addon-actions/src'; +import { action } from '@storybook/addon-actions'; it('storybook expect and fn can be used in vitest test', () => { const spy = fn(); From e98dfb857aa6eb246dc534d06f139304cd835666 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Wed, 10 Apr 2024 07:29:18 +0200 Subject: [PATCH 11/13] Fix test --- .../react/src/__test__/portable-stories.test.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/code/renderers/react/src/__test__/portable-stories.test.tsx b/code/renderers/react/src/__test__/portable-stories.test.tsx index 6c3c38055065..e204eeb0a653 100644 --- a/code/renderers/react/src/__test__/portable-stories.test.tsx +++ b/code/renderers/react/src/__test__/portable-stories.test.tsx @@ -4,8 +4,6 @@ import { vi, it, expect, afterEach, describe } from 'vitest'; import { render, screen, cleanup } from '@testing-library/react'; import { addons } from '@storybook/preview-api'; -import * as addonInteractionsPreview from '@storybook/addon-interactions/preview'; - import * as addonActionsPreview from '@storybook/addon-actions/preview'; import type { Meta } from '@storybook/react'; import { expectTypeOf } from 'expect-type'; @@ -90,9 +88,9 @@ describe('projectAnnotations', () => { expect(buttonElement).not.toBeNull(); }); - it('has spies when addon-interactions annotations are added', async () => { - //@ts-expect-error TODO investigate - const Story = composeStory(stories.WithActionArg, stories.default, addonInteractionsPreview); + it('has spies when the test loader is loaded', async () => { + const Story = composeStory(stories.WithActionArg, stories.default); + await Story.load(); expect(vi.mocked(Story.args.someActionArg!).mock).toBeDefined(); const { container } = render(); From 176dbee017f60d87196671e1f44265cbb1e64868 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Wed, 10 Apr 2024 07:32:36 +0200 Subject: [PATCH 12/13] Fix test --- code/renderers/react/src/__test__/portable-stories.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/code/renderers/react/src/__test__/portable-stories.test.tsx b/code/renderers/react/src/__test__/portable-stories.test.tsx index e204eeb0a653..aab2585743d5 100644 --- a/code/renderers/react/src/__test__/portable-stories.test.tsx +++ b/code/renderers/react/src/__test__/portable-stories.test.tsx @@ -88,7 +88,7 @@ describe('projectAnnotations', () => { expect(buttonElement).not.toBeNull(); }); - it('has spies when the test loader is loaded', async () => { + it('explicit action are spies when the test loader is loaded', async () => { const Story = composeStory(stories.WithActionArg, stories.default); await Story.load(); expect(vi.mocked(Story.args.someActionArg!).mock).toBeDefined(); From f9cb4199ee2a7b01349c95ba74d463a14df57002 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Wed, 10 Apr 2024 10:26:37 +0200 Subject: [PATCH 13/13] Make listeners a set --- code/lib/test/src/spy.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/code/lib/test/src/spy.ts b/code/lib/test/src/spy.ts index 81f5c71eda71..6a97a19c390e 100644 --- a/code/lib/test/src/spy.ts +++ b/code/lib/test/src/spy.ts @@ -18,13 +18,11 @@ export type * from '@vitest/spy'; export { isMockFunction, mocks }; type Listener = (mock: MockInstance, args: unknown[]) => void; -let listeners: Listener[] = []; +const listeners = new Set(); export function onMockCall(callback: Listener): () => void { - listeners = [...listeners, callback]; - return () => { - listeners = listeners.filter((listener) => listener !== callback); - }; + listeners.add(callback); + return () => void listeners.delete(callback); } // @ts-expect-error Make sure we export the exact same type as @vitest/spy