Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions code/.storybook/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
3 changes: 3 additions & 0 deletions code/__mocks__/uuid.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function v4() {
return 'MOCK-V4';
}
1 change: 1 addition & 0 deletions code/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
89 changes: 72 additions & 17 deletions code/core/src/core-server/mocking-utils/resolve.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,85 @@
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) {
const isExternal = (function () {
try {
return (
!isAbsolute(mockPath) && isModuleDirectory(require.resolve(mockPath, { paths: [root] }))
);
} catch (e) {
return false;
/**
* 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 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.
* @returns The absolute path to the module.
*/
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'

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,
});

if (result) {
return join(packageDir, result[0]);
}
})();
}

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;
}
}

const external = isExternal ? mockPath : null;
/**
* 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 = external
? require.resolve(mockPath, { paths: [root] })
: require.resolve(join(previewConfigPath, '..', mockPath), {
paths: [root],
});
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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---

Expand Down Expand Up @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions code/core/template/__mocks__/uuid.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function v4() {
return 'MOCK-V4';
}
26 changes: 26 additions & 0 deletions code/core/template/stories/test/CjsNodeModuleMocking.stories.js
Original file line number Diff line number Diff line change
@@ -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 = {};
Original file line number Diff line number Diff line change
@@ -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';

Expand Down
1 change: 1 addition & 0 deletions code/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
13 changes: 12 additions & 1 deletion code/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions scripts/tasks/sandbox-parts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down
1 change: 1 addition & 0 deletions scripts/tasks/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down