From 36e5d3a4f877779e31f994935cdc998661393913 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 31 Jul 2025 00:13:47 +0200 Subject: [PATCH 1/2] Test: Consider exports map --- code/.storybook/preview.tsx | 1 + code/__mocks__/uuid.js | 3 + code/core/package.json | 1 + .../src/core-server/mocking-utils/resolve.ts | 68 ++++++++++++++++--- .../presets/vitePlugins/vite-mock/plugin.ts | 16 +++-- code/core/template/__mocks__/uuid.js | 3 + .../test/CjsNodeModuleMocking.stories.js | 26 +++++++ .../stories/test/ModuleAutoMocking.stories.ts | 1 + code/package.json | 1 + code/yarn.lock | 13 +++- scripts/tasks/sandbox-parts.ts | 1 + scripts/tasks/sandbox.ts | 1 + 12 files changed, 117 insertions(+), 18 deletions(-) create mode 100644 code/__mocks__/uuid.js create mode 100644 code/core/template/__mocks__/uuid.js create mode 100644 code/core/template/stories/test/CjsNodeModuleMocking.stories.js diff --git a/code/.storybook/preview.tsx b/code/.storybook/preview.tsx index 5ad330df65a6..f51321310e7a 100644 --- a/code/.storybook/preview.tsx +++ b/code/.storybook/preview.tsx @@ -41,6 +41,7 @@ sb.mock(import('../core/template/stories/test/ModuleAutoMocking.utils')); sb.mock(import('lodash-es')); sb.mock(import('lodash-es/add')); sb.mock(import('lodash-es/sum')); +sb.mock(import('uuid')); const { document } = global; globalThis.CONFIG_TYPE = 'DEVELOPMENT'; diff --git a/code/__mocks__/uuid.js b/code/__mocks__/uuid.js new file mode 100644 index 000000000000..4a718e51431d --- /dev/null +++ b/code/__mocks__/uuid.js @@ -0,0 +1,3 @@ +export function v4() { + return 'MOCK-V4'; +} diff --git a/code/core/package.json b/code/core/package.json index 83107306ab34..4cc0d27938e0 100644 --- a/code/core/package.json +++ b/code/core/package.json @@ -567,6 +567,7 @@ "react-transition-group": "^4.4.5", "require-from-string": "^2.0.2", "resolve-from": "^5.0.0", + "resolve.exports": "^2.0.3", "sirv": "^2.0.4", "slash": "^5.0.0", "source-map": "^0.7.4", diff --git a/code/core/src/core-server/mocking-utils/resolve.ts b/code/core/src/core-server/mocking-utils/resolve.ts index bc39a42deecd..a4ed4bcefb69 100644 --- a/code/core/src/core-server/mocking-utils/resolve.ts +++ b/code/core/src/core-server/mocking-utils/resolve.ts @@ -1,26 +1,74 @@ +import { readFileSync } from 'node:fs'; + import { findMockRedirect } from '@vitest/mocker/redirect'; -import { isAbsolute, join, resolve } from 'pathe'; +import { dirname, isAbsolute, join, resolve } from 'pathe'; +import { exports as resolveExports } from 'resolve.exports'; import { isModuleDirectory } from './extract'; -export function resolveMock(mockPath: string, root: string, previewConfigPath: string) { +/** + * Finds the package.json for a given module specifier. + * + * @param specifier The module specifier (e.g., 'uuid', 'lodash-es/add'). + * @param basedir The directory to start the search from. + * @returns The path to the package.json and the package's contents. + */ +function findPackageJson(specifier: string, basedir: string): { path: string; data: any } { + const packageJsonPath = require.resolve(`${specifier}/package.json`, { paths: [basedir] }); + return { + path: packageJsonPath, + data: JSON.parse(readFileSync(packageJsonPath, 'utf-8')), + }; +} + +/** + * Resolves a mock path to its absolute path and checks for a `__mocks__` redirect. This function + * uses `resolve.exports` to correctly handle modern ESM packages. + * + * @param path The raw module path from the `sb.mock()` call. + * @param root The project's root directory. + * @param importer The absolute path of the file containing the mock call (the preview file). + */ +export function resolveMock(path: string, root: string, importer: string) { const isExternal = (function () { try { - return ( - !isAbsolute(mockPath) && isModuleDirectory(require.resolve(mockPath, { paths: [root] })) - ); + return !isAbsolute(path) && isModuleDirectory(require.resolve(path, { paths: [root] })); } catch (e) { return false; } })(); - const external = isExternal ? mockPath : null; + const external = isExternal ? path : null; + + let absolutePath: string | undefined; - const absolutePath = external - ? require.resolve(mockPath, { paths: [root] }) - : require.resolve(join(previewConfigPath, '..', mockPath), { - paths: [root], + if (isExternal) { + // --- External Package Resolution --- + const parts = path.split('/'); + // For scoped packages like `@foo/bar`, the package name is the first two parts. + const packageName = path.startsWith('@') ? `${parts[0]}/${parts[1]}` : parts[0]; + const entry = `.${path.slice(packageName.length)}`; // e.g., './add' from 'lodash-es/add' + + const { path: packageJsonPath, data: pkg } = findPackageJson(packageName, root); + const packageDir = dirname(packageJsonPath); + + // 1. Try to resolve using the "exports" map. + if (pkg.exports) { + const result = resolveExports(pkg, entry, { + browser: true, }); + absolutePath = result ? join(packageDir, result[0]) : undefined; + } + + // 2. If "exports" map fails or doesn't exist, fall back to standard resolution + if (!absolutePath) { + absolutePath = require.resolve(path, { paths: [root] }); + } + } else { + // --- Local File Resolution --- + // For relative paths, Node's standard resolver is sufficient and correct. + absolutePath = require.resolve(path, { paths: [dirname(importer)] }); + } const normalizedAbsolutePath = resolve(absolutePath); diff --git a/code/core/src/core-server/presets/vitePlugins/vite-mock/plugin.ts b/code/core/src/core-server/presets/vitePlugins/vite-mock/plugin.ts index 2960f992b2ca..2ed72c7b90fe 100644 --- a/code/core/src/core-server/presets/vitePlugins/vite-mock/plugin.ts +++ b/code/core/src/core-server/presets/vitePlugins/vite-mock/plugin.ts @@ -133,14 +133,16 @@ export function viteMockPlugin(options: MockPluginOptions): Plugin[] { const idNorm = normalizePathForComparison(id, preserveSymlinks); const callNorm = normalizePathForComparison(call.absolutePath, preserveSymlinks); - if (callNorm !== idNorm && viteConfig.command !== 'serve') { - continue; - } - - const cleanId = getCleanId(idNorm); + if (viteConfig.command !== 'serve') { + if (callNorm !== idNorm) { + continue; + } + } else { + const cleanId = getCleanId(idNorm); - if (viteConfig.command === 'serve' && call.path !== cleanId && callNorm !== idNorm) { - continue; + if (call.path !== cleanId && callNorm !== idNorm) { + continue; + } } try { diff --git a/code/core/template/__mocks__/uuid.js b/code/core/template/__mocks__/uuid.js new file mode 100644 index 000000000000..4a718e51431d --- /dev/null +++ b/code/core/template/__mocks__/uuid.js @@ -0,0 +1,3 @@ +export function v4() { + return 'MOCK-V4'; +} diff --git a/code/core/template/stories/test/CjsNodeModuleMocking.stories.js b/code/core/template/stories/test/CjsNodeModuleMocking.stories.js new file mode 100644 index 000000000000..f0fed64a8698 --- /dev/null +++ b/code/core/template/stories/test/CjsNodeModuleMocking.stories.js @@ -0,0 +1,26 @@ +import { global as globalThis } from '@storybook/global'; + +import { expect } from 'storybook/test'; +import { v4 } from 'uuid'; + +// This story is used to test the node module mocking for modules which have an exports field in their package.json. + +export default { + component: globalThis.__TEMPLATE_COMPONENTS__.Pre, + decorators: [ + (storyFn) => + storyFn({ + args: { + text: `UUID Version: ${v4()}`, + }, + }), + ], + parameters: { + layout: 'fullscreen', + }, + play: async ({ canvasElement }) => { + await expect(canvasElement.innerHTML).toContain('UUID Version: MOCK-V4'); + }, +}; + +export const Original = {}; diff --git a/code/core/template/stories/test/ModuleAutoMocking.stories.ts b/code/core/template/stories/test/ModuleAutoMocking.stories.ts index 587d6ab63a1c..4d9c750437aa 100644 --- a/code/core/template/stories/test/ModuleAutoMocking.stories.ts +++ b/code/core/template/stories/test/ModuleAutoMocking.stories.ts @@ -1,6 +1,7 @@ import { global as globalThis } from '@storybook/global'; import { expect } from 'storybook/test'; +import { v4 } from 'uuid'; import { fn } from './ModuleAutoMocking.utils'; diff --git a/code/package.json b/code/package.json index 7361d9f312e8..f7cdbc8b41b5 100644 --- a/code/package.json +++ b/code/package.json @@ -209,6 +209,7 @@ "svelte": "^5.0.0-next.268", "ts-dedent": "^2.0.0", "typescript": "^5.8.3", + "uuid": "^11.1.0", "vite": "^6.2.5", "vite-plugin-inspect": "^11.0.0", "vitest": "^3.2.4", diff --git a/code/yarn.lock b/code/yarn.lock index ee5b25c13a97..205be878bea1 100644 --- a/code/yarn.lock +++ b/code/yarn.lock @@ -7194,6 +7194,7 @@ __metadata: svelte: "npm:^5.0.0-next.268" ts-dedent: "npm:^2.0.0" typescript: "npm:^5.8.3" + uuid: "npm:^11.1.0" vite: "npm:^6.2.5" vite-plugin-inspect: "npm:^11.0.0" vitest: "npm:^3.2.4" @@ -23507,7 +23508,7 @@ __metadata: languageName: node linkType: hard -"resolve.exports@npm:2.0.3": +"resolve.exports@npm:2.0.3, resolve.exports@npm:^2.0.3": version: 2.0.3 resolution: "resolve.exports@npm:2.0.3" checksum: 10c0/1ade1493f4642a6267d0a5e68faeac20b3d220f18c28b140343feb83694d8fed7a286852aef43689d16042c61e2ddb270be6578ad4a13990769e12065191200d @@ -25081,6 +25082,7 @@ __metadata: recast: "npm:^0.23.5" require-from-string: "npm:^2.0.2" resolve-from: "npm:^5.0.0" + resolve.exports: "npm:^2.0.3" semver: "npm:^7.6.2" sirv: "npm:^2.0.4" slash: "npm:^5.0.0" @@ -26871,6 +26873,15 @@ __metadata: languageName: node linkType: hard +"uuid@npm:^11.1.0": + version: 11.1.0 + resolution: "uuid@npm:11.1.0" + bin: + uuid: dist/esm/bin/uuid + checksum: 10c0/34aa51b9874ae398c2b799c88a127701408cd581ee89ec3baa53509dd8728cbb25826f2a038f9465f8b7be446f0fbf11558862965b18d21c993684297628d4d3 + languageName: node + linkType: hard + "uuid@npm:^8.0.0, uuid@npm:^8.3.2": version: 8.3.2 resolution: "uuid@npm:8.3.2" diff --git a/scripts/tasks/sandbox-parts.ts b/scripts/tasks/sandbox-parts.ts index 54820d378397..d7d90ec58f16 100644 --- a/scripts/tasks/sandbox-parts.ts +++ b/scripts/tasks/sandbox-parts.ts @@ -801,6 +801,7 @@ export const extendPreview: Task['run'] = async ({ template, sandboxDir }) => { "sb.mock(import('lodash-es'));", "sb.mock(import('lodash-es/add'));", "sb.mock(import('lodash-es/sum'));", + "sb.mock(import('uuid'));", '', ].join('\n'); diff --git a/scripts/tasks/sandbox.ts b/scripts/tasks/sandbox.ts index 966b06ec9926..6d052d537e67 100644 --- a/scripts/tasks/sandbox.ts +++ b/scripts/tasks/sandbox.ts @@ -77,6 +77,7 @@ export const sandbox: Task = { // Adding the dep makes sure that even npx will use the linked workspace version. '@storybook/cli', 'lodash-es', + 'uuid', ]; const shouldAddVitestIntegration = !details.template.skipTasks?.includes('vitest-integration'); From 139af72d007dc678159fb3af8a2c25484c47e628 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 31 Jul 2025 08:00:41 +0200 Subject: [PATCH 2/2] Test: Fix dual package export mocks in Webpack --- .../src/core-server/mocking-utils/resolve.ts | 83 ++++++++++--------- .../webpack/plugins/webpack-mock-plugin.ts | 13 ++- 2 files changed, 54 insertions(+), 42 deletions(-) diff --git a/code/core/src/core-server/mocking-utils/resolve.ts b/code/core/src/core-server/mocking-utils/resolve.ts index a4ed4bcefb69..246e4a26586e 100644 --- a/code/core/src/core-server/mocking-utils/resolve.ts +++ b/code/core/src/core-server/mocking-utils/resolve.ts @@ -22,57 +22,64 @@ function findPackageJson(specifier: string, basedir: string): { path: string; da } /** - * Resolves a mock path to its absolute path and checks for a `__mocks__` redirect. This function - * uses `resolve.exports` to correctly handle modern ESM packages. + * Resolves an external module path to its absolute path. It considers the "exports" map in the + * package.json file. * * @param path The raw module path from the `sb.mock()` call. * @param root The project's root directory. - * @param importer The absolute path of the file containing the mock call (the preview file). + * @returns The absolute path to the module. */ -export function resolveMock(path: string, root: string, importer: string) { - const isExternal = (function () { - try { - return !isAbsolute(path) && isModuleDirectory(require.resolve(path, { paths: [root] })); - } catch (e) { - return false; - } - })(); - - const external = isExternal ? path : null; +export function resolveExternalModule(path: string, root: string) { + // --- External Package Resolution --- + const parts = path.split('/'); + // For scoped packages like `@foo/bar`, the package name is the first two parts. + const packageName = path.startsWith('@') ? `${parts[0]}/${parts[1]}` : parts[0]; + const entry = `.${path.slice(packageName.length)}`; // e.g., './add' from 'lodash-es/add' - let absolutePath: string | undefined; + const { path: packageJsonPath, data: pkg } = findPackageJson(packageName, root); + const packageDir = dirname(packageJsonPath); - if (isExternal) { - // --- External Package Resolution --- - const parts = path.split('/'); - // For scoped packages like `@foo/bar`, the package name is the first two parts. - const packageName = path.startsWith('@') ? `${parts[0]}/${parts[1]}` : parts[0]; - const entry = `.${path.slice(packageName.length)}`; // e.g., './add' from 'lodash-es/add' + // 1. Try to resolve using the "exports" map. + if (pkg.exports) { + const result = resolveExports(pkg, entry, { + browser: true, + }); - const { path: packageJsonPath, data: pkg } = findPackageJson(packageName, root); - const packageDir = dirname(packageJsonPath); - - // 1. Try to resolve using the "exports" map. - if (pkg.exports) { - const result = resolveExports(pkg, entry, { - browser: true, - }); - absolutePath = result ? join(packageDir, result[0]) : undefined; + if (result) { + return join(packageDir, result[0]); } + } - // 2. If "exports" map fails or doesn't exist, fall back to standard resolution - if (!absolutePath) { - absolutePath = require.resolve(path, { paths: [root] }); - } - } else { - // --- Local File Resolution --- - // For relative paths, Node's standard resolver is sufficient and correct. - absolutePath = require.resolve(path, { paths: [dirname(importer)] }); + return require.resolve(path, { paths: [root] }); +} + +export function getIsExternal(path: string, importer: string) { + try { + return !isAbsolute(path) && isModuleDirectory(require.resolve(path, { paths: [importer] })); + } catch (e) { + return false; } +} + +/** + * Resolves a mock path to its absolute path and checks for a `__mocks__` redirect. This function + * uses `resolve.exports` to correctly handle modern ESM packages. + * + * @param path The raw module path from the `sb.mock()` call. + * @param root The project's root directory. + * @param importer The absolute path of the file containing the mock call (the preview file). + */ +export function resolveMock(path: string, root: string, importer: string) { + const isExternal = getIsExternal(path, root); + const externalPath = isExternal ? path : null; + + const absolutePath = isExternal + ? resolveExternalModule(path, root) + : require.resolve(path, { paths: [dirname(importer)] }); const normalizedAbsolutePath = resolve(absolutePath); - const redirectPath = findMockRedirect(root, normalizedAbsolutePath, external); + const redirectPath = findMockRedirect(root, normalizedAbsolutePath, externalPath); return { absolutePath: normalizedAbsolutePath, diff --git a/code/core/src/core-server/presets/webpack/plugins/webpack-mock-plugin.ts b/code/core/src/core-server/presets/webpack/plugins/webpack-mock-plugin.ts index 78846e89f3f4..976691815b37 100644 --- a/code/core/src/core-server/presets/webpack/plugins/webpack-mock-plugin.ts +++ b/code/core/src/core-server/presets/webpack/plugins/webpack-mock-plugin.ts @@ -3,7 +3,7 @@ import { dirname, isAbsolute } from 'node:path'; import type { Compiler } from 'webpack'; import { babelParser, extractMockCalls } from '../../../mocking-utils/extract'; -import { resolveMock } from '../../../mocking-utils/resolve'; +import { getIsExternal, resolveExternalModule, resolveMock } from '../../../mocking-utils/resolve'; // --- Type Definitions --- @@ -73,9 +73,14 @@ export class WebpackMockPlugin { // Apply the replacement plugin. Its callback will now use the dynamically updated mockMap. new compiler.webpack.NormalModuleReplacementPlugin(/.*/, (resource) => { try { - const absolutePath = require.resolve(resource.request, { - paths: [resource.context], - }); + const path = resource.request; + const importer = resource.context; + + const isExternal = getIsExternal(path, importer); + const absolutePath = isExternal + ? resolveExternalModule(path, importer) + : require.resolve(path, { paths: [importer] }); + if (this.mockMap.has(absolutePath)) { const mock = this.mockMap.get(absolutePath)!; resource.request = mock.replacementResource;