Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
it('should be able to handle spaces in path to exposes', async () => {
const { default: test1 } = await import('./test 1');
const { default: test2 } = await import('./path with spaces/test-2');
expect(test1()).toBe('test 1');
expect(test2()).toBe('test 2');
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function test() {
return 'test 2';
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function test() {
return 'test 1';
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
const { ModuleFederationPlugin } = require('../../../../dist/src');

module.exports = {
mode: 'development',
devtool: false,
output: {
publicPath: 'http://localhost:3000/',
},
plugins: [
new ModuleFederationPlugin({
name: 'remote',
filename: 'remoteEntry.js',
manifest: true,
exposes: {
'./test-1': './test 1.js',
'./test-2': './path with spaces/test-2.js',
},
}),
],
};
110 changes: 38 additions & 72 deletions packages/enhanced/test/unit/sharing/resolveMatchedConfigs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,54 +3,23 @@
* Testing all resolution paths: relative, absolute, prefix, and regular module requests
*/

import ModuleNotFoundError from 'webpack/lib/ModuleNotFoundError';
import LazySet from 'webpack/lib/util/LazySet';

import { resolveMatchedConfigs } from '../../../src/lib/sharing/resolveMatchedConfigs';
import type { ConsumeOptions } from '../../../src/declarations/plugins/sharing/ConsumeSharedModule';

jest.mock('@module-federation/sdk/normalize-webpack-path', () => ({
normalizeWebpackPath: jest.fn((path) => path),
}));

// Mock webpack classes
jest.mock(
'webpack/lib/ModuleNotFoundError',
() =>
jest.fn().mockImplementation((module, err, details) => {
return { module, err, details };
}),
{
virtual: true,
},
);
jest.mock(
'webpack/lib/util/LazySet',
() =>
jest.fn().mockImplementation(() => ({
add: jest.fn(),
addAll: jest.fn(),
})),
{ virtual: true },
);

describe('resolveMatchedConfigs', () => {
let mockCompilation: any;
let mockResolver: any;
let mockResolveContext: any;
let MockModuleNotFoundError: any;
let MockLazySet: any;

beforeEach(() => {
jest.clearAllMocks();

// Get the mocked classes
MockModuleNotFoundError = require('webpack/lib/ModuleNotFoundError');
MockLazySet = require('webpack/lib/util/LazySet');

mockResolveContext = {
fileDependencies: { add: jest.fn(), addAll: jest.fn() },
contextDependencies: { add: jest.fn(), addAll: jest.fn() },
missingDependencies: { add: jest.fn(), addAll: jest.fn() },
};

mockResolver = {
resolve: jest.fn(),
};
Expand All @@ -67,9 +36,6 @@ describe('resolveMatchedConfigs', () => {
fileDependencies: { addAll: jest.fn() },
missingDependencies: { addAll: jest.fn() },
};

// Setup LazySet mock instances
MockLazySet.mockImplementation(() => mockResolveContext.fileDependencies);
});

describe('relative path resolution', () => {
Expand Down Expand Up @@ -138,14 +104,15 @@ describe('resolveMatchedConfigs', () => {
expect(result.unresolved.size).toBe(0);
expect(result.prefixed.size).toBe(0);
expect(mockCompilation.errors).toHaveLength(1);
expect(MockModuleNotFoundError).toHaveBeenCalledWith(null, resolveError, {
const error = mockCompilation.errors[0] as InstanceType<
typeof ModuleNotFoundError
>;
expect(error).toBeInstanceOf(ModuleNotFoundError);
expect(error.module).toBeNull();
expect(error.error).toBe(resolveError);
expect(error.loc).toEqual({
name: 'shared module ./missing-module',
});
expect(mockCompilation.errors[0]).toEqual({
module: null,
err: resolveError,
details: { name: 'shared module ./missing-module' },
});
});

it('should handle resolver returning false', async () => {
Expand All @@ -163,17 +130,15 @@ describe('resolveMatchedConfigs', () => {

expect(result.resolved.size).toBe(0);
expect(mockCompilation.errors).toHaveLength(1);
expect(MockModuleNotFoundError).toHaveBeenCalledWith(
null,
expect.any(Error),
{ name: 'shared module ./invalid-module' },
);
expect(mockCompilation.errors[0]).toEqual({
module: null,
err: expect.objectContaining({
message: "Can't resolve ./invalid-module",
}),
details: { name: 'shared module ./invalid-module' },
const error = mockCompilation.errors[0] as InstanceType<
typeof ModuleNotFoundError
>;
expect(error).toBeInstanceOf(ModuleNotFoundError);
expect(error.module).toBeNull();
expect(error.error).toBeInstanceOf(Error);
expect(error.error.message).toContain("Can't resolve ./invalid-module");
expect(error.loc).toEqual({
name: 'shared module ./invalid-module',
});
});

Expand Down Expand Up @@ -459,12 +424,6 @@ describe('resolveMatchedConfigs', () => {
['./relative', { shareScope: 'default' }],
];

const resolveContext = {
fileDependencies: { add: jest.fn(), addAll: jest.fn() },
contextDependencies: { add: jest.fn(), addAll: jest.fn() },
missingDependencies: { add: jest.fn(), addAll: jest.fn() },
};

mockResolver.resolve.mockImplementation(
(context, basePath, request, rc, callback) => {
// Simulate adding dependencies during resolution
Expand All @@ -475,22 +434,29 @@ describe('resolveMatchedConfigs', () => {
},
);

// Update LazySet mock to return the actual resolve context
MockLazySet.mockReturnValueOnce(resolveContext.fileDependencies)
.mockReturnValueOnce(resolveContext.contextDependencies)
.mockReturnValueOnce(resolveContext.missingDependencies);

await resolveMatchedConfigs(mockCompilation, configs);

expect(mockCompilation.contextDependencies.addAll).toHaveBeenCalledWith(
resolveContext.contextDependencies,
);
expect(mockCompilation.fileDependencies.addAll).toHaveBeenCalledWith(
resolveContext.fileDependencies,
expect(mockCompilation.contextDependencies.addAll).toHaveBeenCalledTimes(
1,
);
expect(mockCompilation.missingDependencies.addAll).toHaveBeenCalledWith(
resolveContext.missingDependencies,
expect(mockCompilation.fileDependencies.addAll).toHaveBeenCalledTimes(1);
expect(mockCompilation.missingDependencies.addAll).toHaveBeenCalledTimes(
1,
);

const [contextDeps] =
mockCompilation.contextDependencies.addAll.mock.calls[0];
const [fileDeps] = mockCompilation.fileDependencies.addAll.mock.calls[0];
const [missingDeps] =
mockCompilation.missingDependencies.addAll.mock.calls[0];

expect(contextDeps).toBeInstanceOf(LazySet);
expect(fileDeps).toBeInstanceOf(LazySet);
expect(missingDeps).toBeInstanceOf(LazySet);

expect(contextDeps.has('/some/context')).toBe(true);
expect(fileDeps.has('/some/file.js')).toBe(true);
expect(missingDeps.has('/missing/file')).toBe(true);
});
});

Expand Down
179 changes: 179 additions & 0 deletions packages/manifest/__tests__/ModuleHandler.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import type { StatsModule } from 'webpack';

jest.mock(
'@module-federation/sdk',
() => ({
composeKeyWithSeparator: (...parts: string[]) => parts.join(':'),
moduleFederationPlugin: {},
createLogger: () => ({
debug: () => undefined,
error: () => undefined,
info: () => undefined,
warn: () => undefined,
}),
}),
{ virtual: true },
);

jest.mock(
'@module-federation/dts-plugin/core',
() => ({
isTSProject: () => false,
retrieveTypesAssetsInfo: () => ({}) as const,
}),
{ virtual: true },
);

jest.mock(
'@module-federation/managers',
() => ({
ContainerManager: class {
options?: { name?: string; exposes?: unknown };

init(options: { name?: string; exposes?: unknown }) {
this.options = options;
}

get enable() {
const { name, exposes } = this.options || {};

if (!name || !exposes) {
return false;
}

if (Array.isArray(exposes)) {
return exposes.length > 0;
}

return Object.keys(exposes as Record<string, unknown>).length > 0;
}

get containerPluginExposesOptions() {
const { exposes } = this.options || {};

if (!exposes || Array.isArray(exposes)) {
return {};
}

return Object.entries(exposes as Record<string, unknown>).reduce(
(acc, [exposeKey, exposeValue]) => {
if (typeof exposeValue === 'string') {
acc[exposeKey] = { import: [exposeValue] };
} else if (Array.isArray(exposeValue)) {
acc[exposeKey] = { import: exposeValue as string[] };
} else if (
exposeValue &&
typeof exposeValue === 'object' &&
'import' in exposeValue
) {
const exposeImport = (
exposeValue as { import: string | string[] }
).import;
acc[exposeKey] = {
import: Array.isArray(exposeImport)
? exposeImport
: [exposeImport],
};
}

return acc;
},
{} as Record<string, { import: string[] }>,
);
}
},
RemoteManager: class {
statsRemoteWithEmptyUsedIn: unknown[] = [];
init() {}
},
SharedManager: class {
normalizedOptions: Record<string, { requiredVersion?: string }> = {};
init() {}
},
}),
{ virtual: true },
);

import type { moduleFederationPlugin } from '@module-federation/sdk';
// eslint-disable-next-line import/first
import { ModuleHandler } from '../src/ModuleHandler';

describe('ModuleHandler', () => {
it('initializes exposes from plugin options when import paths contain spaces', () => {
const options = {
name: 'test-app',
exposes: {
'./Button': './src/path with spaces/Button.tsx',
},
} as const;

const moduleHandler = new ModuleHandler(options, [], {
bundler: 'webpack',
});

const { exposesMap } = moduleHandler.collect();

const expose = exposesMap['./src/path with spaces/Button'];

expect(expose).toBeDefined();
expect(expose?.path).toBe('./Button');
expect(expose?.file).toBe('src/path with spaces/Button.tsx');
});

it('parses container exposes when identifiers contain spaces', () => {
const options = {
name: 'test-app',
} as const;

const modules: StatsModule[] = [
{
identifier:
'container entry (default) [["./Button",{"import":["./src/path with spaces/Button.tsx"],"name":"__federation_expose_Button"}]]',
} as StatsModule,
];

const moduleHandler = new ModuleHandler(options, modules, {
bundler: 'webpack',
});

const { exposesMap } = moduleHandler.collect();

const expose = exposesMap['./src/path with spaces/Button'];

expect(expose).toBeDefined();
expect(expose?.path).toBe('./Button');
expect(expose?.file).toBe('src/path with spaces/Button.tsx');
});

it('falls back to normalized exposes when identifier parsing fails', () => {
const options = {
exposes: {
'./Button': './src/Button.tsx',
'./Card': { import: ['./src/Card.tsx'], name: 'Card' },
'./Invalid': { import: [] },
'./Empty': '',
},
} as const;

const modules: StatsModule[] = [
{
identifier: 'container entry (default)',
} as StatsModule,
];

const moduleHandler = new ModuleHandler(
options as unknown as moduleFederationPlugin.ModuleFederationPluginOptions,
modules,
{
bundler: 'webpack',
},
);

const { exposesMap } = moduleHandler.collect();

expect(exposesMap['./src/Button']).toBeDefined();
expect(exposesMap['./src/Card']).toBeDefined();
expect(exposesMap['./src/Button']?.path).toBe('./Button');
expect(exposesMap['./src/Card']?.path).toBe('./Card');
});
});
Loading