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 b17647949b86..a0f6b1b08554 100644 --- a/code/addons/actions/src/runtime/action.ts +++ b/code/addons/actions/src/runtime/action.ts @@ -46,6 +46,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(); const id = uuidv4(); const minDepth = 5; // anything less is really just storybook internals diff --git a/code/addons/interactions/src/preview.ts b/code/addons/interactions/src/preview.ts index 972126421393..d393a0aefb20 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'; @@ -33,7 +35,7 @@ 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; } @@ -56,7 +58,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..59cb8cf3c37e 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', diff --git a/code/lib/instrumenter/src/instrumenter.test.ts b/code/lib/instrumenter/src/instrumenter.test.ts index eadb1f7d5c84..184c6773a747 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 = {}; diff --git a/code/lib/instrumenter/src/instrumenter.ts b/code/lib/instrumenter/src/instrumenter.ts index 357b9df52817..de7914f3d64a 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'; @@ -24,8 +24,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 +49,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 +289,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 +338,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 +351,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 +367,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 +381,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 +396,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; @@ -510,7 +533,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 +660,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..0a528a5c161a 100644 --- a/code/lib/instrumenter/src/types.ts +++ b/code/lib/instrumenter/src/types.ts @@ -90,4 +90,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..7de3167917c8 --- /dev/null +++ b/code/lib/test/package.json @@ -0,0 +1,74 @@ +{ + "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", + "expect": "^29.6.2", + "ts-dedent": "^2.2.0", + "util": "^0.12.4" + }, + "devDependencies": { + "type-fest": "~2.19", + "typescript": "~4.9.3" + }, + "publishConfig": { + "access": "public" + }, + "bundler": { + "entries": [ + "./src/index.ts" + ] + }, + "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 7a14a389b3d7..4e20c819c7b7 100644 --- a/code/package.json +++ b/code/package.json @@ -80,7 +80,6 @@ ], "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", @@ -89,7 +88,8 @@ "playwright": "1.36.0", "playwright-core": "1.36.0", "serialize-javascript": "^3.1.0", - "type-fest": "~2.19" + "type-fest": "~2.19", + "@vitest/expect@^0.34.2": "patch:@vitest/expect@npm%3A0.34.5#./.yarn/patches/@vitest-expect-npm-0.34.5-8031508efe.patch" }, "dependencies": { "@babel/core": "^7.22.9", 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 9a13dc094783..935d30de7443 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 3ec46c6397a8..d879303d9c2a 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 @@ -7206,6 +7206,7 @@ __metadata: "@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 @@ -8099,6 +8100,29 @@ __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": ^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 + expect: ^29.6.2 + ts-dedent: ^2.2.0 + type-fest: ~2.19 + typescript: ~4.9.3 + util: ^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" @@ -8694,7 +8718,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: @@ -8727,6 +8751,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": ^4.3.0 + "@babel/runtime": ^7.9.2 + aria-query: ^5.0.0 + chalk: ^3.0.0 + css.escape: ^1.5.1 + dom-accessibility-api: ^0.5.6 + lodash: ^4.17.15 + redent: ^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" @@ -8751,7 +8805,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: @@ -8928,6 +8982,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" @@ -10140,6 +10201,57 @@ __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": 0.34.5 + "@vitest/utils": 0.34.5 + chai: ^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": 0.34.5 + "@vitest/utils": 0.34.5 + chai: ^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: ^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: ^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: ^29.4.3 + loupe: ^2.3.6 + pretty-format: ^29.5.0 + checksum: 99cc5974ada1dab2b02220005c0fc97147baba175601a0faa1b2b6687c7f579d21a401077377d6f759b3aa8a07dcc8851cdc3e07f9a550ec289286107487ac36 + 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" @@ -11465,6 +11577,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" @@ -12893,6 +13012,21 @@ __metadata: languageName: node linkType: hard +"chai@npm:^4.3.7": + version: 4.3.10 + resolution: "chai@npm:4.3.10" + dependencies: + assertion-error: ^1.1.0 + check-error: ^1.0.3 + deep-eql: ^4.1.3 + get-func-name: ^2.0.2 + loupe: ^2.3.6 + pathval: ^1.1.1 + type-detect: ^4.0.8 + checksum: c887d24f67be6fb554c7ebbde3bb0568697a8833d475e4768296916891ba143f25fc079f6eb34146f3dd5a3279d34c1f387c32c9a6ab288e579f948d9ccf53fe + languageName: node + linkType: hard + "chalk@npm:4.1.0": version: 4.1.0 resolution: "chalk@npm:4.1.0" @@ -13020,6 +13154,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: ^2.0.2 + checksum: 94aa37a7315c0e8a83d0112b5bfb5a8624f7f0f81057c73e4707729cdd8077166c6aefb3d8e2b92c63ee130d4a2ff94bad46d547e12f3238cc1d78342a973841 + languageName: node + linkType: hard + "checkup@npm:^1.3.0": version: 1.3.0 resolution: "checkup@npm:1.3.0" @@ -14517,6 +14660,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: ^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" @@ -14830,7 +14982,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 @@ -16696,7 +16848,7 @@ __metadata: languageName: node linkType: hard -"expect@npm:^29.0.0, expect@npm:^29.7.0": +"expect@npm:^29.0.0, expect@npm:^29.6.2, expect@npm:^29.7.0": version: 29.7.0 resolution: "expect@npm:29.7.0" dependencies: @@ -17788,6 +17940,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" @@ -22142,6 +22301,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: ^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" @@ -25622,6 +25790,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" @@ -26223,7 +26398,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: @@ -30430,6 +30605,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" @@ -30881,7 +31063,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 8a1ace601a4d..7f73bcaa6bdd 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -89,6 +89,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": "^16.0.0", "@types/node-fetch": "^2.5.7", diff --git a/scripts/prepare/bundle.ts b/scripts/prepare/bundle.ts index a15f71cd6a34..02199ecff992 100755 --- a/scripts/prepare/bundle.ts +++ b/scripts/prepare/bundle.ts @@ -79,9 +79,12 @@ const run = async ({ cwd, flags }: { cwd: string; flags: string[] }) => { */ const nonPresetEntries = allEntries.filter((f) => !path.parse(f).name.includes('preset')); + const noExternal = [/^@vitest\/.+$/]; + if (formats.includes('esm')) { tasks.push( build({ + noExternal, silent: true, treeshake: true, entry: nonPresetEntries, @@ -116,6 +119,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 117d10948b51..af689708050f 100644 --- a/scripts/tasks/sandbox.ts +++ b/scripts/tasks/sandbox.ts @@ -49,7 +49,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); @@ -84,6 +86,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 19568a9f4a70..c75ada78e9a2 100644 --- a/scripts/yarn.lock +++ b/scripts/yarn.lock @@ -2917,6 +2917,7 @@ __metadata: "@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": ^16.0.0 "@types/node-fetch": ^2.5.7 @@ -3562,6 +3563,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: ^29.0.0 + pretty-format: ^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"