Skip to content

Commit 7452cc0

Browse files
fix(enhanced): Add fallback parsing for container module exposes (#4034) (#4083)
Co-authored-by: Vinicius Rocha <[email protected]>
1 parent f0c1e79 commit 7452cc0

File tree

7 files changed

+464
-80
lines changed

7 files changed

+464
-80
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
it('should be able to handle spaces in path to exposes', async () => {
2+
const { default: test1 } = await import('./test 1');
3+
const { default: test2 } = await import('./path with spaces/test-2');
4+
expect(test1()).toBe('test 1');
5+
expect(test2()).toBe('test 2');
6+
});
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default function test() {
2+
return 'test 2';
3+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default function test() {
2+
return 'test 1';
3+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
const { ModuleFederationPlugin } = require('../../../../dist/src');
2+
3+
module.exports = {
4+
mode: 'development',
5+
devtool: false,
6+
output: {
7+
publicPath: 'http://localhost:3000/',
8+
},
9+
plugins: [
10+
new ModuleFederationPlugin({
11+
name: 'remote',
12+
filename: 'remoteEntry.js',
13+
manifest: true,
14+
exposes: {
15+
'./test-1': './test 1.js',
16+
'./test-2': './path with spaces/test-2.js',
17+
},
18+
}),
19+
],
20+
};

packages/enhanced/test/unit/sharing/resolveMatchedConfigs.test.ts

Lines changed: 38 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -3,54 +3,23 @@
33
* Testing all resolution paths: relative, absolute, prefix, and regular module requests
44
*/
55

6+
import ModuleNotFoundError from 'webpack/lib/ModuleNotFoundError';
7+
import LazySet from 'webpack/lib/util/LazySet';
8+
69
import { resolveMatchedConfigs } from '../../../src/lib/sharing/resolveMatchedConfigs';
710
import type { ConsumeOptions } from '../../../src/declarations/plugins/sharing/ConsumeSharedModule';
811

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

13-
// Mock webpack classes
14-
jest.mock(
15-
'webpack/lib/ModuleNotFoundError',
16-
() =>
17-
jest.fn().mockImplementation((module, err, details) => {
18-
return { module, err, details };
19-
}),
20-
{
21-
virtual: true,
22-
},
23-
);
24-
jest.mock(
25-
'webpack/lib/util/LazySet',
26-
() =>
27-
jest.fn().mockImplementation(() => ({
28-
add: jest.fn(),
29-
addAll: jest.fn(),
30-
})),
31-
{ virtual: true },
32-
);
33-
3416
describe('resolveMatchedConfigs', () => {
3517
let mockCompilation: any;
3618
let mockResolver: any;
37-
let mockResolveContext: any;
38-
let MockModuleNotFoundError: any;
39-
let MockLazySet: any;
4019

4120
beforeEach(() => {
4221
jest.clearAllMocks();
4322

44-
// Get the mocked classes
45-
MockModuleNotFoundError = require('webpack/lib/ModuleNotFoundError');
46-
MockLazySet = require('webpack/lib/util/LazySet');
47-
48-
mockResolveContext = {
49-
fileDependencies: { add: jest.fn(), addAll: jest.fn() },
50-
contextDependencies: { add: jest.fn(), addAll: jest.fn() },
51-
missingDependencies: { add: jest.fn(), addAll: jest.fn() },
52-
};
53-
5423
mockResolver = {
5524
resolve: jest.fn(),
5625
};
@@ -67,9 +36,6 @@ describe('resolveMatchedConfigs', () => {
6736
fileDependencies: { addAll: jest.fn() },
6837
missingDependencies: { addAll: jest.fn() },
6938
};
70-
71-
// Setup LazySet mock instances
72-
MockLazySet.mockImplementation(() => mockResolveContext.fileDependencies);
7339
});
7440

7541
describe('relative path resolution', () => {
@@ -138,14 +104,15 @@ describe('resolveMatchedConfigs', () => {
138104
expect(result.unresolved.size).toBe(0);
139105
expect(result.prefixed.size).toBe(0);
140106
expect(mockCompilation.errors).toHaveLength(1);
141-
expect(MockModuleNotFoundError).toHaveBeenCalledWith(null, resolveError, {
107+
const error = mockCompilation.errors[0] as InstanceType<
108+
typeof ModuleNotFoundError
109+
>;
110+
expect(error).toBeInstanceOf(ModuleNotFoundError);
111+
expect(error.module).toBeNull();
112+
expect(error.error).toBe(resolveError);
113+
expect(error.loc).toEqual({
142114
name: 'shared module ./missing-module',
143115
});
144-
expect(mockCompilation.errors[0]).toEqual({
145-
module: null,
146-
err: resolveError,
147-
details: { name: 'shared module ./missing-module' },
148-
});
149116
});
150117

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

164131
expect(result.resolved.size).toBe(0);
165132
expect(mockCompilation.errors).toHaveLength(1);
166-
expect(MockModuleNotFoundError).toHaveBeenCalledWith(
167-
null,
168-
expect.any(Error),
169-
{ name: 'shared module ./invalid-module' },
170-
);
171-
expect(mockCompilation.errors[0]).toEqual({
172-
module: null,
173-
err: expect.objectContaining({
174-
message: "Can't resolve ./invalid-module",
175-
}),
176-
details: { name: 'shared module ./invalid-module' },
133+
const error = mockCompilation.errors[0] as InstanceType<
134+
typeof ModuleNotFoundError
135+
>;
136+
expect(error).toBeInstanceOf(ModuleNotFoundError);
137+
expect(error.module).toBeNull();
138+
expect(error.error).toBeInstanceOf(Error);
139+
expect(error.error.message).toContain("Can't resolve ./invalid-module");
140+
expect(error.loc).toEqual({
141+
name: 'shared module ./invalid-module',
177142
});
178143
});
179144

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

462-
const resolveContext = {
463-
fileDependencies: { add: jest.fn(), addAll: jest.fn() },
464-
contextDependencies: { add: jest.fn(), addAll: jest.fn() },
465-
missingDependencies: { add: jest.fn(), addAll: jest.fn() },
466-
};
467-
468427
mockResolver.resolve.mockImplementation(
469428
(context, basePath, request, rc, callback) => {
470429
// Simulate adding dependencies during resolution
@@ -475,22 +434,29 @@ describe('resolveMatchedConfigs', () => {
475434
},
476435
);
477436

478-
// Update LazySet mock to return the actual resolve context
479-
MockLazySet.mockReturnValueOnce(resolveContext.fileDependencies)
480-
.mockReturnValueOnce(resolveContext.contextDependencies)
481-
.mockReturnValueOnce(resolveContext.missingDependencies);
482-
483437
await resolveMatchedConfigs(mockCompilation, configs);
484438

485-
expect(mockCompilation.contextDependencies.addAll).toHaveBeenCalledWith(
486-
resolveContext.contextDependencies,
487-
);
488-
expect(mockCompilation.fileDependencies.addAll).toHaveBeenCalledWith(
489-
resolveContext.fileDependencies,
439+
expect(mockCompilation.contextDependencies.addAll).toHaveBeenCalledTimes(
440+
1,
490441
);
491-
expect(mockCompilation.missingDependencies.addAll).toHaveBeenCalledWith(
492-
resolveContext.missingDependencies,
442+
expect(mockCompilation.fileDependencies.addAll).toHaveBeenCalledTimes(1);
443+
expect(mockCompilation.missingDependencies.addAll).toHaveBeenCalledTimes(
444+
1,
493445
);
446+
447+
const [contextDeps] =
448+
mockCompilation.contextDependencies.addAll.mock.calls[0];
449+
const [fileDeps] = mockCompilation.fileDependencies.addAll.mock.calls[0];
450+
const [missingDeps] =
451+
mockCompilation.missingDependencies.addAll.mock.calls[0];
452+
453+
expect(contextDeps).toBeInstanceOf(LazySet);
454+
expect(fileDeps).toBeInstanceOf(LazySet);
455+
expect(missingDeps).toBeInstanceOf(LazySet);
456+
457+
expect(contextDeps.has('/some/context')).toBe(true);
458+
expect(fileDeps.has('/some/file.js')).toBe(true);
459+
expect(missingDeps.has('/missing/file')).toBe(true);
494460
});
495461
});
496462

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import type { StatsModule } from 'webpack';
2+
3+
jest.mock(
4+
'@module-federation/sdk',
5+
() => ({
6+
composeKeyWithSeparator: (...parts: string[]) => parts.join(':'),
7+
moduleFederationPlugin: {},
8+
createLogger: () => ({
9+
debug: () => undefined,
10+
error: () => undefined,
11+
info: () => undefined,
12+
warn: () => undefined,
13+
}),
14+
}),
15+
{ virtual: true },
16+
);
17+
18+
jest.mock(
19+
'@module-federation/dts-plugin/core',
20+
() => ({
21+
isTSProject: () => false,
22+
retrieveTypesAssetsInfo: () => ({}) as const,
23+
}),
24+
{ virtual: true },
25+
);
26+
27+
jest.mock(
28+
'@module-federation/managers',
29+
() => ({
30+
ContainerManager: class {
31+
options?: { name?: string; exposes?: unknown };
32+
33+
init(options: { name?: string; exposes?: unknown }) {
34+
this.options = options;
35+
}
36+
37+
get enable() {
38+
const { name, exposes } = this.options || {};
39+
40+
if (!name || !exposes) {
41+
return false;
42+
}
43+
44+
if (Array.isArray(exposes)) {
45+
return exposes.length > 0;
46+
}
47+
48+
return Object.keys(exposes as Record<string, unknown>).length > 0;
49+
}
50+
51+
get containerPluginExposesOptions() {
52+
const { exposes } = this.options || {};
53+
54+
if (!exposes || Array.isArray(exposes)) {
55+
return {};
56+
}
57+
58+
return Object.entries(exposes as Record<string, unknown>).reduce(
59+
(acc, [exposeKey, exposeValue]) => {
60+
if (typeof exposeValue === 'string') {
61+
acc[exposeKey] = { import: [exposeValue] };
62+
} else if (Array.isArray(exposeValue)) {
63+
acc[exposeKey] = { import: exposeValue as string[] };
64+
} else if (
65+
exposeValue &&
66+
typeof exposeValue === 'object' &&
67+
'import' in exposeValue
68+
) {
69+
const exposeImport = (
70+
exposeValue as { import: string | string[] }
71+
).import;
72+
acc[exposeKey] = {
73+
import: Array.isArray(exposeImport)
74+
? exposeImport
75+
: [exposeImport],
76+
};
77+
}
78+
79+
return acc;
80+
},
81+
{} as Record<string, { import: string[] }>,
82+
);
83+
}
84+
},
85+
RemoteManager: class {
86+
statsRemoteWithEmptyUsedIn: unknown[] = [];
87+
init() {}
88+
},
89+
SharedManager: class {
90+
normalizedOptions: Record<string, { requiredVersion?: string }> = {};
91+
init() {}
92+
},
93+
}),
94+
{ virtual: true },
95+
);
96+
97+
import type { moduleFederationPlugin } from '@module-federation/sdk';
98+
// eslint-disable-next-line import/first
99+
import { ModuleHandler } from '../src/ModuleHandler';
100+
101+
describe('ModuleHandler', () => {
102+
it('initializes exposes from plugin options when import paths contain spaces', () => {
103+
const options = {
104+
name: 'test-app',
105+
exposes: {
106+
'./Button': './src/path with spaces/Button.tsx',
107+
},
108+
} as const;
109+
110+
const moduleHandler = new ModuleHandler(options, [], {
111+
bundler: 'webpack',
112+
});
113+
114+
const { exposesMap } = moduleHandler.collect();
115+
116+
const expose = exposesMap['./src/path with spaces/Button'];
117+
118+
expect(expose).toBeDefined();
119+
expect(expose?.path).toBe('./Button');
120+
expect(expose?.file).toBe('src/path with spaces/Button.tsx');
121+
});
122+
123+
it('parses container exposes when identifiers contain spaces', () => {
124+
const options = {
125+
name: 'test-app',
126+
} as const;
127+
128+
const modules: StatsModule[] = [
129+
{
130+
identifier:
131+
'container entry (default) [["./Button",{"import":["./src/path with spaces/Button.tsx"],"name":"__federation_expose_Button"}]]',
132+
} as StatsModule,
133+
];
134+
135+
const moduleHandler = new ModuleHandler(options, modules, {
136+
bundler: 'webpack',
137+
});
138+
139+
const { exposesMap } = moduleHandler.collect();
140+
141+
const expose = exposesMap['./src/path with spaces/Button'];
142+
143+
expect(expose).toBeDefined();
144+
expect(expose?.path).toBe('./Button');
145+
expect(expose?.file).toBe('src/path with spaces/Button.tsx');
146+
});
147+
148+
it('falls back to normalized exposes when identifier parsing fails', () => {
149+
const options = {
150+
exposes: {
151+
'./Button': './src/Button.tsx',
152+
'./Card': { import: ['./src/Card.tsx'], name: 'Card' },
153+
'./Invalid': { import: [] },
154+
'./Empty': '',
155+
},
156+
} as const;
157+
158+
const modules: StatsModule[] = [
159+
{
160+
identifier: 'container entry (default)',
161+
} as StatsModule,
162+
];
163+
164+
const moduleHandler = new ModuleHandler(
165+
options as unknown as moduleFederationPlugin.ModuleFederationPluginOptions,
166+
modules,
167+
{
168+
bundler: 'webpack',
169+
},
170+
);
171+
172+
const { exposesMap } = moduleHandler.collect();
173+
174+
expect(exposesMap['./src/Button']).toBeDefined();
175+
expect(exposesMap['./src/Card']).toBeDefined();
176+
expect(exposesMap['./src/Button']?.path).toBe('./Button');
177+
expect(exposesMap['./src/Card']?.path).toBe('./Card');
178+
});
179+
});

0 commit comments

Comments
 (0)