diff --git a/.gitignore b/.gitignore index 63f9445af854..0d0e9ac8d8ce 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ test-results !/**/.yarn/plugins !/**/.yarn/sdks !/**/.yarn/versions +!/**/.yarn/patches /**/.pnp.* !/node_modules diff --git a/code/.yarn/patches/@vitest-expect-npm-0.34.5-8031508efe.patch b/code/.yarn/patches/@vitest-expect-npm-0.34.5-8031508efe.patch new file mode 100644 index 000000000000..175c8fbcc343 --- /dev/null +++ b/code/.yarn/patches/@vitest-expect-npm-0.34.5-8031508efe.patch @@ -0,0 +1,37 @@ +diff --git a/dist/index.js b/dist/index.js +index 5a61947ad50426d27390b4e82533179323ad3ba1..32bfc45909b645cb31cec2e204c8baa23f21fdd2 100644 +--- a/dist/index.js ++++ b/dist/index.js +@@ -6,23 +6,29 @@ import { processError } from '@vitest/utils/error'; + import { util } from 'chai'; + + const MATCHERS_OBJECT = Symbol.for("matchers-object"); +-const JEST_MATCHERS_OBJECT = Symbol.for("$$jest-matchers-object"); ++// Patched this symbol for storybook, so that @storybook/test can be used in a jest environment as well. ++// Otherwise, vitest will override global jest matchers, and crash. ++const JEST_MATCHERS_OBJECT = Symbol.for("$$jest-matchers-object-storybook"); + const GLOBAL_EXPECT = Symbol.for("expect-global"); + + if (!Object.prototype.hasOwnProperty.call(globalThis, MATCHERS_OBJECT)) { + const globalState = /* @__PURE__ */ new WeakMap(); +- const matchers = /* @__PURE__ */ Object.create(null); + Object.defineProperty(globalThis, MATCHERS_OBJECT, { + get: () => globalState + }); ++} ++ ++if (!Object.prototype.hasOwnProperty.call(globalThis, JEST_MATCHERS_OBJECT)) { ++ const matchers = /* @__PURE__ */ Object.create(null); + Object.defineProperty(globalThis, JEST_MATCHERS_OBJECT, { + configurable: true, + get: () => ({ +- state: globalState.get(globalThis[GLOBAL_EXPECT]), ++ state: globalThis[MATCHERS_OBJECT].get(globalThis[GLOBAL_EXPECT]), + matchers + }) + }); + } ++ + function getState(expect) { + return globalThis[MATCHERS_OBJECT].get(expect); + } diff --git a/code/addons/actions/src/addArgs.ts b/code/addons/actions/src/addArgs.ts index 5742bd8627c0..db14aee0ce3d 100644 --- a/code/addons/actions/src/addArgs.ts +++ b/code/addons/actions/src/addArgs.ts @@ -1,7 +1,12 @@ import type { ArgsEnhancer } from '@storybook/types'; -import { addActionsFromArgTypes, inferActionsFromArgTypesRegex } from './addArgsHelpers'; +import { + addActionsFromArgTypes, + attachActionsToFunctionMocks, + inferActionsFromArgTypesRegex, +} from './addArgsHelpers'; export const argsEnhancers: ArgsEnhancer[] = [ addActionsFromArgTypes, inferActionsFromArgTypesRegex, + attachActionsToFunctionMocks, ]; diff --git a/code/addons/actions/src/addArgsHelpers.ts b/code/addons/actions/src/addArgsHelpers.ts index 7f56922d3962..0dcb56c32f55 100644 --- a/code/addons/actions/src/addArgsHelpers.ts +++ b/code/addons/actions/src/addArgsHelpers.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-underscore-dangle,no-param-reassign */ import type { Args, Renderer, ArgsEnhancer } from '@storybook/types'; import { action } from './runtime/action'; @@ -31,7 +32,7 @@ export const inferActionsFromArgTypesRegex: ArgsEnhancer = (context) = return argTypesMatchingRegex.reduce((acc, [name, argType]) => { if (isInInitialArgs(name, initialArgs)) { - acc[name] = action(name); + acc[name] = action(name, { implicit: true }); } return acc; }, {} as Args); @@ -61,3 +62,33 @@ export const addActionsFromArgTypes: ArgsEnhancer = (context) => { return acc; }, {} as Args); }; + +export const attachActionsToFunctionMocks: ArgsEnhancer = (context) => { + const { + initialArgs, + argTypes, + parameters: { actions }, + } = context; + if (actions?.disable || !argTypes) { + return {}; + } + + const argTypesWithAction = Object.entries(initialArgs).filter( + ([, value]) => + typeof value === 'function' && + '_isMockFunction' in value && + value._isMockFunction && + !value._actionAttached + ); + + return argTypesWithAction.reduce((acc, [key, value]) => { + const previous = value.getMockImplementation(); + value.mockImplementation((...args: unknown[]) => { + action(key)(...args); + return previous?.(...args); + }); + // this enhancer is being called multiple times + value._actionAttached = true; + return acc; + }, {} as Args); +}; diff --git a/code/addons/actions/src/models/ActionOptions.ts b/code/addons/actions/src/models/ActionOptions.ts index 6678e5138929..b503df069d5c 100644 --- a/code/addons/actions/src/models/ActionOptions.ts +++ b/code/addons/actions/src/models/ActionOptions.ts @@ -4,6 +4,7 @@ interface Options { depth: number; // backards compatibility, remove in 7.0 clearOnStoryChange: boolean; limit: number; + implicit: boolean; } export type ActionOptions = Partial & Partial; diff --git a/code/addons/actions/src/runtime/action.ts b/code/addons/actions/src/runtime/action.ts index d41e215d3fda..9cb6055a5feb 100644 --- a/code/addons/actions/src/runtime/action.ts +++ b/code/addons/actions/src/runtime/action.ts @@ -54,6 +54,23 @@ export function action(name: string, options: ActionOptions = {}): HandlerFuncti }; const handler = function actionHandler(...args: any[]) { + // TODO: Enable once codemods are finished + // if (options.implicit) { + // const preview = + // '__STORYBOOK_PREVIEW__' in global + // ? (global.__STORYBOOK_PREVIEW__ as PreviewWeb) + // : undefined; + // if ( + // preview?.storyRenders.some( + // (render) => render.phase === 'playing' || render.phase === 'rendering' + // ) + // ) { + // console.warn( + // 'Can not use implicit actions during rendering or playing of a story.' + // ); + // } + // } + const channel = addons.getChannel(); // this makes sure that in js enviroments like react native you can still get an id const id = generateId(); diff --git a/code/addons/interactions/src/components/Interaction.tsx b/code/addons/interactions/src/components/Interaction.tsx index bacaea0fe358..af2bea6bd20b 100644 --- a/code/addons/interactions/src/components/Interaction.tsx +++ b/code/addons/interactions/src/components/Interaction.tsx @@ -4,7 +4,7 @@ import { type Call, CallStates, type ControlStates } from '@storybook/instrument import { styled, typography } from '@storybook/theming'; import { transparentize } from 'polished'; -import { MatcherResult } from './MatcherResult'; +import { Expected, MatcherResult, Received } from './MatcherResult'; import { MethodCall } from './MethodCall'; import { StatusIcon } from './StatusIcon'; @@ -120,6 +120,29 @@ const Exception = ({ exception }: { exception: Call['exception'] }) => { return (
{paragraphs[0]}
+ + {exception.showDiff && exception.diff ? ( + <> +
+ + + ) : ( +
+          
+ {exception.expected && ( + <> + Expected: +
+ + )} + {exception.actual && ( + <> + Received: +
+ + )} +
+ )} {more &&

See the full stack trace in the browser console.

}
); diff --git a/code/addons/interactions/src/components/MatcherResult.tsx b/code/addons/interactions/src/components/MatcherResult.tsx index a8a1e00a63f7..6f1d8aef9f1d 100644 --- a/code/addons/interactions/src/components/MatcherResult.tsx +++ b/code/addons/interactions/src/components/MatcherResult.tsx @@ -45,7 +45,13 @@ export const Expected = ({ value, parsed }: { value: any; parsed?: boolean }) => return {value}; }; -export const MatcherResult = ({ message }: { message: string }) => { +export const MatcherResult = ({ + message, + style = {}, +}: { + message: string; + style?: React.CSSProperties; +}) => { const lines = message.split('\n'); return (
 {
         margin: 0,
         padding: '8px 10px 8px 36px',
         fontSize: typography.size.s1,
+        ...style,
       }}
     >
       {lines.flatMap((line: string, index: number) => {
diff --git a/code/addons/interactions/src/preview.ts b/code/addons/interactions/src/preview.ts
index 8aa37ef431ea..54c7c18faab5 100644
--- a/code/addons/interactions/src/preview.ts
+++ b/code/addons/interactions/src/preview.ts
@@ -1,3 +1,4 @@
+/* eslint-disable no-param-reassign,no-underscore-dangle */
 /// 
 
 import { addons } from '@storybook/preview-api';
@@ -9,6 +10,7 @@ import type {
   PlayFunction,
   PlayFunctionContext,
   StepLabel,
+  Args,
 } from '@storybook/types';
 import { instrument } from '@storybook/instrumenter';
 import { ModuleMocker } from 'jest-mock';
@@ -30,14 +32,13 @@ const addSpies = (id: string, val: any, key?: string): any => {
   try {
     if (Object.prototype.toString.call(val) === '[object Object]') {
       // We have to mutate the original object for this to survive HMR.
-      // eslint-disable-next-line no-restricted-syntax, no-param-reassign
+      // eslint-disable-next-line no-restricted-syntax
       for (const [k, v] of Object.entries(val)) val[k] = addSpies(id, v, k);
       return val;
     }
     if (Array.isArray(val)) {
       return val.map((item, index) => addSpies(id, item, `${key}[${index}]`));
     }
-    // eslint-disable-next-line no-underscore-dangle
     if (typeof val === 'function' && val.isAction && !val._isMockFunction) {
       Object.defineProperty(val, 'name', { value: key, writable: false });
       Object.defineProperty(val, '__storyId__', { value: id, writable: false });
@@ -54,7 +55,25 @@ const addSpies = (id: string, val: any, key?: string): any => {
 const addActionsFromArgTypes: ArgsEnhancer = ({ id, initialArgs }) =>
   addSpies(id, initialArgs);
 
-export const argsEnhancers = [addActionsFromArgTypes];
+const instrumentSpies: ArgsEnhancer = ({ initialArgs }) => {
+  const argTypesWithAction = Object.entries(initialArgs).filter(
+    ([, value]) =>
+      typeof value === 'function' &&
+      '_isMockFunction' in value &&
+      value._isMockFunction &&
+      !value._instrumented
+  );
+
+  return argTypesWithAction.reduce((acc, [key, value]) => {
+    const instrumented = instrument({ [key]: () => value }, { retain: true })[key];
+    acc[key] = instrumented();
+    // this enhancer is being called multiple times
+    value._instrumented = true;
+    return acc;
+  }, {} as Args);
+};
+
+export const argsEnhancers = [addActionsFromArgTypes, instrumentSpies];
 
 export const { step: runStep } = instrument(
   {
diff --git a/code/jest.config.base.js b/code/jest.config.base.js
index 9fdc75f588a9..89ff7acf0b8b 100644
--- a/code/jest.config.base.js
+++ b/code/jest.config.base.js
@@ -23,6 +23,7 @@ const modulesToTransform = [
   '@angular',
   '@lit',
   '@mdx-js',
+  '@vitest',
   'ccount',
   'character-entities',
   'decode-named-character-reference',
@@ -60,6 +61,8 @@ module.exports = {
       path.resolve('./__mocks__/fileMock.js'),
     '\\.(css|scss|stylesheet)$': path.resolve('./__mocks__/styleMock.js'),
     '\\.(md)$': path.resolve('./__mocks__/htmlMock.js'),
+    '@vitest/utils/(.*)': '@vitest/utils/dist/$1.js',
+    '@vitest/utils': '@vitest/utils/dist/index.js',
   },
   transform: {
     '^.+\\.(t|j)sx?$': ['@swc/jest', swcrc],
diff --git a/code/lib/instrumenter/package.json b/code/lib/instrumenter/package.json
index 2ab38d5a8770..37c76c998109 100644
--- a/code/lib/instrumenter/package.json
+++ b/code/lib/instrumenter/package.json
@@ -48,7 +48,9 @@
     "@storybook/client-logger": "workspace:*",
     "@storybook/core-events": "workspace:*",
     "@storybook/global": "^5.0.0",
-    "@storybook/preview-api": "workspace:*"
+    "@storybook/preview-api": "workspace:*",
+    "@vitest/utils": "^0.34.6",
+    "util": "^0.12.4"
   },
   "devDependencies": {
     "typescript": "~4.9.3"
diff --git a/code/lib/instrumenter/src/instrumenter.test.ts b/code/lib/instrumenter/src/instrumenter.test.ts
index eadb1f7d5c84..35f0b6a87830 100644
--- a/code/lib/instrumenter/src/instrumenter.test.ts
+++ b/code/lib/instrumenter/src/instrumenter.test.ts
@@ -112,6 +112,44 @@ describe('Instrumenter', () => {
     expect(result.fn1.fn2.__originalFn__).toBe(fn1.fn2);
   });
 
+  it('patches functions correctly that reference this', () => {
+    const object = {
+      name: 'name',
+      method() {
+        return this.name;
+      },
+    };
+
+    const instrumented = instrument(object);
+    expect(object.method()).toEqual(instrumented.method());
+
+    expect(instrumented.method).toEqual(expect.any(Function));
+    expect(instrumented.method.__originalFn__).toBe(object.method);
+  });
+
+  it('patches functions correctly that use proxies', () => {
+    const object = new Proxy(
+      {
+        name: 'name',
+        method() {
+          return this.name;
+        },
+      },
+      {
+        get(target, prop, receiver) {
+          if (prop === 'name') return `${target[prop]}!`;
+          return Reflect.get(target, prop, receiver);
+        },
+      }
+    );
+
+    const instrumented = instrument(object);
+    expect(object.method()).toEqual(instrumented.method());
+
+    expect(instrumented.method).toEqual(expect.any(Function));
+    expect(instrumented.method.__originalFn__).toBe(object.method);
+  });
+
   it('patched functions call the original function when invoked', () => {
     const { fn } = instrument({ fn: jest.fn() });
     const obj = {};
@@ -510,12 +548,12 @@ describe('Instrumenter', () => {
       expect(callSpy).toHaveBeenCalledWith(
         expect.objectContaining({
           id: 'kind--story [0] fn',
-          exception: {
+          exception: expect.objectContaining({
             name: 'Error',
             message: 'Boom!',
             stack: expect.stringContaining('Error: Boom!'),
             callId: 'kind--story [0] fn',
-          },
+          }),
         })
       );
     });
diff --git a/code/lib/instrumenter/src/instrumenter.ts b/code/lib/instrumenter/src/instrumenter.ts
index 357b9df52817..0ffaa0ca0d5f 100644
--- a/code/lib/instrumenter/src/instrumenter.ts
+++ b/code/lib/instrumenter/src/instrumenter.ts
@@ -1,4 +1,4 @@
-/* eslint-disable no-underscore-dangle */
+/* eslint-disable no-underscore-dangle,no-param-reassign */
 import type { Channel } from '@storybook/channels';
 import { addons } from '@storybook/preview-api';
 import type { StoryId } from '@storybook/types';
@@ -10,6 +10,7 @@ import {
   STORY_RENDER_PHASE_CHANGED,
 } from '@storybook/core-events';
 import { global } from '@storybook/global';
+import { processError } from '@vitest/utils/error';
 
 import type { Call, CallRef, ControlStates, LogItem, Options, State, SyncPayload } from './types';
 import { CallStates } from './types';
@@ -24,8 +25,8 @@ export const EVENTS = {
   END: 'storybook/instrumenter/end',
 };
 
-type PatchedObj = {
-  [Property in keyof TObj]: TObj[Property] & { __originalFn__: PatchedObj };
+type PatchedObj> = {
+  [Property in keyof TObj]: TObj[Property] & { __originalFn__: TObj[Property] };
 };
 
 const controlsDisabled: ControlStates = {
@@ -49,7 +50,6 @@ const isInstrumentable = (o: unknown) => {
   if (o.constructor === undefined) return true;
   const proto = o.constructor.prototype;
   if (!isObject(proto)) return false;
-  if (Object.prototype.hasOwnProperty.call(proto, 'isPrototypeOf') === false) return false;
   return true;
 };
 
@@ -290,28 +290,46 @@ export class Instrumenter {
   // Traverses the object structure to recursively patch all function properties.
   // Returns the original object, or a new object with the same constructor,
   // depending on whether it should mutate.
-  instrument(obj: TObj, options: Options): PatchedObj {
-    if (!isInstrumentable(obj)) return obj;
+  instrument>(
+    obj: TObj,
+    options: Options,
+    depth = 0
+  ): PatchedObj {
+    if (!isInstrumentable(obj)) return obj as PatchedObj;
 
     const { mutate = false, path = [] } = options;
-    return Object.keys(obj).reduce(
+
+    const keys = options.getKeys ? options.getKeys(obj, depth) : Object.keys(obj);
+    depth += 1;
+    return keys.reduce(
       (acc, key) => {
+        const descriptor = getPropertyDescriptor(obj, key);
+        if (typeof descriptor?.get === 'function') {
+          const getter = () => descriptor?.get?.bind(obj)?.();
+          Object.defineProperty(acc, key, {
+            get: () => {
+              return this.instrument(getter(), { ...options, path: path.concat(key) }, depth);
+            },
+          });
+          return acc;
+        }
+
         const value = (obj as Record)[key];
 
         // Nothing to patch, but might be instrumentable, so we recurse
         if (typeof value !== 'function') {
-          acc[key] = this.instrument(value, { ...options, path: path.concat(key) });
+          acc[key] = this.instrument(value, { ...options, path: path.concat(key) }, depth);
           return acc;
         }
 
         // Already patched, so we pass through unchanged
-        if (typeof value.__originalFn__ === 'function') {
+        if ('__originalFn__' in value && typeof value.__originalFn__ === 'function') {
           acc[key] = value;
           return acc;
         }
 
         // Patch the function and mark it "patched" by adding a reference to the original function
-        acc[key] = (...args: any[]) => this.track(key, value, args, options);
+        acc[key] = (...args: any[]) => this.track(key, value, obj, args, options);
         acc[key].__originalFn__ = value;
 
         // Reuse the original name as the patched function's name
@@ -321,7 +339,7 @@ export class Instrumenter {
         if (Object.keys(value).length > 0) {
           Object.assign(
             acc[key],
-            this.instrument({ ...value }, { ...options, path: path.concat(key) })
+            this.instrument({ ...value }, { ...options, path: path.concat(key) }, depth)
           );
         }
 
@@ -334,7 +352,13 @@ export class Instrumenter {
   // Monkey patch an object method to record calls.
   // Returns a function that invokes the original function, records the invocation ("call") and
   // returns the original result.
-  track(method: string, fn: Function, args: any[], options: Options) {
+  track(
+    method: string,
+    fn: Function,
+    object: Record,
+    args: any[],
+    options: Options
+  ) {
     const storyId: StoryId =
       args?.[0]?.__storyId__ || global.__STORYBOOK_PREVIEW__?.selectionStore?.selection?.storyId;
     const { cursor, ancestors } = this.getState(storyId);
@@ -344,11 +368,11 @@ export class Instrumenter {
     const interceptable = typeof intercept === 'function' ? intercept(method, path) : intercept;
     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);
+    const result = interceptOrInvoke.call(this, fn, object, call, options);
     return this.instrument(result, { ...options, mutate: true, path: [{ __callId__: call.id }] });
   }
 
-  intercept(fn: Function, call: Call, options: Options) {
+  intercept(fn: Function, object: Record, call: Call, options: Options) {
     const { chainedCallIds, isDebugging, playUntil } = this.getState(call.storyId);
 
     // For a "jump to step" action, continue playing until we hit a call by that ID.
@@ -358,7 +382,7 @@ export class Instrumenter {
       if (playUntil === call.id) {
         this.setState(call.storyId, { playUntil: undefined });
       }
-      return this.invoke(fn, call, options);
+      return this.invoke(fn, object, call, options);
     }
 
     // Instead of invoking the function, defer the function call until we continue playing.
@@ -373,11 +397,11 @@ export class Instrumenter {
         const { [call.id]: _, ...resolvers } = state.resolvers;
         return { isLocked: true, resolvers };
       });
-      return this.invoke(fn, call, options);
+      return this.invoke(fn, object, call, options);
     });
   }
 
-  invoke(fn: Function, call: Call, options: Options) {
+  invoke(fn: Function, object: Record, call: Call, options: Options) {
     // TODO this doesnt work because the abortSignal we have here is the newly created one
     // const { abortSignal } = global.window.__STORYBOOK_PREVIEW__ || {};
     // if (abortSignal && abortSignal.aborted) throw IGNORED_EXCEPTION;
@@ -443,7 +467,16 @@ export class Instrumenter {
     const handleException = (e: any) => {
       if (e instanceof Error) {
         const { name, message, stack, callId = call.id } = e as Error & { callId: Call['id'] };
-        const exception = { name, message, stack, callId };
+
+        // This will calculate the diff for chai errors
+        const {
+          showDiff = undefined,
+          diff = undefined,
+          actual = undefined,
+          expected = undefined,
+        } = processError(e);
+
+        const exception = { name, message, stack, callId, showDiff, diff, actual, expected };
         this.update({ ...info, status: CallStates.ERROR, exception });
 
         // Always track errors to their originating call.
@@ -510,7 +543,7 @@ export class Instrumenter {
         };
       });
 
-      const result = fn(...finalArgs);
+      const result = fn.apply(object, finalArgs);
 
       // Track the result so we can trace later uses of it back to the originating call.
       // Primitive results (undefined, null, boolean, string, number, BigInt) are ignored.
@@ -637,3 +670,15 @@ export function instrument>(
     return obj;
   }
 }
+
+function getPropertyDescriptor(obj: T, propName: keyof T) {
+  let target = obj;
+  while (target != null) {
+    const descriptor = Object.getOwnPropertyDescriptor(target, propName);
+    if (descriptor) {
+      return descriptor;
+    }
+    target = Object.getPrototypeOf(target);
+  }
+  return undefined;
+}
diff --git a/code/lib/instrumenter/src/types.ts b/code/lib/instrumenter/src/types.ts
index 1076d4dd3a1d..f3a2ee274200 100644
--- a/code/lib/instrumenter/src/types.ts
+++ b/code/lib/instrumenter/src/types.ts
@@ -16,6 +16,10 @@ export interface Call {
     message: Error['message'];
     stack: Error['stack'];
     callId: Call['id'];
+    showDiff?: boolean;
+    diff?: string;
+    actual?: unknown;
+    expected?: unknown;
   };
 }
 
@@ -90,4 +94,5 @@ export interface Options {
   mutate?: boolean;
   path?: Array;
   getArgs?: (call: Call, state: State) => Call['args'];
+  getKeys?: (originalObject: Record, depth: number) => string[];
 }
diff --git a/code/lib/test/jest.config.js b/code/lib/test/jest.config.js
new file mode 100644
index 000000000000..4396fbc7010d
--- /dev/null
+++ b/code/lib/test/jest.config.js
@@ -0,0 +1,7 @@
+const path = require('path');
+const baseConfig = require('../../jest.config.browser');
+
+module.exports = {
+  ...baseConfig,
+  displayName: __dirname.split(path.sep).slice(-2).join(path.posix.sep),
+};
diff --git a/code/lib/test/package.json b/code/lib/test/package.json
new file mode 100644
index 000000000000..73b04a6e593f
--- /dev/null
+++ b/code/lib/test/package.json
@@ -0,0 +1,79 @@
+{
+  "name": "@storybook/test",
+  "version": "7.4.0-alpha.0",
+  "description": "",
+  "keywords": [
+    "storybook"
+  ],
+  "homepage": "https://github.com/storybookjs/storybook/tree/next/code/lib/test",
+  "bugs": {
+    "url": "https://github.com/storybookjs/storybook/issues"
+  },
+  "repository": {
+    "type": "git",
+    "url": "https://github.com/storybookjs/storybook.git",
+    "directory": "code/lib/test"
+  },
+  "funding": {
+    "type": "opencollective",
+    "url": "https://opencollective.com/storybook"
+  },
+  "license": "MIT",
+  "sideEffects": false,
+  "exports": {
+    ".": {
+      "types": "./dist/index.d.ts",
+      "node": "./dist/index.js",
+      "require": "./dist/index.js",
+      "import": "./dist/index.mjs"
+    },
+    "./package.json": "./package.json"
+  },
+  "main": "dist/index.js",
+  "module": "dist/index.mjs",
+  "types": "dist/index.d.ts",
+  "files": [
+    "dist/**/*",
+    "README.md",
+    "*.js",
+    "*.d.ts"
+  ],
+  "scripts": {
+    "check": "../../../scripts/prepare/check.ts",
+    "prep": "../../../scripts/prepare/bundle.ts"
+  },
+  "dependencies": {
+    "@storybook/client-logger": "workspace:*",
+    "@storybook/core-events": "workspace:*",
+    "@storybook/instrumenter": "workspace:*",
+    "@storybook/preview-api": "workspace:*",
+    "@testing-library/dom": "^9.3.1",
+    "@testing-library/jest-dom": "^6.1.3",
+    "@testing-library/user-event": "^14.4.3",
+    "@types/chai": "^4",
+    "@vitest/expect": "^0.34.2",
+    "@vitest/spy": "^0.34.1",
+    "chai": "^4.3.7",
+    "util": "^0.12.4"
+  },
+  "devDependencies": {
+    "ts-dedent": "^2.2.0",
+    "type-fest": "~2.19",
+    "typescript": "~4.9.3"
+  },
+  "publishConfig": {
+    "access": "public"
+  },
+  "bundler": {
+    "entries": [
+      "./src/index.ts"
+    ],
+    "noExternal": [
+      "@testing-library/dom",
+      "@testing-library/jest-dom",
+      "@testing-library/user-event",
+      "chai"
+    ]
+  },
+  "gitHead": "e6a7fd8a655c69780bc20b9749c2699e44beae17"
+}
diff --git a/code/lib/test/project.json b/code/lib/test/project.json
new file mode 100644
index 000000000000..68c18c664fd3
--- /dev/null
+++ b/code/lib/test/project.json
@@ -0,0 +1,6 @@
+{
+  "name": "@storybook/test",
+  "$schema": "../../node_modules/nx/schemas/project-schema.json",
+  "implicitDependencies": [],
+  "type": "library"
+}
diff --git a/code/lib/test/src/expect.ts b/code/lib/test/src/expect.ts
new file mode 100644
index 000000000000..d277380a3596
--- /dev/null
+++ b/code/lib/test/src/expect.ts
@@ -0,0 +1,134 @@
+import * as chai from 'chai';
+import type {
+  AsymmetricMatchersContaining,
+  ExpectStatic,
+  JestAssertion,
+  MatchersObject,
+  MatcherState,
+} from '@vitest/expect';
+import {
+  getState,
+  GLOBAL_EXPECT,
+  JestAsymmetricMatchers,
+  JestChaiExpect,
+  JestExtend,
+  setState,
+} from '@vitest/expect';
+import * as matchers from '@testing-library/jest-dom/matchers';
+import type { TestingLibraryMatchers } from '@testing-library/jest-dom/types/matchers';
+import type { PromisifyObject } from './utils';
+
+// We only expose the jest compatible API for now
+export interface Assertion
+  extends PromisifyObject>,
+    TestingLibraryMatchers, Promise> {
+  toHaveBeenCalledOnce(): Promise;
+  toSatisfy(matcher: (value: E) => boolean, message?: string): Promise;
+  resolves: Assertion;
+  rejects: Assertion;
+  not: Assertion;
+}
+
+export interface Expect extends AsymmetricMatchersContaining {
+  (actual: T, message?: string): Assertion;
+  unreachable(message?: string): Promise;
+  soft(actual: T, message?: string): Assertion;
+  extend(expects: MatchersObject): void;
+  assertions(expected: number): Promise;
+  hasAssertions(): Promise;
+  anything(): any;
+  any(constructor: unknown): any;
+  getState(): MatcherState;
+  setState(state: Partial): void;
+  not: AsymmetricMatchersContaining;
+}
+
+export function createExpect() {
+  chai.use(JestExtend);
+  chai.use(JestChaiExpect);
+  chai.use(JestAsymmetricMatchers);
+
+  const expect = ((value: unknown, message?: string) => {
+    const { assertionCalls } = getState(expect);
+    setState({ assertionCalls: assertionCalls + 1, soft: false }, expect);
+    return chai.expect(value, message);
+  }) as ExpectStatic;
+
+  Object.assign(expect, chai.expect);
+
+  // The below methods are added to make chai jest compatible
+
+  expect.getState = () => getState(expect);
+  expect.setState = (state) => setState(state as Partial, expect);
+
+  // @ts-expect-error chai.extend is not typed
+  expect.extend = (expects: MatchersObject) => chai.expect.extend(expect, expects);
+
+  expect.soft = (...args) => {
+    const assert = expect(...args);
+    expect.setState({
+      soft: true,
+    });
+    return assert;
+  };
+
+  expect.unreachable = (message?: string): never => {
+    chai.assert.fail(`expected${message ? ` "${message}" ` : ' '}not to be reached`);
+  };
+
+  function assertions(expected: number) {
+    const errorGen = () =>
+      new Error(
+        `expected number of assertions to be ${expected}, but got ${
+          expect.getState().assertionCalls
+        }`
+      );
+    if ('captureStackTrace' in Error && typeof Error.captureStackTrace === 'function')
+      Error.captureStackTrace(errorGen(), assertions);
+
+    expect.setState({
+      expectedAssertionsNumber: expected,
+      expectedAssertionsNumberErrorGen: errorGen,
+    });
+  }
+
+  function hasAssertions() {
+    const error = new Error('expected any number of assertion, but got none');
+    if ('captureStackTrace' in Error && typeof Error.captureStackTrace === 'function')
+      Error.captureStackTrace(error, hasAssertions);
+
+    expect.setState({
+      isExpectingAssertions: true,
+      isExpectingAssertionsError: error,
+    });
+  }
+
+  setState(
+    {
+      // this should also add "snapshotState" that is added conditionally
+      assertionCalls: 0,
+      isExpectingAssertions: false,
+      isExpectingAssertionsError: null,
+      expectedAssertionsNumber: null,
+      expectedAssertionsNumberErrorGen: null,
+    },
+    expect
+  );
+
+  chai.util.addMethod(expect, 'assertions', assertions);
+  chai.util.addMethod(expect, 'hasAssertions', hasAssertions);
+  expect.extend(matchers);
+
+  return expect as unknown as Expect;
+}
+
+const expect = createExpect();
+
+// @vitest/expect expects this to be set
+Object.defineProperty(globalThis, GLOBAL_EXPECT, {
+  value: expect,
+  writable: true,
+  configurable: true,
+});
+
+export { expect };
diff --git a/code/lib/test/src/index.ts b/code/lib/test/src/index.ts
new file mode 100644
index 000000000000..34d59391676e
--- /dev/null
+++ b/code/lib/test/src/index.ts
@@ -0,0 +1,34 @@
+import { instrument } from '@storybook/instrumenter';
+import * as spy from '@vitest/spy';
+import chai from 'chai';
+import { FORCE_REMOUNT, STORY_RENDER_PHASE_CHANGED } from '@storybook/core-events';
+import { addons } from '@storybook/preview-api';
+import { expect as rawExpect } from './expect';
+
+export * from '@vitest/spy';
+
+const channel = addons.getChannel();
+
+channel.on(FORCE_REMOUNT, () => spy.spies.forEach((mock) => mock.mockClear()));
+channel.on(STORY_RENDER_PHASE_CHANGED, ({ newPhase }) => {
+  if (newPhase === 'loading') spy.spies.forEach((mock) => mock.mockClear());
+});
+
+export const { expect } = instrument(
+  { expect: rawExpect },
+  {
+    getKeys: (obj: Record, depth) => {
+      const privateApi = ['assert', '__methods', '__flags', '_obj'];
+      if (obj.constructor === chai.Assertion) {
+        const keys = Object.keys(Object.getPrototypeOf(obj)).filter(
+          (it) => !privateApi.includes(it)
+        );
+        return depth > 2 ? keys : [...keys, 'not'];
+      }
+      return Object.keys(obj);
+    },
+    intercept: (method) => method !== 'expect',
+  }
+);
+
+export * from './testing-library';
diff --git a/code/lib/test/src/testing-library.ts b/code/lib/test/src/testing-library.ts
new file mode 100644
index 000000000000..ccac1f448923
--- /dev/null
+++ b/code/lib/test/src/testing-library.ts
@@ -0,0 +1,108 @@
+/* eslint-disable @typescript-eslint/ban-types */
+import { once } from '@storybook/client-logger';
+import { instrument } from '@storybook/instrumenter';
+import * as domTestingLibrary from '@testing-library/dom';
+import _userEvent from '@testing-library/user-event';
+import dedent from 'ts-dedent';
+import type { FireFunction, FireObject } from '@testing-library/dom/types/events';
+import type { Writable } from 'type-fest';
+import type { Promisify, PromisifyObject } from './utils';
+
+type TestingLibraryDom = typeof domTestingLibrary;
+
+const testingLibrary = instrument(
+  { ...domTestingLibrary },
+  {
+    intercept: (method, path) =>
+      path[0] === 'fireEvent' || method.startsWith('find') || method.startsWith('waitFor'),
+  }
+) as {} as Writable> & {
+  fireEvent: Promisify & PromisifyObject;
+};
+
+testingLibrary.screen = new Proxy(testingLibrary.screen, {
+  get(target, prop, receiver) {
+    once.warn(dedent`
+          You are using Testing Library's \`screen\` object. Use \`within(canvasElement)\` instead.
+          More info: https://storybook.js.org/docs/react/essentials/interactions
+        `);
+    return Reflect.get(target, prop, receiver);
+  },
+});
+
+export const {
+  buildQueries,
+  configure,
+  createEvent,
+  fireEvent,
+  findAllByAltText,
+  findAllByDisplayValue,
+  findAllByLabelText,
+  findAllByPlaceholderText,
+  findAllByRole,
+  findAllByTestId,
+  findAllByText,
+  findAllByTitle,
+  findByAltText,
+  findByDisplayValue,
+  findByLabelText,
+  findByPlaceholderText,
+  findByRole,
+  findByTestId,
+  findByText,
+  findByTitle,
+  getAllByAltText,
+  getAllByDisplayValue,
+  getAllByLabelText,
+  getAllByPlaceholderText,
+  getAllByRole,
+  getAllByTestId,
+  getAllByText,
+  getAllByTitle,
+  getByAltText,
+  getByDisplayValue,
+  getByLabelText,
+  getByPlaceholderText,
+  getByRole,
+  getByTestId,
+  getByText,
+  getByTitle,
+  getConfig,
+  getDefaultNormalizer,
+  getElementError,
+  getNodeText,
+  getQueriesForElement,
+  getRoles,
+  getSuggestedQuery,
+  isInaccessible,
+  logDOM,
+  logRoles,
+  prettyDOM,
+  queries,
+  queryAllByAltText,
+  queryAllByAttribute,
+  queryAllByDisplayValue,
+  queryAllByLabelText,
+  queryAllByPlaceholderText,
+  queryAllByRole,
+  queryAllByTestId,
+  queryAllByText,
+  queryAllByTitle,
+  queryByAltText,
+  queryByAttribute,
+  queryByDisplayValue,
+  queryByLabelText,
+  queryByPlaceholderText,
+  queryByRole,
+  queryByTestId,
+  queryByText,
+  queryByTitle,
+  queryHelpers,
+  screen,
+  waitFor,
+  waitForElementToBeRemoved,
+  within,
+  prettyFormat,
+} = testingLibrary;
+
+export const { userEvent } = instrument({ userEvent: _userEvent }, { intercept: true });
diff --git a/code/lib/test/src/utils.ts b/code/lib/test/src/utils.ts
new file mode 100644
index 000000000000..6f093cd0b9f4
--- /dev/null
+++ b/code/lib/test/src/utils.ts
@@ -0,0 +1,5 @@
+export type Promisify = Fn extends (...args: infer A) => infer R
+  ? (...args: A) => R extends Promise ? R : Promise
+  : Fn;
+
+export type PromisifyObject = { [K in keyof O]: Promisify };
diff --git a/code/lib/test/tsconfig.json b/code/lib/test/tsconfig.json
new file mode 100644
index 000000000000..52d43eaaa9b9
--- /dev/null
+++ b/code/lib/test/tsconfig.json
@@ -0,0 +1,4 @@
+{
+  "extends": "../../tsconfig.json",
+  "include": ["src/**/*"]
+}
diff --git a/code/package.json b/code/package.json
index c9ea0be5b379..97c99ba5ed1b 100644
--- a/code/package.json
+++ b/code/package.json
@@ -80,10 +80,10 @@
   ],
   "resolutions": {
     "@playwright/test": "1.36.0",
-    "@testing-library/jest-dom": "^5.11.9",
     "@typescript-eslint/eslint-plugin": "^5.45.0",
     "@typescript-eslint/experimental-utils": "^5.45.0",
     "@typescript-eslint/parser": "^5.45.0",
+    "@vitest/expect@^0.34.2": "patch:@vitest/expect@npm%3A0.34.5#./.yarn/patches/@vitest-expect-npm-0.34.5-8031508efe.patch",
     "esbuild": "^0.18.0",
     "eslint": "^8.28.0",
     "playwright": "1.36.0",
diff --git a/code/renderers/react/package.json b/code/renderers/react/package.json
index 4dbbad62aa47..cfb4b0f0b4ce 100644
--- a/code/renderers/react/package.json
+++ b/code/renderers/react/package.json
@@ -77,6 +77,7 @@
   },
   "devDependencies": {
     "@babel/core": "^7.22.9",
+    "@storybook/test": "workspace:*",
     "@types/util-deprecate": "^1.0.0",
     "expect-type": "^0.15.0",
     "jest-specific-snapshot": "^8.0.0",
diff --git a/code/renderers/react/src/public-types.test.tsx b/code/renderers/react/src/public-types.test.tsx
index 1fc3fd15d10c..ea4beb7810c0 100644
--- a/code/renderers/react/src/public-types.test.tsx
+++ b/code/renderers/react/src/public-types.test.tsx
@@ -7,6 +7,8 @@ import type { KeyboardEventHandler, ReactNode } from 'react';
 import React from 'react';
 
 import type { SetOptional } from 'type-fest';
+import type { Mock } from '@storybook/test';
+import { fn } from '@storybook/test';
 
 import type { Decorator, Meta, StoryObj } from './public-types';
 import type { ReactRenderer } from './types';
@@ -300,3 +302,29 @@ test('Meta is broken when using discriminating types, issue #23629', () => {
     },
   }).toMatchTypeOf>();
 });
+
+test('Infer mock function given to args in meta.', () => {
+  type Props = { label: string; onClick: () => void; onRender: () => JSX.Element };
+  const TestButton = (props: Props) => <>;
+
+  const meta = {
+    component: TestButton,
+    args: { label: 'label', onClick: fn(), onRender: () => <>some jsx },
+  } satisfies Meta;
+
+  type Story = StoryObj;
+
+  const Basic: Story = {
+    play: async ({ args }) => {
+      expectTypeOf(args.onClick).toEqualTypeOf>();
+      expectTypeOf(args.onRender).toEqualTypeOf<() => JSX.Element>();
+    },
+  };
+  type Expected = StoryAnnotations<
+    ReactRenderer,
+    Props & { onClick: Mock<[], void> },
+    Partial
+  >;
+
+  expectTypeOf(Basic).toEqualTypeOf();
+});
diff --git a/code/renderers/react/src/public-types.ts b/code/renderers/react/src/public-types.ts
index 592b82b03ff9..95ad7111a3ea 100644
--- a/code/renderers/react/src/public-types.ts
+++ b/code/renderers/react/src/public-types.ts
@@ -56,7 +56,7 @@ export type StoryObj = [TMetaOrCmpOrArgs] extends [
     > extends infer TArgs
     ? StoryAnnotations<
         ReactRenderer,
-        TArgs,
+        AddMocks,
         SetOptional)>
       >
     : never
@@ -64,6 +64,16 @@ export type StoryObj = [TMetaOrCmpOrArgs] extends [
   ? StoryAnnotations>
   : StoryAnnotations;
 
+// This performs a downcast to function types that are mocks, when a mock fn is given to meta args.
+type AddMocks = Simplify<{
+  [T in keyof TArgs]: T extends keyof DefaultArgs
+    ? // eslint-disable-next-line @typescript-eslint/ban-types
+      DefaultArgs[T] extends (...args: any) => any & { mock: {} } // allow any function with a mock object
+      ? DefaultArgs[T]
+      : TArgs[T]
+    : TArgs[T];
+}>;
+
 type ActionArgs = {
   // This can be read as: filter TArgs on functions where we can assign a void function to that function.
   // The docs addon argsEnhancers can only safely provide a default value for void functions.
diff --git a/code/ui/blocks/src/blocks/internal/InternalCanvas.stories.tsx b/code/ui/blocks/src/blocks/internal/InternalCanvas.stories.tsx
index fb0f444720c3..b45015c94b50 100644
--- a/code/ui/blocks/src/blocks/internal/InternalCanvas.stories.tsx
+++ b/code/ui/blocks/src/blocks/internal/InternalCanvas.stories.tsx
@@ -1,5 +1,4 @@
 /// ;
-/// ;
 import React from 'react';
 import type { Meta, StoryObj } from '@storybook/react';
 import { userEvent, within } from '@storybook/testing-library';
diff --git a/code/ui/manager/package.json b/code/ui/manager/package.json
index f8f56fef6500..02b19b41ce34 100644
--- a/code/ui/manager/package.json
+++ b/code/ui/manager/package.json
@@ -61,6 +61,7 @@
     "@storybook/global": "^5.0.0",
     "@storybook/manager-api": "workspace:*",
     "@storybook/router": "workspace:*",
+    "@storybook/test": "workspace:*",
     "@storybook/theming": "workspace:*",
     "@storybook/types": "workspace:*",
     "@testing-library/react": "^11.2.2",
diff --git a/code/yarn.lock b/code/yarn.lock
index a4f61e11d94f..e985d4e8c31e 100644
--- a/code/yarn.lock
+++ b/code/yarn.lock
@@ -24,7 +24,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@adobe/css-tools@npm:^4.0.1":
+"@adobe/css-tools@npm:^4.0.1, @adobe/css-tools@npm:^4.3.0":
   version: 4.3.1
   resolution: "@adobe/css-tools@npm:4.3.1"
   checksum: 05672719b544cc0c21ae3ed0eb6349bf458e9d09457578eeeb07cf0f696469ac6417e9c9be1b129e5d6a18098a061c1db55b2275591760ef30a79822436fcbfa
@@ -6733,12 +6733,12 @@ __metadata:
   languageName: unknown
   linkType: soft
 
-"@storybook/client-logger@npm:7.5.0":
-  version: 7.5.0
-  resolution: "@storybook/client-logger@npm:7.5.0"
+"@storybook/client-logger@npm:7.4.6":
+  version: 7.4.6
+  resolution: "@storybook/client-logger@npm:7.4.6"
   dependencies:
     "@storybook/global": "npm:^5.0.0"
-  checksum: 90326c49a224bf21680c04ffee94725bf75658086093ccb839a8aae39476929c4719eafb18e498a148cf0dd956d4e9a5d3b2a34d09ca4fd25e2af553458558ac
+  checksum: 170ad58c17e2608639533fe24aaa96ddd4d77d23b4b28f265b2cb67510fef966fc20b029e070fdc7216ba1cdb724d1210b2f8edc8aa538de32fd6e549f9010cf
   languageName: node
   linkType: hard
 
@@ -7166,7 +7166,9 @@ __metadata:
     "@storybook/core-events": "workspace:*"
     "@storybook/global": "npm:^5.0.0"
     "@storybook/preview-api": "workspace:*"
+    "@vitest/utils": "npm:^0.34.6"
     typescript: "npm:~4.9.3"
+    util: "npm:^0.12.4"
   languageName: unknown
   linkType: soft
 
@@ -7254,6 +7256,7 @@ __metadata:
     "@storybook/global": "npm:^5.0.0"
     "@storybook/manager-api": "workspace:*"
     "@storybook/router": "workspace:*"
+    "@storybook/test": "workspace:*"
     "@storybook/theming": "workspace:*"
     "@storybook/types": "workspace:*"
     "@testing-library/react": "npm:^11.2.2"
@@ -7750,6 +7753,7 @@ __metadata:
     "@storybook/global": "npm:^5.0.0"
     "@storybook/preview-api": "workspace:*"
     "@storybook/react-dom-shim": "workspace:*"
+    "@storybook/test": "workspace:*"
     "@storybook/types": "workspace:*"
     "@types/escodegen": "npm:^0.0.6"
     "@types/estree": "npm:^0.0.51"
@@ -8148,6 +8152,28 @@ __metadata:
   languageName: unknown
   linkType: soft
 
+"@storybook/test@workspace:*, @storybook/test@workspace:lib/test":
+  version: 0.0.0-use.local
+  resolution: "@storybook/test@workspace:lib/test"
+  dependencies:
+    "@storybook/client-logger": "workspace:*"
+    "@storybook/core-events": "workspace:*"
+    "@storybook/instrumenter": "workspace:*"
+    "@storybook/preview-api": "workspace:*"
+    "@testing-library/dom": "npm:^9.3.1"
+    "@testing-library/jest-dom": "npm:^6.1.3"
+    "@testing-library/user-event": "npm:^14.4.3"
+    "@types/chai": "npm:^4"
+    "@vitest/expect": "npm:^0.34.2"
+    "@vitest/spy": "npm:^0.34.1"
+    chai: "npm:^4.3.7"
+    ts-dedent: "npm:^2.2.0"
+    type-fest: "npm:~2.19"
+    typescript: "npm:~4.9.3"
+    util: "npm:^0.12.4"
+  languageName: unknown
+  linkType: soft
+
 "@storybook/testing-library@npm:next":
   version: 0.2.2-next.0
   resolution: "@storybook/testing-library@npm:0.2.2-next.0"
@@ -8160,17 +8186,17 @@ __metadata:
   linkType: hard
 
 "@storybook/theming@npm:^7.0.2":
-  version: 7.5.0
-  resolution: "@storybook/theming@npm:7.5.0"
+  version: 7.4.6
+  resolution: "@storybook/theming@npm:7.4.6"
   dependencies:
     "@emotion/use-insertion-effect-with-fallbacks": "npm:^1.0.0"
-    "@storybook/client-logger": "npm:7.5.0"
+    "@storybook/client-logger": "npm:7.4.6"
     "@storybook/global": "npm:^5.0.0"
     memoizerific: "npm:^1.11.3"
   peerDependencies:
     react: ^16.8.0 || ^17.0.0 || ^18.0.0
     react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0
-  checksum: 57da8e27c748cbec4dc1661cdd2d449949d97476d8e97933696b31d07c7361cbbcca8d7225cc00ca078daa160023b8965ddec7c23519ce0a4ef2658246b062e7
+  checksum: 6250a413c346971792623bf5a907811fc009ff4a36b8f292d0f45c677269b2a50c29d84ab1e869ada7df3eb23d49614e1342bd2c88e71d4467702b92ebc42f2d
   languageName: node
   linkType: hard
 
@@ -8743,7 +8769,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@testing-library/dom@npm:^9.0.0":
+"@testing-library/dom@npm:^9.0.0, @testing-library/dom@npm:^9.3.1":
   version: 9.3.3
   resolution: "@testing-library/dom@npm:9.3.3"
   dependencies:
@@ -8776,6 +8802,36 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@testing-library/jest-dom@npm:^6.1.2, @testing-library/jest-dom@npm:^6.1.3":
+  version: 6.1.3
+  resolution: "@testing-library/jest-dom@npm:6.1.3"
+  dependencies:
+    "@adobe/css-tools": "npm:^4.3.0"
+    "@babel/runtime": "npm:^7.9.2"
+    aria-query: "npm:^5.0.0"
+    chalk: "npm:^3.0.0"
+    css.escape: "npm:^1.5.1"
+    dom-accessibility-api: "npm:^0.5.6"
+    lodash: "npm:^4.17.15"
+    redent: "npm:^3.0.0"
+  peerDependencies:
+    "@jest/globals": ">= 28"
+    "@types/jest": ">= 28"
+    jest: ">= 28"
+    vitest: ">= 0.32"
+  peerDependenciesMeta:
+    "@jest/globals":
+      optional: true
+    "@types/jest":
+      optional: true
+    jest:
+      optional: true
+    vitest:
+      optional: true
+  checksum: 544e01939d3c14a3d44ae2e2bb9fe2a0cb5a9e4992ca2728f41188fb9fb2d56e25f1a2e1c12000be2a94d8da36cb220b24020e1b5c5c4c4bede9058a0d80583d
+  languageName: node
+  linkType: hard
+
 "@testing-library/react@npm:^11.2.2":
   version: 11.2.7
   resolution: "@testing-library/react@npm:11.2.7"
@@ -8800,7 +8856,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@testing-library/user-event@npm:^14.4.0":
+"@testing-library/user-event@npm:^14.4.0, @testing-library/user-event@npm:^14.4.3":
   version: 14.5.1
   resolution: "@testing-library/user-event@npm:14.5.1"
   peerDependencies:
@@ -8977,6 +9033,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@types/chai@npm:^4":
+  version: 4.3.6
+  resolution: "@types/chai@npm:4.3.6"
+  checksum: 388af382b11453a69808800479dcaff0323a0d1e15df1619175ebd55b294d716d560058f560ed55434e8846af46f017d7d78544822571f6322d3fac6d5f8a29d
+  languageName: node
+  linkType: hard
+
 "@types/cheerio@npm:^0.22.22":
   version: 0.22.32
   resolution: "@types/cheerio@npm:0.22.32"
@@ -10189,6 +10252,68 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@vitest/expect@npm:0.34.5":
+  version: 0.34.5
+  resolution: "@vitest/expect@npm:0.34.5"
+  dependencies:
+    "@vitest/spy": "npm:0.34.5"
+    "@vitest/utils": "npm:0.34.5"
+    chai: "npm:^4.3.7"
+  checksum: dc30a5e1f2732a1906df57f65381df1129dbf994496734c27e4a3f832852862501eaba1ec2987215ec12ee23a8f2ef1d8ff63c7cd5490046a7a26800da1adcb2
+  languageName: node
+  linkType: hard
+
+"@vitest/expect@patch:@vitest/expect@npm%3A0.34.5#./.yarn/patches/@vitest-expect-npm-0.34.5-8031508efe.patch::locator=%40storybook%2Froot%40workspace%3A.":
+  version: 0.34.5
+  resolution: "@vitest/expect@patch:@vitest/expect@npm%3A0.34.5#./.yarn/patches/@vitest-expect-npm-0.34.5-8031508efe.patch::version=0.34.5&hash=f89b80&locator=%40storybook%2Froot%40workspace%3A."
+  dependencies:
+    "@vitest/spy": "npm:0.34.5"
+    "@vitest/utils": "npm:0.34.5"
+    chai: "npm:^4.3.7"
+  checksum: b08f0b1df6a37305f3f68feec15cfac048ca9e3924998698625394296faac4e539e23d7422eec59c0850a83b7342b574a2d2d174aaa33a7eb0004e4e366c515c
+  languageName: node
+  linkType: hard
+
+"@vitest/spy@npm:0.34.5":
+  version: 0.34.5
+  resolution: "@vitest/spy@npm:0.34.5"
+  dependencies:
+    tinyspy: "npm:^2.1.1"
+  checksum: bbee495ca6300f50dde6418d14db0d3281daf38df15abae95202ddef253d6dd8bedf9f4a79da5a2246d3758ab24aa737caccf752fabcd8ba902a4f14801c2a0c
+  languageName: node
+  linkType: hard
+
+"@vitest/spy@npm:^0.34.1":
+  version: 0.34.7
+  resolution: "@vitest/spy@npm:0.34.7"
+  dependencies:
+    tinyspy: "npm:^2.1.1"
+  checksum: 1150b270eb72a5e8e7da997bcba90ebe5ed2ac50de1ea1f81738e16a19ab4bc77ca4d17639988df65695d4b325fe3647a1e4204d01024bcf5ecac8ba7764a2cc
+  languageName: node
+  linkType: hard
+
+"@vitest/utils@npm:0.34.5":
+  version: 0.34.5
+  resolution: "@vitest/utils@npm:0.34.5"
+  dependencies:
+    diff-sequences: "npm:^29.4.3"
+    loupe: "npm:^2.3.6"
+    pretty-format: "npm:^29.5.0"
+  checksum: 99cc5974ada1dab2b02220005c0fc97147baba175601a0faa1b2b6687c7f579d21a401077377d6f759b3aa8a07dcc8851cdc3e07f9a550ec289286107487ac36
+  languageName: node
+  linkType: hard
+
+"@vitest/utils@npm:^0.34.6":
+  version: 0.34.7
+  resolution: "@vitest/utils@npm:0.34.7"
+  dependencies:
+    diff-sequences: "npm:^29.4.3"
+    loupe: "npm:^2.3.6"
+    pretty-format: "npm:^29.5.0"
+  checksum: 5f26ec5b4a53709a50efdb57aa753e8090b3411e888774f67a0d192eb7f046ed5fcc6884eb3d6275d2674926e724b731e8d28cd3cea96a7f3d27462a0d44af9e
+  languageName: node
+  linkType: hard
+
 "@volar/language-core@npm:1.10.1, @volar/language-core@npm:~1.10.0":
   version: 1.10.1
   resolution: "@volar/language-core@npm:1.10.1"
@@ -11514,6 +11639,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"assertion-error@npm:^1.1.0":
+  version: 1.1.0
+  resolution: "assertion-error@npm:1.1.0"
+  checksum: 25456b2aa333250f01143968e02e4884a34588a8538fbbf65c91a637f1dbfb8069249133cd2f4e530f10f624d206a664e7df30207830b659e9f5298b00a4099b
+  languageName: node
+  linkType: hard
+
 "assign-symbols@npm:^1.0.0":
   version: 1.0.0
   resolution: "assign-symbols@npm:1.0.0"
@@ -12942,6 +13074,21 @@ __metadata:
   languageName: node
   linkType: hard
 
+"chai@npm:^4.3.7":
+  version: 4.3.10
+  resolution: "chai@npm:4.3.10"
+  dependencies:
+    assertion-error: "npm:^1.1.0"
+    check-error: "npm:^1.0.3"
+    deep-eql: "npm:^4.1.3"
+    get-func-name: "npm:^2.0.2"
+    loupe: "npm:^2.3.6"
+    pathval: "npm:^1.1.1"
+    type-detect: "npm:^4.0.8"
+  checksum: c887d24f67be6fb554c7ebbde3bb0568697a8833d475e4768296916891ba143f25fc079f6eb34146f3dd5a3279d34c1f387c32c9a6ab288e579f948d9ccf53fe
+  languageName: node
+  linkType: hard
+
 "chalk@npm:4.1.0":
   version: 4.1.0
   resolution: "chalk@npm:4.1.0"
@@ -13069,6 +13216,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"check-error@npm:^1.0.3":
+  version: 1.0.3
+  resolution: "check-error@npm:1.0.3"
+  dependencies:
+    get-func-name: "npm:^2.0.2"
+  checksum: 94aa37a7315c0e8a83d0112b5bfb5a8624f7f0f81057c73e4707729cdd8077166c6aefb3d8e2b92c63ee130d4a2ff94bad46d547e12f3238cc1d78342a973841
+  languageName: node
+  linkType: hard
+
 "checkup@npm:^1.3.0":
   version: 1.3.0
   resolution: "checkup@npm:1.3.0"
@@ -14566,6 +14722,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"deep-eql@npm:^4.1.3":
+  version: 4.1.3
+  resolution: "deep-eql@npm:4.1.3"
+  dependencies:
+    type-detect: "npm:^4.0.0"
+  checksum: ff34e8605d8253e1bf9fe48056e02c6f347b81d9b5df1c6650a1b0f6f847b4a86453b16dc226b34f853ef14b626e85d04e081b022e20b00cd7d54f079ce9bbdd
+  languageName: node
+  linkType: hard
+
 "deep-equal@npm:^1.1.1":
   version: 1.1.1
   resolution: "deep-equal@npm:1.1.1"
@@ -14879,7 +15044,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"diff-sequences@npm:^29.6.3":
+"diff-sequences@npm:^29.4.3, diff-sequences@npm:^29.6.3":
   version: 29.6.3
   resolution: "diff-sequences@npm:29.6.3"
   checksum: 32e27ac7dbffdf2fb0eb5a84efd98a9ad084fbabd5ac9abb8757c6770d5320d2acd172830b28c4add29bb873d59420601dfc805ac4064330ce59b1adfd0593b2
@@ -17837,6 +18002,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"get-func-name@npm:^2.0.0, get-func-name@npm:^2.0.2":
+  version: 2.0.2
+  resolution: "get-func-name@npm:2.0.2"
+  checksum: 89830fd07623fa73429a711b9daecdb304386d237c71268007f788f113505ef1d4cc2d0b9680e072c5082490aec9df5d7758bf5ac6f1c37062855e8e3dc0b9df
+  languageName: node
+  linkType: hard
+
 "get-intrinsic@npm:^1.0.2, get-intrinsic@npm:^1.1.1, get-intrinsic@npm:^1.1.3, get-intrinsic@npm:^1.2.0, get-intrinsic@npm:^1.2.1":
   version: 1.2.1
   resolution: "get-intrinsic@npm:1.2.1"
@@ -22191,6 +22363,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"loupe@npm:^2.3.6":
+  version: 2.3.6
+  resolution: "loupe@npm:2.3.6"
+  dependencies:
+    get-func-name: "npm:^2.0.0"
+  checksum: a974841ce94ef2a35aac7144e7f9e789e3887f82286cd9ffe7ff00f2ac9d117481989948657465e2b0b102f23136d89ae0a18fd4a32d9015012cd64464453289
+  languageName: node
+  linkType: hard
+
 "lower-case@npm:^2.0.2":
   version: 2.0.2
   resolution: "lower-case@npm:2.0.2"
@@ -25653,6 +25834,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"pathval@npm:^1.1.1":
+  version: 1.1.1
+  resolution: "pathval@npm:1.1.1"
+  checksum: f63e1bc1b33593cdf094ed6ff5c49c1c0dc5dc20a646ca9725cc7fe7cd9995002d51d5685b9b2ec6814342935748b711bafa840f84c0bb04e38ff40a335c94dc
+  languageName: node
+  linkType: hard
+
 "pbkdf2@npm:^3.0.3":
   version: 3.1.2
   resolution: "pbkdf2@npm:3.1.2"
@@ -26243,7 +26431,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"pretty-format@npm:^29.0.0, pretty-format@npm:^29.7.0":
+"pretty-format@npm:^29.0.0, pretty-format@npm:^29.5.0, pretty-format@npm:^29.7.0":
   version: 29.7.0
   resolution: "pretty-format@npm:29.7.0"
   dependencies:
@@ -30450,6 +30638,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"tinyspy@npm:^2.1.1":
+  version: 2.2.0
+  resolution: "tinyspy@npm:2.2.0"
+  checksum: 8c7b70748dd8590e85d52741db79243746c15bc03c92d75c23160a762142db577e7f53e360ba7300e321b12bca5c42dd2522a8dbeec6ba3830302573dd8516bc
+  languageName: node
+  linkType: hard
+
 "tmp@npm:0.0.28":
   version: 0.0.28
   resolution: "tmp@npm:0.0.28"
@@ -30901,7 +31096,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"type-detect@npm:4.0.8":
+"type-detect@npm:4.0.8, type-detect@npm:^4.0.0, type-detect@npm:^4.0.8":
   version: 4.0.8
   resolution: "type-detect@npm:4.0.8"
   checksum: 8fb9a51d3f365a7de84ab7f73b653534b61b622aa6800aecdb0f1095a4a646d3f5eb295322127b6573db7982afcd40ab492d038cf825a42093a58b1e1353e0bd
diff --git a/scripts/package.json b/scripts/package.json
index 85c1d15a3693..784302828efd 100644
--- a/scripts/package.json
+++ b/scripts/package.json
@@ -92,6 +92,7 @@
     "@types/express": "^4.17.11",
     "@types/fs-extra": "^11.0.1",
     "@types/http-server": "^0.12.1",
+    "@types/jest": "^29.5.5",
     "@types/lodash": "^4",
     "@types/node": "^18.0.0",
     "@types/node-fetch": "^2.5.7",
diff --git a/scripts/prepare/bundle.ts b/scripts/prepare/bundle.ts
index a15f71cd6a34..1228e445a034 100755
--- a/scripts/prepare/bundle.ts
+++ b/scripts/prepare/bundle.ts
@@ -16,6 +16,7 @@ type Formats = 'esm' | 'cjs';
 type BundlerConfig = {
   entries: string[];
   externals: string[];
+  noExternal: string[];
   platform: Options['platform'];
   pre: string;
   post: string;
@@ -36,6 +37,7 @@ const run = async ({ cwd, flags }: { cwd: string; flags: string[] }) => {
     bundler: {
       entries = [],
       externals: extraExternals = [],
+      noExternal: extraNoExternal = [],
       platform,
       pre,
       post,
@@ -79,9 +81,12 @@ const run = async ({ cwd, flags }: { cwd: string; flags: string[] }) => {
    */
   const nonPresetEntries = allEntries.filter((f) => !path.parse(f).name.includes('preset'));
 
+  const noExternal = [/^@vitest\/.+$/, ...extraNoExternal];
+
   if (formats.includes('esm')) {
     tasks.push(
       build({
+        noExternal,
         silent: true,
         treeshake: true,
         entry: nonPresetEntries,
@@ -116,6 +121,7 @@ const run = async ({ cwd, flags }: { cwd: string; flags: string[] }) => {
   if (formats.includes('cjs')) {
     tasks.push(
       build({
+        noExternal,
         silent: true,
         entry: allEntries,
         watch,
diff --git a/scripts/tasks/sandbox-parts.ts b/scripts/tasks/sandbox-parts.ts
index e6b1bc188fd4..393169b244f8 100644
--- a/scripts/tasks/sandbox-parts.ts
+++ b/scripts/tasks/sandbox-parts.ts
@@ -354,7 +354,7 @@ async function linkPackageStories(
   );
 }
 
-async function addExtraDependencies({
+export async function addExtraDependencies({
   cwd,
   dryRun,
   debug,
@@ -378,7 +378,7 @@ async function addExtraDependencies({
 
 export const addStories: Task['run'] = async (
   { sandboxDir, template, key },
-  { addon: extraAddons, dryRun, debug, disableDocs }
+  { addon: extraAddons, disableDocs }
 ) => {
   logger.log('💃 adding stories');
   const cwd = sandboxDir;
@@ -516,9 +516,6 @@ export const addStories: Task['run'] = async (
     }
   }
 
-  // Some addon stories require extra dependencies
-  await addExtraDependencies({ cwd, dryRun, debug });
-
   await writeConfig(mainConfig);
 };
 
diff --git a/scripts/tasks/sandbox.ts b/scripts/tasks/sandbox.ts
index 1f63527ca056..58e7d92fe421 100644
--- a/scripts/tasks/sandbox.ts
+++ b/scripts/tasks/sandbox.ts
@@ -55,7 +55,9 @@ export const sandbox: Task = {
       await remove(details.sandboxDir);
     }
 
-    const { create, install, addStories, extendMain, init } = await import('./sandbox-parts');
+    const { create, install, addStories, extendMain, init, addExtraDependencies } = await import(
+      './sandbox-parts'
+    );
 
     let startTime = now();
     await create(details, options);
@@ -90,6 +92,12 @@ export const sandbox: Task = {
       await addStories(details, options);
     }
 
+    await addExtraDependencies({
+      cwd: details.sandboxDir,
+      debug: options.debug,
+      dryRun: options.dryRun,
+    });
+
     await extendMain(details, options);
 
     logger.info(`✅ Storybook sandbox created at ${details.sandboxDir}`);
diff --git a/scripts/yarn.lock b/scripts/yarn.lock
index 4dc6ec7e7c0f..9ab659ee9f9d 100644
--- a/scripts/yarn.lock
+++ b/scripts/yarn.lock
@@ -2969,6 +2969,7 @@ __metadata:
     "@types/express": "npm:^4.17.11"
     "@types/fs-extra": "npm:^11.0.1"
     "@types/http-server": "npm:^0.12.1"
+    "@types/jest": "npm:^29.5.5"
     "@types/lodash": "npm:^4"
     "@types/node": "npm:^18.0.0"
     "@types/node-fetch": "npm:^2.5.7"
@@ -3614,6 +3615,16 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@types/jest@npm:^29.5.5":
+  version: 29.5.5
+  resolution: "@types/jest@npm:29.5.5"
+  dependencies:
+    expect: "npm:^29.0.0"
+    pretty-format: "npm:^29.0.0"
+  checksum: 0a3481f119099e6a0a381fec0d410cd33241267a0981576a7a832687fc3f888f79285289dc7c054c3589fd443f7ed1598d25fa7bc9708491b58da17e423b4aff
+  languageName: node
+  linkType: hard
+
 "@types/jsdom@npm:^20.0.0":
   version: 20.0.1
   resolution: "@types/jsdom@npm:20.0.1"