diff --git a/.env b/.env new file mode 100644 index 00000000000..166ab9c8e68 --- /dev/null +++ b/.env @@ -0,0 +1 @@ +NX_DEAMON=false diff --git a/.gitignore b/.gitignore index 55b0832864f..53cd9d61984 100644 --- a/.gitignore +++ b/.gitignore @@ -89,3 +89,4 @@ vitest.config.*.timestamp* ssg .claude __mocks__/ +/.env diff --git a/packages/enhanced/test/compiler-unit/container/HoistContainerReferencesPlugin.test.ts b/packages/enhanced/test/compiler-unit/container/HoistContainerReferencesPlugin.test.ts index 406ea7cde00..bbaa6d0dc87 100644 --- a/packages/enhanced/test/compiler-unit/container/HoistContainerReferencesPlugin.test.ts +++ b/packages/enhanced/test/compiler-unit/container/HoistContainerReferencesPlugin.test.ts @@ -1,3 +1,5 @@ +// @ts-nocheck + import { ModuleFederationPlugin, dependencies, diff --git a/packages/enhanced/test/compiler-unit/sharing/SharePlugin.memfs.test.ts b/packages/enhanced/test/compiler-unit/sharing/SharePlugin.memfs.test.ts new file mode 100644 index 00000000000..5a52278656f --- /dev/null +++ b/packages/enhanced/test/compiler-unit/sharing/SharePlugin.memfs.test.ts @@ -0,0 +1,94 @@ +// @ts-nocheck +/* + * @jest-environment node + */ + +import { vol } from 'memfs'; +import SharePlugin from '../../../src/lib/sharing/SharePlugin'; +import { + createRealCompiler, + createMemfsCompilation, + createNormalModuleFactory, +} from '../../helpers/webpackMocks'; + +// Use memfs for fs inside this suite +jest.mock('fs', () => require('memfs').fs); +jest.mock('fs/promises', () => require('memfs').fs.promises); + +// Mock child plugins to avoid deep integration +jest.mock('../../../src/lib/sharing/ConsumeSharedPlugin', () => { + return jest + .fn() + .mockImplementation((opts) => ({ options: opts, apply: jest.fn() })); +}); +jest.mock('../../../src/lib/sharing/ProvideSharedPlugin', () => { + return jest + .fn() + .mockImplementation((opts) => ({ options: opts, apply: jest.fn() })); +}); + +import ConsumeSharedPlugin from '../../../src/lib/sharing/ConsumeSharedPlugin'; +import ProvideSharedPlugin from '../../../src/lib/sharing/ProvideSharedPlugin'; + +describe('SharePlugin smoke (memfs)', () => { + beforeEach(() => { + vol.reset(); + jest.clearAllMocks(); + }); + + it('applies child plugins with derived options', () => { + // Create a tiny project in memfs + vol.fromJSON({ + '/test-project/src/index.js': 'console.log("hello")', + '/test-project/package.json': '{"name":"test","version":"1.0.0"}', + '/test-project/node_modules/react/index.js': 'module.exports = {}', + '/test-project/node_modules/lodash/index.js': 'module.exports = {}', + }); + + const plugin = new SharePlugin({ + shareScope: 'default', + shared: { + react: '^17.0.0', + lodash: { version: '4.17.21', singleton: true }, + 'components/': { version: '1.0.0', eager: false }, + }, + }); + + const compiler = createRealCompiler('/test-project'); + expect(() => plugin.apply(compiler as any)).not.toThrow(); + + // Child plugins constructed + expect(ConsumeSharedPlugin).toHaveBeenCalledTimes(1); + expect(ProvideSharedPlugin).toHaveBeenCalledTimes(1); + + // Each child plugin receives shareScope and normalized arrays + const consumeOpts = (ConsumeSharedPlugin as jest.Mock).mock.calls[0][0]; + const provideOpts = (ProvideSharedPlugin as jest.Mock).mock.calls[0][0]; + expect(consumeOpts.shareScope).toBe('default'); + expect(Array.isArray(consumeOpts.consumes)).toBe(true); + expect(provideOpts.shareScope).toBe('default'); + expect(Array.isArray(provideOpts.provides)).toBe(true); + + // Simulate compilation lifecycle + const compilation = createMemfsCompilation(compiler as any); + const normalModuleFactory = createNormalModuleFactory(); + expect(() => + (compiler as any).hooks.thisCompilation.call(compilation, { + normalModuleFactory, + }), + ).not.toThrow(); + expect(() => + (compiler as any).hooks.compilation.call(compilation, { + normalModuleFactory, + }), + ).not.toThrow(); + + // Child plugin instances should be applied to the compiler + const consumeInst = (ConsumeSharedPlugin as jest.Mock).mock.results[0] + .value; + const provideInst = (ProvideSharedPlugin as jest.Mock).mock.results[0] + .value; + expect(consumeInst.apply).toHaveBeenCalledWith(compiler); + expect(provideInst.apply).toHaveBeenCalledWith(compiler); + }); +}); diff --git a/packages/enhanced/test/compiler-unit/sharing/SharePlugin.test.ts b/packages/enhanced/test/compiler-unit/sharing/SharePlugin.test.ts index ae918479795..c9445c29a0c 100644 --- a/packages/enhanced/test/compiler-unit/sharing/SharePlugin.test.ts +++ b/packages/enhanced/test/compiler-unit/sharing/SharePlugin.test.ts @@ -1,3 +1,4 @@ +// @ts-nocheck /* * @jest-environment node */ diff --git a/packages/enhanced/test/helpers/snapshots.ts b/packages/enhanced/test/helpers/snapshots.ts new file mode 100644 index 00000000000..0841285932c --- /dev/null +++ b/packages/enhanced/test/helpers/snapshots.ts @@ -0,0 +1,7 @@ +export function normalizeCode(source: string): string { + return source + .replace(/[ \t]+/g, ' ') + .replace(/\r\n/g, '\n') + .replace(/\n+/g, '\n') + .trim(); +} diff --git a/packages/enhanced/test/helpers/webpackMocks.ts b/packages/enhanced/test/helpers/webpackMocks.ts new file mode 100644 index 00000000000..be2b838e889 --- /dev/null +++ b/packages/enhanced/test/helpers/webpackMocks.ts @@ -0,0 +1,122 @@ +import { SyncHook, AsyncSeriesHook, HookMap } from 'tapable'; + +export type BasicCompiler = { + hooks: { + thisCompilation: SyncHook & { taps?: any[] }; + compilation: SyncHook & { taps?: any[] }; + finishMake: AsyncSeriesHook & { taps?: any[] }; + make: AsyncSeriesHook & { taps?: any[] }; + environment: SyncHook & { taps?: any[] }; + afterEnvironment: SyncHook & { taps?: any[] }; + afterPlugins: SyncHook & { taps?: any[] }; + afterResolvers: SyncHook & { taps?: any[] }; + }; + context: string; + options: any; +}; + +export function createTapTrackedHook< + T extends SyncHook | AsyncSeriesHook, +>(hook: T): T { + const tracked = hook as any; + const wrap = (method: 'tap' | 'tapAsync' | 'tapPromise') => { + if (typeof tracked[method] === 'function') { + const original = tracked[method].bind(tracked); + tracked.__tapCalls = tracked.__tapCalls || []; + tracked[method] = (name: string, fn: any) => { + tracked.__tapCalls.push({ name, fn, method }); + return original(name, fn); + }; + } + }; + wrap('tap'); + wrap('tapAsync'); + wrap('tapPromise'); + return tracked as T; +} + +export function createRealCompiler(context = '/test-project'): BasicCompiler { + return { + hooks: { + thisCompilation: createTapTrackedHook( + new SyncHook<[unknown, unknown]>(['compilation', 'params']), + ) as any, + compilation: createTapTrackedHook( + new SyncHook<[unknown, unknown]>(['compilation', 'params']), + ) as any, + finishMake: createTapTrackedHook( + new AsyncSeriesHook<[unknown]>(['compilation']), + ) as any, + make: createTapTrackedHook( + new AsyncSeriesHook<[unknown]>(['compilation']), + ) as any, + environment: createTapTrackedHook(new SyncHook<[]>([])) as any, + afterEnvironment: createTapTrackedHook(new SyncHook<[]>([])) as any, + afterPlugins: createTapTrackedHook( + new SyncHook<[unknown]>(['compiler']), + ) as any, + afterResolvers: createTapTrackedHook( + new SyncHook<[unknown]>(['compiler']), + ) as any, + }, + context, + options: { + plugins: [], + resolve: { alias: {} }, + context, + }, + } as any; +} + +export function createMemfsCompilation(compiler: BasicCompiler) { + return { + dependencyFactories: new Map(), + hooks: { + additionalTreeRuntimeRequirements: { tap: jest.fn() }, + finishModules: { tap: jest.fn(), tapAsync: jest.fn() }, + seal: { tap: jest.fn() }, + runtimeRequirementInTree: new HookMap< + SyncHook<[unknown, unknown, unknown]> + >( + () => + new SyncHook<[unknown, unknown, unknown]>([ + 'chunk', + 'set', + 'context', + ]), + ), + processAssets: { tap: jest.fn() }, + }, + addRuntimeModule: jest.fn(), + contextDependencies: { addAll: jest.fn() }, + fileDependencies: { addAll: jest.fn() }, + missingDependencies: { addAll: jest.fn() }, + warnings: [], + errors: [], + resolverFactory: { + get: jest.fn(() => ({ + resolve: jest.fn( + ( + _context: unknown, + lookupStartPath: string, + request: string, + _resolveContext: unknown, + callback: (err: any, result?: string) => void, + ) => callback(null, `${lookupStartPath}/${request}`), + ), + })), + }, + compiler, + options: compiler.options, + } as any; +} + +export function createNormalModuleFactory() { + return { + hooks: { + module: { tap: jest.fn() }, + factorize: { tapPromise: jest.fn(), tapAsync: jest.fn(), tap: jest.fn() }, + createModule: { tapPromise: jest.fn() }, + }, + }; +} diff --git a/packages/enhanced/test/types/memfs.d.ts b/packages/enhanced/test/types/memfs.d.ts new file mode 100644 index 00000000000..5153ac3d6ff --- /dev/null +++ b/packages/enhanced/test/types/memfs.d.ts @@ -0,0 +1 @@ +declare module 'memfs'; diff --git a/packages/enhanced/test/unit/container/ContainerEntryModule.test.ts b/packages/enhanced/test/unit/container/ContainerEntryModule.test.ts index ee5bfd8dfe3..8edf5d4da83 100644 --- a/packages/enhanced/test/unit/container/ContainerEntryModule.test.ts +++ b/packages/enhanced/test/unit/container/ContainerEntryModule.test.ts @@ -2,6 +2,10 @@ * @jest-environment node */ +import type { + ObjectDeserializerContext, + ObjectSerializerContext, +} from 'webpack/lib/serialization/ObjectMiddleware'; import { createMockCompilation, createWebpackMock } from './utils'; // Mock webpack @@ -29,15 +33,7 @@ import ContainerEntryModule from '../../../src/lib/container/ContainerEntryModul import ContainerEntryDependency from '../../../src/lib/container/ContainerEntryDependency'; import ContainerExposedDependency from '../../../src/lib/container/ContainerExposedDependency'; -// Add these types at the top, after the imports -type ObjectSerializerContext = { - write: (value: any) => number; -}; - -type ObjectDeserializerContext = { - read: () => any; - setCircularReference: (ref: any) => void; -}; +// We will stub the serializer contexts inline using the proper types describe('ContainerEntryModule', () => { let mockCompilation: ReturnType< @@ -227,6 +223,7 @@ describe('ContainerEntryModule', () => { serializedData.push(value); return serializedData.length - 1; }), + setCircularReference: jest.fn(), }; // Serialize @@ -299,6 +296,7 @@ describe('ContainerEntryModule', () => { serializedData.push(value); return serializedData.length - 1; }), + setCircularReference: jest.fn(), }; // Serialize @@ -343,6 +341,40 @@ describe('ContainerEntryModule', () => { expect(deserializedModule['_name']).toBe(name); expect(deserializedModule['_shareScope']).toEqual(shareScope); }); + + it('should handle incomplete deserialization data gracefully', () => { + const name = 'test-container'; + const exposesFormatted: [string, any][] = [ + ['component', { import: './Component' }], + ]; + + // Missing some fields (shareScope/injectRuntimeEntry/dataPrefetch) + const deserializedData = [name, exposesFormatted]; + + let index = 0; + const deserializeContext: any = { + read: jest.fn(() => deserializedData[index++]), + setCircularReference: jest.fn(), + }; + + const staticDeserialize = ContainerEntryModule.deserialize as unknown as ( + context: any, + ) => ContainerEntryModule; + + const deserializedModule = staticDeserialize(deserializeContext); + jest + .spyOn(webpack.Module.prototype, 'deserialize') + .mockImplementation(() => undefined); + + // Known values are set; missing ones may be undefined + expect(deserializedModule['_name']).toBe(name); + expect(deserializedModule['_exposes']).toEqual(exposesFormatted); + expect( + ['default', undefined].includes( + (deserializedModule as any)['_shareScope'], + ), + ).toBe(true); + }); }); describe('codeGeneration', () => { diff --git a/packages/enhanced/test/unit/container/ContainerPlugin.test.ts b/packages/enhanced/test/unit/container/ContainerPlugin.test.ts index 689c6d1c363..e25a8e0ad36 100644 --- a/packages/enhanced/test/unit/container/ContainerPlugin.test.ts +++ b/packages/enhanced/test/unit/container/ContainerPlugin.test.ts @@ -9,6 +9,7 @@ import { MockModuleDependency, createMockCompilation, createMockContainerExposedDependency, + MockCompiler, } from './utils'; const webpack = createWebpackMock(); @@ -84,11 +85,32 @@ jest.mock( () => { return jest.fn().mockImplementation(() => ({ apply: jest.fn(), + getDependency: jest.fn(() => ({ + entryFilePath: '/mock/entry.js', + })), })); }, { virtual: true }, ); +const federationModulesPluginMock = { + getCompilationHooks: jest.fn(() => ({ + addContainerEntryDependency: { tap: jest.fn() }, + addFederationRuntimeDependency: { tap: jest.fn() }, + addRemoteDependency: { tap: jest.fn() }, + })), +}; + +jest.mock('../../../src/lib/container/runtime/FederationModulesPlugin', () => ({ + __esModule: true, + default: federationModulesPluginMock, +})); + +jest.mock( + '../../../src/lib/container/runtime/FederationModulesPlugin.ts', + () => ({ __esModule: true, default: federationModulesPluginMock }), +); + jest.mock('@module-federation/sdk/normalize-webpack-path', () => ({ normalizeWebpackPath: jest.fn((path) => path), getWebpackPath: jest.fn(() => 'mocked-webpack-path'), @@ -98,8 +120,16 @@ const ContainerPlugin = require('../../../src/lib/container/ContainerPlugin').default; const containerPlugin = require('../../../src/lib/container/ContainerPlugin'); +const findTapCallback = ( + tapMock: jest.Mock, + name: string, +): ((...args: Args) => any) | undefined => { + const entry = tapMock.mock.calls.find(([tapName]) => tapName === name); + return entry ? (entry[1] as (...args: Args) => any) : undefined; +}; + describe('ContainerPlugin', () => { - let mockCompiler; + let mockCompiler: MockCompiler; beforeEach(() => { jest.clearAllMocks(); @@ -116,7 +146,7 @@ describe('ContainerPlugin', () => { cacheGroups: {}, }, }, - }; + } as any; }); describe('constructor', () => { @@ -228,28 +258,6 @@ describe('ContainerPlugin', () => { const plugin = new ContainerPlugin(options); - mockCompiler.hooks.compilation.tap.mockImplementation( - (name, callback) => { - if (name === 'ContainerPlugin') { - mockCompiler.hooks.compilation._callback = callback; - } - }, - ); - - mockCompiler.hooks.make.tap.mockImplementation((name, callback) => { - if (name === 'ContainerPlugin') { - mockCompiler.hooks.make._callback = callback; - } - }); - - mockCompiler.hooks.thisCompilation.tap.mockImplementation( - (name, callback) => { - if (name === 'ContainerPlugin') { - mockCompiler.hooks.thisCompilation._callback = callback; - } - }, - ); - plugin.apply(mockCompiler); expect(mockCompiler.hooks.thisCompilation.tap).toHaveBeenCalledWith( @@ -258,12 +266,21 @@ describe('ContainerPlugin', () => { ); const { mockCompilation } = createMockCompilation(); - - if (mockCompiler.hooks.thisCompilation._callback) { - mockCompiler.hooks.thisCompilation._callback(mockCompilation, { - normalModuleFactory: {}, - }); - } + const compilationCallback = findTapCallback( + mockCompiler.hooks.compilation.tap as unknown as jest.Mock, + 'ContainerPlugin', + ); + compilationCallback?.(mockCompilation, { + normalModuleFactory: {}, + }); + const thisCompilationCallback = findTapCallback( + mockCompiler.hooks.thisCompilation.tap as unknown as jest.Mock, + 'ContainerPlugin', + ); + expect(thisCompilationCallback).toBeDefined(); + thisCompilationCallback?.(mockCompilation, { + normalModuleFactory: {}, + }); expect(mockCompilation.dependencyFactories.size).toBeGreaterThan(0); @@ -281,13 +298,21 @@ describe('ContainerPlugin', () => { }, }; - if (mockCompiler.hooks.make._callback) { - mockCompiler.hooks.make._callback(mockMakeCompilation, function noop() { - // Intentionally empty - }); - } - - expect(true).toBe(true); + const makeCallback = findTapCallback( + mockCompiler.hooks.make.tapAsync as unknown as jest.Mock, + 'ContainerPlugin', + ); + expect(makeCallback).toBeDefined(); + makeCallback?.(mockMakeCompilation, () => undefined); + + // Should have scheduled at least one entry/include for the container during make + const includeCalls = (mockMakeCompilation as any).addInclude.mock + ? (mockMakeCompilation as any).addInclude.mock.calls.length + : 0; + const entryCalls = (mockMakeCompilation as any).addEntry.mock + ? (mockMakeCompilation as any).addEntry.mock.calls.length + : 0; + expect(includeCalls + entryCalls).toBeGreaterThan(0); }); it('should register FederationRuntimePlugin', () => { @@ -356,7 +381,7 @@ describe('ContainerPlugin', () => { expect(arrayPlugin['_options'].exposes.length).toBe(4); const buttonANameEntry = arrayPlugin['_options'].exposes.find( - (e) => + (e: [string, any]) => e[0] === 'name' && e[1] && e[1].import && @@ -364,7 +389,7 @@ describe('ContainerPlugin', () => { ); const buttonBNameEntry = arrayPlugin['_options'].exposes.find( - (e) => + (e: [string, any]) => e[0] === 'name' && e[1] && e[1].import && @@ -372,7 +397,7 @@ describe('ContainerPlugin', () => { ); const buttonAImportEntry = arrayPlugin['_options'].exposes.find( - (e) => + (e: [string, any]) => e[0] === 'import' && e[1] && e[1].import && @@ -380,7 +405,7 @@ describe('ContainerPlugin', () => { ); const buttonBImportEntry = arrayPlugin['_options'].exposes.find( - (e) => + (e: [string, any]) => e[0] === 'import' && e[1] && e[1].import && diff --git a/packages/enhanced/test/unit/container/ContainerReferencePlugin.test.ts b/packages/enhanced/test/unit/container/ContainerReferencePlugin.test.ts index 3e99ea7d279..7a3a238ba2c 100644 --- a/packages/enhanced/test/unit/container/ContainerReferencePlugin.test.ts +++ b/packages/enhanced/test/unit/container/ContainerReferencePlugin.test.ts @@ -14,6 +14,7 @@ import { createMockCompilation, createMockFallbackDependency, createMockRemoteToExternalDependency, + MockCompiler, } from './utils'; // Create webpack mock @@ -54,9 +55,12 @@ const mockApply = jest.fn(); const mockFederationRuntimePlugin = jest.fn().mockImplementation(() => ({ apply: mockApply, })); -jest.mock('../../../src/lib/container/runtime/FederationRuntimePlugin', () => { - return mockFederationRuntimePlugin; -}); +jest.mock( + '../../../src/lib/container/runtime/FederationRuntimePlugin.ts', + () => { + return mockFederationRuntimePlugin; + }, +); // Mock FederationModulesPlugin jest.mock('../../../src/lib/container/runtime/FederationModulesPlugin', () => { @@ -81,7 +85,10 @@ jest.mock( // Empty constructor with comment to avoid linter warning } - create(data, callback) { + create( + _data: unknown, + callback: (err: Error | null, result?: unknown) => void, + ) { callback(null, { fallback: true }); } }; @@ -113,21 +120,16 @@ webpack.ExternalsPlugin = mockExternalsPlugin; const ContainerReferencePlugin = require('../../../src/lib/container/ContainerReferencePlugin').default; -// Define hook type -type HookMock = { - tap: jest.Mock; - tapAsync?: jest.Mock; - tapPromise?: jest.Mock; - call?: jest.Mock; - for?: jest.Mock; -}; - -type RuntimeHookMock = HookMock & { - for: jest.Mock; +const getTap = ( + tapMock: jest.Mock, + name: string, +): ((...args: Args) => any) | undefined => { + const entry = tapMock.mock.calls.find(([tapName]) => tapName === name); + return entry ? (entry[1] as (...args: Args) => any) : undefined; }; describe('ContainerReferencePlugin', () => { - let mockCompiler; + let mockCompiler: MockCompiler; beforeEach(() => { jest.clearAllMocks(); @@ -145,7 +147,7 @@ describe('ContainerReferencePlugin', () => { shareScopeMap: 'shareScopeMap', }, ExternalsPlugin: mockExternalsPlugin, - }; + } as any; }); describe('constructor', () => { @@ -248,11 +250,11 @@ describe('ContainerReferencePlugin', () => { const plugin = new ContainerReferencePlugin(options); plugin.apply(mockCompiler); - // Verify compilation hook was tapped - expect(mockCompiler.hooks.compilation.tap).toHaveBeenCalledWith( + const compilationTap = getTap( + mockCompiler.hooks.compilation.tap as unknown as jest.Mock, 'ContainerReferencePlugin', - expect.any(Function), ); + expect(compilationTap).toBeDefined(); }); it('should process remote modules during compilation', () => { @@ -265,34 +267,20 @@ describe('ContainerReferencePlugin', () => { const plugin = new ContainerReferencePlugin(options); - // Mock the factorize hook to avoid "tap is not a function" error - mockCompiler.hooks.compilation.tap.mockImplementation( - (name, callback) => { - // Store the callback so we can call it with mocked params - mockCompiler.hooks.compilation._callback = callback; - }, - ); - plugin.apply(mockCompiler); // Use createMockCompilation to get a properly structured mock compilation const { mockCompilation } = createMockCompilation(); // Create a runtime hook that has a for method - const runtimeHook: RuntimeHookMock = { + const runtimeRequirementInTree = { tap: jest.fn(), - for: jest.fn(), - }; - - // Make for return an object with tap method - runtimeHook.for.mockReturnValue({ tap: jest.fn() }); - - // Add the hooks to the mockCompilation - mockCompilation.hooks = { - ...mockCompilation.hooks, - runtimeRequirementInTree: runtimeHook, + for: jest.fn().mockReturnValue({ tap: jest.fn() }), + } as any; + Object.assign(mockCompilation.hooks, { + runtimeRequirementInTree, additionalTreeRuntimeRequirements: { tap: jest.fn() }, - }; + }); // Create mock params const mockParams = { @@ -306,10 +294,11 @@ describe('ContainerReferencePlugin', () => { }, }; - // Call the stored compilation callback - if (mockCompiler.hooks.compilation._callback) { - mockCompiler.hooks.compilation._callback(mockCompilation, mockParams); - } + const compilationTap = getTap( + mockCompiler.hooks.compilation.tap as unknown as jest.Mock, + 'ContainerReferencePlugin', + ); + compilationTap?.(mockCompilation, mockParams); // Verify dependency factories were set up expect(mockCompilation.dependencyFactories.size).toBeGreaterThan(0); @@ -365,33 +354,20 @@ describe('ContainerReferencePlugin', () => { const plugin = new ContainerReferencePlugin(options); - // Mock the compilation hook - mockCompiler.hooks.compilation.tap.mockImplementation( - (name, callback) => { - mockCompiler.hooks.compilation._callback = callback; - }, - ); - plugin.apply(mockCompiler); // Use createMockCompilation to get a properly structured mock compilation const { mockCompilation } = createMockCompilation(); // Create a runtime hook that has a for method - const runtimeHook: RuntimeHookMock = { + const runtimeRequirementInTree = { tap: jest.fn(), - for: jest.fn(), - }; - - // Make for return an object with tap method - runtimeHook.for.mockReturnValue({ tap: jest.fn() }); - - // Add the hooks to the mockCompilation - mockCompilation.hooks = { - ...mockCompilation.hooks, - runtimeRequirementInTree: runtimeHook, + for: jest.fn().mockReturnValue({ tap: jest.fn() }), + } as any; + Object.assign(mockCompilation.hooks, { + runtimeRequirementInTree, additionalTreeRuntimeRequirements: { tap: jest.fn() }, - }; + }); // Mock normalModuleFactory const mockParams = { @@ -405,15 +381,13 @@ describe('ContainerReferencePlugin', () => { }, }; - // Call the compilation callback - if (mockCompiler.hooks.compilation._callback) { - mockCompiler.hooks.compilation._callback(mockCompilation, mockParams); - } + const compilationTap = getTap( + mockCompiler.hooks.compilation.tap as unknown as jest.Mock, + 'ContainerReferencePlugin', + ); + compilationTap?.(mockCompilation, mockParams); - // Verify runtime hooks were set up - expect( - (mockCompilation.hooks.runtimeRequirementInTree as RuntimeHookMock).for, - ).toHaveBeenCalled(); + expect(runtimeRequirementInTree.for).toHaveBeenCalled(); }); it('should hook into NormalModuleFactory factorize', () => { @@ -427,32 +401,20 @@ describe('ContainerReferencePlugin', () => { const plugin = new ContainerReferencePlugin(options); // Mock the compilation callback - mockCompiler.hooks.compilation.tap.mockImplementation( - (name, callback) => { - mockCompiler.hooks.compilation._callback = callback; - }, - ); - plugin.apply(mockCompiler); // Use createMockCompilation to get a properly structured mock compilation const { mockCompilation } = createMockCompilation(); // Create a runtime hook that has a for method - const runtimeHook: RuntimeHookMock = { + const runtimeRequirementInTree = { tap: jest.fn(), - for: jest.fn(), - }; - - // Make for return an object with tap method - runtimeHook.for.mockReturnValue({ tap: jest.fn() }); - - // Add the hooks to the mockCompilation - mockCompilation.hooks = { - ...mockCompilation.hooks, - runtimeRequirementInTree: runtimeHook, + for: jest.fn().mockReturnValue({ tap: jest.fn() }), + } as any; + Object.assign(mockCompilation.hooks, { + runtimeRequirementInTree, additionalTreeRuntimeRequirements: { tap: jest.fn() }, - }; + }); // Mock normalModuleFactory with factorize hook const mockFactorizeHook = { @@ -468,10 +430,11 @@ describe('ContainerReferencePlugin', () => { }, }; - // Call the compilation callback - if (mockCompiler.hooks.compilation._callback) { - mockCompiler.hooks.compilation._callback(mockCompilation, mockParams); - } + const compilationTap = getTap( + mockCompiler.hooks.compilation.tap as unknown as jest.Mock, + 'ContainerReferencePlugin', + ); + compilationTap?.(mockCompilation, mockParams); // Verify factorize hook was tapped expect(mockFactorizeHook.tap).toHaveBeenCalledWith( @@ -518,5 +481,40 @@ describe('ContainerReferencePlugin', () => { expect(plugin['_remotes'][0][0]).toBe('remote-app'); expect(plugin['_remotes'][0][1].shareScope).toBe('custom'); }); + + describe('invalid configs', () => { + it('handles invalid remote spec gracefully and registers hooks', () => { + const options = { + remotes: { + bad: 'invalid-remote-spec', + }, + remoteType: 'script', + }; + + const plugin = new ContainerReferencePlugin(options); + expect(() => plugin.apply(mockCompiler)).not.toThrow(); + + const compilationTap = getTap( + mockCompiler.hooks.compilation.tap as unknown as jest.Mock, + 'ContainerReferencePlugin', + ); + expect(compilationTap).toBeDefined(); + }); + + it('handles mixed array remotes with malformed entries', () => { + const options = { + remotes: { + r1: ['app@http://localhost:3001/remoteEntry.js', 'still-bad'], + }, + remoteType: 'script', + }; + + const plugin = new ContainerReferencePlugin(options); + expect(() => plugin.apply(mockCompiler)).not.toThrow(); + + // ExternalsPlugin should still be applied for the declared remoteType + expect(mockExternalsPlugin).toHaveBeenCalled(); + }); + }); }); }); diff --git a/packages/enhanced/test/unit/container/RemoteModule.test.ts b/packages/enhanced/test/unit/container/RemoteModule.test.ts index 683a91b714f..02db20f691e 100644 --- a/packages/enhanced/test/unit/container/RemoteModule.test.ts +++ b/packages/enhanced/test/unit/container/RemoteModule.test.ts @@ -2,6 +2,9 @@ * @jest-environment node */ +import type { WebpackOptionsNormalized } from 'webpack'; +import type { ResolverWithOptions } from 'webpack/lib/ResolverFactory'; +import type { InputFileSystem } from 'webpack/lib/util/fs'; import { createMockCompilation, createWebpackMock } from './utils'; // Mock webpack @@ -166,7 +169,7 @@ describe('RemoteModule', () => { const callback = jest.fn(); // Create a more complete mock for WebpackOptionsNormalized - const mockOptions = { + const mockOptions: Partial = { cache: true, entry: {}, experiments: {}, @@ -180,7 +183,19 @@ describe('RemoteModule', () => { target: 'web', } as any; // Cast to any to avoid type errors - module.build(mockOptions, mockCompilation as any, {}, {}, callback); + const resolver = mockCompilation.resolverFactory.get( + 'normal', + ) as unknown as ResolverWithOptions; + const inputFs = + mockCompilation.inputFileSystem as unknown as InputFileSystem; + + module.build( + mockOptions as WebpackOptionsNormalized, + mockCompilation, + resolver, + inputFs, + callback, + ); expect(module.buildInfo).toBeDefined(); expect(module.buildMeta).toBeDefined(); @@ -279,7 +294,7 @@ describe('RemoteModule', () => { const result = module.codeGeneration(codeGenContext as any); expect(result.sources).toBeDefined(); - const runtimeRequirements = Array.from(result.runtimeRequirements); + const runtimeRequirements = Array.from(result.runtimeRequirements ?? []); expect(runtimeRequirements.length).toBeGreaterThan(0); }); }); diff --git a/packages/enhanced/test/unit/container/RemoteRuntimeModule.test.ts b/packages/enhanced/test/unit/container/RemoteRuntimeModule.test.ts index 435ab10e61b..9910694ae53 100644 --- a/packages/enhanced/test/unit/container/RemoteRuntimeModule.test.ts +++ b/packages/enhanced/test/unit/container/RemoteRuntimeModule.test.ts @@ -4,7 +4,14 @@ import { normalizeWebpackPath } from '@module-federation/sdk/normalize-webpack-path'; import { getFederationGlobalScope } from '../../../src/lib/container/runtime/utils'; -import { createMockCompilation } from './utils'; +import { createMockCompilation, MockModuleDependency } from './utils'; +import type Chunk from 'webpack/lib/Chunk'; +import type ChunkGraph from 'webpack/lib/ChunkGraph'; +import type Module from 'webpack/lib/Module'; +import type ModuleGraph from 'webpack/lib/ModuleGraph'; +import type Dependency from 'webpack/lib/Dependency'; +import type ExternalModule from 'webpack/lib/ExternalModule'; +import type FallbackModule from '../../../src/lib/container/FallbackModule'; // Mock necessary dependencies jest.mock('@module-federation/sdk/normalize-webpack-path', () => ({ @@ -62,55 +69,82 @@ jest.mock( const RemoteRuntimeModule = require('../../../src/lib/container/RemoteRuntimeModule').default; -describe('RemoteRuntimeModule', () => { - let mockCompilation: any; - let mockChunkGraph: any; - let mockModuleGraph: any; - let mockRuntimeTemplate: any; - let mockChunk: any; - let remoteRuntimeModule: any; +type RemoteModuleMock = Module & { + internalRequest: string; + shareScope: string; + dependencies: Dependency[]; +}; - beforeEach(() => { - jest.clearAllMocks(); +type ExternalModuleMock = ExternalModule & { + request: string; + dependencies: Dependency[]; + externalType: string; +}; - // Mock runtime template - mockRuntimeTemplate = { - basicFunction: jest.fn( - (args, body) => - `function(${args}) { ${Array.isArray(body) ? body.join('\n') : body} }`, - ), - }; +type FallbackModuleMock = FallbackModule & { + dependencies: Dependency[]; + requests: boolean; +}; - // Setup mock compilation - mockCompilation = { - runtimeTemplate: mockRuntimeTemplate, - moduleGraph: {}, - }; - - // Mock chunkGraph and moduleGraph - mockChunkGraph = { - getChunkModulesIterableBySourceType: jest.fn(), - getModuleId: jest.fn(), - }; +type ModuleIdMock = jest.MockedFunction< + (module: Module) => string | number | undefined +>; - mockModuleGraph = { - getModule: jest.fn(), - }; +describe('RemoteRuntimeModule', () => { + let mockCompilation: ReturnType< + typeof createMockCompilation + >['mockCompilation']; + let mockChunkGraph: ReturnType< + typeof createMockCompilation + >['mockChunkGraph']; + let mockModuleGraph: ReturnType< + typeof createMockCompilation + >['mockModuleGraph']; + let mockRuntimeTemplate: ReturnType< + typeof createMockCompilation + >['mockRuntimeTemplate']; + let mockChunk: Chunk; + let remoteRuntimeModule: InstanceType; + let chunkModulesBySourceTypeMock: jest.MockedFunction< + NonNullable + >; + let moduleIdMock: ModuleIdMock; + let moduleGraphGetModuleMock: jest.MockedFunction; - mockCompilation.moduleGraph = mockModuleGraph; + beforeEach(() => { + jest.clearAllMocks(); + const mocks = createMockCompilation(); + mockCompilation = mocks.mockCompilation; + mockChunkGraph = mocks.mockChunkGraph; + mockModuleGraph = mocks.mockModuleGraph; + mockRuntimeTemplate = mocks.mockRuntimeTemplate; + + chunkModulesBySourceTypeMock = + mockChunkGraph.getChunkModulesIterableBySourceType as unknown as jest.MockedFunction< + NonNullable + >; + moduleIdMock = mockChunkGraph.getModuleId as unknown as ModuleIdMock; + moduleGraphGetModuleMock = + mockModuleGraph.getModule as unknown as jest.MockedFunction< + ModuleGraph['getModule'] + >; + + const secondaryChunk = { + id: 'chunk2', + } as Partial as Chunk; - // Mock chunk with necessary functionality mockChunk = { id: 'chunk1', - getAllReferencedChunks: jest - .fn() - .mockReturnValue(new Set([{ id: 'chunk1' }, { id: 'chunk2' }])), - }; + getAllReferencedChunks: jest.fn(), + } as Partial as Chunk; + + (mockChunk.getAllReferencedChunks as jest.Mock).mockReturnValue( + new Set([mockChunk, secondaryChunk]), + ); - // Create the RemoteRuntimeModule instance remoteRuntimeModule = new RemoteRuntimeModule(); remoteRuntimeModule.compilation = mockCompilation; - remoteRuntimeModule.chunkGraph = mockChunkGraph; + remoteRuntimeModule.chunkGraph = mockChunkGraph as unknown as ChunkGraph; remoteRuntimeModule.chunk = mockChunk; }); @@ -121,53 +155,59 @@ describe('RemoteRuntimeModule', () => { }); describe('generate', () => { - it('should return null when no remote modules are found', () => { + it('should return scaffold when no remote modules are found (snapshot)', () => { // Mock no modules found - mockChunkGraph.getChunkModulesIterableBySourceType.mockReturnValue(null); + chunkModulesBySourceTypeMock.mockReturnValue(undefined); // Call generate and check result const result = remoteRuntimeModule.generate(); - // Verify Template.asString was called with expected arguments - expect(result).toContain('var chunkMapping = {}'); - expect(result).toContain('var idToExternalAndNameMapping = {}'); - expect(result).toContain('var idToRemoteMap = {}'); + // Compare normalized output to stable expected string + const { normalizeCode } = require('../../helpers/snapshots'); + const normalized = normalizeCode(result as string); + const expected = [ + 'var chunkMapping = {};', + 'var idToExternalAndNameMapping = {};', + 'var idToRemoteMap = {};', + '__FEDERATION__.bundlerRuntimeOptions.remotes = {idToRemoteMap,chunkMapping, idToExternalAndNameMapping, webpackRequire:__webpack_require__};', + '__webpack_require__.e.remotes = function(chunkId, promises) { __FEDERATION__.bundlerRuntime.remotes({idToRemoteMap,chunkMapping, idToExternalAndNameMapping, chunkId, promises, webpackRequire:__webpack_require__}); }', + ].join('\n'); + expect(normalized).toBe(expected); }); it('should process remote modules and generate correct runtime code', () => { // Mock RemoteModule instances + const remoteDependency1 = new MockModuleDependency( + 'remote-dep-1', + ) as unknown as Dependency; + const remoteDependency2 = new MockModuleDependency( + 'remote-dep-2', + ) as unknown as Dependency; + const mockRemoteModule1 = { internalRequest: './component1', shareScope: 'default', - dependencies: [ - { - /* mock dependency */ - }, - ], - }; + dependencies: [remoteDependency1], + } as unknown as RemoteModuleMock; const mockRemoteModule2 = { internalRequest: './component2', shareScope: 'custom', - dependencies: [ - { - /* mock dependency */ - }, - ], - }; + dependencies: [remoteDependency2], + } as unknown as RemoteModuleMock; // Mock external modules const mockExternalModule1 = { externalType: 'script', request: 'app1@http://localhost:3001/remoteEntry.js', - dependencies: [], - }; + dependencies: [] as Dependency[], + } as unknown as ExternalModuleMock; const mockExternalModule2 = { externalType: 'var', request: 'app2', - dependencies: [], - }; + dependencies: [] as Dependency[], + } as unknown as ExternalModuleMock; // Mock the extractUrlAndGlobal function mockExtractUrlAndGlobal.mockImplementation((request) => { @@ -178,7 +218,7 @@ describe('RemoteRuntimeModule', () => { }); // Setup module IDs - mockChunkGraph.getModuleId.mockImplementation((module) => { + moduleIdMock.mockImplementation((module) => { if (module === mockRemoteModule1) return 'module1'; if (module === mockRemoteModule2) return 'module2'; if (module === mockExternalModule1) return 'external1'; @@ -187,24 +227,20 @@ describe('RemoteRuntimeModule', () => { }); // Setup moduleGraph to return external modules - mockModuleGraph.getModule.mockImplementation((dep) => { - if (dep === mockRemoteModule1.dependencies[0]) - return mockExternalModule1; - if (dep === mockRemoteModule2.dependencies[0]) - return mockExternalModule2; + moduleGraphGetModuleMock.mockImplementation((dep) => { + if (dep === remoteDependency1) return mockExternalModule1; + if (dep === remoteDependency2) return mockExternalModule2; return null; }); // Setup mock modules for each chunk - mockChunkGraph.getChunkModulesIterableBySourceType.mockImplementation( - (chunk, type) => { - if (type === 'remote') { - if (chunk.id === 'chunk1') return [mockRemoteModule1]; - if (chunk.id === 'chunk2') return [mockRemoteModule2]; - } - return null; - }, - ); + chunkModulesBySourceTypeMock.mockImplementation((chunk, type) => { + if (type === 'remote') { + if (chunk.id === 'chunk1') return [mockRemoteModule1]; + if (chunk.id === 'chunk2') return [mockRemoteModule2]; + } + return undefined; + }); // Call generate and check result const result = remoteRuntimeModule.generate(); @@ -242,41 +278,40 @@ describe('RemoteRuntimeModule', () => { it('should handle fallback modules with requests', () => { // Mock RemoteModule instance + const remoteDependency = new MockModuleDependency( + 'remote-dep', + ) as unknown as Dependency; + const fallbackDependency1 = new MockModuleDependency( + 'fallback-dep-1', + ) as unknown as Dependency; + const fallbackDependency2 = new MockModuleDependency( + 'fallback-dep-2', + ) as unknown as Dependency; + const mockRemoteModule = { internalRequest: './component', shareScope: 'default', - dependencies: [ - { - /* mock dependency */ - }, - ], - }; + dependencies: [remoteDependency], + } as unknown as RemoteModuleMock; // Mock fallback module with requests const mockFallbackModule = { requests: true, - dependencies: [ - { - /* mock dependency 1 */ - }, - { - /* mock dependency 2 */ - }, - ], - }; + dependencies: [fallbackDependency1, fallbackDependency2], + } as unknown as FallbackModuleMock; // Mock external modules const mockExternalModule1 = { externalType: 'script', request: 'app1@http://localhost:3001/remoteEntry.js', - dependencies: [], - }; + dependencies: [] as Dependency[], + } as unknown as ExternalModuleMock; const mockExternalModule2 = { externalType: 'var', request: 'app2', - dependencies: [], - }; + dependencies: [] as Dependency[], + } as unknown as ExternalModuleMock; // Mock the extractUrlAndGlobal function mockExtractUrlAndGlobal.mockImplementation((request) => { @@ -287,7 +322,7 @@ describe('RemoteRuntimeModule', () => { }); // Setup module IDs - mockChunkGraph.getModuleId.mockImplementation((module) => { + moduleIdMock.mockImplementation((module) => { if (module === mockRemoteModule) return 'module1'; if (module === mockFallbackModule) return 'fallback1'; if (module === mockExternalModule1) return 'external1'; @@ -296,24 +331,20 @@ describe('RemoteRuntimeModule', () => { }); // Setup moduleGraph to return modules - mockModuleGraph.getModule.mockImplementation((dep) => { - if (dep === mockRemoteModule.dependencies[0]) return mockFallbackModule; - if (dep === mockFallbackModule.dependencies[0]) - return mockExternalModule1; - if (dep === mockFallbackModule.dependencies[1]) - return mockExternalModule2; + moduleGraphGetModuleMock.mockImplementation((dep) => { + if (dep === remoteDependency) return mockFallbackModule; + if (dep === fallbackDependency1) return mockExternalModule1; + if (dep === fallbackDependency2) return mockExternalModule2; return null; }); // Setup mock modules for each chunk - mockChunkGraph.getChunkModulesIterableBySourceType.mockImplementation( - (chunk, type) => { - if (type === 'remote' && chunk.id === 'chunk1') { - return [mockRemoteModule]; - } - return null; - }, - ); + chunkModulesBySourceTypeMock.mockImplementation((chunk, type) => { + if (type === 'remote' && chunk.id === 'chunk1') { + return [mockRemoteModule]; + } + return undefined; + }); // Call generate and check result const result = remoteRuntimeModule.generate(); @@ -332,22 +363,22 @@ describe('RemoteRuntimeModule', () => { it('should handle extractUrlAndGlobal errors gracefully', () => { // Mock RemoteModule instance + const remoteDependency = new MockModuleDependency( + 'remote-dep', + ) as unknown as Dependency; + const mockRemoteModule = { internalRequest: './component', shareScope: 'default', - dependencies: [ - { - /* mock dependency */ - }, - ], - }; + dependencies: [remoteDependency], + } as unknown as RemoteModuleMock; // Mock external module that will cause extractUrlAndGlobal to throw const mockExternalModule = { externalType: 'script', request: 'invalid-format', - dependencies: [], - }; + dependencies: [] as Dependency[], + } as unknown as ExternalModuleMock; // Mock extractUrlAndGlobal to throw an error mockExtractUrlAndGlobal.mockImplementation(() => { @@ -355,27 +386,25 @@ describe('RemoteRuntimeModule', () => { }); // Setup module IDs - mockChunkGraph.getModuleId.mockImplementation((module) => { + moduleIdMock.mockImplementation((module) => { if (module === mockRemoteModule) return 'module1'; if (module === mockExternalModule) return 'external1'; return undefined; }); // Setup moduleGraph to return external module - mockModuleGraph.getModule.mockImplementation((dep) => { - if (dep === mockRemoteModule.dependencies[0]) return mockExternalModule; + moduleGraphGetModuleMock.mockImplementation((dep) => { + if (dep === remoteDependency) return mockExternalModule; return null; }); // Setup mock modules for each chunk - mockChunkGraph.getChunkModulesIterableBySourceType.mockImplementation( - (chunk, type) => { - if (type === 'remote' && chunk.id === 'chunk1') { - return [mockRemoteModule]; - } - return null; - }, - ); + chunkModulesBySourceTypeMock.mockImplementation((chunk, type) => { + if (type === 'remote' && chunk.id === 'chunk1') { + return [mockRemoteModule]; + } + return undefined; + }); // Call generate and check result const result = remoteRuntimeModule.generate(); diff --git a/packages/enhanced/test/unit/container/utils.ts b/packages/enhanced/test/unit/container/utils.ts index cfa08b228bd..01d851137e4 100644 --- a/packages/enhanced/test/unit/container/utils.ts +++ b/packages/enhanced/test/unit/container/utils.ts @@ -1,17 +1,22 @@ // Utility functions and constants for testing Module Federation container components import { normalizeWebpackPath } from '@module-federation/sdk/normalize-webpack-path'; - -// Import the actual Compilation class for instanceof checks -const Compilation = require( - normalizeWebpackPath('webpack/lib/Compilation'), -) as typeof import('webpack/lib/Compilation'); +import type { Compiler, Compilation } from 'webpack'; +import type { RuntimeGlobals } from 'webpack'; +import type { + ObjectSerializerContext, + ObjectDeserializerContext, +} from 'webpack/lib/serialization/ObjectMiddleware'; +import type RuntimeTemplate from 'webpack/lib/RuntimeTemplate'; +import type ChunkGraph from 'webpack/lib/ChunkGraph'; +import type Module from 'webpack/lib/Module'; +import type Dependency from 'webpack/lib/Dependency'; /** * Create a mock compilation with all the necessary objects for testing Module Federation components */ export const createMockCompilation = () => { - const mockRuntimeTemplate = { + const mockRuntimeTemplate: Partial = { basicFunction: jest.fn( (args, body) => `function(${args}) { ${Array.isArray(body) ? body.join('\n') : body} }`, @@ -23,7 +28,7 @@ export const createMockCompilation = () => { supportsArrowFunction: jest.fn(() => true), }; - const mockChunkGraph = { + const mockChunkGraph: Partial = { getChunkModulesIterableBySourceType: jest.fn(), getOrderedChunkModulesIterableBySourceType: jest.fn(), getModuleId: jest.fn().mockReturnValue('mockModuleId'), @@ -39,28 +44,33 @@ export const createMockCompilation = () => { }; // Create a mock compilation that extends the actual Compilation class - const mockCompilation = Object.create(Compilation.prototype); + const compilationPrototype = require( + normalizeWebpackPath('webpack/lib/Compilation'), + ).prototype; + + const mockCompilation = Object.create( + compilationPrototype, + ) as jest.Mocked; - // Add all the necessary properties and methods Object.assign(mockCompilation, { runtimeTemplate: mockRuntimeTemplate, moduleGraph: mockModuleGraph, chunkGraph: mockChunkGraph, - dependencyFactories: new Map(), + dependencyFactories: new Map(), dependencyTemplates: new Map(), addRuntimeModule: jest.fn(), contextDependencies: { addAll: jest.fn() }, fileDependencies: { addAll: jest.fn() }, missingDependencies: { addAll: jest.fn() }, - warnings: [], - errors: [], + warnings: [] as Error[], + errors: [] as Error[], hooks: { additionalTreeRuntimeRequirements: { tap: jest.fn() }, runtimeRequirementInTree: { tap: jest.fn() }, }, resolverFactory: { get: jest.fn().mockReturnValue({ - resolve: jest.fn().mockResolvedValue({ path: '/resolved/path' }), + resolve: jest.fn(), }), }, codeGenerationResults: { @@ -86,7 +96,7 @@ export const createMockCompilation = () => { /** * Create a mock compiler with hooks and plugins for testing webpack plugins */ -export const createMockCompiler = () => { +export const createMockCompiler = (): jest.Mocked => { const createTapableMock = (name: string) => { return { tap: jest.fn(), @@ -98,7 +108,7 @@ export const createMockCompiler = () => { }; }; - return { + const compiler = { hooks: { thisCompilation: createTapableMock('thisCompilation'), compilation: createTapableMock('compilation'), @@ -149,7 +159,9 @@ export const createMockCompiler = () => { }, }, }, - }; + } as unknown as jest.Mocked; + + return compiler; }; /** @@ -520,14 +532,14 @@ export function createWebpackMock() { // Don't mock validation functions const ExternalsPlugin = class { type: string; - externals: any; + externals: unknown; + apply: jest.Mock; - constructor(type, externals) { + constructor(type: string, externals: unknown) { this.type = type; this.externals = externals; + this.apply = jest.fn(); } - - apply = jest.fn(); }; // Keep optimize as an empty object instead of removing it completely @@ -563,6 +575,11 @@ export function createWebpackMock() { }; } +export type MockCompiler = ReturnType; +export type MockCompilation = ReturnType< + typeof createMockCompilation +>['mockCompilation']; + /** * Create a mocked container exposed dependency - returns a jest mock function */ diff --git a/packages/enhanced/test/unit/sharing/ConsumeSharedModule.test.ts b/packages/enhanced/test/unit/sharing/ConsumeSharedModule.test.ts deleted file mode 100644 index a1b5a127813..00000000000 --- a/packages/enhanced/test/unit/sharing/ConsumeSharedModule.test.ts +++ /dev/null @@ -1,522 +0,0 @@ -/* - * @jest-environment node - */ - -import { - createMockCompilation, - testModuleOptions, - createWebpackMock, - shareScopes, - createModuleMock, -} from './utils'; -import { WEBPACK_MODULE_TYPE_CONSUME_SHARED_MODULE } from '../../../src/lib/Constants'; - -// Add ConsumeOptions type -import type { ConsumeOptions } from '../../../src/lib/sharing/ConsumeSharedModule'; - -// Define interfaces needed for type assertions -interface CodeGenerationContext { - moduleGraph: any; - chunkGraph: any; - runtimeTemplate: any; - dependencyTemplates: Map; - runtime: string; - codeGenerationResults: { getData: (...args: any[]) => any }; -} - -interface ObjectSerializerContext { - write: (data: any) => void; - read?: () => any; - setCircularReference: (ref: any) => void; -} - -// Mock dependencies -jest.mock('@module-federation/sdk/normalize-webpack-path', () => ({ - normalizeWebpackPath: jest.fn((path) => path), -})); - -// Mock webpack -jest.mock( - 'webpack', - () => { - return createWebpackMock(); - }, - { virtual: true }, -); - -// Get the webpack mock -const webpack = require('webpack'); - -jest.mock( - 'webpack/lib/util/semver', - () => ({ - rangeToString: jest.fn((range) => (range ? range.toString() : '*')), - stringifyHoley: jest.fn((version) => JSON.stringify(version)), - }), - { virtual: true }, -); - -jest.mock('webpack/lib/util/makeSerializable', () => jest.fn(), { - virtual: true, -}); - -// Mock ConsumeSharedFallbackDependency -jest.mock( - '../../../src/lib/sharing/ConsumeSharedFallbackDependency', - () => { - return jest.fn().mockImplementation((request) => ({ request })); - }, - { virtual: true }, -); - -// Use the mock Module class to ensure ConsumeSharedModule can properly extend it -createModuleMock(webpack); - -// Import the real module -import ConsumeSharedModule from '../../../src/lib/sharing/ConsumeSharedModule'; - -describe('ConsumeSharedModule', () => { - let mockCompilation: ReturnType< - typeof createMockCompilation - >['mockCompilation']; - let mockSerializeContext: ObjectSerializerContext; - - beforeEach(() => { - jest.clearAllMocks(); - - const { mockCompilation: compilation } = createMockCompilation(); - mockCompilation = compilation; - - mockSerializeContext = { - write: jest.fn(), - read: jest.fn(), - setCircularReference: jest.fn(), - }; - }); - - describe('constructor', () => { - it('should initialize with string shareScope', () => { - const options = { - ...testModuleOptions.basic, - shareScope: shareScopes.string, - }; - - const module = new ConsumeSharedModule( - '/context', - options as any as ConsumeOptions, - ); - - expect(module.options).toEqual( - expect.objectContaining({ - shareScope: shareScopes.string, - shareKey: 'react', - requiredVersion: '^17.0.0', - singleton: true, - }), - ); - expect(module.layer).toBeNull(); - }); - - it('should initialize with array shareScope', () => { - const options = { - ...testModuleOptions.basic, - shareScope: shareScopes.array, - }; - - const module = new ConsumeSharedModule( - '/context', - options as any as ConsumeOptions, - ); - - expect(module.options).toEqual( - expect.objectContaining({ - shareScope: shareScopes.array, - shareKey: 'react', - requiredVersion: '^17.0.0', - singleton: true, - }), - ); - }); - - it('should initialize with layer if provided', () => { - const options = testModuleOptions.withLayer; - - const module = new ConsumeSharedModule( - '/context', - options as any as ConsumeOptions, - ); - - expect(module.layer).toBe('test-layer'); - }); - }); - - describe('identifier', () => { - it('should generate identifier with string shareScope', () => { - const module = new ConsumeSharedModule('/context', { - ...testModuleOptions.basic, - shareScope: shareScopes.string, - importResolved: './node_modules/react/index.js', - } as any as ConsumeOptions); - - const identifier = module.identifier(); - - expect(identifier).toContain(WEBPACK_MODULE_TYPE_CONSUME_SHARED_MODULE); - expect(identifier).toContain('default'); // shareScope - expect(identifier).toContain('react'); // shareKey - }); - - it('should generate identifier with array shareScope', () => { - const module = new ConsumeSharedModule('/context', { - ...testModuleOptions.basic, - shareScope: shareScopes.array, - importResolved: './node_modules/react/index.js', - } as any as ConsumeOptions); - - const identifier = module.identifier(); - - expect(identifier).toContain(WEBPACK_MODULE_TYPE_CONSUME_SHARED_MODULE); - expect(identifier).toContain('default|custom'); // shareScope - expect(identifier).toContain('react'); // shareKey - }); - }); - - describe('readableIdentifier', () => { - it('should generate readable identifier with string shareScope', () => { - const module = new ConsumeSharedModule('/context', { - ...testModuleOptions.basic, - shareScope: shareScopes.string, - importResolved: './node_modules/react/index.js', - }); - - const identifier = module.readableIdentifier({ - shorten: (path) => path, - contextify: (path) => path, - }); - - expect(identifier).toContain('consume shared module'); - expect(identifier).toContain('(default)'); // shareScope - expect(identifier).toContain('react@'); // shareKey - expect(identifier).toContain('(singleton)'); - }); - - it('should generate readable identifier with array shareScope', () => { - const module = new ConsumeSharedModule('/context', { - ...testModuleOptions.basic, - shareScope: shareScopes.array, - importResolved: './node_modules/react/index.js', - } as any as ConsumeOptions); - - const identifier = module.readableIdentifier({ - shorten: (path) => path, - contextify: (path) => path, - }); - - expect(identifier).toContain('consume shared module'); - expect(identifier).toContain('(default|custom)'); // shareScope joined - expect(identifier).toContain('react@'); // shareKey - }); - }); - - describe('libIdent', () => { - it('should generate library identifier with string shareScope', () => { - const module = new ConsumeSharedModule('/context', { - ...testModuleOptions.basic, - shareScope: shareScopes.string, - import: './react', - } as any as ConsumeOptions); - - const libId = module.libIdent({ context: '/some/context' }); - - expect(libId).toContain('webpack/sharing/consume/'); - expect(libId).toContain('default'); // shareScope - expect(libId).toContain('react'); // shareKey - expect(libId).toContain('./react'); // import - }); - - it('should generate library identifier with array shareScope', () => { - const module = new ConsumeSharedModule('/context', { - ...testModuleOptions.basic, - shareScope: shareScopes.array, - import: './react', - } as any as ConsumeOptions); - - const libId = module.libIdent({ context: '/some/context' }); - - expect(libId).toContain('webpack/sharing/consume/'); - expect(libId).toContain('default|custom'); // shareScope - expect(libId).toContain('react'); // shareKey - expect(libId).toContain('./react'); // import - }); - - it('should include layer in library identifier when specified', () => { - const module = new ConsumeSharedModule( - '/context', - testModuleOptions.withLayer as any as ConsumeOptions, - ); - - const libId = module.libIdent({ context: '/some/context' }); - - expect(libId).toContain('(test-layer)/'); - expect(libId).toContain('webpack/sharing/consume/'); - expect(libId).toContain('default'); // shareScope - expect(libId).toContain('react'); // shareKey - }); - }); - - describe('build', () => { - it('should add fallback dependency when import exists and eager=true', () => { - const module = new ConsumeSharedModule('/context', { - ...testModuleOptions.eager, - import: './react', - } as any as ConsumeOptions); - - // Named callback function to satisfy linter - function buildCallback() { - // Empty callback needed for the build method - } - module.build({} as any, {} as any, {} as any, {} as any, buildCallback); - - expect(module.dependencies.length).toBe(1); - expect(module.blocks.length).toBe(0); - }); - - it('should add fallback in async block when import exists and eager=false', () => { - const module = new ConsumeSharedModule('/context', { - ...testModuleOptions.basic, - import: './react', - } as any as ConsumeOptions); - - // Named callback function to satisfy linter - function buildCallback() { - // Empty callback needed for the build method - } - module.build({} as any, {} as any, {} as any, {} as any, buildCallback); - - expect(module.dependencies.length).toBe(0); - expect(module.blocks.length).toBe(1); - }); - - it('should not add fallback when import does not exist', () => { - const module = new ConsumeSharedModule('/context', { - ...testModuleOptions.basic, - import: undefined, - } as any as ConsumeOptions); - - // Named callback function to satisfy linter - function buildCallback() { - // Empty callback needed for the build method - } - module.build({} as any, {} as any, {} as any, {} as any, buildCallback); - - expect(module.dependencies.length).toBe(0); - expect(module.blocks.length).toBe(0); - }); - }); - - describe('codeGeneration', () => { - it('should generate code with string shareScope', () => { - const options = { - ...testModuleOptions.basic, - shareScope: shareScopes.string, - }; - - const module = new ConsumeSharedModule( - 'react-context', - options as any as ConsumeOptions, - ); - - const codeGenContext: CodeGenerationContext = { - chunkGraph: {}, - moduleGraph: { - getExportsInfo: jest - .fn() - .mockReturnValue({ isModuleUsed: () => true }), - }, - runtimeTemplate: { - outputOptions: {}, - returningFunction: jest.fn( - (args, body) => `function(${args}) { ${body} }`, - ), - syncModuleFactory: jest.fn(() => 'syncModuleFactory()'), - asyncModuleFactory: jest.fn(() => 'asyncModuleFactory()'), - }, - dependencyTemplates: new Map(), - runtime: 'webpack-runtime', - codeGenerationResults: { getData: jest.fn() }, - }; - - const result = module.codeGeneration(codeGenContext as any); - - expect(result.runtimeRequirements).toBeDefined(); - expect( - result.runtimeRequirements.has(webpack.RuntimeGlobals.shareScopeMap), - ).toBe(true); - }); - - it('should generate code with array shareScope', () => { - const { mockCompilation } = createMockCompilation(); - - const module = new ConsumeSharedModule('/context', { - ...testModuleOptions.basic, - shareScope: shareScopes.array, - } as any as ConsumeOptions); - - const codeGenContext: CodeGenerationContext = { - chunkGraph: mockCompilation.chunkGraph, - moduleGraph: { - getExportsInfo: jest - .fn() - .mockReturnValue({ isModuleUsed: () => true }), - }, - runtimeTemplate: { - outputOptions: {}, - returningFunction: jest.fn( - (args, body) => `function(${args}) { ${body} }`, - ), - syncModuleFactory: jest.fn(() => 'syncModuleFactory()'), - asyncModuleFactory: jest.fn(() => 'asyncModuleFactory()'), - }, - dependencyTemplates: new Map(), - runtime: 'webpack-runtime', - codeGenerationResults: { getData: jest.fn() }, - }; - - const result = module.codeGeneration(codeGenContext as any); - - expect(result.runtimeRequirements).toBeDefined(); - expect( - result.runtimeRequirements.has(webpack.RuntimeGlobals.shareScopeMap), - ).toBe(true); - }); - - it('should handle different combinations of strictVersion, singleton, and fallback', () => { - const testCombinations = [ - { strictVersion: true, singleton: true, import: './react' }, - { strictVersion: true, singleton: false, import: './react' }, - { strictVersion: false, singleton: true, import: './react' }, - { strictVersion: false, singleton: false, import: './react' }, - { strictVersion: true, singleton: true, import: undefined }, - { strictVersion: true, singleton: false, import: undefined }, - { strictVersion: false, singleton: true, import: undefined }, - { strictVersion: false, singleton: false, import: undefined }, - ]; - - const codeGenContext: CodeGenerationContext = { - chunkGraph: {}, - moduleGraph: { - getExportsInfo: jest - .fn() - .mockReturnValue({ isModuleUsed: () => true }), - }, - runtimeTemplate: { - outputOptions: {}, - returningFunction: jest.fn( - (args, body) => `function(${args}) { ${body} }`, - ), - syncModuleFactory: jest.fn(() => 'syncModuleFactory()'), - asyncModuleFactory: jest.fn(() => 'asyncModuleFactory()'), - }, - dependencyTemplates: new Map(), - runtime: 'webpack-runtime', - codeGenerationResults: { getData: jest.fn() }, - }; - - for (const combo of testCombinations) { - const module = new ConsumeSharedModule('/context', { - ...testModuleOptions.basic, - ...combo, - } as any as ConsumeOptions); - - const result = module.codeGeneration(codeGenContext as any); - - expect(result.runtimeRequirements).toBeDefined(); - expect( - result.runtimeRequirements.has(webpack.RuntimeGlobals.shareScopeMap), - ).toBe(true); - } - }); - - it('should generate code with correct requirements', () => { - const options = { - ...testModuleOptions.basic, - import: './react', - }; - - const module = new ConsumeSharedModule( - 'react-context', - options as any as ConsumeOptions, - ); - - const codeGenContext: CodeGenerationContext = { - chunkGraph: {}, - moduleGraph: { - getExportsInfo: jest - .fn() - .mockReturnValue({ isModuleUsed: () => true }), - }, - runtimeTemplate: { - outputOptions: {}, - returningFunction: jest.fn( - (args, body) => `function(${args}) { ${body} }`, - ), - syncModuleFactory: jest.fn(() => 'syncModuleFactory()'), - asyncModuleFactory: jest.fn(() => 'asyncModuleFactory()'), - }, - dependencyTemplates: new Map(), - runtime: 'webpack-runtime', - codeGenerationResults: { getData: jest.fn() }, - }; - - const result = module.codeGeneration(codeGenContext as any); - - expect(result.runtimeRequirements).toBeDefined(); - expect( - result.runtimeRequirements.has(webpack.RuntimeGlobals.shareScopeMap), - ).toBe(true); - }); - }); - - describe('serialization', () => { - it('should serialize module data', () => { - const context: ObjectSerializerContext = { - write: jest.fn(), - setCircularReference: jest.fn(), - }; - - const module = new ConsumeSharedModule( - '/context', - testModuleOptions.basic as any as ConsumeOptions, - ); - - // We can't directly test the serialization in a fully functional way without proper webpack setup - // Just verify the serialize method exists and can be called - expect(typeof module.serialize).toBe('function'); - expect(() => { - module.serialize(context as any); - }).not.toThrow(); - }); - - it('should handle array shareScope serialization', () => { - const options = { - ...testModuleOptions.basic, - shareScope: shareScopes.array, - }; - - const context: ObjectSerializerContext = { - write: jest.fn(), - setCircularReference: jest.fn(), - }; - - const module = new ConsumeSharedModule( - '/context', - options as any as ConsumeOptions, - ); - - // Just verify the serialize method exists and can be called - expect(typeof module.serialize).toBe('function'); - expect(() => { - module.serialize(context as any); - }).not.toThrow(); - }); - }); -}); diff --git a/packages/enhanced/test/unit/sharing/ConsumeSharedPlugin.focused.test.ts b/packages/enhanced/test/unit/sharing/ConsumeSharedPlugin.focused.test.ts deleted file mode 100644 index 7e92081dbfa..00000000000 --- a/packages/enhanced/test/unit/sharing/ConsumeSharedPlugin.focused.test.ts +++ /dev/null @@ -1,569 +0,0 @@ -/* - * @jest-environment node - */ - -import ConsumeSharedPlugin from '../../../src/lib/sharing/ConsumeSharedPlugin'; -import ConsumeSharedModule from '../../../src/lib/sharing/ConsumeSharedModule'; -import { vol } from 'memfs'; - -// Mock file system for controlled testing -jest.mock('fs', () => require('memfs').fs); -jest.mock('fs/promises', () => require('memfs').fs.promises); - -// Mock webpack internals -jest.mock('@module-federation/sdk/normalize-webpack-path', () => ({ - getWebpackPath: jest.fn(() => 'webpack'), - normalizeWebpackPath: jest.fn((p) => p), -})); - -// Mock FederationRuntimePlugin to avoid complex dependencies -jest.mock('../../../src/lib/container/runtime/FederationRuntimePlugin', () => { - return jest.fn().mockImplementation(() => ({ - apply: jest.fn(), - })); -}); - -// Mock the webpack fs utilities that are used by getDescriptionFile -jest.mock('webpack/lib/util/fs', () => ({ - join: (fs: any, ...paths: string[]) => require('path').join(...paths), - dirname: (fs: any, filePath: string) => require('path').dirname(filePath), - readJson: (fs: any, filePath: string, callback: Function) => { - const memfs = require('memfs').fs; - memfs.readFile(filePath, 'utf8', (err: any, content: any) => { - if (err) return callback(err); - try { - const data = JSON.parse(content); - callback(null, data); - } catch (e) { - callback(e); - } - }); - }, -})); - -describe('ConsumeSharedPlugin - Focused Quality Tests', () => { - beforeEach(() => { - vol.reset(); - jest.clearAllMocks(); - }); - - describe('Configuration behavior tests', () => { - it('should parse consume configurations correctly and preserve semantic meaning', () => { - const plugin = new ConsumeSharedPlugin({ - shareScope: 'default', - consumes: { - // Test different configuration formats - 'string-version': '^1.0.0', - 'object-config': { - requiredVersion: '^2.0.0', - singleton: true, - strictVersion: false, - eager: true, - }, - 'custom-import': { - import: './custom-path', - shareKey: 'custom-key', - requiredVersion: false, - }, - 'layered-module': { - issuerLayer: 'client', - shareScope: 'client-scope', - }, - 'complex-config': { - import: './src/lib', - shareKey: 'shared-lib', - requiredVersion: '^3.0.0', - singleton: true, - strictVersion: true, - eager: false, - issuerLayer: 'server', - include: { version: '^3.0.0' }, - exclude: { request: /test/ }, - }, - }, - }); - - // Access internal _consumes to verify parsing (this is legitimate for testing plugin behavior) - const consumes = (plugin as any)._consumes; - expect(consumes).toHaveLength(5); - - // Verify string version parsing - const stringConfig = consumes.find( - ([key]: [string, any]) => key === 'string-version', - ); - expect(stringConfig).toBeDefined(); - expect(stringConfig[1]).toMatchObject({ - shareKey: 'string-version', - requiredVersion: '^1.0.0', - shareScope: 'default', - singleton: false, - strictVersion: true, // Default is true - eager: false, - }); - - // Verify object configuration parsing - const objectConfig = consumes.find( - ([key]: [string, any]) => key === 'object-config', - ); - expect(objectConfig[1]).toMatchObject({ - requiredVersion: '^2.0.0', - singleton: true, - strictVersion: false, - eager: true, - shareScope: 'default', - }); - - // Verify custom import configuration - const customConfig = consumes.find( - ([key]: [string, any]) => key === 'custom-import', - ); - expect(customConfig[1]).toMatchObject({ - import: './custom-path', - shareKey: 'custom-key', - requiredVersion: false, - }); - - // Verify layered configuration - const layeredConfig = consumes.find( - ([key]: [string, any]) => key === 'layered-module', - ); - expect(layeredConfig[1]).toMatchObject({ - issuerLayer: 'client', - shareScope: 'client-scope', - }); - - // Verify complex configuration with filters - const complexConfig = consumes.find( - ([key]: [string, any]) => key === 'complex-config', - ); - expect(complexConfig[1]).toMatchObject({ - import: './src/lib', - shareKey: 'shared-lib', - requiredVersion: '^3.0.0', - singleton: true, - strictVersion: true, - eager: false, - issuerLayer: 'server', - }); - expect(complexConfig[1].include?.version).toBe('^3.0.0'); - expect(complexConfig[1].exclude?.request).toBeInstanceOf(RegExp); - }); - - it('should validate configurations and reject invalid inputs', () => { - // Test invalid array configuration - expect(() => { - new ConsumeSharedPlugin({ - shareScope: 'default', - consumes: { - // @ts-ignore - intentionally testing invalid input - invalid: ['should', 'not', 'work'], - }, - }); - }).toThrow(); - - // Test valid edge cases - expect(() => { - new ConsumeSharedPlugin({ - shareScope: 'test', - consumes: { - 'empty-config': {}, - 'false-required': { requiredVersion: false }, - 'false-import': { import: false }, - }, - }); - }).not.toThrow(); - }); - }); - - describe('Real module creation behavior', () => { - it('should create ConsumeSharedModule with real package.json data', async () => { - // Setup realistic file system with package.json - vol.fromJSON({ - '/test-project/package.json': JSON.stringify({ - name: 'test-app', - version: '1.0.0', - dependencies: { - react: '^17.0.2', - lodash: '^4.17.21', - }, - }), - '/test-project/node_modules/react/package.json': JSON.stringify({ - name: 'react', - version: '17.0.2', - main: 'index.js', - }), - }); - - const plugin = new ConsumeSharedPlugin({ - shareScope: 'default', - consumes: { react: '^17.0.0' }, - }); - - // Create realistic compilation context - const mockCompilation = { - compiler: { context: '/test-project' }, - resolverFactory: { - get: () => ({ - resolve: ( - context: string, - lookupStartPath: string, - request: string, - resolveContext: any, - callback: Function, - ) => { - // Simulate successful resolution - callback(null, `/test-project/node_modules/${request}`); - }, - }), - }, - inputFileSystem: require('fs'), // Use memfs - contextDependencies: { addAll: jest.fn() }, - fileDependencies: { addAll: jest.fn() }, - missingDependencies: { addAll: jest.fn() }, - warnings: [], - errors: [], - }; - - const result = await plugin.createConsumeSharedModule( - mockCompilation as any, - '/test-project', - 'react', - { - import: undefined, - shareScope: 'default', - shareKey: 'react', - requiredVersion: '^17.0.0', - strictVersion: false, - packageName: 'react', - singleton: false, - eager: false, - issuerLayer: undefined, - layer: undefined, - request: 'react', - include: undefined, - exclude: undefined, - nodeModulesReconstructedLookup: undefined, - }, - ); - - // Verify real module creation - expect(result).toBeInstanceOf(ConsumeSharedModule); - expect(mockCompilation.warnings).toHaveLength(0); - expect(mockCompilation.errors).toHaveLength(0); - - // Verify the module has correct properties - access via options - expect(result.options.shareScope).toBe('default'); - expect(result.options.shareKey).toBe('react'); - }); - - it('should handle version mismatches appropriately', async () => { - // Setup with version conflict - vol.fromJSON({ - '/test-project/package.json': JSON.stringify({ - name: 'test-app', - dependencies: { oldLib: '^1.0.0' }, - }), - '/test-project/node_modules/oldLib/package.json': JSON.stringify({ - name: 'oldLib', - version: '1.5.0', // Available version - }), - }); - - const plugin = new ConsumeSharedPlugin({ - shareScope: 'default', - consumes: { - oldLib: { - requiredVersion: '^2.0.0', // Required version (conflict!) - strictVersion: false, // Not strict, should still work - }, - }, - }); - - const mockCompilation = { - compiler: { context: '/test-project' }, - resolverFactory: { - get: () => ({ - resolve: ( - context: string, - lookupStartPath: string, - request: string, - resolveContext: any, - callback: Function, - ) => { - callback(null, `/test-project/node_modules/${request}`); - }, - }), - }, - inputFileSystem: require('fs'), - contextDependencies: { addAll: jest.fn() }, - fileDependencies: { addAll: jest.fn() }, - missingDependencies: { addAll: jest.fn() }, - warnings: [], - errors: [], - }; - - const result = await plugin.createConsumeSharedModule( - mockCompilation as any, - '/test-project', - 'oldLib', - { - import: undefined, - shareScope: 'default', - shareKey: 'oldLib', - requiredVersion: '^2.0.0', - strictVersion: false, - packageName: 'oldLib', - singleton: false, - eager: false, - issuerLayer: undefined, - layer: undefined, - request: 'oldLib', - include: undefined, - exclude: undefined, - nodeModulesReconstructedLookup: undefined, - }, - ); - - // Should create module despite version mismatch (strictVersion: false) - expect(result).toBeInstanceOf(ConsumeSharedModule); - - // With strictVersion: false, warnings might not be generated immediately - // The warning would be generated later during runtime validation - // So we just verify the module was created successfully - expect(result.options.requiredVersion).toBe('^2.0.0'); - }); - - it('should handle missing package.json files gracefully', async () => { - // Setup with missing package.json - vol.fromJSON({ - '/test-project/package.json': JSON.stringify({ name: 'test-app' }), - // No react package.json - }); - - const plugin = new ConsumeSharedPlugin({ - shareScope: 'default', - consumes: { react: '^17.0.0' }, - }); - - const mockCompilation = { - compiler: { context: '/test-project' }, - resolverFactory: { - get: () => ({ - resolve: ( - context: string, - lookupStartPath: string, - request: string, - resolveContext: any, - callback: Function, - ) => { - callback(null, `/test-project/node_modules/${request}`); - }, - }), - }, - inputFileSystem: require('fs'), - contextDependencies: { addAll: jest.fn() }, - fileDependencies: { addAll: jest.fn() }, - missingDependencies: { addAll: jest.fn() }, - warnings: [], - errors: [], - }; - - const result = await plugin.createConsumeSharedModule( - mockCompilation as any, - '/test-project', - 'react', - { - import: undefined, - shareScope: 'default', - shareKey: 'react', - requiredVersion: '^17.0.0', - strictVersion: false, - packageName: 'react', - singleton: false, - eager: false, - issuerLayer: undefined, - layer: undefined, - request: 'react', - include: undefined, - exclude: undefined, - nodeModulesReconstructedLookup: undefined, - }, - ); - - // Should still create module - expect(result).toBeInstanceOf(ConsumeSharedModule); - - // Without package.json, module is created but warnings are deferred - // Verify module was created with correct config - expect(result.options.shareKey).toBe('react'); - expect(result.options.requiredVersion).toBe('^17.0.0'); - }); - }); - - describe('Include/exclude filtering behavior', () => { - it('should apply version filtering correctly', async () => { - vol.fromJSON({ - '/test-project/package.json': JSON.stringify({ name: 'test-app' }), - '/test-project/node_modules/testLib/package.json': JSON.stringify({ - name: 'testLib', - version: '1.5.0', - }), - }); - - const plugin = new ConsumeSharedPlugin({ - shareScope: 'default', - consumes: { - includedLib: { - requiredVersion: '^1.0.0', - include: { version: '^1.0.0' }, // Should include (1.5.0 matches ^1.0.0) - }, - excludedLib: { - requiredVersion: '^1.0.0', - exclude: { version: '^1.0.0' }, // Should exclude (1.5.0 matches ^1.0.0) - }, - }, - }); - - const mockCompilation = { - compiler: { context: '/test-project' }, - resolverFactory: { - get: () => ({ - resolve: ( - context: string, - lookupStartPath: string, - request: string, - resolveContext: any, - callback: Function, - ) => { - callback(null, `/test-project/node_modules/testLib`); - }, - }), - }, - inputFileSystem: require('fs'), - contextDependencies: { addAll: jest.fn() }, - fileDependencies: { addAll: jest.fn() }, - missingDependencies: { addAll: jest.fn() }, - warnings: [], - errors: [], - }; - - // Test include filter - should create module - const includedResult = await plugin.createConsumeSharedModule( - mockCompilation as any, - '/test-project', - 'testLib', - { - import: '/test-project/node_modules/testLib/index.js', - importResolved: '/test-project/node_modules/testLib/index.js', - shareScope: 'default', - shareKey: 'includedLib', - requiredVersion: '^1.0.0', - strictVersion: false, - packageName: 'testLib', - singleton: false, - eager: false, - issuerLayer: undefined, - layer: undefined, - request: 'testLib', - include: { version: '^1.0.0' }, - exclude: undefined, - nodeModulesReconstructedLookup: undefined, - }, - ); - - expect(includedResult).toBeInstanceOf(ConsumeSharedModule); - - // Test exclude filter - should not create module - const excludedResult = await plugin.createConsumeSharedModule( - mockCompilation as any, - '/test-project', - 'testLib', // Use the actual package name - { - import: '/test-project/node_modules/testLib/index.js', // Need import path for exclude logic - importResolved: '/test-project/node_modules/testLib/index.js', // Needs resolved path - shareScope: 'default', - shareKey: 'excludedLib', - requiredVersion: '^1.0.0', - strictVersion: false, - packageName: 'testLib', - singleton: false, - eager: false, - issuerLayer: undefined, - layer: undefined, - request: 'testLib', // Match the package name - include: undefined, - exclude: { version: '^1.0.0' }, - nodeModulesReconstructedLookup: undefined, - }, - ); - - // When calling createConsumeSharedModule directly with importResolved, - // the module is created but the exclude filter will be applied during runtime - // The actual filtering happens in the webpack hooks, not in this method - expect(excludedResult).toBeInstanceOf(ConsumeSharedModule); - expect(excludedResult.options.exclude).toEqual({ version: '^1.0.0' }); - }); - }); - - describe('Edge cases and error scenarios', () => { - it('should handle resolver errors gracefully', async () => { - const plugin = new ConsumeSharedPlugin({ - shareScope: 'default', - consumes: { failingModule: '^1.0.0' }, - }); - - const mockCompilation = { - compiler: { context: '/test-project' }, - resolverFactory: { - get: () => ({ - resolve: ( - context: string, - lookupStartPath: string, - request: string, - resolveContext: any, - callback: Function, - ) => { - // Simulate resolver failure - callback(new Error('Resolution failed'), null); - }, - }), - }, - inputFileSystem: require('fs'), - contextDependencies: { addAll: jest.fn() }, - fileDependencies: { addAll: jest.fn() }, - missingDependencies: { addAll: jest.fn() }, - warnings: [], - errors: [], - }; - - const result = await plugin.createConsumeSharedModule( - mockCompilation as any, - '/test-project', - 'failingModule', - { - import: './failing-path', - shareScope: 'default', - shareKey: 'failingModule', - requiredVersion: '^1.0.0', - strictVersion: false, - packageName: undefined, - singleton: false, - eager: false, - issuerLayer: undefined, - layer: undefined, - request: 'failingModule', - include: undefined, - exclude: undefined, - nodeModulesReconstructedLookup: undefined, - }, - ); - - // Should create module despite resolution failure - expect(result).toBeInstanceOf(ConsumeSharedModule); - - // Should report error - expect(mockCompilation.errors).toHaveLength(1); - expect(mockCompilation.errors[0].message).toContain('Resolution failed'); - }); - }); -}); diff --git a/packages/enhanced/test/unit/sharing/ConsumeSharedPlugin.improved.test.ts b/packages/enhanced/test/unit/sharing/ConsumeSharedPlugin.improved.test.ts deleted file mode 100644 index a11715259f1..00000000000 --- a/packages/enhanced/test/unit/sharing/ConsumeSharedPlugin.improved.test.ts +++ /dev/null @@ -1,476 +0,0 @@ -/* - * @jest-environment node - */ - -import ConsumeSharedPlugin from '../../../src/lib/sharing/ConsumeSharedPlugin'; -import ConsumeSharedModule from '../../../src/lib/sharing/ConsumeSharedModule'; -import { vol } from 'memfs'; -import { SyncHook, AsyncSeriesHook } from 'tapable'; - -// Mock file system only for controlled testing -jest.mock('fs', () => require('memfs').fs); -jest.mock('fs/promises', () => require('memfs').fs.promises); - -// Mock webpack internals minimally -jest.mock('@module-federation/sdk/normalize-webpack-path', () => ({ - getWebpackPath: jest.fn(() => 'webpack'), - normalizeWebpackPath: jest.fn((p) => p), -})); - -// Mock FederationRuntimePlugin to avoid complex dependencies -jest.mock('../../../src/lib/container/runtime/FederationRuntimePlugin', () => { - return jest.fn().mockImplementation(() => ({ - apply: jest.fn(), - })); -}); - -// Mock the webpack fs utilities that are used by getDescriptionFile -jest.mock('webpack/lib/util/fs', () => ({ - join: (_fs: any, ...paths: string[]) => require('path').join(...paths), - dirname: (_fs: any, filePath: string) => require('path').dirname(filePath), - readJson: ( - _fs: any, - filePath: string, - callback: (err: any, data?: any) => void, - ) => { - const memfs = require('memfs').fs; - memfs.readFile(filePath, 'utf8', (err: any, content: any) => { - if (err) return callback(err); - try { - const data = JSON.parse(content); - callback(null, data); - } catch (e) { - callback(e); - } - }); - }, -})); - -describe('ConsumeSharedPlugin - Improved Quality Tests', () => { - beforeEach(() => { - vol.reset(); - jest.clearAllMocks(); - }); - - describe('Real webpack integration', () => { - it('should apply plugin to webpack compiler and register hooks correctly', () => { - // Create real tapable hooks - const thisCompilationHook = new SyncHook(['compilation']); - const compiler = { - hooks: { thisCompilation: thisCompilationHook }, - context: '/test-project', - options: { - plugins: [], // Add empty plugins array to prevent runtime plugin error - output: { - uniqueName: 'test-app', - }, - }, - }; - - const plugin = new ConsumeSharedPlugin({ - shareScope: 'default', - consumes: { - react: '^17.0.0', - lodash: { requiredVersion: '^4.0.0' }, - }, - }); - - // Track hook registration - let compilationCallback: - | ((compilation: any, params: any) => void) - | null = null; - const originalTap = thisCompilationHook.tap; - thisCompilationHook.tap = jest.fn((name, callback) => { - compilationCallback = callback; - return originalTap.call(thisCompilationHook, name, callback); - }); - - // Apply plugin - plugin.apply(compiler as any); - - // Verify hook was registered - expect(thisCompilationHook.tap).toHaveBeenCalledWith( - 'ConsumeSharedPlugin', - expect.any(Function), - ); - - // Test hook execution with real compilation-like object - expect(compilationCallback).not.toBeNull(); - if (compilationCallback) { - const factorizeHook = new AsyncSeriesHook(['resolveData']); - const createModuleHook = new AsyncSeriesHook(['resolveData']); - - const mockCompilation = { - dependencyFactories: new Map(), - hooks: { - additionalTreeRuntimeRequirements: new SyncHook(['chunk']), - finishModules: new AsyncSeriesHook(['modules']), - seal: new SyncHook(['modules']), - }, - resolverFactory: { - get: jest.fn(() => ({ - resolve: jest.fn( - ( - _context, - _contextPath, - request, - _resolveContext, - callback, - ) => { - callback(null, `/resolved/${request}`); - }, - ), - })), - }, - compiler: { context: '/test-project' }, - contextDependencies: { addAll: jest.fn() }, - fileDependencies: { addAll: jest.fn() }, - missingDependencies: { addAll: jest.fn() }, - warnings: [], - errors: [], - }; - - const mockNormalModuleFactory = { - hooks: { - factorize: factorizeHook, - createModule: createModuleHook, - }, - }; - - // Execute the compilation hook - expect(() => { - if (compilationCallback) { - compilationCallback(mockCompilation, { - normalModuleFactory: mockNormalModuleFactory, - }); - } - }).not.toThrow(); - - // Verify dependency factory was set - expect(mockCompilation.dependencyFactories.size).toBeGreaterThan(0); - } - }); - - it('should handle real module resolution with package.json', async () => { - // Setup realistic file system - vol.fromJSON({ - '/test-project/package.json': JSON.stringify({ - name: 'test-app', - version: '1.0.0', - dependencies: { - react: '^17.0.2', - lodash: '^4.17.21', - }, - }), - '/test-project/node_modules/react/package.json': JSON.stringify({ - name: 'react', - version: '17.0.2', - }), - '/test-project/node_modules/lodash/package.json': JSON.stringify({ - name: 'lodash', - version: '4.17.21', - }), - }); - - const plugin = new ConsumeSharedPlugin({ - shareScope: 'default', - consumes: { - react: '^17.0.0', - lodash: '^4.0.0', - }, - }); - - // Create realistic compilation context - const mockCompilation = { - compiler: { context: '/test-project' }, - resolverFactory: { - get: () => ({ - resolve: ( - _context: string, - _lookupStartPath: string, - request: string, - _resolveContext: any, - callback: (err: any, result?: string) => void, - ) => { - // Simulate real module resolution - const resolvedPath = `/test-project/node_modules/${request}`; - callback(null, resolvedPath); - }, - }), - }, - inputFileSystem: require('fs'), - contextDependencies: { addAll: jest.fn() }, - fileDependencies: { addAll: jest.fn() }, - missingDependencies: { addAll: jest.fn() }, - warnings: [], - errors: [], - }; - - // Test createConsumeSharedModule with real package.json reading - const result = await plugin.createConsumeSharedModule( - mockCompilation as any, - '/test-project', - 'react', - { - import: undefined, - shareScope: 'default', - shareKey: 'react', - requiredVersion: '^17.0.0', - strictVersion: false, - packageName: 'react', - singleton: false, - eager: false, - issuerLayer: undefined, - layer: undefined, - request: 'react', - include: undefined, - exclude: undefined, - nodeModulesReconstructedLookup: undefined, - }, - ); - - expect(result).toBeInstanceOf(ConsumeSharedModule); - expect(mockCompilation.warnings).toHaveLength(0); - expect(mockCompilation.errors).toHaveLength(0); - }); - - it('should handle version conflicts correctly', async () => { - // Setup conflicting versions - vol.fromJSON({ - '/test-project/package.json': JSON.stringify({ - name: 'test-app', - dependencies: { react: '^16.0.0' }, - }), - '/test-project/node_modules/react/package.json': JSON.stringify({ - name: 'react', - version: '16.14.0', - }), - }); - - const plugin = new ConsumeSharedPlugin({ - shareScope: 'default', - consumes: { - react: { requiredVersion: '^17.0.0', strictVersion: true }, - }, - }); - - const mockCompilation = { - compiler: { context: '/test-project' }, - resolverFactory: { - get: () => ({ - resolve: ( - _context: string, - _lookupStartPath: string, - request: string, - _resolveContext: any, - callback: (err: any, result?: string) => void, - ) => { - callback(null, `/test-project/node_modules/${request}`); - }, - }), - }, - inputFileSystem: require('fs'), - contextDependencies: { addAll: jest.fn() }, - fileDependencies: { addAll: jest.fn() }, - missingDependencies: { addAll: jest.fn() }, - warnings: [], - errors: [], - }; - - const result = await plugin.createConsumeSharedModule( - mockCompilation as any, - '/test-project', - 'react', - { - import: undefined, - shareScope: 'default', - shareKey: 'react', - requiredVersion: '^17.0.0', - strictVersion: true, - packageName: 'react', - singleton: false, - eager: false, - issuerLayer: undefined, - layer: undefined, - request: 'react', - include: undefined, - exclude: undefined, - nodeModulesReconstructedLookup: undefined, - }, - ); - - // Should still create module (version conflicts are handled at runtime, not build time) - expect(result).toBeInstanceOf(ConsumeSharedModule); - expect(mockCompilation.warnings.length).toBeGreaterThanOrEqual(0); - }); - }); - - describe('Configuration parsing behavior', () => { - it('should parse different consume configuration formats correctly', () => { - const plugin = new ConsumeSharedPlugin({ - shareScope: 'default', - consumes: { - // String format - react: '^17.0.0', - // Object format - lodash: { - requiredVersion: '^4.0.0', - singleton: true, - strictVersion: false, - }, - // Advanced format with custom request - 'my-lib': { - import: './custom-lib', - shareKey: 'my-shared-lib', - requiredVersion: false, - }, - // Layer-specific consumption - 'client-only': { - issuerLayer: 'client', - shareScope: 'client-scope', - }, - }, - }); - - // Access plugin internals to verify parsing (using proper method) - const consumes = (plugin as any)._consumes; - - expect(consumes).toHaveLength(4); - - // Verify string format parsing - const reactConfig = consumes.find( - ([key]: [string, any]) => key === 'react', - ); - expect(reactConfig).toBeDefined(); - expect(reactConfig[1].requiredVersion).toBe('^17.0.0'); - - // Verify object format parsing - const lodashConfig = consumes.find( - ([key]: [string, any]) => key === 'lodash', - ); - expect(lodashConfig).toBeDefined(); - expect(lodashConfig[1].singleton).toBe(true); - expect(lodashConfig[1].strictVersion).toBe(false); - - // Verify advanced configuration - const myLibConfig = consumes.find( - ([key]: [string, any]) => key === 'my-lib', - ); - expect(myLibConfig).toBeDefined(); - expect(myLibConfig[1].import).toBe('./custom-lib'); - expect(myLibConfig[1].shareKey).toBe('my-shared-lib'); - - // Verify layer-specific configuration - const clientOnlyConfig = consumes.find( - ([key]: [string, any]) => key === 'client-only', - ); - expect(clientOnlyConfig).toBeDefined(); - expect(clientOnlyConfig[1].issuerLayer).toBe('client'); - expect(clientOnlyConfig[1].shareScope).toBe('client-scope'); - }); - - it('should handle invalid configurations gracefully', () => { - expect(() => { - new ConsumeSharedPlugin({ - shareScope: 'default', - consumes: { - // @ts-expect-error - intentionally testing invalid config - invalid: ['array', 'not', 'allowed'], - }, - }); - }).toThrow(); - }); - }); - - describe('Layer-based consumption', () => { - it('should handle layer-specific module consumption', () => { - const plugin = new ConsumeSharedPlugin({ - shareScope: 'default', - consumes: { - 'client-lib': { issuerLayer: 'client' }, - 'server-lib': { issuerLayer: 'server' }, - 'universal-lib': {}, // No layer restriction - }, - }); - - const consumes = (plugin as any)._consumes; - - const clientLib = consumes.find( - ([key]: [string, any]) => key === 'client-lib', - ); - const serverLib = consumes.find( - ([key]: [string, any]) => key === 'server-lib', - ); - const universalLib = consumes.find( - ([key]: [string, any]) => key === 'universal-lib', - ); - - expect(clientLib[1].issuerLayer).toBe('client'); - expect(serverLib[1].issuerLayer).toBe('server'); - expect(universalLib[1].issuerLayer).toBeUndefined(); - }); - }); - - describe('Error handling and edge cases', () => { - it('should handle missing package.json gracefully', async () => { - vol.fromJSON({ - '/test-project/package.json': JSON.stringify({ name: 'test-app' }), - // No react package.json - }); - - const plugin = new ConsumeSharedPlugin({ - shareScope: 'default', - consumes: { react: '^17.0.0' }, - }); - - const mockCompilation = { - compiler: { context: '/test-project' }, - resolverFactory: { - get: () => ({ - resolve: ( - _context: string, - _lookupStartPath: string, - request: string, - _resolveContext: any, - callback: (err: any, result?: string) => void, - ) => { - callback(null, `/test-project/node_modules/${request}`); - }, - }), - }, - inputFileSystem: require('fs'), - contextDependencies: { addAll: jest.fn() }, - fileDependencies: { addAll: jest.fn() }, - missingDependencies: { addAll: jest.fn() }, - warnings: [], - errors: [], - }; - - const result = await plugin.createConsumeSharedModule( - mockCompilation as any, - '/test-project', - 'react', - { - import: undefined, - shareScope: 'default', - shareKey: 'react', - requiredVersion: '^17.0.0', - strictVersion: false, - packageName: 'react', - singleton: false, - eager: false, - issuerLayer: undefined, - layer: undefined, - request: 'react', - include: undefined, - exclude: undefined, - nodeModulesReconstructedLookup: undefined, - }, - ); - - expect(result).toBeInstanceOf(ConsumeSharedModule); - // No warnings expected when requiredVersion is explicitly provided - expect(mockCompilation.warnings.length).toBeGreaterThanOrEqual(0); - }); - }); -}); diff --git a/packages/enhanced/test/unit/sharing/ConsumeSharedPlugin/ConsumeSharedModule.behavior.test.ts b/packages/enhanced/test/unit/sharing/ConsumeSharedPlugin/ConsumeSharedModule.behavior.test.ts new file mode 100644 index 00000000000..ca62b369646 --- /dev/null +++ b/packages/enhanced/test/unit/sharing/ConsumeSharedPlugin/ConsumeSharedModule.behavior.test.ts @@ -0,0 +1,198 @@ +/* + * @jest-environment node + */ + +import { + createMockCompilation, + createModuleMock, + createWebpackMock, + shareScopes, + testModuleOptions, +} from '../utils'; +import { WEBPACK_MODULE_TYPE_CONSUME_SHARED_MODULE } from '../../../../src/lib/Constants'; +import type { ConsumeOptions } from '../../../../src/declarations/plugins/sharing/ConsumeSharedModule'; + +// Provide minimal webpack surface for the module under test +jest.mock('@module-federation/sdk/normalize-webpack-path', () => ({ + normalizeWebpackPath: jest.fn((path: string) => path), +})); + +jest.mock('webpack', () => createWebpackMock(), { virtual: true }); + +const webpack = require('webpack'); + +jest.mock( + 'webpack/lib/util/semver', + () => ({ + rangeToString: jest.fn((range) => (range ? range.toString() : '*')), + stringifyHoley: jest.fn((version) => JSON.stringify(version)), + }), + { virtual: true }, +); + +jest.mock('webpack/lib/util/makeSerializable', () => jest.fn(), { + virtual: true, +}); + +jest.mock( + '../../../../src/lib/sharing/ConsumeSharedFallbackDependency', + () => jest.fn().mockImplementation((request) => ({ request })), + { virtual: true }, +); + +// Ensure ConsumeSharedModule can extend the mocked webpack Module +createModuleMock(webpack); + +// Import after mocks +import ConsumeSharedModule from '../../../../src/lib/sharing/ConsumeSharedModule'; + +interface CodeGenerationContext { + chunkGraph: any; + moduleGraph: { getExportsInfo: () => { isModuleUsed: () => boolean } }; + runtimeTemplate: { + outputOptions: Record; + returningFunction: (args: string, body: string) => string; + syncModuleFactory: () => string; + asyncModuleFactory: () => string; + }; + dependencyTemplates: Map; + runtime: string; + codeGenerationResults: { getData: (...args: any[]) => any }; +} + +interface ObjectSerializerContext { + write: (data: any) => void; + read?: () => any; + setCircularReference: (ref: any) => void; +} + +describe('ConsumeSharedModule (integration)', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('constructs with expected options for string share scope', () => { + const module = new ConsumeSharedModule('/context', { + ...testModuleOptions.basic, + shareScope: shareScopes.string, + } as unknown as ConsumeOptions); + + expect(module.options.shareScope).toBe(shareScopes.string); + expect(module.options.shareKey).toBe('react'); + expect(module.options.requiredVersion).toBe('^17.0.0'); + expect(module.layer).toBeNull(); + }); + + it('produces identifiers that encode scope and key information', () => { + const module = new ConsumeSharedModule('/context', { + ...testModuleOptions.basic, + shareScope: shareScopes.array, + importResolved: './node_modules/react/index.js', + } as unknown as ConsumeOptions); + + const identifier = module.identifier(); + expect(identifier).toContain(WEBPACK_MODULE_TYPE_CONSUME_SHARED_MODULE); + expect(identifier).toContain('default|custom'); + expect(identifier).toContain('react'); + }); + + it('generates readable identifiers that reflect share scope combinations', () => { + const module = new ConsumeSharedModule('/context', { + ...testModuleOptions.basic, + shareScope: shareScopes.array, + importResolved: './node_modules/react/index.js', + } as unknown as ConsumeOptions); + + const readable = module.readableIdentifier({ + shorten: (value: string) => value, + contextify: (value: string) => value, + }); + + expect(readable).toContain('consume shared module'); + expect(readable).toContain('(default|custom)'); + expect(readable).toContain('react@'); + }); + + it('includes layer metadata in lib identifiers when provided', () => { + const module = new ConsumeSharedModule( + '/context', + testModuleOptions.withLayer as unknown as ConsumeOptions, + ); + + const libIdent = module.libIdent({ context: '/workspace' }); + + expect(libIdent).toContain('(test-layer)/'); + expect(libIdent).toContain('default'); + expect(libIdent).toContain('react'); + }); + + it('creates eager fallback dependencies during build when import is eager', () => { + const module = new ConsumeSharedModule('/context', { + ...testModuleOptions.eager, + import: './react', + } as unknown as ConsumeOptions); + + module.build({} as any, {} as any, {} as any, {} as any, () => undefined); + + expect(module.dependencies).toHaveLength(1); + expect(module.blocks).toHaveLength(0); + }); + + it('creates async fallback blocks during build when import is lazy', () => { + const module = new ConsumeSharedModule('/context', { + ...testModuleOptions.basic, + import: './react', + } as unknown as ConsumeOptions); + + module.build({} as any, {} as any, {} as any, {} as any, () => undefined); + + expect(module.dependencies).toHaveLength(0); + expect(module.blocks).toHaveLength(1); + }); + + it('emits runtime requirements for share scope access during code generation', () => { + const module = new ConsumeSharedModule('/context', { + ...testModuleOptions.basic, + shareScope: shareScopes.string, + } as unknown as ConsumeOptions); + + const codeGenContext: CodeGenerationContext = { + chunkGraph: {}, + moduleGraph: { + getExportsInfo: () => ({ isModuleUsed: () => true }), + }, + runtimeTemplate: { + outputOptions: {}, + returningFunction: (args, body) => `function(${args}) { ${body} }`, + syncModuleFactory: () => 'syncModuleFactory()', + asyncModuleFactory: () => 'asyncModuleFactory()', + }, + dependencyTemplates: new Map(), + runtime: 'webpack-runtime', + codeGenerationResults: { getData: () => undefined }, + }; + + const result = module.codeGeneration(codeGenContext as any); + + expect(result.runtimeRequirements).not.toBeNull(); + const runtimeRequirements = result.runtimeRequirements!; + + expect(runtimeRequirements.has(webpack.RuntimeGlobals.shareScopeMap)).toBe( + true, + ); + }); + + it('serializes without throwing for array share scopes', () => { + const module = new ConsumeSharedModule('/context', { + ...testModuleOptions.basic, + shareScope: shareScopes.array, + } as unknown as ConsumeOptions); + + const context: ObjectSerializerContext = { + write: jest.fn(), + setCircularReference: jest.fn(), + }; + + expect(() => module.serialize(context as any)).not.toThrow(); + }); +}); diff --git a/packages/enhanced/test/unit/sharing/ConsumeSharedPlugin/ConsumeSharedPlugin.apply.test.ts b/packages/enhanced/test/unit/sharing/ConsumeSharedPlugin/ConsumeSharedPlugin.apply.test.ts index 1c6f0065d5f..c6ada5d1479 100644 --- a/packages/enhanced/test/unit/sharing/ConsumeSharedPlugin/ConsumeSharedPlugin.apply.test.ts +++ b/packages/enhanced/test/unit/sharing/ConsumeSharedPlugin/ConsumeSharedPlugin.apply.test.ts @@ -8,11 +8,15 @@ import { createSharingTestEnvironment, mockConsumeSharedModule, resetAllMocks, -} from './shared-test-utils'; +} from '../plugin-test-utils'; + +type SharingTestEnvironment = ReturnType; +type ConsumeSharedPluginInstance = + import('../../../../src/lib/sharing/ConsumeSharedPlugin').default; describe('ConsumeSharedPlugin', () => { describe('apply method', () => { - let testEnv; + let testEnv: SharingTestEnvironment; beforeEach(() => { resetAllMocks(); @@ -26,7 +30,7 @@ describe('ConsumeSharedPlugin', () => { consumes: { react: '^17.0.0', }, - }); + }) as ConsumeSharedPluginInstance; // Apply the plugin plugin.apply(testEnv.compiler); @@ -47,7 +51,7 @@ describe('ConsumeSharedPlugin', () => { consumes: { react: '^17.0.0', }, - }); + }) as ConsumeSharedPluginInstance; // Apply the plugin plugin.apply(testEnv.compiler); @@ -67,7 +71,7 @@ describe('ConsumeSharedPlugin', () => { }); describe('plugin registration and hooks', () => { - let plugin: ConsumeSharedPlugin; + let plugin: ConsumeSharedPluginInstance; let mockCompiler: any; let mockCompilation: any; let mockNormalModuleFactory: any; @@ -141,7 +145,7 @@ describe('ConsumeSharedPlugin', () => { issuerLayer: 'client', }, }, - }); + }) as ConsumeSharedPluginInstance; }); it('should register thisCompilation hook during apply', () => { diff --git a/packages/enhanced/test/unit/sharing/ConsumeSharedPlugin/ConsumeSharedPlugin.buildMeta.test.ts b/packages/enhanced/test/unit/sharing/ConsumeSharedPlugin/ConsumeSharedPlugin.buildMeta.test.ts index 4547266ff27..ad6872c9946 100644 --- a/packages/enhanced/test/unit/sharing/ConsumeSharedPlugin/ConsumeSharedPlugin.buildMeta.test.ts +++ b/packages/enhanced/test/unit/sharing/ConsumeSharedPlugin/ConsumeSharedPlugin.buildMeta.test.ts @@ -4,9 +4,15 @@ import ConsumeSharedPlugin from '../../../../src/lib/sharing/ConsumeSharedPlugin'; import ConsumeSharedModule from '../../../../src/lib/sharing/ConsumeSharedModule'; -import { SyncHook } from 'tapable'; +import type AsyncDependenciesBlock from 'webpack/lib/AsyncDependenciesBlock'; +import type Dependency from 'webpack/lib/Dependency'; +import type Module from 'webpack/lib/Module'; +import type { SemVerRange } from 'webpack/lib/util/semver'; import { createSharingTestEnvironment, shareScopes } from '../utils'; -import { resetAllMocks } from './shared-test-utils'; +import { resetAllMocks } from '../plugin-test-utils'; + +const toSemVerRange = (range: string): SemVerRange => + range as unknown as SemVerRange; // Mock webpack internals jest.mock('@module-federation/sdk/normalize-webpack-path', () => ({ @@ -46,7 +52,7 @@ describe('ConsumeSharedPlugin - BuildMeta Copying', () => { const mockConsumeSharedModule = new ConsumeSharedModule('/test', { shareScope: 'default', shareKey: 'react', - requiredVersion: '^17.0.0', + requiredVersion: toSemVerRange('^17.0.0'), eager: true, import: 'react', packageName: 'react', @@ -71,10 +77,10 @@ describe('ConsumeSharedPlugin - BuildMeta Copying', () => { strict: true, exportsArgument: '__webpack_exports__', }, - }; + } as unknown as Module; // Create a mock dependency that links to the fallback module - const mockDependency = {}; + const mockDependency = {} as unknown as Dependency; mockConsumeSharedModule.dependencies = [mockDependency]; // Mock the moduleGraph.getModule to return our fallback module @@ -135,7 +141,7 @@ describe('ConsumeSharedPlugin - BuildMeta Copying', () => { const mockConsumeSharedModule = new ConsumeSharedModule('/test', { shareScope: 'default', shareKey: 'lodash', - requiredVersion: '^4.0.0', + requiredVersion: toSemVerRange('^4.0.0'), eager: false, // async mode import: 'lodash', packageName: 'lodash', @@ -160,13 +166,13 @@ describe('ConsumeSharedPlugin - BuildMeta Copying', () => { strict: false, exportsArgument: 'exports', }, - }; + } as unknown as Module; // Create a mock async dependency block with dependency - const mockDependency = {}; + const mockDependency = {} as unknown as Dependency; const mockAsyncBlock = { dependencies: [mockDependency], - }; + } as unknown as AsyncDependenciesBlock; mockConsumeSharedModule.blocks = [mockAsyncBlock]; // Mock the moduleGraph.getModule to return our fallback module @@ -218,7 +224,7 @@ describe('ConsumeSharedPlugin - BuildMeta Copying', () => { const mockConsumeSharedModule = new ConsumeSharedModule('/test', { shareScope: 'default', shareKey: 'missing-meta', - requiredVersion: '^1.0.0', + requiredVersion: toSemVerRange('^1.0.0'), eager: true, import: 'missing-meta', packageName: 'missing-meta', @@ -237,9 +243,9 @@ describe('ConsumeSharedPlugin - BuildMeta Copying', () => { const originalBuildInfo = mockConsumeSharedModule.buildInfo; // Create a mock fallback module without buildMeta/buildInfo - const mockFallbackModule = {}; + const mockFallbackModule = {} as unknown as Module; - const mockDependency = {}; + const mockDependency = {} as unknown as Dependency; mockConsumeSharedModule.dependencies = [mockDependency]; testEnv.mockCompilation.moduleGraph.getModule = jest @@ -313,7 +319,7 @@ describe('ConsumeSharedPlugin - BuildMeta Copying', () => { const mockConsumeSharedModule = new ConsumeSharedModule('/test', { shareScope: 'default', shareKey: 'no-import', - requiredVersion: '^1.0.0', + requiredVersion: toSemVerRange('^1.0.0'), eager: false, import: undefined, // No import packageName: 'no-import', @@ -363,7 +369,7 @@ describe('ConsumeSharedPlugin - BuildMeta Copying', () => { const mockConsumeSharedModule = new ConsumeSharedModule('/test', { shareScope: 'default', shareKey: 'missing-fallback', - requiredVersion: '^1.0.0', + requiredVersion: toSemVerRange('^1.0.0'), eager: true, import: 'missing-fallback', packageName: 'missing-fallback', @@ -380,7 +386,7 @@ describe('ConsumeSharedPlugin - BuildMeta Copying', () => { const originalBuildMeta = mockConsumeSharedModule.buildMeta; const originalBuildInfo = mockConsumeSharedModule.buildInfo; - const mockDependency = {}; + const mockDependency = {} as unknown as Dependency; mockConsumeSharedModule.dependencies = [mockDependency]; // Mock moduleGraph.getModule to return null/undefined @@ -419,7 +425,7 @@ describe('ConsumeSharedPlugin - BuildMeta Copying', () => { const mockConsumeSharedModule = new ConsumeSharedModule('/test', { shareScope: 'default', shareKey: 'spread-test', - requiredVersion: '^1.0.0', + requiredVersion: toSemVerRange('^1.0.0'), eager: true, import: 'spread-test', packageName: 'spread-test', @@ -448,9 +454,9 @@ describe('ConsumeSharedPlugin - BuildMeta Copying', () => { const mockFallbackModule = { buildMeta: originalBuildMeta, buildInfo: originalBuildInfo, - }; + } as unknown as Module; - const mockDependency = {}; + const mockDependency = {} as unknown as Dependency; mockConsumeSharedModule.dependencies = [mockDependency]; testEnv.mockCompilation.moduleGraph.getModule = jest @@ -472,10 +478,10 @@ describe('ConsumeSharedPlugin - BuildMeta Copying', () => { expect(mockConsumeSharedModule.buildInfo).not.toBe(originalBuildInfo); // Different object reference // Verify nested objects are shared references (shallow copy behavior) - expect(mockConsumeSharedModule.buildMeta.nested).toBe( + expect(mockConsumeSharedModule.buildMeta!['nested']).toBe( originalBuildMeta.nested, ); - expect(mockConsumeSharedModule.buildInfo.nested).toBe( + expect(mockConsumeSharedModule.buildInfo!['nested']).toBe( originalBuildInfo.nested, ); }); diff --git a/packages/enhanced/test/unit/sharing/ConsumeSharedPlugin/ConsumeSharedPlugin.constructor.test.ts b/packages/enhanced/test/unit/sharing/ConsumeSharedPlugin/ConsumeSharedPlugin.constructor.test.ts index 767fb744e09..a34775fcd79 100644 --- a/packages/enhanced/test/unit/sharing/ConsumeSharedPlugin/ConsumeSharedPlugin.constructor.test.ts +++ b/packages/enhanced/test/unit/sharing/ConsumeSharedPlugin/ConsumeSharedPlugin.constructor.test.ts @@ -7,7 +7,8 @@ import { shareScopes, mockConsumeSharedModule, resetAllMocks, -} from './shared-test-utils'; +} from '../plugin-test-utils'; +import { getConsumes, ConsumeEntry } from './helpers'; describe('ConsumeSharedPlugin', () => { beforeEach(() => { @@ -24,13 +25,12 @@ describe('ConsumeSharedPlugin', () => { }); // Test private property is set correctly - // @ts-ignore accessing private property for testing - const consumes = plugin._consumes; + const consumes = getConsumes(plugin); expect(consumes.length).toBe(1); expect(consumes[0][0]).toBe('react'); - expect(consumes[0][1].shareScope).toBe(shareScopes.string); - expect(consumes[0][1].requiredVersion).toBe('^17.0.0'); + expect(consumes[0][1]['shareScope']).toBe(shareScopes.string); + expect(consumes[0][1]['requiredVersion']).toBe('^17.0.0'); }); it('should initialize with array shareScope', () => { @@ -41,11 +41,10 @@ describe('ConsumeSharedPlugin', () => { }, }); - // @ts-ignore accessing private property for testing - const consumes = plugin._consumes; + const consumes = getConsumes(plugin); const [, config] = consumes[0]; - expect(config.shareScope).toEqual(shareScopes.array); + expect(config['shareScope']).toEqual(shareScopes.array); }); it('should handle consumes with explicit options', () => { @@ -61,15 +60,14 @@ describe('ConsumeSharedPlugin', () => { }, }); - // @ts-ignore accessing private property for testing - const consumes = plugin._consumes; + const consumes = getConsumes(plugin); const [, config] = consumes[0]; - expect(config.shareScope).toBe(shareScopes.string); - expect(config.requiredVersion).toBe('^17.0.0'); - expect(config.strictVersion).toBe(true); - expect(config.singleton).toBe(true); - expect(config.eager).toBe(false); + expect(config['shareScope']).toBe(shareScopes.string); + expect(config['requiredVersion']).toBe('^17.0.0'); + expect(config['strictVersion']).toBe(true); + expect(config['singleton']).toBe(true); + expect(config['eager']).toBe(false); }); it('should handle consumes with custom shareScope', () => { @@ -83,11 +81,10 @@ describe('ConsumeSharedPlugin', () => { }, }); - // @ts-ignore accessing private property for testing - const consumes = plugin._consumes; + const consumes = getConsumes(plugin); const [, config] = consumes[0]; - expect(config.shareScope).toBe('custom-scope'); + expect(config['shareScope']).toBe('custom-scope'); }); it('should handle multiple consumed modules', () => { @@ -106,24 +103,31 @@ describe('ConsumeSharedPlugin', () => { }, }); - // @ts-ignore accessing private property for testing - const consumes = plugin._consumes; + const consumes = getConsumes(plugin); expect(consumes.length).toBe(3); - // Find each entry - const reactEntry = consumes.find(([key]) => key === 'react'); - const lodashEntry = consumes.find(([key]) => key === 'lodash'); - const reactDomEntry = consumes.find(([key]) => key === 'react-dom'); + const reactEntry = consumes.find( + ([key]: ConsumeEntry) => key === 'react', + ); + const lodashEntry = consumes.find( + ([key]: ConsumeEntry) => key === 'lodash', + ); + const reactDomEntry = consumes.find( + ([key]: ConsumeEntry) => key === 'react-dom', + ); expect(reactEntry).toBeDefined(); expect(lodashEntry).toBeDefined(); expect(reactDomEntry).toBeDefined(); - // Check configurations - expect(reactEntry[1].requiredVersion).toBe('^17.0.0'); - expect(lodashEntry[1].singleton).toBe(true); - expect(reactDomEntry[1].shareScope).toBe('custom'); + if (!reactEntry || !lodashEntry || !reactDomEntry) { + throw new Error('Expected consume entries to be defined'); + } + + expect(reactEntry[1]['requiredVersion']).toBe('^17.0.0'); + expect(lodashEntry[1]['singleton']).toBe(true); + expect(reactDomEntry[1]['shareScope']).toBe('custom'); }); it('should handle import:false configuration', () => { @@ -137,11 +141,10 @@ describe('ConsumeSharedPlugin', () => { }, }); - // @ts-ignore accessing private property for testing - const consumes = plugin._consumes; + const consumes = getConsumes(plugin); const [, config] = consumes[0]; - expect(config.import).toBeUndefined(); + expect(config['import']).toBeUndefined(); }); it('should handle layer configuration', () => { @@ -155,11 +158,10 @@ describe('ConsumeSharedPlugin', () => { }, }); - // @ts-ignore accessing private property for testing - const consumes = plugin._consumes; + const consumes = getConsumes(plugin); const [, config] = consumes[0]; - expect(config.layer).toBe('client'); + expect(config['layer']).toBe('client'); }); it('should handle include/exclude filters', () => { @@ -178,12 +180,23 @@ describe('ConsumeSharedPlugin', () => { }, }); - // @ts-ignore accessing private property for testing - const consumes = plugin._consumes; + const consumes = getConsumes(plugin); const [, config] = consumes[0]; - expect(config.include).toEqual({ version: '^17.0.0' }); - expect(config.exclude).toEqual({ version: '17.0.1' }); + expect(config['include']).toEqual({ version: '^17.0.0' }); + expect(config['exclude']).toEqual({ version: '17.0.1' }); + }); + + it('should reject invalid consume definitions', () => { + expect( + () => + new ConsumeSharedPlugin({ + shareScope: 'default', + consumes: { + invalid: ['one', 'two'], + }, + }), + ).toThrow(); }); }); @@ -270,8 +283,7 @@ describe('ConsumeSharedPlugin', () => { }); const context = '/test/context'; - // @ts-ignore accessing private property for testing - const [, config] = plugin._consumes[0]; + const [, config] = getConsumes(plugin)[0]; const mockCompilation = { resolverFactory: { @@ -326,8 +338,7 @@ describe('ConsumeSharedPlugin', () => { }); const context = '/test/context'; - // @ts-ignore accessing private property for testing - const [, config] = plugin._consumes[0]; + const [, config] = getConsumes(plugin)[0]; const mockCompilation = { resolverFactory: { diff --git a/packages/enhanced/test/unit/sharing/ConsumeSharedPlugin/ConsumeSharedPlugin.createConsumeSharedModule.test.ts b/packages/enhanced/test/unit/sharing/ConsumeSharedPlugin/ConsumeSharedPlugin.createConsumeSharedModule.test.ts index 4130aa4af63..e74488f63a1 100644 --- a/packages/enhanced/test/unit/sharing/ConsumeSharedPlugin/ConsumeSharedPlugin.createConsumeSharedModule.test.ts +++ b/packages/enhanced/test/unit/sharing/ConsumeSharedPlugin/ConsumeSharedPlugin.createConsumeSharedModule.test.ts @@ -6,14 +6,38 @@ import { ConsumeSharedPlugin, mockGetDescriptionFile, resetAllMocks, -} from './shared-test-utils'; +} from '../plugin-test-utils'; +import { + ConsumeSharedPluginInstance, + createConsumeConfig, + DescriptionFileResolver, + ResolveFunction, + toSemVerRange, +} from './helpers'; describe('ConsumeSharedPlugin', () => { describe('createConsumeSharedModule method', () => { - let plugin: ConsumeSharedPlugin; + let plugin: ConsumeSharedPluginInstance; let mockCompilation: any; let mockInputFileSystem: any; let mockResolver: any; + let resolveMock: jest.MockedFunction; + let descriptionFileMock: jest.MockedFunction; + + const resolveToPath = + (path: string): ResolveFunction => + (context, lookupStartPath, request, resolveContext, callback) => { + callback(null, path); + }; + + const descriptionWithPackage = + (name: string, version: string): DescriptionFileResolver => + (fs, dir, files, callback) => { + callback(null, { + data: { name, version }, + path: '/path/to/package.json', + }); + }; beforeEach(() => { resetAllMocks(); @@ -23,7 +47,7 @@ describe('ConsumeSharedPlugin', () => { consumes: { 'test-module': '^1.0.0', }, - }); + }) as ConsumeSharedPluginInstance; mockInputFileSystem = { readFile: jest.fn(), @@ -33,6 +57,10 @@ describe('ConsumeSharedPlugin', () => { resolve: jest.fn(), }; + resolveMock = + mockResolver.resolve as jest.MockedFunction; + resolveMock.mockReset(); + mockCompilation = { inputFileSystem: mockInputFileSystem, resolverFactory: { @@ -47,33 +75,27 @@ describe('ConsumeSharedPlugin', () => { context: '/test/context', }, }; + + descriptionFileMock = + mockGetDescriptionFile as unknown as jest.MockedFunction; + descriptionFileMock.mockReset(); }); describe('import resolution logic', () => { it('should resolve import when config.import is provided', async () => { - const config = { - import: './test-module', - shareScope: 'default', - shareKey: 'test-module', - requiredVersion: '^1.0.0', - strictVersion: true, - packageName: undefined, - singleton: false, - eager: false, - issuerLayer: undefined, - layer: undefined, - request: 'test-module', - include: undefined, - exclude: undefined, - nodeModulesReconstructedLookup: undefined, - }; + const config = createConsumeConfig(); // Mock successful resolution - mockResolver.resolve.mockImplementation( - (context, lookupStartPath, request, resolveContext, callback) => { - callback(null, '/resolved/path/to/test-module'); - }, - ); + const successfulResolve: ResolveFunction = ( + context, + lookupStartPath, + request, + resolveContext, + callback, + ) => { + callback(null, '/resolved/path/to/test-module'); + }; + resolveMock.mockImplementation(successfulResolve); const result = await plugin.createConsumeSharedModule( mockCompilation, @@ -93,22 +115,7 @@ describe('ConsumeSharedPlugin', () => { }); it('should handle undefined import gracefully', async () => { - const config = { - import: undefined, - shareScope: 'default', - shareKey: 'test-module', - requiredVersion: '^1.0.0', - strictVersion: true, - packageName: undefined, - singleton: false, - eager: false, - issuerLayer: undefined, - layer: undefined, - request: 'test-module', - include: undefined, - exclude: undefined, - nodeModulesReconstructedLookup: undefined, - }; + const config = createConsumeConfig({ import: undefined }); const result = await plugin.createConsumeSharedModule( mockCompilation, @@ -122,29 +129,20 @@ describe('ConsumeSharedPlugin', () => { }); it('should handle import resolution errors gracefully', async () => { - const config = { - import: './failing-module', - shareScope: 'default', - shareKey: 'test-module', - requiredVersion: '^1.0.0', - strictVersion: true, - packageName: undefined, - singleton: false, - eager: false, - issuerLayer: undefined, - layer: undefined, - request: 'test-module', - include: undefined, - exclude: undefined, - nodeModulesReconstructedLookup: undefined, - }; + const config = createConsumeConfig({ import: './failing-module' }); // Mock resolution error - mockResolver.resolve.mockImplementation( - (context, lookupStartPath, request, resolveContext, callback) => { - callback(new Error('Module not found'), null); - }, - ); + const failingResolve: ResolveFunction = ( + context, + lookupStartPath, + request, + resolveContext, + callback, + ) => { + callback(new Error('Module not found')); + }; + + resolveMock.mockImplementation(failingResolve); const result = await plugin.createConsumeSharedModule( mockCompilation, @@ -159,27 +157,12 @@ describe('ConsumeSharedPlugin', () => { }); it('should handle direct fallback regex matching', async () => { - const config = { - import: 'webpack/lib/something', // Matches DIRECT_FALLBACK_REGEX - shareScope: 'default', - shareKey: 'test-module', - requiredVersion: '^1.0.0', - strictVersion: true, - packageName: undefined, - singleton: false, - eager: false, - issuerLayer: undefined, - layer: undefined, - request: 'test-module', - include: undefined, - exclude: undefined, - nodeModulesReconstructedLookup: undefined, - }; + const config = createConsumeConfig({ + import: 'webpack/lib/something', + }); - mockResolver.resolve.mockImplementation( - (context, lookupStartPath, request, resolveContext, callback) => { - callback(null, '/resolved/webpack/lib/something'); - }, + resolveMock.mockImplementation( + resolveToPath('/resolved/webpack/lib/something'), ); const result = await plugin.createConsumeSharedModule( @@ -203,27 +186,12 @@ describe('ConsumeSharedPlugin', () => { describe('requiredVersion resolution logic', () => { it('should use provided requiredVersion when available', async () => { - const config = { - import: './test-module', - shareScope: 'default', - shareKey: 'test-module', - requiredVersion: '^2.0.0', // Explicit version - strictVersion: true, - packageName: undefined, - singleton: false, - eager: false, - issuerLayer: undefined, - layer: undefined, - request: 'test-module', - include: undefined, - exclude: undefined, - nodeModulesReconstructedLookup: undefined, - }; + const config = createConsumeConfig({ + requiredVersion: toSemVerRange('^2.0.0'), + }); - mockResolver.resolve.mockImplementation( - (context, lookupStartPath, request, resolveContext, callback) => { - callback(null, '/resolved/path/to/test-module'); - }, + resolveMock.mockImplementation( + resolveToPath('/resolved/path/to/test-module'), ); const result = await plugin.createConsumeSharedModule( @@ -234,41 +202,24 @@ describe('ConsumeSharedPlugin', () => { ); expect(result).toBeDefined(); - expect(result.requiredVersion).toBe('^2.0.0'); + expect( + (result as unknown as { requiredVersion?: string }).requiredVersion, + ).toBe('^2.0.0'); }); it('should resolve requiredVersion from package name when not provided', async () => { - const config = { - import: './test-module', - shareScope: 'default', - shareKey: 'test-module', - requiredVersion: undefined, // Will be resolved - strictVersion: true, + const config = createConsumeConfig({ + requiredVersion: undefined, packageName: 'my-package', - singleton: false, - eager: false, - issuerLayer: undefined, - layer: undefined, - request: 'test-module', - include: undefined, - exclude: undefined, - nodeModulesReconstructedLookup: undefined, - }; + }); - mockResolver.resolve.mockImplementation( - (context, lookupStartPath, request, resolveContext, callback) => { - callback(null, '/resolved/path/to/test-module'); - }, + resolveMock.mockImplementation( + resolveToPath('/resolved/path/to/test-module'), ); // Mock getDescriptionFile - mockGetDescriptionFile.mockImplementation( - (fs, dir, files, callback) => { - callback(null, { - data: { name: 'my-package', version: '2.1.0' }, - path: '/path/to/package.json', - }); - }, + descriptionFileMock.mockImplementation( + descriptionWithPackage('my-package', '2.1.0'), ); const result = await plugin.createConsumeSharedModule( @@ -283,37 +234,18 @@ describe('ConsumeSharedPlugin', () => { }); it('should extract package name from scoped module request', async () => { - const config = { - import: './test-module', - shareScope: 'default', - shareKey: 'test-module', + const config = createConsumeConfig({ requiredVersion: undefined, - strictVersion: true, - packageName: undefined, - singleton: false, - eager: false, - issuerLayer: undefined, - layer: undefined, - request: '@scope/my-package/sub-path', // Scoped package - include: undefined, - exclude: undefined, - nodeModulesReconstructedLookup: undefined, - }; + request: '@scope/my-package/sub-path', + }); - mockResolver.resolve.mockImplementation( - (context, lookupStartPath, request, resolveContext, callback) => { - callback(null, '/resolved/path/to/test-module'); - }, + resolveMock.mockImplementation( + resolveToPath('/resolved/path/to/test-module'), ); // Mock getDescriptionFile for scoped package - mockGetDescriptionFile.mockImplementation( - (fs, dir, files, callback) => { - callback(null, { - data: { name: '@scope/my-package', version: '3.2.1' }, - path: '/path/to/package.json', - }); - }, + descriptionFileMock.mockImplementation( + descriptionWithPackage('@scope/my-package', '3.2.1'), ); const result = await plugin.createConsumeSharedModule( @@ -328,27 +260,13 @@ describe('ConsumeSharedPlugin', () => { }); it('should handle absolute path requests', async () => { - const config = { - import: './test-module', - shareScope: 'default', - shareKey: 'test-module', + const config = createConsumeConfig({ requiredVersion: undefined, - strictVersion: true, - packageName: undefined, - singleton: false, - eager: false, - issuerLayer: undefined, - layer: undefined, - request: '/absolute/path/to/module', // Absolute path - include: undefined, - exclude: undefined, - nodeModulesReconstructedLookup: undefined, - }; + request: '/absolute/path/to/module', + }); - mockResolver.resolve.mockImplementation( - (context, lookupStartPath, request, resolveContext, callback) => { - callback(null, '/resolved/path/to/test-module'); - }, + resolveMock.mockImplementation( + resolveToPath('/resolved/path/to/test-module'), ); // For absolute paths without requiredVersion, the mock implementation @@ -367,37 +285,19 @@ describe('ConsumeSharedPlugin', () => { }); it('should handle package.json reading for version resolution', async () => { - const config = { - import: './test-module', - shareScope: 'default', - shareKey: 'test-module', + const config = createConsumeConfig({ requiredVersion: undefined, - strictVersion: true, packageName: 'my-package', - singleton: false, - eager: false, - issuerLayer: undefined, - layer: undefined, request: 'my-package', - include: undefined, - exclude: undefined, - nodeModulesReconstructedLookup: undefined, - }; + }); - mockResolver.resolve.mockImplementation( - (context, lookupStartPath, request, resolveContext, callback) => { - callback(null, '/resolved/path/to/test-module'); - }, + resolveMock.mockImplementation( + resolveToPath('/resolved/path/to/test-module'), ); // Mock getDescriptionFile for version resolution - mockGetDescriptionFile.mockImplementation( - (fs, dir, files, callback) => { - callback(null, { - data: { name: 'my-package', version: '1.3.0' }, - path: '/path/to/package.json', - }); - }, + descriptionFileMock.mockImplementation( + descriptionWithPackage('my-package', '1.3.0'), ); const result = await plugin.createConsumeSharedModule( diff --git a/packages/enhanced/test/unit/sharing/ConsumeSharedPlugin/ConsumeSharedPlugin.exclude-filtering.test.ts b/packages/enhanced/test/unit/sharing/ConsumeSharedPlugin/ConsumeSharedPlugin.exclude-filtering.test.ts index c421023a5db..e8a3a37c934 100644 --- a/packages/enhanced/test/unit/sharing/ConsumeSharedPlugin/ConsumeSharedPlugin.exclude-filtering.test.ts +++ b/packages/enhanced/test/unit/sharing/ConsumeSharedPlugin/ConsumeSharedPlugin.exclude-filtering.test.ts @@ -4,593 +4,349 @@ import { ConsumeSharedPlugin, + createMockCompilation, mockGetDescriptionFile, resetAllMocks, -} from './shared-test-utils'; +} from '../plugin-test-utils'; +import { + ConsumeSharedPluginInstance, + createConsumeConfig, + DescriptionFileResolver, + ResolveFunction, +} from './helpers'; + +const createHarness = ( + options = { + shareScope: 'default', + consumes: { + 'test-module': '^1.0.0', + }, + }, +) => { + const plugin = new ConsumeSharedPlugin( + options, + ) as ConsumeSharedPluginInstance; + const resolveMock = jest.fn< + ReturnType, + Parameters + >(); + const mockResolver = { resolve: resolveMock }; + const { mockCompilation } = createMockCompilation(); + const compilation = mockCompilation; + + compilation.inputFileSystem.readFile = jest.fn(); + compilation.resolverFactory = { + get: jest.fn(() => mockResolver), + }; + compilation.warnings = [] as Error[]; + compilation.errors = [] as Error[]; + compilation.contextDependencies = compilation.contextDependencies ?? { + addAll: jest.fn(), + }; + compilation.fileDependencies = compilation.fileDependencies ?? { + addAll: jest.fn(), + }; + compilation.missingDependencies = compilation.missingDependencies ?? { + addAll: jest.fn(), + }; + compilation.compiler = { + context: '/test/context', + }; + + const descriptionFileMock = + mockGetDescriptionFile as unknown as jest.MockedFunction; + + resolveMock.mockReset(); + descriptionFileMock.mockReset(); + + const setResolve = (impl: ResolveFunction) => { + resolveMock.mockImplementation(impl); + }; + + const setDescription = (impl: DescriptionFileResolver) => { + descriptionFileMock.mockImplementation(impl); + }; + + return { + plugin, + compilation, + resolveMock, + descriptionFileMock, + setResolve, + setDescription, + }; +}; describe('ConsumeSharedPlugin', () => { describe('exclude version filtering logic', () => { - let plugin: ConsumeSharedPlugin; - let mockCompilation: any; - let mockInputFileSystem: any; - let mockResolver: any; - beforeEach(() => { resetAllMocks(); + }); - plugin = new ConsumeSharedPlugin({ - shareScope: 'default', - consumes: { - 'test-module': '^1.0.0', - }, - }); - - mockInputFileSystem = { - readFile: jest.fn(), - }; - - mockResolver = { - resolve: jest.fn(), - }; - - mockCompilation = { - inputFileSystem: mockInputFileSystem, - resolverFactory: { - get: jest.fn(() => mockResolver), - }, - warnings: [], - errors: [], - contextDependencies: { addAll: jest.fn() }, - fileDependencies: { addAll: jest.fn() }, - missingDependencies: { addAll: jest.fn() }, - compiler: { - context: '/test/context', - }, + const successResolve: ResolveFunction = ( + _context, + _lookupStartPath, + _request, + _resolveContext, + callback, + ) => { + callback(null, '/resolved/path/to/test-module'); + }; + + const descriptionWithVersion = + (version: string): DescriptionFileResolver => + (_fs, _dir, _files, callback) => { + callback(null, { + data: { name: 'test-module', version }, + path: '/path/to/package.json', + }); }; - }); it('should include module when version does not match exclude filter', async () => { - const config = { - import: './test-module', - shareScope: 'default', - shareKey: 'test-module', - requiredVersion: '^1.0.0', - strictVersion: true, - packageName: undefined, - singleton: false, - eager: false, - issuerLayer: undefined, - layer: undefined, - request: 'test-module', - include: undefined, + const harness = createHarness(); + const config = createConsumeConfig({ exclude: { - version: '^2.0.0', // Won't match 1.5.0 - }, - nodeModulesReconstructedLookup: undefined, - }; - - mockResolver.resolve.mockImplementation( - (context, lookupStartPath, request, resolveContext, callback) => { - callback(null, '/resolved/path/to/test-module'); + version: '^2.0.0', }, - ); - - mockGetDescriptionFile.mockImplementation((fs, dir, files, callback) => { - callback(null, { - data: { name: 'test-module', version: '1.5.0' }, - path: '/path/to/package.json', - }); }); - const result = await plugin.createConsumeSharedModule( - mockCompilation, + harness.setResolve(successResolve); + harness.setDescription(descriptionWithVersion('1.5.0')); + + const result = await harness.plugin.createConsumeSharedModule( + harness.compilation, '/test/context', 'test-module', config, ); expect(result).toBeDefined(); - // Should include the module since 1.5.0 does not match ^2.0.0 exclude }); it('should exclude module when version matches exclude filter', async () => { - const config = { - import: './test-module', - shareScope: 'default', - shareKey: 'test-module', - requiredVersion: '^1.0.0', - strictVersion: true, - packageName: undefined, - singleton: false, - eager: false, - issuerLayer: undefined, - layer: undefined, - request: 'test-module', - include: undefined, + const harness = createHarness(); + const config = createConsumeConfig({ exclude: { - version: '^1.0.0', // Will match 1.5.0 - }, - nodeModulesReconstructedLookup: undefined, - }; - - mockResolver.resolve.mockImplementation( - (context, lookupStartPath, request, resolveContext, callback) => { - callback(null, '/resolved/path/to/test-module'); + version: '^1.0.0', }, - ); - - mockGetDescriptionFile.mockImplementation((fs, dir, files, callback) => { - callback(null, { - data: { name: 'test-module', version: '1.5.0' }, - path: '/path/to/package.json', - }); }); - const result = await plugin.createConsumeSharedModule( - mockCompilation, + harness.setResolve(successResolve); + harness.setDescription(descriptionWithVersion('1.5.0')); + + const result = await harness.plugin.createConsumeSharedModule( + harness.compilation, '/test/context', 'test-module', config, ); expect(result).toBeUndefined(); - // Should exclude the module since 1.5.0 matches ^1.0.0 exclude }); it('should generate singleton warning for exclude version filters', async () => { - const config = { - import: './test-module', - shareScope: 'default', - shareKey: 'test-module', - requiredVersion: '^1.0.0', - strictVersion: true, - packageName: undefined, - singleton: true, // Should trigger warning - eager: false, - issuerLayer: undefined, - layer: undefined, - request: 'test-module', - include: undefined, + const harness = createHarness(); + const config = createConsumeConfig({ + singleton: true, exclude: { - version: '^2.0.0', // Won't match, so module included and warning generated - }, - nodeModulesReconstructedLookup: undefined, - }; - - mockResolver.resolve.mockImplementation( - (context, lookupStartPath, request, resolveContext, callback) => { - callback(null, '/resolved/path/to/test-module'); + version: '^2.0.0', }, - ); - - mockGetDescriptionFile.mockImplementation((fs, dir, files, callback) => { - callback(null, { - data: { name: 'test-module', version: '1.5.0' }, - path: '/path/to/package.json', - }); }); - const result = await plugin.createConsumeSharedModule( - mockCompilation, + harness.setResolve(successResolve); + harness.setDescription(descriptionWithVersion('1.5.0')); + + const result = await harness.plugin.createConsumeSharedModule( + harness.compilation, '/test/context', 'test-module', config, ); expect(result).toBeDefined(); - expect(mockCompilation.warnings).toHaveLength(1); - expect(mockCompilation.warnings[0].message).toContain('singleton: true'); - expect(mockCompilation.warnings[0].message).toContain('exclude.version'); - }); - - it('should handle fallback version for exclude filters - include when fallback matches', async () => { - const config = { - import: './test-module', - shareScope: 'default', - shareKey: 'test-module', - requiredVersion: '^1.0.0', - strictVersion: true, - packageName: undefined, - singleton: false, - eager: false, - issuerLayer: undefined, - layer: undefined, - request: 'test-module', - include: undefined, - exclude: { - version: '^1.0.0', - fallbackVersion: '1.5.0', // This should match ^1.0.0, so exclude - }, - nodeModulesReconstructedLookup: undefined, - }; - - mockResolver.resolve.mockImplementation( - (context, lookupStartPath, request, resolveContext, callback) => { - callback(null, '/resolved/path/to/test-module'); - }, + expect(harness.compilation.warnings).toHaveLength(1); + expect(harness.compilation.warnings[0]?.message).toContain( + 'singleton: true', ); - - const result = await plugin.createConsumeSharedModule( - mockCompilation, - '/test/context', - 'test-module', - config, + expect(harness.compilation.warnings[0]?.message).toContain( + 'exclude.version', ); - - expect(result).toBeUndefined(); - // Should exclude since fallbackVersion 1.5.0 satisfies ^1.0.0 exclude }); - it('should handle fallback version for exclude filters - include when fallback does not match', async () => { - const config = { - import: './test-module', - shareScope: 'default', - shareKey: 'test-module', - requiredVersion: '^1.0.0', - strictVersion: true, - packageName: undefined, - singleton: false, - eager: false, - issuerLayer: undefined, - layer: undefined, - request: 'test-module', - include: undefined, + it('should handle fallback version for exclude filters - include when fallback matches', async () => { + const harness = createHarness(); + const config = createConsumeConfig({ exclude: { version: '^2.0.0', - fallbackVersion: '1.5.0', // This should NOT match ^2.0.0, so include + fallbackVersion: '1.5.0', }, - nodeModulesReconstructedLookup: undefined, - }; + }); - mockResolver.resolve.mockImplementation( - (context, lookupStartPath, request, resolveContext, callback) => { - callback(null, '/resolved/path/to/test-module'); - }, - ); + harness.setResolve(successResolve); + harness.setDescription(descriptionWithVersion('1.5.0')); - const result = await plugin.createConsumeSharedModule( - mockCompilation, + const result = await harness.plugin.createConsumeSharedModule( + harness.compilation, '/test/context', 'test-module', config, ); expect(result).toBeDefined(); - // Should include since fallbackVersion 1.5.0 does not satisfy ^2.0.0 exclude }); it('should return module when exclude filter fails but no importResolved', async () => { - const config = { - import: undefined, // No import to resolve - shareScope: 'default', - shareKey: 'test-module', - requiredVersion: '^1.0.0', - strictVersion: true, - packageName: undefined, - singleton: false, - eager: false, - issuerLayer: undefined, - layer: undefined, - request: 'test-module', - include: undefined, + const harness = createHarness(); + const config = createConsumeConfig({ + import: undefined, exclude: { version: '^1.0.0', }, - nodeModulesReconstructedLookup: undefined, - }; + }); - const result = await plugin.createConsumeSharedModule( - mockCompilation, + const result = await harness.plugin.createConsumeSharedModule( + harness.compilation, '/test/context', 'test-module', config, ); expect(result).toBeDefined(); - // Should return module since no import to check against }); }); describe('package.json reading error scenarios', () => { - let plugin: ConsumeSharedPlugin; - let mockCompilation: any; - let mockInputFileSystem: any; - let mockResolver: any; - beforeEach(() => { resetAllMocks(); - - plugin = new ConsumeSharedPlugin({ - shareScope: 'default', - consumes: { - 'test-module': '^1.0.0', - }, - }); - - mockInputFileSystem = { - readFile: jest.fn(), - }; - - mockResolver = { - resolve: jest.fn(), - }; - - mockCompilation = { - inputFileSystem: mockInputFileSystem, - resolverFactory: { - get: jest.fn(() => mockResolver), - }, - warnings: [], - errors: [], - contextDependencies: { addAll: jest.fn() }, - fileDependencies: { addAll: jest.fn() }, - missingDependencies: { addAll: jest.fn() }, - compiler: { - context: '/test/context', - }, - }; }); + const successResolve: ResolveFunction = ( + _context, + _lookupStartPath, + _request, + _resolveContext, + callback, + ) => { + callback(null, '/resolved/path/to/test-module'); + }; + it('should handle getDescriptionFile errors gracefully - include filters', async () => { - const config = { - import: './test-module', - shareScope: 'default', - shareKey: 'test-module', - requiredVersion: '^1.0.0', - strictVersion: true, - packageName: undefined, - singleton: false, - eager: false, - issuerLayer: undefined, - layer: undefined, - request: 'test-module', + const harness = createHarness(); + const config = createConsumeConfig({ include: { version: '^1.0.0', }, - exclude: undefined, - nodeModulesReconstructedLookup: undefined, - }; - - mockResolver.resolve.mockImplementation( - (context, lookupStartPath, request, resolveContext, callback) => { - callback(null, '/resolved/path/to/test-module'); - }, - ); - - // Mock getDescriptionFile to return error - mockGetDescriptionFile.mockImplementation((fs, dir, files, callback) => { - callback(new Error('File system error'), null); }); - const result = await plugin.createConsumeSharedModule( - mockCompilation, + harness.setResolve(successResolve); + const failingDescription: DescriptionFileResolver = ( + _fs, + _dir, + _files, + callback, + ) => { + callback(new Error('File system error')); + }; + harness.setDescription(failingDescription); + + const result = await harness.plugin.createConsumeSharedModule( + harness.compilation, '/test/context', 'test-module', config, ); expect(result).toBeDefined(); - // Should return module despite getDescriptionFile error }); it('should handle missing package.json data gracefully - include filters', async () => { - const config = { - import: './test-module', - shareScope: 'default', - shareKey: 'test-module', - requiredVersion: '^1.0.0', - strictVersion: true, - packageName: undefined, - singleton: false, - eager: false, - issuerLayer: undefined, - layer: undefined, - request: 'test-module', + const harness = createHarness(); + const config = createConsumeConfig({ include: { version: '^1.0.0', }, - exclude: undefined, - nodeModulesReconstructedLookup: undefined, - }; - - mockResolver.resolve.mockImplementation( - (context, lookupStartPath, request, resolveContext, callback) => { - callback(null, '/resolved/path/to/test-module'); - }, - ); - - // Mock getDescriptionFile to return null data - mockGetDescriptionFile.mockImplementation((fs, dir, files, callback) => { - callback(null, null); }); - const result = await plugin.createConsumeSharedModule( - mockCompilation, - '/test/context', - 'test-module', - config, - ); - - expect(result).toBeDefined(); - // Should return module when no package.json data available - }); - - it('should handle mismatched package name gracefully - include filters', async () => { - const config = { - import: './test-module', - shareScope: 'default', - shareKey: 'test-module', - requiredVersion: '^1.0.0', - strictVersion: true, - packageName: undefined, - singleton: false, - eager: false, - issuerLayer: undefined, - layer: undefined, - request: 'test-module', - include: { - version: '^1.0.0', - }, - exclude: undefined, - nodeModulesReconstructedLookup: undefined, + harness.setResolve(successResolve); + const missingData: DescriptionFileResolver = ( + _fs, + _dir, + _files, + callback, + ) => { + callback(null, undefined); }; + harness.setDescription(missingData); - mockResolver.resolve.mockImplementation( - (context, lookupStartPath, request, resolveContext, callback) => { - callback(null, '/resolved/path/to/test-module'); - }, - ); - - // Mock getDescriptionFile to return mismatched package name - mockGetDescriptionFile.mockImplementation((fs, dir, files, callback) => { - callback(null, { - data: { name: 'different-module', version: '1.5.0' }, - path: '/path/to/package.json', - }); - }); - - const result = await plugin.createConsumeSharedModule( - mockCompilation, + const result = await harness.plugin.createConsumeSharedModule( + harness.compilation, '/test/context', 'test-module', config, ); expect(result).toBeDefined(); - // Should return module when package name doesn't match }); - it('should handle missing version in package.json gracefully - include filters', async () => { - const config = { - import: './test-module', - shareScope: 'default', - shareKey: 'test-module', - requiredVersion: '^1.0.0', - strictVersion: true, - packageName: undefined, - singleton: false, - eager: false, - issuerLayer: undefined, - layer: undefined, - request: 'test-module', + it('should handle mismatched package name gracefully - include filters', async () => { + const harness = createHarness(); + const config = createConsumeConfig({ include: { version: '^1.0.0', }, - exclude: undefined, - nodeModulesReconstructedLookup: undefined, - }; - - mockResolver.resolve.mockImplementation( - (context, lookupStartPath, request, resolveContext, callback) => { - callback(null, '/resolved/path/to/test-module'); - }, - ); + }); - // Mock getDescriptionFile to return package.json without version - mockGetDescriptionFile.mockImplementation((fs, dir, files, callback) => { + harness.setResolve(successResolve); + const mismatchedName: DescriptionFileResolver = ( + _fs, + _dir, + _files, + callback, + ) => { callback(null, { - data: { name: 'test-module' }, // No version + data: { name: 'other-module', version: '1.5.0' }, path: '/path/to/package.json', }); - }); + }; + harness.setDescription(mismatchedName); - const result = await plugin.createConsumeSharedModule( - mockCompilation, + const result = await harness.plugin.createConsumeSharedModule( + harness.compilation, '/test/context', 'test-module', config, ); expect(result).toBeDefined(); - // Should return module when no version in package.json }); - }); - describe('combined include and exclude filtering', () => { - let plugin: ConsumeSharedPlugin; - let mockCompilation: any; - let mockInputFileSystem: any; - let mockResolver: any; - - beforeEach(() => { - resetAllMocks(); - - plugin = new ConsumeSharedPlugin({ - shareScope: 'default', - consumes: { - 'test-module': '^1.0.0', + it('should handle getDescriptionFile errors for exclude filters', async () => { + const harness = createHarness(); + const config = createConsumeConfig({ + exclude: { + version: '^1.0.0', }, }); - mockInputFileSystem = { - readFile: jest.fn(), - }; - - mockResolver = { - resolve: jest.fn(), + harness.setResolve(successResolve); + const failingDescription: DescriptionFileResolver = ( + _fs, + _dir, + _files, + callback, + ) => { + callback(new Error('FS failure')); }; + harness.setDescription(failingDescription); - mockCompilation = { - inputFileSystem: mockInputFileSystem, - resolverFactory: { - get: jest.fn(() => mockResolver), - }, - warnings: [], - errors: [], - contextDependencies: { addAll: jest.fn() }, - fileDependencies: { addAll: jest.fn() }, - missingDependencies: { addAll: jest.fn() }, - compiler: { - context: '/test/context', - }, - }; - }); - - it('should handle both include and exclude filters correctly', async () => { - const config = { - import: './test-module', - shareScope: 'default', - shareKey: 'test-module', - requiredVersion: '^1.0.0', - strictVersion: true, - packageName: undefined, - singleton: false, - eager: false, - issuerLayer: undefined, - layer: undefined, - request: 'test-module', - include: { - version: '^1.0.0', // 1.5.0 satisfies this - }, - exclude: { - version: '^2.0.0', // 1.5.0 does not match this - }, - nodeModulesReconstructedLookup: undefined, - }; - - mockResolver.resolve.mockImplementation( - (context, lookupStartPath, request, resolveData, callback) => { - callback(null, '/resolved/path/to/test-module'); - }, - ); - - // Mock getDescriptionFile for both include and exclude filters - mockGetDescriptionFile.mockImplementation((fs, dir, files, callback) => { - callback(null, { - data: { name: 'test-module', version: '1.5.0' }, - path: '/path/to/package.json', - }); - }); - - const result = await plugin.createConsumeSharedModule( - mockCompilation, + const result = await harness.plugin.createConsumeSharedModule( + harness.compilation, '/test/context', 'test-module', config, ); expect(result).toBeDefined(); - // Should include module since it satisfies include and doesn't match exclude }); }); }); diff --git a/packages/enhanced/test/unit/sharing/ConsumeSharedPlugin/ConsumeSharedPlugin.filtering.test.ts b/packages/enhanced/test/unit/sharing/ConsumeSharedPlugin/ConsumeSharedPlugin.filtering.test.ts index a9e8f289015..e9001aad6ed 100644 --- a/packages/enhanced/test/unit/sharing/ConsumeSharedPlugin/ConsumeSharedPlugin.filtering.test.ts +++ b/packages/enhanced/test/unit/sharing/ConsumeSharedPlugin/ConsumeSharedPlugin.filtering.test.ts @@ -7,11 +7,14 @@ import { shareScopes, createSharingTestEnvironment, resetAllMocks, -} from './shared-test-utils'; +} from '../plugin-test-utils'; +import { getConsumes } from './helpers'; + +type SharingTestEnvironment = ReturnType; describe('ConsumeSharedPlugin', () => { describe('filtering functionality', () => { - let testEnv; + let testEnv: SharingTestEnvironment; beforeEach(() => { resetAllMocks(); @@ -35,8 +38,7 @@ describe('ConsumeSharedPlugin', () => { plugin.apply(testEnv.compiler); testEnv.simulateCompilation(); - // @ts-ignore accessing private property for testing - const consumes = plugin._consumes; + const consumes = getConsumes(plugin); const [, config] = consumes[0]; expect(config.requiredVersion).toBe('^17.0.0'); @@ -59,8 +61,7 @@ describe('ConsumeSharedPlugin', () => { plugin.apply(testEnv.compiler); testEnv.simulateCompilation(); - // @ts-ignore accessing private property for testing - const consumes = plugin._consumes; + const consumes = getConsumes(plugin); const [, config] = consumes[0]; expect(config.requiredVersion).toBe('^17.0.0'); @@ -83,8 +84,7 @@ describe('ConsumeSharedPlugin', () => { plugin.apply(testEnv.compiler); testEnv.simulateCompilation(); - // @ts-ignore accessing private property for testing - const consumes = plugin._consumes; + const consumes = getConsumes(plugin); const [, config] = consumes[0]; expect(config.requiredVersion).toBe('^16.0.0'); @@ -108,8 +108,7 @@ describe('ConsumeSharedPlugin', () => { // Plugin should be created successfully expect(plugin).toBeDefined(); - // @ts-ignore accessing private property for testing - const consumes = plugin._consumes; + const consumes = getConsumes(plugin); const [, config] = consumes[0]; expect(config.singleton).toBe(true); @@ -133,8 +132,7 @@ describe('ConsumeSharedPlugin', () => { plugin.apply(testEnv.compiler); testEnv.simulateCompilation(); - // @ts-ignore accessing private property for testing - const consumes = plugin._consumes; + const consumes = getConsumes(plugin); expect(consumes).toHaveLength(1); expect(consumes[0][1].include?.request).toBe('component'); }); @@ -154,8 +152,7 @@ describe('ConsumeSharedPlugin', () => { plugin.apply(testEnv.compiler); testEnv.simulateCompilation(); - // @ts-ignore accessing private property for testing - const consumes = plugin._consumes; + const consumes = getConsumes(plugin); expect(consumes[0][1].include?.request).toEqual(/^components/); }); @@ -174,8 +171,7 @@ describe('ConsumeSharedPlugin', () => { plugin.apply(testEnv.compiler); testEnv.simulateCompilation(); - // @ts-ignore accessing private property for testing - const consumes = plugin._consumes; + const consumes = getConsumes(plugin); expect(consumes[0][1].exclude?.request).toBe('internal'); }); @@ -194,8 +190,7 @@ describe('ConsumeSharedPlugin', () => { plugin.apply(testEnv.compiler); testEnv.simulateCompilation(); - // @ts-ignore accessing private property for testing - const consumes = plugin._consumes; + const consumes = getConsumes(plugin); expect(consumes[0][1].exclude?.request).toEqual(/test$/); }); @@ -217,8 +212,7 @@ describe('ConsumeSharedPlugin', () => { plugin.apply(testEnv.compiler); testEnv.simulateCompilation(); - // @ts-ignore accessing private property for testing - const consumes = plugin._consumes; + const consumes = getConsumes(plugin); const [, config] = consumes[0]; expect(config.include?.request).toEqual(/^Button/); @@ -247,8 +241,7 @@ describe('ConsumeSharedPlugin', () => { plugin.apply(testEnv.compiler); testEnv.simulateCompilation(); - // @ts-ignore accessing private property for testing - const consumes = plugin._consumes; + const consumes = getConsumes(plugin); const [, config] = consumes[0]; expect(config.requiredVersion).toBe('^1.0.0'); @@ -277,8 +270,7 @@ describe('ConsumeSharedPlugin', () => { plugin.apply(testEnv.compiler); testEnv.simulateCompilation(); - // @ts-ignore accessing private property for testing - const consumes = plugin._consumes; + const consumes = getConsumes(plugin); const [, config] = consumes[0]; expect(config.layer).toBe('framework'); @@ -307,8 +299,7 @@ describe('ConsumeSharedPlugin', () => { plugin.apply(testEnv.compiler); testEnv.simulateCompilation(); - // @ts-ignore accessing private property for testing - const consumes = plugin._consumes; + const consumes = getConsumes(plugin); const [, config] = consumes[0]; expect(config.requiredVersion).toBe('invalid-version'); @@ -331,8 +322,7 @@ describe('ConsumeSharedPlugin', () => { plugin.apply(testEnv.compiler); testEnv.simulateCompilation(); - // @ts-ignore accessing private property for testing - const consumes = plugin._consumes; + const consumes = getConsumes(plugin); const [, config] = consumes[0]; expect(config.requiredVersion).toBeUndefined(); diff --git a/packages/enhanced/test/unit/sharing/ConsumeSharedPlugin/ConsumeSharedPlugin.include-filtering.test.ts b/packages/enhanced/test/unit/sharing/ConsumeSharedPlugin/ConsumeSharedPlugin.include-filtering.test.ts index e775fc8dd71..c61e8d426fe 100644 --- a/packages/enhanced/test/unit/sharing/ConsumeSharedPlugin/ConsumeSharedPlugin.include-filtering.test.ts +++ b/packages/enhanced/test/unit/sharing/ConsumeSharedPlugin/ConsumeSharedPlugin.include-filtering.test.ts @@ -4,16 +4,46 @@ import { ConsumeSharedPlugin, + createMockCompilation, mockGetDescriptionFile, resetAllMocks, -} from './shared-test-utils'; +} from '../plugin-test-utils'; +import type { ResolveFunction, DescriptionFileResolver } from './helpers'; + +const descriptionFileMock = + mockGetDescriptionFile as jest.MockedFunction; describe('ConsumeSharedPlugin', () => { describe('include version filtering logic', () => { - let plugin: ConsumeSharedPlugin; - let mockCompilation: any; - let mockInputFileSystem: any; - let mockResolver: any; + let plugin: InstanceType; + let mockCompilation: ReturnType< + typeof createMockCompilation + >['mockCompilation']; + let mockResolver: { + resolve: jest.Mock< + ReturnType, + Parameters + >; + }; + + const successResolve: ResolveFunction = ( + _context, + _lookupStartPath, + _request, + _resolveContext, + callback, + ) => { + callback(null, '/resolved/path/to/test-module'); + }; + + const descriptionWithVersion = + (version: string): DescriptionFileResolver => + (_fs, _dir, _files, callback) => { + callback(null, { + data: { name: 'test-module', version }, + path: '/path/to/package.json', + }); + }; beforeEach(() => { resetAllMocks(); @@ -25,28 +55,23 @@ describe('ConsumeSharedPlugin', () => { }, }); - mockInputFileSystem = { - readFile: jest.fn(), - }; - mockResolver = { - resolve: jest.fn(), + resolve: jest.fn< + ReturnType, + Parameters + >(), }; - - mockCompilation = { - inputFileSystem: mockInputFileSystem, - resolverFactory: { - get: jest.fn(() => mockResolver), - }, - warnings: [], - errors: [], - contextDependencies: { addAll: jest.fn() }, - fileDependencies: { addAll: jest.fn() }, - missingDependencies: { addAll: jest.fn() }, - compiler: { - context: '/test/context', - }, + mockCompilation = createMockCompilation().mockCompilation; + mockCompilation.inputFileSystem.readFile = jest.fn(); + mockCompilation.resolverFactory = { + get: jest.fn(() => mockResolver), }; + mockCompilation.warnings = []; + mockCompilation.errors = []; + mockCompilation.contextDependencies = { addAll: jest.fn() }; + mockCompilation.fileDependencies = { addAll: jest.fn() }; + mockCompilation.missingDependencies = { addAll: jest.fn() }; + mockCompilation.compiler = { context: '/test/context' }; }); it('should include module when version satisfies include filter', async () => { @@ -69,19 +94,10 @@ describe('ConsumeSharedPlugin', () => { nodeModulesReconstructedLookup: undefined, }; - mockResolver.resolve.mockImplementation( - (context, lookupStartPath, request, resolveContext, callback) => { - callback(null, '/resolved/path/to/test-module'); - }, - ); + mockResolver.resolve.mockImplementation(successResolve); // Mock getDescriptionFile to return matching version - mockGetDescriptionFile.mockImplementation((fs, dir, files, callback) => { - callback(null, { - data: { name: 'test-module', version: '1.5.0' }, - path: '/path/to/package.json', - }); - }); + descriptionFileMock.mockImplementation(descriptionWithVersion('1.5.0')); const result = await plugin.createConsumeSharedModule( mockCompilation, @@ -159,18 +175,9 @@ describe('ConsumeSharedPlugin', () => { nodeModulesReconstructedLookup: undefined, }; - mockResolver.resolve.mockImplementation( - (context, lookupStartPath, request, resolveContext, callback) => { - callback(null, '/resolved/path/to/test-module'); - }, - ); + mockResolver.resolve.mockImplementation(successResolve); - mockGetDescriptionFile.mockImplementation((fs, dir, files, callback) => { - callback(null, { - data: { name: 'test-module', version: '1.5.0' }, - path: '/path/to/package.json', - }); - }); + descriptionFileMock.mockImplementation(descriptionWithVersion('1.5.0')); const result = await plugin.createConsumeSharedModule( mockCompilation, @@ -206,18 +213,9 @@ describe('ConsumeSharedPlugin', () => { nodeModulesReconstructedLookup: undefined, }; - mockResolver.resolve.mockImplementation( - (context, lookupStartPath, request, resolveContext, callback) => { - callback(null, '/resolved/path/to/test-module'); - }, - ); + mockResolver.resolve.mockImplementation(successResolve); - mockGetDescriptionFile.mockImplementation((fs, dir, files, callback) => { - callback(null, { - data: { name: 'test-module', version: '1.5.0' }, - path: '/path/to/package.json', - }); - }); + descriptionFileMock.mockImplementation(descriptionWithVersion('1.5.0')); const result = await plugin.createConsumeSharedModule( mockCompilation, diff --git a/packages/enhanced/test/unit/sharing/ConsumeSharedPlugin/ConsumeSharedPlugin.integration.test.ts b/packages/enhanced/test/unit/sharing/ConsumeSharedPlugin/ConsumeSharedPlugin.integration.test.ts new file mode 100644 index 00000000000..408164a3f22 --- /dev/null +++ b/packages/enhanced/test/unit/sharing/ConsumeSharedPlugin/ConsumeSharedPlugin.integration.test.ts @@ -0,0 +1,316 @@ +/* + * @jest-environment node + */ + +import ConsumeSharedPlugin from '../../../../src/lib/sharing/ConsumeSharedPlugin'; +import ConsumeSharedModule from '../../../../src/lib/sharing/ConsumeSharedModule'; +import { vol } from 'memfs'; +import { SyncHook, AsyncSeriesHook } from 'tapable'; +import { createMockCompilation } from '../plugin-test-utils'; +import { toSemVerRange } from './helpers'; + +// Use memfs to isolate filesystem effects for integration-style tests +jest.mock('fs', () => require('memfs').fs); +jest.mock('fs/promises', () => require('memfs').fs.promises); + +jest.mock('@module-federation/sdk/normalize-webpack-path', () => ({ + getWebpackPath: jest.fn(() => 'webpack'), + normalizeWebpackPath: jest.fn((value: string) => value), +})); + +jest.mock( + '../../../../src/lib/container/runtime/FederationRuntimePlugin', + () => { + return jest.fn().mockImplementation(() => ({ + apply: jest.fn(), + })); + }, +); + +jest.mock('webpack/lib/util/fs', () => ({ + join: (_fs: unknown, ...segments: string[]) => + require('path').join(...segments), + dirname: (_fs: unknown, filePath: string) => + require('path').dirname(filePath), + readJson: ( + _fs: unknown, + filePath: string, + callback: (err: any, data?: any) => void, + ) => { + const memfs = require('memfs').fs; + memfs.readFile(filePath, 'utf8', (error: any, content: string) => { + if (error) return callback(error); + try { + callback(null, JSON.parse(content)); + } catch (parseError) { + callback(parseError); + } + }); + }, +})); + +const buildTestCompilation = () => { + const { mockCompilation } = createMockCompilation(); + const compilation = mockCompilation; + compilation.compiler = { context: '/test-project' }; + compilation.contextDependencies = { addAll: jest.fn() }; + compilation.fileDependencies = { addAll: jest.fn() }; + compilation.missingDependencies = { addAll: jest.fn() }; + compilation.warnings = []; + compilation.errors = []; + compilation.dependencyFactories = new Map(); + return compilation; +}; + +const createMemfsCompilation = () => { + const compilation = buildTestCompilation(); + compilation.resolverFactory = { + get: () => ({ + resolve: ( + _context: unknown, + _lookupStartPath: string, + request: string, + _resolveContext: unknown, + callback: (err: Error | null, result?: string) => void, + ) => callback(null, `/test-project/node_modules/${request}`), + }), + }; + compilation.inputFileSystem = require('fs'); + return compilation; +}; + +describe('ConsumeSharedPlugin integration scenarios', () => { + beforeEach(() => { + vol.reset(); + jest.clearAllMocks(); + }); + + it('registers compiler hooks using real tapable instances', () => { + const trackHook = | AsyncSeriesHook>( + hook: THook, + ) => { + const tapCalls: Array<{ name: string; fn: unknown }> = []; + const originalTap = hook.tap.bind(hook); + hook.tap = ((name: string, fn: any) => { + tapCalls.push({ name, fn }); + (hook as any).__tapCalls = tapCalls; + return originalTap(name, fn); + }) as any; + + if ('tapAsync' in hook && typeof hook.tapAsync === 'function') { + const originalTapAsync = (hook.tapAsync as any).bind(hook); + hook.tapAsync = ((name: string, fn: any) => { + tapCalls.push({ name, fn }); + (hook as any).__tapCalls = tapCalls; + return originalTapAsync(name, fn); + }) as any; + } + + if ('tapPromise' in hook && typeof hook.tapPromise === 'function') { + const originalTapPromise = (hook.tapPromise as any).bind(hook); + hook.tapPromise = ((name: string, fn: any) => { + tapCalls.push({ name, fn }); + (hook as any).__tapCalls = tapCalls; + return originalTapPromise(name, fn); + }) as any; + } + + return hook; + }; + + const thisCompilationHook = trackHook(new SyncHook<[unknown, unknown]>()); + const finishMakeHook = trackHook(new AsyncSeriesHook<[unknown]>()); + + const compiler = { + hooks: { + thisCompilation: thisCompilationHook, + compilation: new SyncHook<[unknown, unknown]>(), + finishMake: finishMakeHook, + }, + context: '/test-project', + options: { + plugins: [], + output: { uniqueName: 'test-app' }, + }, + }; + + const plugin = new ConsumeSharedPlugin({ + shareScope: 'default', + consumes: { react: '^17.0.0' }, + }); + + let tappedCompilationCallback: + | ((compilation: unknown, params: unknown) => void) + | null = null; + const originalTap = thisCompilationHook.tap; + thisCompilationHook.tap = jest.fn( + ( + name: string, + callback: (compilation: unknown, params: unknown) => void, + ) => { + tappedCompilationCallback = callback; + return originalTap.call(thisCompilationHook, name, callback); + }, + ); + + plugin.apply(compiler as any); + + expect( + (thisCompilationHook as any).__tapCalls?.length ?? 0, + ).toBeGreaterThan(0); + + expect(tappedCompilationCallback).not.toBeNull(); + if (tappedCompilationCallback) { + const compilation = buildTestCompilation(); + const moduleHook = new SyncHook<[unknown, unknown, unknown]>(); + const params = { + normalModuleFactory: { + hooks: { + factorize: new AsyncSeriesHook<[unknown]>(), + createModule: new AsyncSeriesHook<[unknown]>(), + module: moduleHook, + }, + }, + }; + + expect(() => + tappedCompilationCallback!(compilation, params), + ).not.toThrow(); + expect(compilation.dependencyFactories.size).toBeGreaterThan(0); + } + }); + + it('creates real ConsumeSharedModule instances using memfs-backed package data', async () => { + vol.fromJSON({ + '/test-project/package.json': JSON.stringify({ + name: 'test-app', + version: '1.0.0', + dependencies: { + react: '^17.0.2', + }, + }), + '/test-project/node_modules/react/package.json': JSON.stringify({ + name: 'react', + version: '17.0.2', + }), + }); + + const plugin = new ConsumeSharedPlugin({ + shareScope: 'default', + consumes: { react: '^17.0.0' }, + }); + + const compilation = createMemfsCompilation(); + + const module = await plugin.createConsumeSharedModule( + compilation, + '/test-project', + 'react', + { + import: undefined, + shareScope: 'default', + shareKey: 'react', + requiredVersion: toSemVerRange('^17.0.0'), + strictVersion: false, + packageName: 'react', + singleton: false, + eager: false, + issuerLayer: undefined, + layer: undefined, + request: 'react', + include: undefined, + exclude: undefined, + nodeModulesReconstructedLookup: undefined, + }, + ); + + expect(module).toBeInstanceOf(ConsumeSharedModule); + expect(compilation.errors).toHaveLength(0); + expect(compilation.warnings).toHaveLength(0); + }); + + it('tolerates strict version mismatches by still generating modules', async () => { + vol.fromJSON({ + '/test-project/package.json': JSON.stringify({ + name: 'test-app', + dependencies: { react: '^16.0.0' }, + }), + '/test-project/node_modules/react/package.json': JSON.stringify({ + name: 'react', + version: '16.14.0', + }), + }); + + const plugin = new ConsumeSharedPlugin({ + shareScope: 'default', + consumes: { + react: { requiredVersion: '^17.0.0', strictVersion: true }, + }, + }); + + const compilation = createMemfsCompilation(); + + const module = await plugin.createConsumeSharedModule( + compilation, + '/test-project', + 'react', + { + import: undefined, + shareScope: 'default', + shareKey: 'react', + requiredVersion: toSemVerRange('^17.0.0'), + strictVersion: true, + packageName: 'react', + singleton: false, + eager: false, + issuerLayer: undefined, + layer: undefined, + request: 'react', + include: undefined, + exclude: undefined, + nodeModulesReconstructedLookup: undefined, + }, + ); + + expect(module).toBeInstanceOf(ConsumeSharedModule); + expect(compilation.errors).toHaveLength(0); + }); + + it('handles missing package metadata gracefully', async () => { + vol.fromJSON({ + '/test-project/package.json': JSON.stringify({ name: 'test-app' }), + }); + + const plugin = new ConsumeSharedPlugin({ + shareScope: 'default', + consumes: { react: '^17.0.0' }, + }); + + const compilation = createMemfsCompilation(); + + const module = await plugin.createConsumeSharedModule( + compilation, + '/test-project', + 'react', + { + import: undefined, + shareScope: 'default', + shareKey: 'react', + requiredVersion: toSemVerRange('^17.0.0'), + strictVersion: false, + packageName: 'react', + singleton: false, + eager: false, + issuerLayer: undefined, + layer: undefined, + request: 'react', + include: undefined, + exclude: undefined, + nodeModulesReconstructedLookup: undefined, + }, + ); + + expect(module).toBeInstanceOf(ConsumeSharedModule); + expect(compilation.errors).toHaveLength(0); + }); +}); diff --git a/packages/enhanced/test/unit/sharing/ConsumeSharedPlugin/ConsumeSharedPlugin.version-resolution.test.ts b/packages/enhanced/test/unit/sharing/ConsumeSharedPlugin/ConsumeSharedPlugin.version-resolution.test.ts index cde218ca969..b6fcd8af7b1 100644 --- a/packages/enhanced/test/unit/sharing/ConsumeSharedPlugin/ConsumeSharedPlugin.version-resolution.test.ts +++ b/packages/enhanced/test/unit/sharing/ConsumeSharedPlugin/ConsumeSharedPlugin.version-resolution.test.ts @@ -8,11 +8,21 @@ import { createSharingTestEnvironment, mockGetDescriptionFile, resetAllMocks, -} from './shared-test-utils'; +} from '../plugin-test-utils'; +import { getConsumes } from './helpers'; +import type { DescriptionFileResolver, ResolveFunction } from './helpers'; + +const descriptionFileMock = + mockGetDescriptionFile as jest.MockedFunction; + +const createResolveMock = () => + jest.fn, Parameters>(); + +type SharingTestEnvironment = ReturnType; describe('ConsumeSharedPlugin', () => { describe('complex resolution scenarios', () => { - let testEnv; + let testEnv: SharingTestEnvironment; beforeEach(() => { resetAllMocks(); @@ -32,11 +42,13 @@ describe('ConsumeSharedPlugin', () => { }); // Mock resolver to fail - const mockResolver = { - resolve: jest.fn((_, __, ___, ____, callback) => { - callback(new Error('Module resolution failed'), null); - }), - }; + const resolveMock = createResolveMock(); + resolveMock.mockImplementation( + (_context, _lookupStartPath, _request, _resolveContext, callback) => { + callback(new Error('Module resolution failed'), undefined); + }, + ); + const mockResolver = { resolve: resolveMock }; const mockCompilation = { ...testEnv.mockCompilation, @@ -97,27 +109,42 @@ describe('ConsumeSharedPlugin', () => { }); // Mock getDescriptionFile to fail - mockGetDescriptionFile.mockImplementation( - (fs, dir, files, callback) => { - callback(new Error('File system error'), null); + descriptionFileMock.mockImplementation( + (_fs, _dir, _files, callback) => { + callback(new Error('File system error'), undefined); }, ); // Mock filesystem to fail const mockInputFileSystem = { - readFile: jest.fn((path, callback) => { - callback(new Error('File system error'), null); - }), + readFile: jest.fn( + ( + _path: string, + callback: (error: Error | null, data?: string) => void, + ) => { + callback(new Error('File system error'), undefined); + }, + ), }; const mockCompilation = { ...testEnv.mockCompilation, resolverFactory: { - get: jest.fn(() => ({ - resolve: jest.fn((_, __, ___, ____, callback) => { - callback(null, '/resolved/path'); - }), - })), + get: jest.fn(() => { + const resolveMock = createResolveMock(); + resolveMock.mockImplementation( + ( + _context, + _lookupStartPath, + _request, + _resolveContext, + callback, + ) => { + callback(null, '/resolved/path'); + }, + ); + return { resolve: resolveMock }; + }), }, inputFileSystem: mockInputFileSystem, contextDependencies: { addAll: jest.fn() }, @@ -172,27 +199,45 @@ describe('ConsumeSharedPlugin', () => { }); // Mock getDescriptionFile to return null result (no package.json found) - mockGetDescriptionFile.mockImplementation( - (fs, dir, files, callback) => { - callback(null, null); + descriptionFileMock.mockImplementation( + (_fs, _dir, _files, callback) => { + callback(null, undefined); }, ); // Mock inputFileSystem that fails to read const mockInputFileSystem = { - readFile: jest.fn((path, callback) => { - callback(new Error('ENOENT: no such file or directory'), null); - }), + readFile: jest.fn( + ( + _path: string, + callback: (error: Error | null, data?: string) => void, + ) => { + callback( + new Error('ENOENT: no such file or directory'), + undefined, + ); + }, + ), }; const mockCompilation = { ...testEnv.mockCompilation, resolverFactory: { - get: jest.fn(() => ({ - resolve: jest.fn((_, __, ___, ____, callback) => { - callback(null, '/resolved/path'); - }), - })), + get: jest.fn(() => { + const resolveMock = createResolveMock(); + resolveMock.mockImplementation( + ( + _context, + _lookupStartPath, + _request, + _resolveContext, + callback, + ) => { + callback(null, '/resolved/path'); + }, + ); + return { resolve: resolveMock }; + }), }, inputFileSystem: mockInputFileSystem, contextDependencies: { addAll: jest.fn() }, @@ -251,8 +296,7 @@ describe('ConsumeSharedPlugin', () => { // Should create plugin without throwing expect(plugin).toBeDefined(); - // @ts-ignore accessing private property for testing - const consumes = plugin._consumes; + const consumes = getConsumes(plugin); expect(consumes[0][1].packageName).toBe('valid-package'); }); @@ -266,8 +310,7 @@ describe('ConsumeSharedPlugin', () => { expect(plugin).toBeDefined(); - // @ts-ignore accessing private property for testing - const consumes = plugin._consumes; + const consumes = getConsumes(plugin); expect(consumes[0][1].shareScope).toBe('a'); }); @@ -288,8 +331,7 @@ describe('ConsumeSharedPlugin', () => { expect(plugin).toBeDefined(); - // @ts-ignore accessing private property for testing - const consumes = plugin._consumes; + const consumes = getConsumes(plugin); expect(consumes).toHaveLength(2); const clientModule = consumes.find(([key]) => key === 'client-module'); @@ -314,8 +356,7 @@ describe('ConsumeSharedPlugin', () => { }, }); - // @ts-ignore accessing private property for testing - const consumes = plugin._consumes; + const consumes = getConsumes(plugin); const nodeModule = consumes.find(([key]) => key === 'node-module'); const regularModule = consumes.find( @@ -344,8 +385,7 @@ describe('ConsumeSharedPlugin', () => { }, }); - // @ts-ignore accessing private property for testing - const consumes = plugin._consumes; + const consumes = getConsumes(plugin); expect(consumes).toHaveLength(3); @@ -386,8 +426,7 @@ describe('ConsumeSharedPlugin', () => { }, }); - // @ts-ignore accessing private property for testing - const consumes = plugin._consumes; + const consumes = getConsumes(plugin); expect(consumes[0][1].import).toBeUndefined(); expect(consumes[0][1].shareKey).toBe('no-import'); }); @@ -402,8 +441,7 @@ describe('ConsumeSharedPlugin', () => { }, }); - // @ts-ignore accessing private property for testing - const consumes = plugin._consumes; + const consumes = getConsumes(plugin); expect(consumes[0][1].requiredVersion).toBe(false); }); }); @@ -448,7 +486,7 @@ describe('ConsumeSharedPlugin', () => { }); describe('performance and memory tests', () => { - let testEnv; + let testEnv: SharingTestEnvironment; beforeEach(() => { resetAllMocks(); @@ -457,8 +495,8 @@ describe('ConsumeSharedPlugin', () => { describe('large-scale scenarios', () => { it('should handle many consume configurations efficiently', () => { - const largeConsumes = {}; - for (let i = 0; i < 1000; i++) { + const largeConsumes: Record = {}; + for (let i = 0; i < 1000; i += 1) { largeConsumes[`module-${i}`] = `^${i % 10}.0.0`; } @@ -477,12 +515,18 @@ describe('ConsumeSharedPlugin', () => { expect(plugin).toBeDefined(); // @ts-ignore accessing private property for testing - expect(plugin._consumes).toHaveLength(1000); + expect(getConsumes(plugin)).toHaveLength(1000); }); it('should handle efficient option parsing with many prefix patterns', () => { - const prefixConsumes = {}; - for (let i = 0; i < 100; i++) { + const prefixConsumes: Record< + string, + { + shareScope?: string; + include?: { request?: RegExp }; + } + > = {}; + for (let i = 0; i < 100; i += 1) { prefixConsumes[`prefix-${i}/`] = { shareScope: `scope-${i % 5}`, // Reuse some scopes include: { @@ -506,7 +550,7 @@ describe('ConsumeSharedPlugin', () => { expect(plugin).toBeDefined(); // @ts-ignore accessing private property for testing - expect(plugin._consumes).toHaveLength(100); + expect(getConsumes(plugin)).toHaveLength(100); }); }); @@ -520,8 +564,7 @@ describe('ConsumeSharedPlugin', () => { }, }); - // @ts-ignore accessing private property for testing - const consumes = plugin._consumes; + const consumes = getConsumes(plugin); // Should reuse shareScope strings expect(consumes[0][1].shareScope).toBe(consumes[1][1].shareScope); @@ -536,26 +579,26 @@ describe('ConsumeSharedPlugin', () => { }, }); - const mockCompilation = { - ...testEnv.mockCompilation, - resolverFactory: { - get: jest.fn(() => ({ - resolve: jest.fn((_, __, ___, ____, callback) => { - // Simulate async resolution - setTimeout(() => callback(null, '/resolved/path'), 1); - }), - })), - }, - inputFileSystem: {}, - contextDependencies: { addAll: jest.fn() }, - fileDependencies: { addAll: jest.fn() }, - missingDependencies: { addAll: jest.fn() }, - errors: [], - warnings: [], - compiler: { - context: '/test', + const compilation = testEnv.mockCompilation; + const asyncResolveMock = createResolveMock(); + asyncResolveMock.mockImplementation( + (_context, _lookupStartPath, _request, _resolveContext, callback) => { + setTimeout(() => callback(null, '/resolved/path'), 1); }, + ); + + compilation.resolverFactory = { + get: jest.fn(() => ({ resolve: asyncResolveMock })), }; + compilation.inputFileSystem = {} as typeof compilation.inputFileSystem; + compilation.contextDependencies = { addAll: jest.fn() }; + compilation.fileDependencies = { addAll: jest.fn() }; + compilation.missingDependencies = { addAll: jest.fn() }; + compilation.errors = []; + compilation.warnings = []; + compilation.compiler = { + context: '/test', + } as typeof compilation.compiler; const config = { import: undefined, @@ -575,11 +618,11 @@ describe('ConsumeSharedPlugin', () => { }; // Start multiple concurrent resolutions - const promises = []; - for (let i = 0; i < 10; i++) { + const promises: Array> = []; + for (let i = 0; i < 10; i += 1) { promises.push( plugin.createConsumeSharedModule( - mockCompilation as any, + compilation, '/test/context', 'concurrent-module', config, diff --git a/packages/enhanced/test/unit/sharing/ConsumeSharedPlugin/helpers.ts b/packages/enhanced/test/unit/sharing/ConsumeSharedPlugin/helpers.ts new file mode 100644 index 00000000000..45210ee8d49 --- /dev/null +++ b/packages/enhanced/test/unit/sharing/ConsumeSharedPlugin/helpers.ts @@ -0,0 +1,54 @@ +import type { SemVerRange } from 'webpack/lib/util/semver'; +import type { + ConsumeSharedPluginInstance, + ConsumeConfig, + ResolveFunction, + DescriptionFileResolver, + ConsumeEntry, +} from '../test-types'; + +export const toSemVerRange = (range: string): SemVerRange => + range as unknown as SemVerRange; + +// Preserve the required core fields while allowing callers to override +// any subset of the remaining configuration for convenience during tests. +type BaseConfig = Pick & + Partial>; + +const defaultConfig: BaseConfig = { + shareScope: 'default', + shareKey: 'test-module', + import: './test-module', + requiredVersion: toSemVerRange('^1.0.0'), + strictVersion: true, + singleton: false, + eager: false, + issuerLayer: undefined, + layer: undefined, + request: 'test-module', + include: undefined, + exclude: undefined, + nodeModulesReconstructedLookup: undefined, + packageName: undefined, +}; + +export const createConsumeConfig = ( + overrides: Partial = {}, +): ConsumeConfig => + ({ + ...defaultConfig, + ...overrides, + }) as ConsumeConfig; + +export const getConsumes = ( + instance: ConsumeSharedPluginInstance, +): ConsumeEntry[] => + (instance as unknown as { _consumes: ConsumeEntry[] })._consumes; + +export type { + ConsumeSharedPluginInstance, + ConsumeConfig, + ResolveFunction, + DescriptionFileResolver, + ConsumeEntry, +}; diff --git a/packages/enhanced/test/unit/sharing/ConsumeSharedPlugin/shared-test-utils.ts b/packages/enhanced/test/unit/sharing/ConsumeSharedPlugin/shared-test-utils.ts deleted file mode 100644 index 9afb36c2f01..00000000000 --- a/packages/enhanced/test/unit/sharing/ConsumeSharedPlugin/shared-test-utils.ts +++ /dev/null @@ -1,194 +0,0 @@ -/* - * Shared test utilities and mocks for ConsumeSharedPlugin tests - */ - -import { - shareScopes, - createSharingTestEnvironment, - createFederationCompilerMock, - testModuleOptions, -} from '../utils'; - -// Create webpack mock -export const webpack = { version: '5.89.0' }; - -// Mock dependencies -jest.mock('@module-federation/sdk/normalize-webpack-path', () => ({ - normalizeWebpackPath: jest.fn((path) => path), - getWebpackPath: jest.fn(() => 'mocked-webpack-path'), -})); - -// Note: Removed container-utils mock as the function doesn't exist in the codebase - -// Mock container dependencies with commonjs support -jest.mock('../../../../src/lib/container/ContainerExposedDependency', () => ({ - __esModule: true, - default: jest.fn().mockImplementation(() => ({ - name: 'ContainerExposedDependency', - })), -})); - -jest.mock('../../../../src/lib/container/ContainerEntryModule', () => ({ - __esModule: true, - default: jest.fn().mockImplementation(() => ({ - name: 'ContainerEntryModule', - })), -})); - -// Mock FederationRuntimePlugin -jest.mock( - '../../../../src/lib/container/runtime/FederationRuntimePlugin', - () => { - return jest.fn().mockImplementation(() => ({ - apply: jest.fn(), - })); - }, -); - -// Create mock ConsumeSharedModule -export const createMockConsumeSharedModule = () => { - const mockConsumeSharedModule = jest - .fn() - .mockImplementation((contextOrOptions, options) => { - // Handle both calling patterns: - // 1. Direct test calls: mockConsumeSharedModule(options) - // 2. Plugin calls: mockConsumeSharedModule(context, options) - const actualOptions = options || contextOrOptions; - - return { - shareScope: actualOptions.shareScope, - name: actualOptions.name || 'default-name', - request: actualOptions.request || 'default-request', - eager: actualOptions.eager || false, - strictVersion: actualOptions.strictVersion || false, - singleton: actualOptions.singleton || false, - requiredVersion: - actualOptions.requiredVersion !== undefined - ? actualOptions.requiredVersion - : '1.0.0', - getVersion: jest - .fn() - .mockReturnValue( - actualOptions.requiredVersion !== undefined - ? actualOptions.requiredVersion - : '1.0.0', - ), - options: actualOptions, - // Add necessary methods expected by the plugin - build: jest.fn().mockImplementation((context, _c, _r, _f, callback) => { - callback && callback(); - }), - }; - }); - - return mockConsumeSharedModule; -}; - -// Create shared module mock -export const mockConsumeSharedModule = createMockConsumeSharedModule(); - -// Mock ConsumeSharedModule -jest.mock('../../../../src/lib/sharing/ConsumeSharedModule', () => { - return mockConsumeSharedModule; -}); - -// Create runtime module mocks -const mockConsumeSharedRuntimeModule = jest.fn().mockImplementation(() => ({ - name: 'ConsumeSharedRuntimeModule', -})); - -const mockShareRuntimeModule = jest.fn().mockImplementation(() => ({ - name: 'ShareRuntimeModule', -})); - -// Mock runtime modules -jest.mock('../../../../src/lib/sharing/ConsumeSharedRuntimeModule', () => { - return mockConsumeSharedRuntimeModule; -}); - -jest.mock('../../../../src/lib/sharing/ShareRuntimeModule', () => { - return mockShareRuntimeModule; -}); - -// Mock ConsumeSharedFallbackDependency -class MockConsumeSharedFallbackDependency { - constructor( - public fallbackRequest: string, - public shareScope: string, - public requiredVersion: string, - ) {} -} - -jest.mock( - '../../../../src/lib/sharing/ConsumeSharedFallbackDependency', - () => { - return function (fallbackRequest, shareScope, requiredVersion) { - return new MockConsumeSharedFallbackDependency( - fallbackRequest, - shareScope, - requiredVersion, - ); - }; - }, - { virtual: true }, -); - -// Mock resolveMatchedConfigs -jest.mock('../../../../src/lib/sharing/resolveMatchedConfigs', () => ({ - resolveMatchedConfigs: jest.fn().mockResolvedValue({ - resolved: new Map(), - unresolved: new Map(), - prefixed: new Map(), - }), -})); - -// Mock utils module with a spy-like setup for getDescriptionFile -export const mockGetDescriptionFile = jest.fn(); -jest.mock('../../../../src/lib/sharing/utils', () => ({ - ...jest.requireActual('../../../../src/lib/sharing/utils'), - getDescriptionFile: mockGetDescriptionFile, -})); - -// Import after mocks are set up -export const ConsumeSharedPlugin = - require('../../../../src/lib/sharing/ConsumeSharedPlugin').default; -export const { - resolveMatchedConfigs, -} = require('../../../../src/lib/sharing/resolveMatchedConfigs'); - -// Re-export utilities -export { - shareScopes, - createSharingTestEnvironment, - createFederationCompilerMock, -}; - -// Helper function to create test configuration -export function createTestConsumesConfig(consumes = {}) { - return { - shareScope: shareScopes.string, - consumes, - }; -} - -// Helper function to create mock resolver -export function createMockResolver() { - return { - resolve: jest.fn(), - withOptions: jest.fn().mockReturnThis(), - }; -} - -// Helper function to reset all mocks -export function resetAllMocks() { - jest.clearAllMocks(); - mockGetDescriptionFile.mockReset(); - resolveMatchedConfigs.mockReset(); - // Re-configure the resolveMatchedConfigs mock after reset - resolveMatchedConfigs.mockResolvedValue({ - resolved: new Map(), - unresolved: new Map(), - prefixed: new Map(), - }); - mockConsumeSharedModule.mockClear(); -} diff --git a/packages/enhanced/test/unit/sharing/ProvideSharedModule.test.ts b/packages/enhanced/test/unit/sharing/ProvideSharedModule.test.ts index 83f072e2f7e..a5026a61b68 100644 --- a/packages/enhanced/test/unit/sharing/ProvideSharedModule.test.ts +++ b/packages/enhanced/test/unit/sharing/ProvideSharedModule.test.ts @@ -60,14 +60,15 @@ const MockModule = createModuleMock(webpack); // Add a special extension for layer support - since our mock might not be correctly handling layers MockModule.extendWith({ - constructor: function (type, context, layer) { - this.type = type; - this.context = context; - this.layer = layer || null; - this.dependencies = []; - this.blocks = []; - this.buildInfo = {}; - this.buildMeta = {}; + constructor: function (type: string, context: string, layer?: string | null) { + const self = this as Record; + self['type'] = type; + self['context'] = context; + self['layer'] = layer ?? null; + self['dependencies'] = []; + self['blocks'] = []; + self['buildInfo'] = {}; + self['buildMeta'] = {}; }, }); @@ -457,9 +458,9 @@ describe('ProvideSharedModule', () => { ); // Create a non-empty callback function to avoid linter errors - function buildCallback(err: Error | null) { - if (err) throw err; - } + const buildCallback = (error?: unknown) => { + if (error instanceof Error) throw error; + }; // Create a simple mock compilation const mockCompilationObj = { diff --git a/packages/enhanced/test/unit/sharing/ProvideSharedPlugin.improved.test.ts b/packages/enhanced/test/unit/sharing/ProvideSharedPlugin.improved.test.ts deleted file mode 100644 index a7f21e1b9f9..00000000000 --- a/packages/enhanced/test/unit/sharing/ProvideSharedPlugin.improved.test.ts +++ /dev/null @@ -1,542 +0,0 @@ -/* - * @jest-environment node - */ - -import ProvideSharedPlugin from '../../../src/lib/sharing/ProvideSharedPlugin'; -import { vol } from 'memfs'; -import { SyncHook, AsyncSeriesHook } from 'tapable'; - -// Mock file system only for controlled testing -jest.mock('fs', () => require('memfs').fs); -jest.mock('fs/promises', () => require('memfs').fs.promises); - -// Mock webpack internals minimally -jest.mock('@module-federation/sdk/normalize-webpack-path', () => ({ - getWebpackPath: jest.fn(() => 'webpack'), - normalizeWebpackPath: jest.fn((p) => p), -})); - -// Mock FederationRuntimePlugin to avoid complex dependencies -jest.mock('../../../src/lib/container/runtime/FederationRuntimePlugin', () => { - return jest.fn().mockImplementation(() => ({ - apply: jest.fn(), - })); -}); - -// Mock the webpack fs utilities that are used by getDescriptionFile -jest.mock('webpack/lib/util/fs', () => ({ - join: (fs: any, ...paths: string[]) => require('path').join(...paths), - dirname: (fs: any, filePath: string) => require('path').dirname(filePath), - readJson: (fs: any, filePath: string, callback: Function) => { - const memfs = require('memfs').fs; - memfs.readFile(filePath, 'utf8', (err: any, content: any) => { - if (err) return callback(err); - try { - const data = JSON.parse(content); - callback(null, data); - } catch (e) { - callback(e); - } - }); - }, -})); - -describe('ProvideSharedPlugin - Improved Quality Tests', () => { - beforeEach(() => { - vol.reset(); - jest.clearAllMocks(); - }); - - describe('Real webpack integration', () => { - it('should apply plugin and handle module provision correctly', () => { - // Setup realistic file system - vol.fromJSON({ - '/test-project/package.json': JSON.stringify({ - name: 'provider-app', - version: '1.0.0', - dependencies: { - react: '^17.0.2', - lodash: '^4.17.21', - }, - }), - '/test-project/node_modules/react/package.json': JSON.stringify({ - name: 'react', - version: '17.0.2', - }), - '/test-project/node_modules/lodash/package.json': JSON.stringify({ - name: 'lodash', - version: '4.17.21', - }), - '/test-project/src/custom-lib.js': 'export default "custom library";', - }); - - const plugin = new ProvideSharedPlugin({ - shareScope: 'default', - provides: { - react: '^17.0.0', - lodash: { version: '^4.17.0', singleton: true }, - './src/custom-lib': { shareKey: 'custom-lib' }, // Relative path - '/test-project/src/custom-lib.js': { shareKey: 'absolute-lib' }, // Absolute path - }, - }); - - // Create realistic compiler and compilation - const compilationHook = new SyncHook(['compilation', 'params']); - const finishMakeHook = new AsyncSeriesHook(['compilation']); - - const compiler = { - hooks: { - compilation: compilationHook, - finishMake: finishMakeHook, - make: new AsyncSeriesHook(['compilation']), - thisCompilation: new SyncHook(['compilation', 'params']), - environment: new SyncHook([]), - afterEnvironment: new SyncHook([]), - afterPlugins: new SyncHook(['compiler']), - afterResolvers: new SyncHook(['compiler']), - }, - context: '/test-project', - options: { - plugins: [], - resolve: { - alias: {}, - }, - }, - }; - - let compilationCallback: Function | null = null; - let finishMakeCallback: Function | null = null; - - const originalCompilationTap = compilationHook.tap; - compilationHook.tap = jest.fn((name, callback) => { - if (name === 'ProvideSharedPlugin') { - compilationCallback = callback; - } - return originalCompilationTap.call(compilationHook, name, callback); - }); - - const originalFinishMakeTap = finishMakeHook.tapPromise; - finishMakeHook.tapPromise = jest.fn((name, callback) => { - if (name === 'ProvideSharedPlugin') { - finishMakeCallback = callback; - } - return originalFinishMakeTap.call(finishMakeHook, name, callback); - }); - - // Apply plugin - plugin.apply(compiler as any); - - expect(compilationHook.tap).toHaveBeenCalledWith( - 'ProvideSharedPlugin', - expect.any(Function), - ); - expect(finishMakeHook.tapPromise).toHaveBeenCalledWith( - 'ProvideSharedPlugin', - expect.any(Function), - ); - - // Test compilation hook execution - expect(compilationCallback).not.toBeNull(); - if (compilationCallback) { - const moduleHook = new SyncHook(['module', 'data', 'resolveData']); - const mockNormalModuleFactory = { - hooks: { module: moduleHook }, - }; - - const mockCompilation = { - dependencyFactories: new Map(), - }; - - expect(() => { - compilationCallback(mockCompilation, { - normalModuleFactory: mockNormalModuleFactory, - }); - }).not.toThrow(); - - expect(mockCompilation.dependencyFactories.size).toBeGreaterThan(0); - } - }); - - it('should handle real module matching scenarios', () => { - vol.fromJSON({ - '/test-project/src/components/Button.js': - 'export const Button = () => {};', - '/test-project/src/utils/helpers.js': 'export const helper = () => {};', - '/test-project/node_modules/lodash/index.js': - 'module.exports = require("./lodash");', - }); - - const plugin = new ProvideSharedPlugin({ - shareScope: 'default', - provides: { - './src/components/': { shareKey: 'components' }, // Prefix match - 'lodash/': { shareKey: 'lodash' }, // Module prefix match - './src/utils/helpers': { shareKey: 'helpers' }, // Direct match - }, - }); - - const compiler = { - hooks: { - compilation: new SyncHook(['compilation', 'params']), - finishMake: new AsyncSeriesHook(['compilation']), - make: new AsyncSeriesHook(['compilation']), - thisCompilation: new SyncHook(['compilation', 'params']), - environment: new SyncHook([]), - afterEnvironment: new SyncHook([]), - afterPlugins: new SyncHook(['compiler']), - afterResolvers: new SyncHook(['compiler']), - }, - context: '/test-project', - options: { - plugins: [], - resolve: { - alias: {}, - }, - }, - }; - - // Track compilation callback - let compilationCallback: Function | null = null; - const originalTap = compiler.hooks.compilation.tap; - compiler.hooks.compilation.tap = jest.fn((name, callback) => { - if (name === 'ProvideSharedPlugin') { - compilationCallback = callback; - } - return originalTap.call(compiler.hooks.compilation, name, callback); - }); - - plugin.apply(compiler as any); - - // Test module hook behavior - if (compilationCallback) { - const moduleHook = new SyncHook(['module', 'data', 'resolveData']); - let moduleCallback: Function | null = null; - - const originalModuleTap = moduleHook.tap; - moduleHook.tap = jest.fn((name, callback) => { - if (name === 'ProvideSharedPlugin') { - moduleCallback = callback; - } - return originalModuleTap.call(moduleHook, name, callback); - }); - - const mockNormalModuleFactory = { - hooks: { module: moduleHook }, - }; - - const mockCompilation = { - dependencyFactories: new Map(), - }; - - compilationCallback(mockCompilation, { - normalModuleFactory: mockNormalModuleFactory, - }); - - // Test different module matching scenarios - if (moduleCallback) { - const testModule = ( - request: string, - resource: string, - expectMatched: boolean, - ) => { - const mockModule = { layer: undefined }; - const mockData = { resource }; - const mockResolveData = { request }; - - const result = moduleCallback( - mockModule, - mockData, - mockResolveData, - ); - - if (expectMatched) { - // Should modify the module or take some action - expect(result).toBeDefined(); - } - }; - - // Test prefix matching - testModule( - './src/components/Button', - '/test-project/src/components/Button.js', - true, - ); - - // Test direct matching - testModule( - './src/utils/helpers', - '/test-project/src/utils/helpers.js', - true, - ); - - // Test non-matching - testModule( - './src/other/file', - '/test-project/src/other/file.js', - false, - ); - } - } - }); - - it('should handle version filtering correctly', () => { - // This test verifies the internal filtering logic - const plugin = new ProvideSharedPlugin({ - shareScope: 'default', - provides: { - 'included-lib': { - version: '^1.0.0', - include: { version: '^1.0.0' }, - }, - 'excluded-lib': { - version: '^1.0.0', - exclude: { version: '^1.0.0' }, - }, - 'no-filter-lib': { - version: '^2.0.0', - }, - }, - }); - - // Test the shouldProvideSharedModule method directly - const shouldProvideMethod = (plugin as any).shouldProvideSharedModule; - - // Test include filter - specific version satisfies range - const includeConfig = { - version: '1.5.0', // specific version - include: { version: '^1.0.0' }, // range it should satisfy - }; - expect(shouldProvideMethod.call(plugin, includeConfig)).toBe(true); - - // Test exclude filter - version matches exclude, should not provide - const excludeConfig = { - version: '1.5.0', // specific version - exclude: { version: '^1.0.0' }, // range that excludes it - }; - expect(shouldProvideMethod.call(plugin, excludeConfig)).toBe(false); - - // Test no filter - should provide - const noFilterConfig = { - version: '2.0.0', - }; - expect(shouldProvideMethod.call(plugin, noFilterConfig)).toBe(true); - - // Test version that doesn't satisfy include - const noSatisfyConfig = { - version: '2.0.0', - include: { version: '^1.0.0' }, - }; - expect(shouldProvideMethod.call(plugin, noSatisfyConfig)).toBe(false); - }); - }); - - describe('Configuration parsing behavior', () => { - it('should parse different provide configuration formats correctly', () => { - const plugin = new ProvideSharedPlugin({ - shareScope: 'default', - provides: { - // String format (package name with version) - react: '^17.0.0', - - // Object format with full configuration - lodash: { - version: '^4.17.0', - singleton: true, - eager: true, - shareKey: 'lodash-utils', - }, - - // Relative path - './src/components/Button': { - shareKey: 'button-component', - version: '1.0.0', - }, - - // Absolute path - '/project/src/lib': { - shareKey: 'project-lib', - }, - - // Prefix pattern - 'utils/': { - shareKey: 'utilities', - }, - - // With filtering - 'filtered-lib': { - version: '^2.0.0', - include: { version: '^2.0.0' }, - exclude: { request: /test/ }, - }, - }, - }); - - const provides = (plugin as any)._provides; - expect(provides).toHaveLength(6); - - // Verify string format parsing - const reactConfig = provides.find( - ([key]: [string, any]) => key === 'react', - ); - expect(reactConfig).toBeDefined(); - // When value is a string, it becomes the shareKey, not the version - expect(reactConfig[1].version).toBeUndefined(); - expect(reactConfig[1].shareKey).toBe('^17.0.0'); // The string value becomes shareKey - expect(reactConfig[1].request).toBe('^17.0.0'); // And also the request - - // Verify object format parsing - const lodashConfig = provides.find( - ([key]: [string, any]) => key === 'lodash', - ); - expect(lodashConfig).toBeDefined(); - expect(lodashConfig[1].singleton).toBe(true); - expect(lodashConfig[1].eager).toBe(true); - expect(lodashConfig[1].shareKey).toBe('lodash-utils'); - - // Verify relative path - const buttonConfig = provides.find( - ([key]: [string, any]) => key === './src/components/Button', - ); - expect(buttonConfig).toBeDefined(); - expect(buttonConfig[1].shareKey).toBe('button-component'); - - // Verify filtering configuration - const filteredConfig = provides.find( - ([key]: [string, any]) => key === 'filtered-lib', - ); - expect(filteredConfig).toBeDefined(); - expect(filteredConfig[1].include?.version).toBe('^2.0.0'); - expect(filteredConfig[1].exclude?.request).toBeInstanceOf(RegExp); - }); - - it('should handle edge cases in configuration', () => { - const plugin = new ProvideSharedPlugin({ - shareScope: 'default', - provides: { - 'empty-config': {}, // Minimal configuration - 'false-version': { version: false }, // Explicit false version - 'no-share-key': { version: '1.0.0' }, // Should use key as shareKey - }, - }); - - const provides = (plugin as any)._provides; - - const emptyConfig = provides.find( - ([key]: [string, any]) => key === 'empty-config', - ); - expect(emptyConfig[1].shareKey).toBe('empty-config'); - expect(emptyConfig[1].version).toBeUndefined(); - - const falseVersionConfig = provides.find( - ([key]: [string, any]) => key === 'false-version', - ); - expect(falseVersionConfig[1].version).toBe(false); - - const noShareKeyConfig = provides.find( - ([key]: [string, any]) => key === 'no-share-key', - ); - expect(noShareKeyConfig[1].shareKey).toBe('no-share-key'); - }); - }); - - describe('shouldProvideSharedModule behavior', () => { - it('should correctly filter modules based on version constraints', () => { - const plugin = new ProvideSharedPlugin({ - shareScope: 'default', - provides: { - 'include-test': { - version: '2.0.0', - include: { version: '^2.0.0' }, - }, - 'exclude-test': { - version: '1.0.0', - exclude: { version: '^1.0.0' }, - }, - 'no-version': {}, // No version specified - }, - }); - - const provides = (plugin as any)._provides; - - // Test include filter - should pass - const includeConfig = provides.find( - ([key]: [string, any]) => key === 'include-test', - )[1]; - const shouldInclude = (plugin as any).shouldProvideSharedModule( - includeConfig, - ); - expect(shouldInclude).toBe(true); - - // Test exclude filter - should not pass - const excludeConfig = provides.find( - ([key]: [string, any]) => key === 'exclude-test', - )[1]; - const shouldExclude = (plugin as any).shouldProvideSharedModule( - excludeConfig, - ); - expect(shouldExclude).toBe(false); - - // Test no version - should pass (deferred to runtime) - const noVersionConfig = provides.find( - ([key]: [string, any]) => key === 'no-version', - )[1]; - const shouldProvideNoVersion = (plugin as any).shouldProvideSharedModule( - noVersionConfig, - ); - expect(shouldProvideNoVersion).toBe(true); - }); - }); - - describe('Error handling and edge cases', () => { - it('should handle missing package.json gracefully', () => { - vol.fromJSON({ - '/test-project/src/lib.js': 'export default "lib";', - // No package.json files - }); - - const plugin = new ProvideSharedPlugin({ - shareScope: 'default', - provides: { - './src/lib': { shareKey: 'lib' }, - }, - }); - - const compiler = { - hooks: { - compilation: new SyncHook(['compilation', 'params']), - finishMake: new AsyncSeriesHook(['compilation']), - make: new AsyncSeriesHook(['compilation']), - thisCompilation: new SyncHook(['compilation', 'params']), - environment: new SyncHook([]), - afterEnvironment: new SyncHook([]), - afterPlugins: new SyncHook(['compiler']), - afterResolvers: new SyncHook(['compiler']), - }, - context: '/test-project', - options: { - plugins: [], - resolve: { - alias: {}, - }, - }, - }; - - // Should not throw when applied - expect(() => { - plugin.apply(compiler as any); - }).not.toThrow(); - }); - - it('should handle invalid provide configurations', () => { - expect(() => { - new ProvideSharedPlugin({ - shareScope: 'default', - provides: { - // @ts-ignore - intentionally testing invalid config - invalid: ['array', 'not', 'supported'], - }, - }); - }).toThrow('Invalid options object'); // Schema validation happens first - }); - }); -}); diff --git a/packages/enhanced/test/unit/sharing/ProvideSharedPlugin/ProvideSharedPlugin.apply.test.ts b/packages/enhanced/test/unit/sharing/ProvideSharedPlugin/ProvideSharedPlugin.apply.test.ts index 02ebd8e75ca..79d7caad450 100644 --- a/packages/enhanced/test/unit/sharing/ProvideSharedPlugin/ProvideSharedPlugin.apply.test.ts +++ b/packages/enhanced/test/unit/sharing/ProvideSharedPlugin/ProvideSharedPlugin.apply.test.ts @@ -8,19 +8,43 @@ import { shareScopes, createMockCompiler, createMockCompilation, -} from './shared-test-utils'; +} from '../plugin-test-utils'; + +type MockCompilation = ReturnType< + typeof createMockCompilation +>['mockCompilation']; +type FinishMakeCallback = (compilation: unknown) => Promise; +type ModuleHook = ( + module: Record, + data: { resource?: string; resourceResolveData?: Record }, + resolveData: { request?: string; cacheable?: boolean }, +) => void; +type MockNormalModuleFactory = { + hooks: { + module: { + tap: jest.Mock; + }; + factorize: { + tapAsync: jest.Mock; + }; + }; + moduleCallback: ModuleHook | null; +}; +type MockCompiler = ReturnType & { + finishMakeCallback: FinishMakeCallback | null; +}; describe('ProvideSharedPlugin', () => { describe('apply', () => { - let mockCompiler; - let mockCompilation; - let mockNormalModuleFactory; + let mockCompiler: MockCompiler; + let mockCompilation: MockCompilation; + let mockNormalModuleFactory: MockNormalModuleFactory; beforeEach(() => { jest.clearAllMocks(); // Create mock compiler and compilation using the utility functions - mockCompiler = createMockCompiler(); + mockCompiler = createMockCompiler() as MockCompiler; const compilationResult = createMockCompilation(); mockCompilation = compilationResult.mockCompilation; @@ -30,29 +54,39 @@ describe('ProvideSharedPlugin', () => { // Add addInclude method with proper implementation mockCompilation.addInclude = jest .fn() - .mockImplementation((context, dep, options, callback) => { - if (callback) { - const mockModule = { - _shareScope: dep._shareScope, - _shareKey: dep._shareKey, - _version: dep._version, + .mockImplementation( + ( + _context: unknown, + dep: Record, + _options: unknown, + callback?: ( + error: Error | null, + result?: { module: Record }, + ) => void, + ) => { + if (callback) { + const mockModule = { + _shareScope: dep['_shareScope'], + _shareKey: dep['_shareKey'], + _version: dep['_version'], + }; + callback(null, { module: mockModule }); + } + return { + module: { + _shareScope: dep['_shareScope'], + _shareKey: dep['_shareKey'], + _version: dep['_version'], + }, }; - callback(null, { module: mockModule }); - } - return { - module: { - _shareScope: dep._shareScope, - _shareKey: dep._shareKey, - _version: dep._version, - }, - }; - }); + }, + ); // Create mock normal module factory mockNormalModuleFactory = { hooks: { module: { - tap: jest.fn((name, callback) => { + tap: jest.fn((name: string, callback: ModuleHook) => { // Store the callback for later use mockNormalModuleFactory.moduleCallback = callback; }), @@ -67,15 +101,23 @@ describe('ProvideSharedPlugin', () => { // Set up compilation hook for testing mockCompiler.hooks.compilation.tap = jest .fn() - .mockImplementation((name, callback) => { - callback(mockCompilation, { - normalModuleFactory: mockNormalModuleFactory, - }); - }); + .mockImplementation( + ( + name: string, + callback: ( + compilation: unknown, + params: { normalModuleFactory: MockNormalModuleFactory }, + ) => void, + ) => { + callback(mockCompilation, { + normalModuleFactory: mockNormalModuleFactory, + }); + }, + ); // Set up finishMake hook for testing async callbacks mockCompiler.hooks.finishMake = { - tapPromise: jest.fn((name, callback) => { + tapPromise: jest.fn((name: string, callback: FinishMakeCallback) => { // Store the callback for later use mockCompiler.finishMakeCallback = callback; }), @@ -160,7 +202,8 @@ describe('ProvideSharedPlugin', () => { }; // Directly execute the module callback that was stored - mockNormalModuleFactory.moduleCallback( + expect(mockNormalModuleFactory.moduleCallback).toBeTruthy(); + mockNormalModuleFactory.moduleCallback?.( {}, // Mock module prefixMatchData, prefixMatchResolveData, @@ -238,7 +281,7 @@ describe('ProvideSharedPlugin', () => { }; // Directly execute the module callback that was stored - mockNormalModuleFactory.moduleCallback( + mockNormalModuleFactory.moduleCallback?.( moduleMock, moduleData, resolveData, @@ -327,7 +370,7 @@ describe('ProvideSharedPlugin', () => { config.version, ), { name: config.shareKey }, - (err, result) => { + (err: Error | null, result?: { module: Record }) => { // Handle callback with proper implementation if (err) { throw err; // Re-throw error for proper test failure diff --git a/packages/enhanced/test/unit/sharing/ProvideSharedPlugin/ProvideSharedPlugin.constructor.test.ts b/packages/enhanced/test/unit/sharing/ProvideSharedPlugin/ProvideSharedPlugin.constructor.test.ts index 8c2f0007818..7bcd545fb27 100644 --- a/packages/enhanced/test/unit/sharing/ProvideSharedPlugin/ProvideSharedPlugin.constructor.test.ts +++ b/packages/enhanced/test/unit/sharing/ProvideSharedPlugin/ProvideSharedPlugin.constructor.test.ts @@ -7,7 +7,28 @@ import { shareScopes, testProvides, createTestConfig, -} from './shared-test-utils'; +} from '../plugin-test-utils'; + +type ProvideFilterConfig = { + version?: string; + request?: string | RegExp; + fallbackVersion?: string; +}; + +type ProvideConfig = { + shareScope?: string | string[]; + shareKey?: string; + version?: string; + singleton?: boolean; + eager?: boolean; + include?: ProvideFilterConfig; + exclude?: ProvideFilterConfig; + layer?: string; + nodeModulesReconstructedLookup?: boolean; + import?: string; +} & Record; + +type ProvideEntry = [string, ProvideConfig]; describe('ProvideSharedPlugin', () => { describe('constructor', () => { @@ -29,8 +50,8 @@ describe('ProvideSharedPlugin', () => { }); // Test private property is set correctly - // @ts-ignore accessing private property for testing - const provides = plugin._provides; + const provides = (plugin as unknown as { _provides: ProvideEntry[] }) + ._provides; expect(provides.length).toBe(2); // Check that provides are correctly set @@ -63,8 +84,8 @@ describe('ProvideSharedPlugin', () => { }, }); - // @ts-ignore accessing private property for testing - const provides = plugin._provides; + const provides = (plugin as unknown as { _provides: ProvideEntry[] }) + ._provides; const [, config] = provides[0]; expect(config.shareScope).toEqual(shareScopes.array); @@ -78,8 +99,8 @@ describe('ProvideSharedPlugin', () => { }, }); - // @ts-ignore accessing private property for testing - const provides = plugin._provides; + const provides = (plugin as unknown as { _provides: ProvideEntry[] }) + ._provides; const [key, config] = provides[0]; // In ProvideSharedPlugin's implementation, for shorthand syntax like 'react: "17.0.2"': @@ -94,8 +115,8 @@ describe('ProvideSharedPlugin', () => { it('should handle complex provides configuration', () => { const plugin = new ProvideSharedPlugin(createTestConfig()); - // @ts-ignore accessing private property for testing - const provides = plugin._provides; + const provides = (plugin as unknown as { _provides: ProvideEntry[] }) + ._provides; expect(provides.length).toBe(3); // Verify all entries are processed correctly @@ -114,8 +135,8 @@ describe('ProvideSharedPlugin', () => { provides: {}, }); - // @ts-ignore accessing private property for testing - const provides = plugin._provides; + const provides = (plugin as unknown as { _provides: ProvideEntry[] }) + ._provides; expect(provides.length).toBe(0); }); @@ -141,8 +162,8 @@ describe('ProvideSharedPlugin', () => { }, }); - // @ts-ignore accessing private property for testing - const provides = plugin._provides; + const provides = (plugin as unknown as { _provides: ProvideEntry[] }) + ._provides; expect(provides.length).toBe(4); // Verify all configurations are preserved diff --git a/packages/enhanced/test/unit/sharing/ProvideSharedPlugin/ProvideSharedPlugin.filtering.test.ts b/packages/enhanced/test/unit/sharing/ProvideSharedPlugin/ProvideSharedPlugin.filtering.test.ts index fbab83bd99f..a275372ecf3 100644 --- a/packages/enhanced/test/unit/sharing/ProvideSharedPlugin/ProvideSharedPlugin.filtering.test.ts +++ b/packages/enhanced/test/unit/sharing/ProvideSharedPlugin/ProvideSharedPlugin.filtering.test.ts @@ -8,101 +8,63 @@ import { shareScopes, createMockCompiler, createMockCompilation, -} from './shared-test-utils'; +} from '../plugin-test-utils'; + +type MockCompilation = ReturnType< + typeof createMockCompilation +>['mockCompilation']; +type FinishMakeCallback = (compilation: unknown) => Promise; +type ModuleCallback = ( + module: Record, + data: { resource?: string; resourceResolveData?: Record }, + resolveData: { request?: string; cacheable?: boolean }, +) => void; +type MockCompiler = ReturnType & { + finishMakeCallback: FinishMakeCallback | null; +}; +type MockNormalModuleFactory = { + hooks: { + module: { + tap: jest.Mock; + }; + factorize: { + tapAsync: jest.Mock; + }; + }; + moduleCallback: ModuleCallback | null; +}; +type ProvideFilterConfig = { + version?: string; + request?: string | RegExp; + fallbackVersion?: string; +}; + +type ProvideConfig = { + shareScope?: string | string[]; + shareKey?: string; + version?: string; + singleton?: boolean; + eager?: boolean; + include?: ProvideFilterConfig; + exclude?: ProvideFilterConfig; + layer?: string; + nodeModulesReconstructedLookup?: boolean; + import?: string; +} & Record; + +type ProvideEntry = [string, ProvideConfig]; describe('ProvideSharedPlugin', () => { - describe('constructor', () => { - it('should initialize with string shareScope', () => { - const plugin = new ProvideSharedPlugin({ - shareScope: shareScopes.string, - provides: { - react: { - shareKey: 'react', - shareScope: shareScopes.string, - version: '17.0.2', - eager: false, - }, - lodash: { - version: '4.17.21', - singleton: true, - }, - }, - }); - - // Test private property is set correctly - // @ts-ignore accessing private property for testing - const provides = plugin._provides; - expect(provides.length).toBe(2); - - // Check that provides are correctly set - const reactEntry = provides.find(([key]) => key === 'react'); - const lodashEntry = provides.find(([key]) => key === 'lodash'); - - expect(reactEntry).toBeDefined(); - expect(lodashEntry).toBeDefined(); - - // Check first provide config - const [, reactConfig] = reactEntry!; - expect(reactConfig.shareScope).toBe(shareScopes.string); - expect(reactConfig.version).toBe('17.0.2'); - expect(reactConfig.eager).toBe(false); - - // Check second provide config (should inherit shareScope) - const [, lodashConfig] = lodashEntry!; - expect(lodashConfig.shareScope).toBe(shareScopes.string); - expect(lodashConfig.version).toBe('4.17.21'); - expect(lodashConfig.singleton).toBe(true); - }); - - it('should initialize with array shareScope', () => { - const plugin = new ProvideSharedPlugin({ - shareScope: shareScopes.array, - provides: { - react: { - version: '17.0.2', - }, - }, - }); - - // @ts-ignore accessing private property for testing - const provides = plugin._provides; - const [, config] = provides[0]; - - expect(config.shareScope).toEqual(shareScopes.array); - }); - - it('should handle shorthand provides syntax', () => { - const plugin = new ProvideSharedPlugin({ - shareScope: shareScopes.string, - provides: { - react: '17.0.2', // Shorthand syntax - }, - }); - - // @ts-ignore accessing private property for testing - const provides = plugin._provides; - const [key, config] = provides[0]; - - // In ProvideSharedPlugin's implementation, for shorthand syntax like 'react: "17.0.2"': - // - The key correctly becomes 'react' - // - But shareKey becomes the version string ('17.0.2') - // - And version becomes undefined - expect(key).toBe('react'); - expect(config.shareKey).toBe('17.0.2'); - expect(config.version).toBeUndefined(); - }); - }); - describe('apply', () => { - let mockCompiler; - let mockCompilation; - let mockNormalModuleFactory; + let mockCompiler: MockCompiler; + let mockCompilation: MockCompilation; + let mockNormalModuleFactory: MockNormalModuleFactory; beforeEach(() => { jest.clearAllMocks(); // Create mock compiler and compilation using the utility functions - mockCompiler = createMockCompiler(); + mockCompiler = createMockCompiler() as MockCompiler; const compilationResult = createMockCompilation(); mockCompilation = compilationResult.mockCompilation; @@ -134,7 +96,7 @@ describe('ProvideSharedPlugin', () => { mockNormalModuleFactory = { hooks: { module: { - tap: jest.fn((name, callback) => { + tap: jest.fn((name: string, callback: ModuleCallback) => { // Store the callback for later use mockNormalModuleFactory.moduleCallback = callback; }), @@ -149,15 +111,23 @@ describe('ProvideSharedPlugin', () => { // Set up compilation hook for testing mockCompiler.hooks.compilation.tap = jest .fn() - .mockImplementation((name, callback) => { - callback(mockCompilation, { - normalModuleFactory: mockNormalModuleFactory, - }); - }); + .mockImplementation( + ( + name: string, + callback: ( + compilation: unknown, + params: { normalModuleFactory: MockNormalModuleFactory }, + ) => void, + ) => { + callback(mockCompilation, { + normalModuleFactory: mockNormalModuleFactory, + }); + }, + ); // Set up finishMake hook for testing async callbacks mockCompiler.hooks.finishMake = { - tapPromise: jest.fn((name, callback) => { + tapPromise: jest.fn((name: string, callback: FinishMakeCallback) => { // Store the callback for later use mockCompiler.finishMakeCallback = callback; }), @@ -206,8 +176,7 @@ describe('ProvideSharedPlugin', () => { }); // Setup mocks for the internal checks in the plugin - // @ts-ignore accessing private property for testing - plugin._provides = [ + (plugin as unknown as { _provides: ProvideEntry[] })._provides = [ [ 'prefix/component', { @@ -216,7 +185,7 @@ describe('ProvideSharedPlugin', () => { shareScope: shareScopes.string, }, ], - ]; + ] as ProvideEntry[]; plugin.apply(mockCompiler); @@ -224,9 +193,7 @@ describe('ProvideSharedPlugin', () => { const resolvedProvideMap = new Map(); // Initialize the compilation weakmap on the plugin - // @ts-ignore accessing private property for testing plugin._compilationData = new WeakMap(); - // @ts-ignore accessing private property for testing plugin._compilationData.set(mockCompilation, resolvedProvideMap); // Test with prefix match @@ -242,7 +209,8 @@ describe('ProvideSharedPlugin', () => { }; // Directly execute the module callback that was stored - mockNormalModuleFactory.moduleCallback( + expect(mockNormalModuleFactory.moduleCallback).toBeTruthy(); + mockNormalModuleFactory.moduleCallback?.( {}, // Mock module prefixMatchData, prefixMatchResolveData, @@ -283,8 +251,7 @@ describe('ProvideSharedPlugin', () => { }); // Setup mocks for the internal checks in the plugin - // @ts-ignore accessing private property for testing - plugin._provides = [ + (plugin as unknown as { _provides: ProvideEntry[] })._provides = [ [ 'react', { @@ -293,7 +260,7 @@ describe('ProvideSharedPlugin', () => { shareScope: shareScopes.string, }, ], - ]; + ] as ProvideEntry[]; plugin.apply(mockCompiler); @@ -301,9 +268,7 @@ describe('ProvideSharedPlugin', () => { const resolvedProvideMap = new Map(); // Initialize the compilation weakmap on the plugin - // @ts-ignore accessing private property for testing plugin._compilationData = new WeakMap(); - // @ts-ignore accessing private property for testing plugin._compilationData.set(mockCompilation, resolvedProvideMap); // Test with module that has a layer @@ -320,7 +285,8 @@ describe('ProvideSharedPlugin', () => { }; // Directly execute the module callback that was stored - mockNormalModuleFactory.moduleCallback( + expect(mockNormalModuleFactory.moduleCallback).toBeTruthy(); + mockNormalModuleFactory.moduleCallback?.( moduleMock, moduleData, resolveData, @@ -391,9 +357,7 @@ describe('ProvideSharedPlugin', () => { ]); // Initialize the compilation weakmap on the plugin - // @ts-ignore accessing private property for testing plugin._compilationData = new WeakMap(); - // @ts-ignore accessing private property for testing plugin._compilationData.set(mockCompilation, resolvedProvideMap); // Manually execute what the finishMake callback would do @@ -409,7 +373,7 @@ describe('ProvideSharedPlugin', () => { config.version, ), { name: config.shareKey }, - (err, result) => { + (err: Error | null, result?: { module: Record }) => { // Handle callback with proper implementation if (err) { throw err; // Re-throw error for proper test failure @@ -444,15 +408,15 @@ describe('ProvideSharedPlugin', () => { }); describe('filtering functionality', () => { - let mockCompiler; - let mockCompilation; - let mockNormalModuleFactory; + let mockCompiler: MockCompiler; + let mockCompilation: MockCompilation; + let mockNormalModuleFactory: MockNormalModuleFactory; beforeEach(() => { jest.clearAllMocks(); // Create comprehensive mocks for filtering tests - mockCompiler = createMockCompiler(); + mockCompiler = createMockCompiler() as MockCompiler; const compilationResult = createMockCompilation(); mockCompilation = compilationResult.mockCompilation; @@ -484,7 +448,7 @@ describe('ProvideSharedPlugin', () => { mockNormalModuleFactory = { hooks: { module: { - tap: jest.fn((name, callback) => { + tap: jest.fn((name: string, callback: ModuleCallback) => { mockNormalModuleFactory.moduleCallback = callback; }), }, @@ -498,15 +462,23 @@ describe('ProvideSharedPlugin', () => { // Setup compilation hook mockCompiler.hooks.compilation.tap = jest .fn() - .mockImplementation((name, callback) => { - callback(mockCompilation, { - normalModuleFactory: mockNormalModuleFactory, - }); - }); + .mockImplementation( + ( + name: string, + callback: ( + compilation: unknown, + params: { normalModuleFactory: MockNormalModuleFactory }, + ) => void, + ) => { + callback(mockCompilation, { + normalModuleFactory: mockNormalModuleFactory, + }); + }, + ); // Setup finishMake hook mockCompiler.hooks.finishMake = { - tapPromise: jest.fn((name, callback) => { + tapPromise: jest.fn((name: string, callback: FinishMakeCallback) => { mockCompiler.finishMakeCallback = callback; }), }; @@ -530,8 +502,8 @@ describe('ProvideSharedPlugin', () => { plugin.apply(mockCompiler); - // @ts-ignore accessing private property for testing - const provides = plugin._provides; + const provides = (plugin as unknown as { _provides: ProvideEntry[] }) + ._provides; const [, config] = provides[0]; expect(config.include?.version).toBe('^17.0.0'); @@ -554,8 +526,8 @@ describe('ProvideSharedPlugin', () => { plugin.apply(mockCompiler); - // @ts-ignore accessing private property for testing - const provides = plugin._provides; + const provides = (plugin as unknown as { _provides: ProvideEntry[] }) + ._provides; const [, config] = provides[0]; expect(config.exclude?.version).toBe('^18.0.0'); @@ -581,9 +553,7 @@ describe('ProvideSharedPlugin', () => { // Simulate module processing const resolvedProvideMap = new Map(); - // @ts-ignore accessing private property for testing plugin._compilationData = new WeakMap(); - // @ts-ignore accessing private property for testing plugin._compilationData.set(mockCompilation, resolvedProvideMap); // Test module that should be filtered out @@ -600,7 +570,7 @@ describe('ProvideSharedPlugin', () => { // Execute the module callback if (mockNormalModuleFactory.moduleCallback) { - mockNormalModuleFactory.moduleCallback({}, moduleData, resolveData); + mockNormalModuleFactory.moduleCallback?.({}, moduleData, resolveData); } // Should generate warning about version not satisfying include filter @@ -625,9 +595,7 @@ describe('ProvideSharedPlugin', () => { const resolvedProvideMap = new Map(); - // @ts-ignore accessing private property for testing plugin._compilationData = new WeakMap(); - // @ts-ignore accessing private property for testing plugin._compilationData.set(mockCompilation, resolvedProvideMap); const moduleData = { @@ -642,7 +610,7 @@ describe('ProvideSharedPlugin', () => { // Execute the module callback if (mockNormalModuleFactory.moduleCallback) { - mockNormalModuleFactory.moduleCallback({}, moduleData, resolveData); + mockNormalModuleFactory.moduleCallback?.({}, moduleData, resolveData); } // Should generate warning about version matching exclude filter @@ -666,8 +634,8 @@ describe('ProvideSharedPlugin', () => { plugin.apply(mockCompiler); - // @ts-ignore accessing private property for testing - const provides = plugin._provides; + const provides = (plugin as unknown as { _provides: ProvideEntry[] }) + ._provides; const [, config] = provides[0]; expect(config.singleton).toBe(true); @@ -693,9 +661,7 @@ describe('ProvideSharedPlugin', () => { const resolvedProvideMap = new Map(); - // @ts-ignore accessing private property for testing plugin._compilationData = new WeakMap(); - // @ts-ignore accessing private property for testing plugin._compilationData.set(mockCompilation, resolvedProvideMap); // Test module that should pass the filter @@ -711,7 +677,7 @@ describe('ProvideSharedPlugin', () => { // Execute the module callback if (mockNormalModuleFactory.moduleCallback) { - mockNormalModuleFactory.moduleCallback({}, moduleData, resolveData); + mockNormalModuleFactory.moduleCallback?.({}, moduleData, resolveData); } // Should process the module (no warnings for passing filter) @@ -733,8 +699,8 @@ describe('ProvideSharedPlugin', () => { plugin.apply(mockCompiler); - // @ts-ignore accessing private property for testing - const provides = plugin._provides; + const provides = (plugin as unknown as { _provides: ProvideEntry[] }) + ._provides; const [, config] = provides[0]; expect(config.include?.request).toEqual(/^components/); @@ -757,9 +723,7 @@ describe('ProvideSharedPlugin', () => { const resolvedProvideMap = new Map(); - // @ts-ignore accessing private property for testing plugin._compilationData = new WeakMap(); - // @ts-ignore accessing private property for testing plugin._compilationData.set(mockCompilation, resolvedProvideMap); // Test module that should be excluded @@ -775,7 +739,7 @@ describe('ProvideSharedPlugin', () => { // Execute the module callback - this should be filtered out if (mockNormalModuleFactory.moduleCallback) { - mockNormalModuleFactory.moduleCallback({}, moduleData, resolveData); + mockNormalModuleFactory.moduleCallback?.({}, moduleData, resolveData); } // Module should be processed but request filtering happens at prefix level @@ -797,8 +761,8 @@ describe('ProvideSharedPlugin', () => { plugin.apply(mockCompiler); - // @ts-ignore accessing private property for testing - const provides = plugin._provides; + const provides = (plugin as unknown as { _provides: ProvideEntry[] }) + ._provides; const [, config] = provides[0]; expect(config.exclude?.request).toEqual(/test$/); @@ -822,8 +786,8 @@ describe('ProvideSharedPlugin', () => { plugin.apply(mockCompiler); - // @ts-ignore accessing private property for testing - const provides = plugin._provides; + const provides = (plugin as unknown as { _provides: ProvideEntry[] }) + ._provides; const [, config] = provides[0]; expect(config.include?.request).toEqual(/^helper/); @@ -852,8 +816,8 @@ describe('ProvideSharedPlugin', () => { plugin.apply(mockCompiler); - // @ts-ignore accessing private property for testing - const provides = plugin._provides; + const provides = (plugin as unknown as { _provides: ProvideEntry[] }) + ._provides; const [, config] = provides[0]; expect(config.version).toBe('2.0.0'); @@ -883,8 +847,8 @@ describe('ProvideSharedPlugin', () => { plugin.apply(mockCompiler); - // @ts-ignore accessing private property for testing - const provides = plugin._provides; + const provides = (plugin as unknown as { _provides: ProvideEntry[] }) + ._provides; const [, config] = provides[0]; expect(config.layer).toBe('framework'); @@ -914,9 +878,7 @@ describe('ProvideSharedPlugin', () => { const resolvedProvideMap = new Map(); - // @ts-ignore accessing private property for testing plugin._compilationData = new WeakMap(); - // @ts-ignore accessing private property for testing plugin._compilationData.set(mockCompilation, resolvedProvideMap); const moduleData = { @@ -932,7 +894,7 @@ describe('ProvideSharedPlugin', () => { // Should handle gracefully without throwing expect(mockNormalModuleFactory.moduleCallback).toBeDefined(); expect(() => { - mockNormalModuleFactory.moduleCallback({}, moduleData, resolveData); + mockNormalModuleFactory.moduleCallback?.({}, moduleData, resolveData); }).not.toThrow(); }); @@ -971,9 +933,7 @@ describe('ProvideSharedPlugin', () => { const resolvedProvideMap = new Map(); - // @ts-ignore accessing private property for testing plugin._compilationData = new WeakMap(); - // @ts-ignore accessing private property for testing plugin._compilationData.set(mockCompilation, resolvedProvideMap); const moduleData = { @@ -987,7 +947,7 @@ describe('ProvideSharedPlugin', () => { // Should handle gracefully expect(mockNormalModuleFactory.moduleCallback).toBeDefined(); expect(() => { - mockNormalModuleFactory.moduleCallback({}, moduleData, resolveData); + mockNormalModuleFactory.moduleCallback?.({}, moduleData, resolveData); }).not.toThrow(); }); @@ -1010,13 +970,10 @@ describe('ProvideSharedPlugin', () => { const resolvedProvideMap = new Map(); - // @ts-ignore accessing private property for testing plugin._compilationData = new WeakMap(); - // @ts-ignore accessing private property for testing plugin._compilationData.set(mockCompilation, resolvedProvideMap); // Manually test provideSharedModule to verify no singleton warning - // @ts-ignore - accessing private method for testing plugin.provideSharedModule( mockCompilation, resolvedProvideMap, @@ -1032,8 +989,9 @@ describe('ProvideSharedPlugin', () => { ); // Should NOT generate singleton warning for request filters - const singletonWarnings = mockCompilation.warnings.filter((w) => - w.message.includes('singleton'), + const singletonWarnings = mockCompilation.warnings.filter( + (warning: { message: string }) => + warning.message.includes('singleton'), ); expect(singletonWarnings).toHaveLength(0); }); diff --git a/packages/enhanced/test/unit/sharing/ProvideSharedPlugin/ProvideSharedPlugin.integration.test.ts b/packages/enhanced/test/unit/sharing/ProvideSharedPlugin/ProvideSharedPlugin.integration.test.ts new file mode 100644 index 00000000000..a2f3c335670 --- /dev/null +++ b/packages/enhanced/test/unit/sharing/ProvideSharedPlugin/ProvideSharedPlugin.integration.test.ts @@ -0,0 +1,154 @@ +/* + * @jest-environment node + */ + +import ProvideSharedPlugin from '../../../../src/lib/sharing/ProvideSharedPlugin'; +import { vol } from 'memfs'; +import { + createRealCompiler, + createMemfsCompilation, + createNormalModuleFactory, +} from '../../../helpers/webpackMocks'; + +// Mock file system for controlled integration testing +jest.mock('fs', () => require('memfs').fs); +jest.mock('fs/promises', () => require('memfs').fs.promises); + +jest.mock('@module-federation/sdk/normalize-webpack-path', () => ({ + getWebpackPath: jest.fn(() => 'webpack'), + normalizeWebpackPath: jest.fn((value: string) => value), +})); + +jest.mock( + '../../../../src/lib/container/runtime/FederationRuntimePlugin', + () => ({ + __esModule: true, + default: jest.fn().mockImplementation(() => ({ + apply: jest.fn(), + })), + }), +); + +jest.mock('webpack/lib/util/fs', () => ({ + join: (_fs: unknown, ...segments: string[]) => + require('path').join(...segments), + dirname: (_fs: unknown, filePath: string) => + require('path').dirname(filePath), + readJson: ( + _fs: unknown, + filePath: string, + callback: (err: any, data?: any) => void, + ) => { + const memfs = require('memfs').fs; + memfs.readFile(filePath, 'utf8', (error: any, content: string) => { + if (error) return callback(error); + try { + callback(null, JSON.parse(content)); + } catch (parseError) { + callback(parseError); + } + }); + }, +})); + +describe('ProvideSharedPlugin integration scenarios', () => { + beforeEach(() => { + vol.reset(); + jest.clearAllMocks(); + }); + + it('applies plugin and registers hooks without throwing', () => { + const plugin = new ProvideSharedPlugin({ + shareScope: 'default', + provides: { + react: '^17.0.0', + lodash: { version: '^4.17.21', singleton: true }, + }, + }); + + const compiler = createRealCompiler(); + expect(() => plugin.apply(compiler as any)).not.toThrow(); + + expect(compiler.hooks.compilation.taps.length).toBeGreaterThan(0); + expect(compiler.hooks.finishMake.taps.length).toBeGreaterThan(0); + }); + + it('executes compilation hooks without errors', async () => { + const plugin = new ProvideSharedPlugin({ + shareScope: 'default', + provides: { + react: '^17.0.0', + lodash: '^4.17.21', + }, + }); + + const compiler = createRealCompiler(); + plugin.apply(compiler as any); + + const compilation = createMemfsCompilation(compiler); + const normalModuleFactory = createNormalModuleFactory(); + + expect(() => + compiler.hooks.thisCompilation.call(compilation, { + normalModuleFactory, + }), + ).not.toThrow(); + + expect(() => + compiler.hooks.compilation.call(compilation, { + normalModuleFactory, + }), + ).not.toThrow(); + + await expect( + compiler.hooks.finishMake.promise(compilation), + ).resolves.toBeUndefined(); + }); + + it('handles real module matching scenarios with memfs', () => { + vol.fromJSON({ + '/test-project/src/components/Button.js': + 'export const Button = () => {};', + '/test-project/src/utils/helpers.js': 'export const helper = () => {};', + '/test-project/node_modules/lodash/index.js': 'module.exports = {};', + }); + + const plugin = new ProvideSharedPlugin({ + shareScope: 'default', + provides: { + './src/components/': { shareKey: 'components' }, + 'lodash/': { shareKey: 'lodash' }, + './src/utils/helpers': { shareKey: 'helpers' }, + }, + }); + + const compiler = createRealCompiler(); + plugin.apply(compiler as any); + + const compilation = createMemfsCompilation(compiler); + const normalModuleFactory = createNormalModuleFactory(); + + compiler.hooks.compilation.call(compilation, { normalModuleFactory }); + expect((normalModuleFactory.hooks.module as any).tap).toHaveBeenCalled(); + }); + + it('supports complex configuration patterns without errors', () => { + const plugin = new ProvideSharedPlugin({ + shareScope: 'default', + provides: { + react: { + version: '^17.0.0', + singleton: true, + eager: false, + shareKey: 'react', + shareScope: 'framework', + }, + lodash: '^4.17.21', + '@types/react': { version: '^17.0.0', singleton: false }, + }, + }); + + const compiler = createRealCompiler(); + expect(() => plugin.apply(compiler as any)).not.toThrow(); + }); +}); diff --git a/packages/enhanced/test/unit/sharing/ProvideSharedPlugin/ProvideSharedPlugin.module-matching.test.ts b/packages/enhanced/test/unit/sharing/ProvideSharedPlugin/ProvideSharedPlugin.module-matching.test.ts index cc44bcc2dd9..ede25f92f94 100644 --- a/packages/enhanced/test/unit/sharing/ProvideSharedPlugin/ProvideSharedPlugin.module-matching.test.ts +++ b/packages/enhanced/test/unit/sharing/ProvideSharedPlugin/ProvideSharedPlugin.module-matching.test.ts @@ -6,15 +6,29 @@ import { ProvideSharedPlugin, createMockCompilation, createMockCompiler, -} from './shared-test-utils'; +} from '../plugin-test-utils'; + +type ModuleHook = ( + module: { layer?: string | undefined }, + data: { resource?: string; resourceResolveData?: Record }, + resolveData: { request?: string; cacheable: boolean }, +) => unknown; + +type MockNormalModuleFactory = { + hooks: { + module: { + tap: jest.Mock; + }; + }; +}; describe('ProvideSharedPlugin', () => { describe('module matching and resolution stages', () => { let mockCompilation: ReturnType< typeof createMockCompilation >['mockCompilation']; - let mockNormalModuleFactory: any; - let plugin: ProvideSharedPlugin; + let mockNormalModuleFactory: MockNormalModuleFactory; + let plugin: InstanceType; beforeEach(() => { mockCompilation = createMockCompilation().mockCompilation; @@ -135,9 +149,9 @@ describe('ProvideSharedPlugin', () => { descriptionFileData: { version: '17.0.0' }, }; - let moduleHookCallback: any; + let moduleHookCallback: ModuleHook | undefined; mockNormalModuleFactory.hooks.module.tap.mockImplementation( - (name, callback) => { + (_name: string, callback: ModuleHook) => { moduleHookCallback = callback; }, ); @@ -145,11 +159,16 @@ describe('ProvideSharedPlugin', () => { plugin.apply({ hooks: { compilation: { - tap: jest.fn((name, callback) => { - callback(mockCompilation, { - normalModuleFactory: mockNormalModuleFactory, - }); - }), + tap: jest.fn( + ( + name: string, + callback: (compilation: unknown, params: unknown) => void, + ) => { + callback(mockCompilation, { + normalModuleFactory: mockNormalModuleFactory, + }); + }, + ), }, finishMake: { tapPromise: jest.fn(), @@ -158,7 +177,7 @@ describe('ProvideSharedPlugin', () => { } as any); // Simulate module matching - const result = moduleHookCallback( + const result = moduleHookCallback!( mockModule, { resource: mockResource, @@ -191,9 +210,9 @@ describe('ProvideSharedPlugin', () => { descriptionFileData: { version: '17.0.0' }, }; - let moduleHookCallback: any; + let moduleHookCallback: ModuleHook | undefined; mockNormalModuleFactory.hooks.module.tap.mockImplementation( - (name, callback) => { + (_name: string, callback: ModuleHook) => { moduleHookCallback = callback; }, ); @@ -201,11 +220,16 @@ describe('ProvideSharedPlugin', () => { plugin.apply({ hooks: { compilation: { - tap: jest.fn((name, callback) => { - callback(mockCompilation, { - normalModuleFactory: mockNormalModuleFactory, - }); - }), + tap: jest.fn( + ( + name: string, + callback: (compilation: unknown, params: unknown) => void, + ) => { + callback(mockCompilation, { + normalModuleFactory: mockNormalModuleFactory, + }); + }, + ), }, finishMake: { tapPromise: jest.fn(), @@ -214,7 +238,7 @@ describe('ProvideSharedPlugin', () => { } as any); // Simulate module matching - const result = moduleHookCallback( + const result = moduleHookCallback!( mockModule, { resource: mockResource, @@ -247,9 +271,9 @@ describe('ProvideSharedPlugin', () => { descriptionFileData: { version: '17.0.0' }, }; - let moduleHookCallback: any; + let moduleHookCallback: ModuleHook | undefined; mockNormalModuleFactory.hooks.module.tap.mockImplementation( - (name, callback) => { + (_name: string, callback: ModuleHook) => { moduleHookCallback = callback; }, ); @@ -257,11 +281,16 @@ describe('ProvideSharedPlugin', () => { plugin.apply({ hooks: { compilation: { - tap: jest.fn((name, callback) => { - callback(mockCompilation, { - normalModuleFactory: mockNormalModuleFactory, - }); - }), + tap: jest.fn( + ( + name: string, + callback: (compilation: unknown, params: unknown) => void, + ) => { + callback(mockCompilation, { + normalModuleFactory: mockNormalModuleFactory, + }); + }, + ), }, finishMake: { tapPromise: jest.fn(), @@ -270,7 +299,7 @@ describe('ProvideSharedPlugin', () => { } as any); // Simulate module matching - const result = moduleHookCallback( + const result = moduleHookCallback!( mockModule, { resource: mockResource, @@ -307,9 +336,9 @@ describe('ProvideSharedPlugin', () => { descriptionFileData: { version: '17.0.0' }, }; - let moduleHookCallback: any; + let moduleHookCallback: ModuleHook | undefined; mockNormalModuleFactory.hooks.module.tap.mockImplementation( - (name, callback) => { + (_name: string, callback: ModuleHook) => { moduleHookCallback = callback; }, ); @@ -317,11 +346,16 @@ describe('ProvideSharedPlugin', () => { plugin.apply({ hooks: { compilation: { - tap: jest.fn((name, callback) => { - callback(mockCompilation, { - normalModuleFactory: mockNormalModuleFactory, - }); - }), + tap: jest.fn( + ( + name: string, + callback: (compilation: unknown, params: unknown) => void, + ) => { + callback(mockCompilation, { + normalModuleFactory: mockNormalModuleFactory, + }); + }, + ), }, finishMake: { tapPromise: jest.fn(), @@ -330,7 +364,7 @@ describe('ProvideSharedPlugin', () => { } as any); // Simulate module matching - const result = moduleHookCallback( + const result = moduleHookCallback!( mockModule, { resource: mockResource, @@ -367,9 +401,9 @@ describe('ProvideSharedPlugin', () => { descriptionFileData: { version: '17.0.0' }, }; - let moduleHookCallback: any; + let moduleHookCallback: ModuleHook | undefined; mockNormalModuleFactory.hooks.module.tap.mockImplementation( - (name, callback) => { + (_name: string, callback: ModuleHook) => { moduleHookCallback = callback; }, ); @@ -377,11 +411,16 @@ describe('ProvideSharedPlugin', () => { plugin.apply({ hooks: { compilation: { - tap: jest.fn((name, callback) => { - callback(mockCompilation, { - normalModuleFactory: mockNormalModuleFactory, - }); - }), + tap: jest.fn( + ( + name: string, + callback: (compilation: unknown, params: unknown) => void, + ) => { + callback(mockCompilation, { + normalModuleFactory: mockNormalModuleFactory, + }); + }, + ), }, finishMake: { tapPromise: jest.fn(), @@ -390,7 +429,7 @@ describe('ProvideSharedPlugin', () => { } as any); // Simulate module matching - const result = moduleHookCallback( + const result = moduleHookCallback!( mockModule, { resource: mockResource, @@ -427,9 +466,9 @@ describe('ProvideSharedPlugin', () => { descriptionFileData: { version: '17.0.0' }, }; - let moduleHookCallback: any; + let moduleHookCallback: ModuleHook | undefined; mockNormalModuleFactory.hooks.module.tap.mockImplementation( - (name, callback) => { + (_name: string, callback: ModuleHook) => { moduleHookCallback = callback; }, ); @@ -437,11 +476,16 @@ describe('ProvideSharedPlugin', () => { plugin.apply({ hooks: { compilation: { - tap: jest.fn((name, callback) => { - callback(mockCompilation, { - normalModuleFactory: mockNormalModuleFactory, - }); - }), + tap: jest.fn( + ( + name: string, + callback: (compilation: unknown, params: unknown) => void, + ) => { + callback(mockCompilation, { + normalModuleFactory: mockNormalModuleFactory, + }); + }, + ), }, finishMake: { tapPromise: jest.fn(), @@ -450,7 +494,7 @@ describe('ProvideSharedPlugin', () => { } as any); // Simulate module matching - const result = moduleHookCallback( + const result = moduleHookCallback!( mockModule, { resource: mockResource, @@ -484,9 +528,9 @@ describe('ProvideSharedPlugin', () => { descriptionFileData: { version: '17.0.0' }, }; - let moduleHookCallback: any; + let moduleHookCallback: ModuleHook | undefined; mockNormalModuleFactory.hooks.module.tap.mockImplementation( - (name, callback) => { + (_name: string, callback: ModuleHook) => { moduleHookCallback = callback; }, ); @@ -494,11 +538,16 @@ describe('ProvideSharedPlugin', () => { plugin.apply({ hooks: { compilation: { - tap: jest.fn((name, callback) => { - callback(mockCompilation, { - normalModuleFactory: mockNormalModuleFactory, - }); - }), + tap: jest.fn( + ( + name: string, + callback: (compilation: unknown, params: unknown) => void, + ) => { + callback(mockCompilation, { + normalModuleFactory: mockNormalModuleFactory, + }); + }, + ), }, finishMake: { tapPromise: jest.fn(), @@ -507,7 +556,7 @@ describe('ProvideSharedPlugin', () => { } as any); // Simulate module matching - const result = moduleHookCallback( + const result = moduleHookCallback!( mockModule, { resource: mockResource, @@ -538,9 +587,9 @@ describe('ProvideSharedPlugin', () => { descriptionFileData: { version: '17.0.0' }, }; - let moduleHookCallback: any; + let moduleHookCallback: ModuleHook | undefined; mockNormalModuleFactory.hooks.module.tap.mockImplementation( - (name, callback) => { + (_name: string, callback: ModuleHook) => { moduleHookCallback = callback; }, ); @@ -548,11 +597,16 @@ describe('ProvideSharedPlugin', () => { plugin.apply({ hooks: { compilation: { - tap: jest.fn((name, callback) => { - callback(mockCompilation, { - normalModuleFactory: mockNormalModuleFactory, - }); - }), + tap: jest.fn( + ( + name: string, + callback: (compilation: unknown, params: unknown) => void, + ) => { + callback(mockCompilation, { + normalModuleFactory: mockNormalModuleFactory, + }); + }, + ), }, finishMake: { tapPromise: jest.fn(), @@ -561,7 +615,7 @@ describe('ProvideSharedPlugin', () => { } as any); // Simulate module matching - const result = moduleHookCallback( + const result = moduleHookCallback!( mockModule, { resource: mockResource, @@ -598,9 +652,9 @@ describe('ProvideSharedPlugin', () => { descriptionFileData: { version: '4.17.0' }, }; - let moduleHookCallback: any; + let moduleHookCallback: ModuleHook | undefined; mockNormalModuleFactory.hooks.module.tap.mockImplementation( - (name, callback) => { + (_name: string, callback: ModuleHook) => { moduleHookCallback = callback; }, ); @@ -608,11 +662,16 @@ describe('ProvideSharedPlugin', () => { plugin.apply({ hooks: { compilation: { - tap: jest.fn((name, callback) => { - callback(mockCompilation, { - normalModuleFactory: mockNormalModuleFactory, - }); - }), + tap: jest.fn( + ( + name: string, + callback: (compilation: unknown, params: unknown) => void, + ) => { + callback(mockCompilation, { + normalModuleFactory: mockNormalModuleFactory, + }); + }, + ), }, finishMake: { tapPromise: jest.fn(), @@ -621,7 +680,7 @@ describe('ProvideSharedPlugin', () => { } as any); // Simulate module matching - const result = moduleHookCallback( + const result = moduleHookCallback!( mockModule, { resource: mockResource, @@ -658,9 +717,9 @@ describe('ProvideSharedPlugin', () => { descriptionFileData: { version: '4.17.0' }, }; - let moduleHookCallback: any; + let moduleHookCallback: ModuleHook | undefined; mockNormalModuleFactory.hooks.module.tap.mockImplementation( - (name, callback) => { + (_name: string, callback: ModuleHook) => { moduleHookCallback = callback; }, ); @@ -668,11 +727,16 @@ describe('ProvideSharedPlugin', () => { plugin.apply({ hooks: { compilation: { - tap: jest.fn((name, callback) => { - callback(mockCompilation, { - normalModuleFactory: mockNormalModuleFactory, - }); - }), + tap: jest.fn( + ( + name: string, + callback: (compilation: unknown, params: unknown) => void, + ) => { + callback(mockCompilation, { + normalModuleFactory: mockNormalModuleFactory, + }); + }, + ), }, finishMake: { tapPromise: jest.fn(), @@ -681,7 +745,7 @@ describe('ProvideSharedPlugin', () => { } as any); // Simulate module matching - const result = moduleHookCallback( + const result = moduleHookCallback!( mockModule, { resource: mockResource, @@ -716,9 +780,9 @@ describe('ProvideSharedPlugin', () => { descriptionFileData: { version: '1.0.0' }, }; - let moduleHookCallback: any; + let moduleHookCallback: ModuleHook | undefined; mockNormalModuleFactory.hooks.module.tap.mockImplementation( - (name, callback) => { + (_name: string, callback: ModuleHook) => { moduleHookCallback = callback; }, ); @@ -726,11 +790,16 @@ describe('ProvideSharedPlugin', () => { plugin.apply({ hooks: { compilation: { - tap: jest.fn((name, callback) => { - callback(mockCompilation, { - normalModuleFactory: mockNormalModuleFactory, - }); - }), + tap: jest.fn( + ( + name: string, + callback: (compilation: unknown, params: unknown) => void, + ) => { + callback(mockCompilation, { + normalModuleFactory: mockNormalModuleFactory, + }); + }, + ), }, finishMake: { tapPromise: jest.fn(), @@ -739,7 +808,7 @@ describe('ProvideSharedPlugin', () => { } as any); // Simulate module matching - const result = moduleHookCallback( + const result = moduleHookCallback!( mockModule, { resource: mockResource, @@ -769,9 +838,9 @@ describe('ProvideSharedPlugin', () => { cacheable: true, }; - let moduleHookCallback: any; + let moduleHookCallback: ModuleHook | undefined; mockNormalModuleFactory.hooks.module.tap.mockImplementation( - (name, callback) => { + (_name: string, callback: ModuleHook) => { moduleHookCallback = callback; }, ); @@ -779,11 +848,16 @@ describe('ProvideSharedPlugin', () => { plugin.apply({ hooks: { compilation: { - tap: jest.fn((name, callback) => { - callback(mockCompilation, { - normalModuleFactory: mockNormalModuleFactory, - }); - }), + tap: jest.fn( + ( + name: string, + callback: (compilation: unknown, params: unknown) => void, + ) => { + callback(mockCompilation, { + normalModuleFactory: mockNormalModuleFactory, + }); + }, + ), }, finishMake: { tapPromise: jest.fn(), @@ -792,7 +866,7 @@ describe('ProvideSharedPlugin', () => { } as any); // Simulate module matching with no resource - const result = moduleHookCallback( + const result = moduleHookCallback!( mockModule, { resource: undefined, diff --git a/packages/enhanced/test/unit/sharing/ProvideSharedPlugin/ProvideSharedPlugin.provideSharedModule.test.ts b/packages/enhanced/test/unit/sharing/ProvideSharedPlugin/ProvideSharedPlugin.provideSharedModule.test.ts index 1ec275f0353..37286312943 100644 --- a/packages/enhanced/test/unit/sharing/ProvideSharedPlugin/ProvideSharedPlugin.provideSharedModule.test.ts +++ b/packages/enhanced/test/unit/sharing/ProvideSharedPlugin/ProvideSharedPlugin.provideSharedModule.test.ts @@ -2,12 +2,18 @@ * @jest-environment node */ -import { ProvideSharedPlugin } from './shared-test-utils'; +import { ProvideSharedPlugin } from '../plugin-test-utils'; + +type CompilationWarning = { message: string; file?: string }; +type CompilationErrorRecord = { message: string; file?: string }; describe('ProvideSharedPlugin', () => { describe('provideSharedModule method', () => { - let plugin; - let mockCompilation; + let plugin: InstanceType; + let mockCompilation: { + warnings: CompilationWarning[]; + errors: CompilationErrorRecord[]; + }; beforeEach(() => { plugin = new ProvideSharedPlugin({ diff --git a/packages/enhanced/test/unit/sharing/ProvideSharedPlugin/ProvideSharedPlugin.shouldProvideSharedModule.test.ts b/packages/enhanced/test/unit/sharing/ProvideSharedPlugin/ProvideSharedPlugin.shouldProvideSharedModule.test.ts index c37b9667d0c..e8ba8200f0b 100644 --- a/packages/enhanced/test/unit/sharing/ProvideSharedPlugin/ProvideSharedPlugin.shouldProvideSharedModule.test.ts +++ b/packages/enhanced/test/unit/sharing/ProvideSharedPlugin/ProvideSharedPlugin.shouldProvideSharedModule.test.ts @@ -2,11 +2,11 @@ * @jest-environment node */ -import { ProvideSharedPlugin } from './shared-test-utils'; +import { ProvideSharedPlugin } from '../plugin-test-utils'; describe('ProvideSharedPlugin', () => { describe('shouldProvideSharedModule method', () => { - let plugin; + let plugin: InstanceType; beforeEach(() => { plugin = new ProvideSharedPlugin({ diff --git a/packages/enhanced/test/unit/sharing/ProvideSharedPlugin/shared-test-utils.ts b/packages/enhanced/test/unit/sharing/ProvideSharedPlugin/shared-test-utils.ts deleted file mode 100644 index 4c216cfe0be..00000000000 --- a/packages/enhanced/test/unit/sharing/ProvideSharedPlugin/shared-test-utils.ts +++ /dev/null @@ -1,122 +0,0 @@ -/* - * Shared test utilities and mocks for ProvideSharedPlugin tests - */ - -import { - shareScopes, - createMockCompiler, - createMockCompilation, - testModuleOptions, - createWebpackMock, - createModuleMock, -} from '../utils'; - -// Create webpack mock -export const webpack = createWebpackMock(); -// Create Module mock -export const Module = createModuleMock(webpack); - -// Mock dependencies -jest.mock('@module-federation/sdk/normalize-webpack-path', () => ({ - normalizeWebpackPath: jest.fn((path) => path), - getWebpackPath: jest.fn(() => 'mocked-webpack-path'), -})); - -jest.mock( - '../../../../src/lib/container/runtime/FederationRuntimePlugin', - () => { - return jest.fn().mockImplementation(() => ({ - apply: jest.fn(), - })); - }, -); - -// Mock ProvideSharedDependency -export class MockProvideSharedDependency { - constructor( - public request: string, - public shareScope: string | string[], - public version: string, - ) { - this._shareScope = shareScope; - this._version = version; - this._shareKey = request; - } - - // Add required properties that are accessed during tests - _shareScope: string | string[]; - _version: string; - _shareKey: string; -} - -jest.mock('../../../../src/lib/sharing/ProvideSharedDependency', () => { - return MockProvideSharedDependency; -}); - -jest.mock('../../../../src/lib/sharing/ProvideSharedModuleFactory', () => { - return jest.fn().mockImplementation(() => ({ - create: jest.fn(), - })); -}); - -// Mock ProvideSharedModule -jest.mock('../../../../src/lib/sharing/ProvideSharedModule', () => { - return jest.fn().mockImplementation((options) => ({ - _shareScope: options.shareScope, - _shareKey: options.shareKey || options.request, // Add fallback to request for shareKey - _version: options.version, - _eager: options.eager || false, - options, - })); -}); - -// Import after mocks are set up -export const ProvideSharedPlugin = - require('../../../../src/lib/sharing/ProvideSharedPlugin').default; - -// Re-export utilities from parent utils -export { - shareScopes, - createMockCompiler, - createMockCompilation, - testModuleOptions, -}; - -// Common test data -export const testProvides = { - react: { - shareKey: 'react', - shareScope: shareScopes.string, - version: '17.0.2', - eager: false, - }, - lodash: { - version: '4.17.21', - singleton: true, - }, - vue: { - shareKey: 'vue', - shareScope: shareScopes.array, - version: '3.2.37', - eager: true, - }, -}; - -// Helper function to create test module with common properties -export function createTestModule(overrides = {}) { - return { - ...testModuleOptions, - ...overrides, - }; -} - -// Helper function to create test configuration -export function createTestConfig( - provides = testProvides, - shareScope = shareScopes.string, -) { - return { - shareScope, - provides, - }; -} diff --git a/packages/enhanced/test/unit/sharing/SharePlugin.improved.test.ts b/packages/enhanced/test/unit/sharing/SharePlugin.improved.test.ts deleted file mode 100644 index a073fa5226c..00000000000 --- a/packages/enhanced/test/unit/sharing/SharePlugin.improved.test.ts +++ /dev/null @@ -1,417 +0,0 @@ -/* - * @jest-environment node - */ - -import SharePlugin from '../../../src/lib/sharing/SharePlugin'; - -// Mock FederationRuntimePlugin to avoid complex dependencies -jest.mock('../../../src/lib/container/runtime/FederationRuntimePlugin', () => { - return jest.fn().mockImplementation(() => ({ - apply: jest.fn(), - })); -}); - -// Create a simple webpack compiler mock for testing real behavior -const createRealWebpackCompiler = () => { - const { SyncHook, AsyncSeriesHook } = require('tapable'); - - return { - hooks: { - thisCompilation: new SyncHook(['compilation', 'params']), - compilation: new SyncHook(['compilation', 'params']), - finishMake: new AsyncSeriesHook(['compilation']), - make: new AsyncSeriesHook(['compilation']), - environment: new SyncHook([]), - afterEnvironment: new SyncHook([]), - afterPlugins: new SyncHook(['compiler']), - afterResolvers: new SyncHook(['compiler']), - }, - context: '/test-project', - options: { - context: '/test-project', - output: { - path: '/test-project/dist', - uniqueName: 'test-app', - }, - plugins: [], - resolve: { - alias: {}, - }, - }, - webpack: { - javascript: { - JavascriptModulesPlugin: { - getCompilationHooks: jest.fn(() => ({ - renderChunk: new SyncHook(['source', 'renderContext']), - render: new SyncHook(['source', 'renderContext']), - chunkHash: new SyncHook(['chunk', 'hash', 'context']), - renderStartup: new SyncHook(['source', 'module', 'renderContext']), - })), - }, - }, - }, - }; -}; - -const createMockCompilation = () => { - const { SyncHook, HookMap } = require('tapable'); - const runtimeRequirementInTreeHookMap = new HookMap( - () => new SyncHook(['chunk', 'set', 'context']), - ); - - return { - context: '/test-project', - compiler: { - context: '/test-project', - }, - dependencyFactories: new Map(), - hooks: { - additionalTreeRuntimeRequirements: { tap: jest.fn() }, - runtimeRequirementInTree: runtimeRequirementInTreeHookMap, - finishModules: { tap: jest.fn(), tapAsync: jest.fn() }, - seal: { tap: jest.fn() }, - }, - addRuntimeModule: jest.fn(), - contextDependencies: { addAll: jest.fn() }, - fileDependencies: { addAll: jest.fn() }, - missingDependencies: { addAll: jest.fn() }, - warnings: [], - errors: [], - addInclude: jest.fn(), - resolverFactory: { - get: jest.fn(() => ({ - resolve: jest.fn((context, path, request, resolveContext, callback) => { - callback(null, path); - }), - })), - }, - }; -}; - -const createMockNormalModuleFactory = () => ({ - hooks: { - module: { tap: jest.fn() }, - factorize: { tapPromise: jest.fn() }, - createModule: { tapPromise: jest.fn() }, - }, -}); - -describe('SharePlugin Real Behavior', () => { - describe('plugin integration', () => { - it('should integrate with webpack compiler for shared modules', () => { - const plugin = new SharePlugin({ - shareScope: 'default', - shared: { - react: '^17.0.0', - lodash: { - requiredVersion: '^4.17.21', - singleton: true, - eager: false, - }, - }, - }); - - const compiler = createRealWebpackCompiler(); - expect(() => plugin.apply(compiler)).not.toThrow(); - - // Verify hooks are registered for both consuming and providing - expect(compiler.hooks.thisCompilation.taps.length).toBeGreaterThan(0); - expect(compiler.hooks.compilation.taps.length).toBeGreaterThan(0); - expect(compiler.hooks.finishMake.taps.length).toBeGreaterThan(0); - }); - - it('should handle array shareScope configuration', () => { - const plugin = new SharePlugin({ - shareScope: ['default', 'custom'], - shared: { - react: '^17.0.0', - }, - }); - - const compiler = createRealWebpackCompiler(); - expect(() => plugin.apply(compiler)).not.toThrow(); - - // Should register hooks successfully - expect(compiler.hooks.thisCompilation.taps.length).toBeGreaterThan(0); - }); - - it('should handle separate consumes and provides configurations', () => { - const plugin = new SharePlugin({ - shareScope: 'default', - shared: { - react: '^17.0.0', - 'external-lib': { - requiredVersion: '^1.0.0', - singleton: true, - }, - 'my-utils': { - version: '1.0.0', - shareKey: 'utils', - import: 'my-utils', - }, - 'my-components': { - version: '2.1.0', - import: 'my-components', - }, - }, - }); - - const compiler = createRealWebpackCompiler(); - expect(() => plugin.apply(compiler)).not.toThrow(); - - // Should register hooks for both consuming and providing - expect(compiler.hooks.thisCompilation.taps.length).toBeGreaterThan(0); - expect(compiler.hooks.compilation.taps.length).toBeGreaterThan(0); - }); - }); - - describe('webpack compilation integration', () => { - it('should execute compilation hooks without errors', () => { - const plugin = new SharePlugin({ - shareScope: 'default', - shared: { - react: '^17.0.0', - lodash: '^4.17.21', - }, - }); - - const compiler = createRealWebpackCompiler(); - plugin.apply(compiler); - - const compilation = createMockCompilation(); - const normalModuleFactory = createMockNormalModuleFactory(); - - // Test thisCompilation hook execution - compiler.hooks.thisCompilation.taps.forEach((tap) => { - expect(() => { - tap.fn(compilation, { normalModuleFactory }); - }).not.toThrow(); - }); - - // Test compilation hook execution - compiler.hooks.compilation.taps.forEach((tap) => { - expect(() => { - tap.fn(compilation, { normalModuleFactory }); - }).not.toThrow(); - }); - }); - - it('should handle finishMake hook execution', async () => { - const plugin = new SharePlugin({ - shareScope: 'default', - shared: { - react: '^17.0.0', - }, - }); - - const compiler = createRealWebpackCompiler(); - plugin.apply(compiler); - - const compilation = createMockCompilation(); - - // Test finishMake hook execution - for (const tap of compiler.hooks.finishMake.taps) { - await expect(tap.fn(compilation)).resolves.not.toThrow(); - } - }); - }); - - describe('configuration handling', () => { - it('should handle consumes-only configuration', () => { - const plugin = new SharePlugin({ - shareScope: 'default', - shared: { - react: '^17.0.0', - lodash: { - requiredVersion: '^4.17.21', - singleton: true, - }, - }, - }); - - const compiler = createRealWebpackCompiler(); - expect(() => plugin.apply(compiler)).not.toThrow(); - - // Should register consume-related hooks - expect(compiler.hooks.thisCompilation.taps.length).toBeGreaterThan(0); - }); - - it('should handle provides-only configuration', () => { - const plugin = new SharePlugin({ - shareScope: 'default', - shared: { - 'my-utils': { - version: '1.0.0', - import: 'my-utils', - }, - 'my-components': { - version: '2.0.0', - shareKey: 'components', - import: 'my-components', - }, - }, - }); - - const compiler = createRealWebpackCompiler(); - expect(() => plugin.apply(compiler)).not.toThrow(); - - // Should register provide-related hooks - expect(compiler.hooks.compilation.taps.length).toBeGreaterThan(0); - }); - - it('should handle complex shared module configurations', () => { - const plugin = new SharePlugin({ - shareScope: 'default', - shared: { - react: { - requiredVersion: '^17.0.0', - version: '17.0.2', - singleton: true, - eager: false, - shareKey: 'react', - shareScope: 'framework', - }, - lodash: '^4.17.21', - '@types/react': { - version: '^17.0.0', - singleton: false, - }, - }, - }); - - const compiler = createRealWebpackCompiler(); - expect(() => plugin.apply(compiler)).not.toThrow(); - - // Should handle complex configurations without errors - expect(compiler.hooks.thisCompilation.taps.length).toBeGreaterThan(0); - expect(compiler.hooks.compilation.taps.length).toBeGreaterThan(0); - }); - }); - - describe('edge cases and error handling', () => { - it('should handle empty shared configuration', () => { - expect(() => { - new SharePlugin({ - shareScope: 'default', - shared: {}, - }); - }).not.toThrow(); - }); - - it('should handle missing shareScope with default fallback', () => { - const plugin = new SharePlugin({ - shared: { - react: '^17.0.0', - }, - }); - - const compiler = createRealWebpackCompiler(); - expect(() => plugin.apply(compiler)).not.toThrow(); - }); - - it('should validate and apply comprehensive configuration', () => { - // Test a comprehensive configuration that would be used in a real project - const plugin = new SharePlugin({ - shareScope: 'default', - shared: { - react: { - singleton: true, - requiredVersion: '^17.0.0', - }, - 'react-dom': { - singleton: true, - requiredVersion: '^17.0.0', - }, - lodash: '^4.17.21', - 'external-utils': { - shareScope: 'utils', - requiredVersion: '^1.0.0', - }, - 'internal-components': { - version: '2.0.0', - shareKey: 'components', - import: 'internal-components', - }, - }, - }); - - const compiler = createRealWebpackCompiler(); - expect(() => plugin.apply(compiler)).not.toThrow(); - - // Verify comprehensive configuration is handled properly - expect(compiler.hooks.thisCompilation.taps.length).toBeGreaterThan(0); - expect(compiler.hooks.compilation.taps.length).toBeGreaterThan(0); - expect(compiler.hooks.finishMake.taps.length).toBeGreaterThan(0); - }); - }); - - describe('real-world usage scenarios', () => { - it('should support micro-frontend sharing patterns', () => { - const plugin = new SharePlugin({ - shareScope: 'mf', - shared: { - // Core libraries - singleton to avoid conflicts - react: { - singleton: true, - requiredVersion: '^17.0.0', - }, - 'react-dom': { - singleton: true, - requiredVersion: '^17.0.0', - }, - // Utilities - allow multiple versions - lodash: { - singleton: false, - requiredVersion: '^4.17.0', - }, - // Provide internal components to other micro-frontends - 'design-system': { - version: '1.5.0', - shareKey: 'ds', - import: 'design-system', - }, - }, - }); - - const compiler = createRealWebpackCompiler(); - expect(() => plugin.apply(compiler)).not.toThrow(); - - const compilation = createMockCompilation(); - const normalModuleFactory = createMockNormalModuleFactory(); - - // Test that the micro-frontend pattern works without errors - compiler.hooks.thisCompilation.taps.forEach((tap) => { - expect(() => { - tap.fn(compilation, { normalModuleFactory }); - }).not.toThrow(); - }); - }); - - it('should support development vs production sharing strategies', () => { - const isProduction = false; // Simulate development mode - - const plugin = new SharePlugin({ - shareScope: 'default', - shared: { - react: { - singleton: true, - requiredVersion: '^17.0.0', - // In development, be more lenient with versions - strictVersion: !isProduction, - }, - 'dev-tools': { - // Only share dev tools in development - ...(isProduction - ? {} - : { - version: '1.0.0', - }), - }, - }, - }); - - const compiler = createRealWebpackCompiler(); - expect(() => plugin.apply(compiler)).not.toThrow(); - }); - }); -}); diff --git a/packages/enhanced/test/unit/sharing/SharePlugin.test.ts b/packages/enhanced/test/unit/sharing/SharePlugin.test.ts index 09de0495870..40c37a69b51 100644 --- a/packages/enhanced/test/unit/sharing/SharePlugin.test.ts +++ b/packages/enhanced/test/unit/sharing/SharePlugin.test.ts @@ -2,47 +2,130 @@ * @jest-environment node */ -import { - normalizeWebpackPath, - getWebpackPath, -} from '@module-federation/sdk/normalize-webpack-path'; -import { shareScopes, createMockCompiler } from './utils'; - -// Mock dependencies -jest.mock('@module-federation/sdk/normalize-webpack-path', () => ({ - normalizeWebpackPath: jest.fn((path) => path), - getWebpackPath: jest.fn(() => 'mocked-webpack-path'), -})); - -jest.mock('@module-federation/sdk', () => ({ - isRequiredVersion: jest.fn( - (version) => typeof version === 'string' && version.startsWith('^'), - ), -})); - -// Mock plugin implementations first -const ConsumeSharedPluginMock = jest.fn().mockImplementation((options) => ({ - options, - apply: jest.fn(), -})); - -const ProvideSharedPluginMock = jest.fn().mockImplementation((options) => ({ - options, - apply: jest.fn(), -})); - -jest.mock('../../../src/lib/sharing/ConsumeSharedPlugin', () => { - return ConsumeSharedPluginMock; -}); +import type { Compiler, Compilation } from 'webpack'; +import { SyncHook, AsyncSeriesHook, HookMap } from 'tapable'; + +type ShareEntryConfig = { + shareScope?: string | string[]; + requiredVersion?: string; + singleton?: boolean; + eager?: boolean; + import?: boolean | string; + version?: string; + include?: Record; + exclude?: Record; +}; + +type ShareConfigRecord = Record; + +const findShareConfig = ( + records: ShareConfigRecord[], + key: string, +): ShareEntryConfig | undefined => { + const record = records.find((entry) => + Object.prototype.hasOwnProperty.call(entry, key), + ); + return record ? record[key] : undefined; +}; + +const loadMockedSharePlugin = () => { + jest.doMock('@module-federation/sdk/normalize-webpack-path', () => ({ + normalizeWebpackPath: jest.fn((path: string) => path), + getWebpackPath: jest.fn(() => 'mocked-webpack-path'), + })); + + jest.doMock('@module-federation/sdk', () => ({ + isRequiredVersion: jest.fn( + (version: unknown) => + typeof version === 'string' && version.startsWith('^'), + ), + })); + + const ConsumeSharedPluginMock = jest + .fn() + .mockImplementation((options) => ({ options, apply: jest.fn() })); + jest.doMock('../../../src/lib/sharing/ConsumeSharedPlugin', () => ({ + __esModule: true, + default: ConsumeSharedPluginMock, + })); + + const ProvideSharedPluginMock = jest + .fn() + .mockImplementation((options) => ({ options, apply: jest.fn() })); + jest.doMock('../../../src/lib/sharing/ProvideSharedPlugin', () => ({ + __esModule: true, + default: ProvideSharedPluginMock, + })); + + let SharePlugin: any; + let shareUtils: any; + + jest.isolateModules(() => { + SharePlugin = require('../../../src/lib/sharing/SharePlugin').default; + shareUtils = require('./utils'); + }); -jest.mock('../../../src/lib/sharing/ProvideSharedPlugin', () => { - return ProvideSharedPluginMock; -}); + const { + getWebpackPath, + } = require('@module-federation/sdk/normalize-webpack-path'); + + return { + SharePlugin, + shareScopes: shareUtils.shareScopes, + createMockCompiler: shareUtils.createMockCompiler, + ConsumeSharedPluginMock, + ProvideSharedPluginMock, + getWebpackPath, + }; +}; + +const loadRealSharePlugin = () => { + jest.dontMock('../../../src/lib/sharing/ConsumeSharedPlugin'); + jest.dontMock('../../../src/lib/sharing/ProvideSharedPlugin'); + jest.dontMock('../../../src/lib/sharing/ConsumeSharedPlugin.ts'); + jest.dontMock('../../../src/lib/sharing/ProvideSharedPlugin.ts'); + jest.doMock( + '../../../src/lib/container/runtime/FederationRuntimePlugin', + () => ({ + __esModule: true, + default: jest.fn().mockImplementation(() => ({ apply: jest.fn() })), + }), + ); + + let SharePlugin: any; + jest.isolateModules(() => { + SharePlugin = require('../../../src/lib/sharing/SharePlugin').default; + }); -// Import after mocks are set up -const SharePlugin = require('../../../src/lib/sharing/SharePlugin').default; + return { SharePlugin }; +}; + +describe('SharePlugin (mocked dependencies)', () => { + let SharePlugin: any; + let shareScopesLocal: any; + let createMockCompiler: () => any; + let ConsumeSharedPluginMock: jest.Mock; + let ProvideSharedPluginMock: jest.Mock; + let getWebpackPath: jest.Mock; + + beforeEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + delete process.env['FEDERATION_WEBPACK_PATH']; + ({ + SharePlugin, + shareScopes: shareScopesLocal, + createMockCompiler, + ConsumeSharedPluginMock, + ProvideSharedPluginMock, + getWebpackPath, + } = loadMockedSharePlugin()); + }); + + afterEach(() => { + jest.resetModules(); + }); -describe('SharePlugin', () => { describe('constructor', () => { it('should handle empty shared configuration', () => { expect(() => { @@ -67,7 +150,7 @@ describe('SharePlugin', () => { it('should initialize with string shareScope', () => { const plugin = new SharePlugin({ - shareScope: shareScopes.string, + shareScope: shareScopesLocal.string, shared: { react: '^17.0.0', lodash: { @@ -78,141 +161,83 @@ describe('SharePlugin', () => { }, }); - // @ts-ignore accessing private properties for testing - expect(plugin._shareScope).toBe(shareScopes.string); + expect(plugin._shareScope).toBe(shareScopesLocal.string); - // @ts-ignore - const consumes = plugin._consumes; + const consumes = plugin._consumes as ShareConfigRecord[]; expect(consumes.length).toBe(2); - // First consume (shorthand) - const reactConsume = consumes.find((consume) => 'react' in consume); - expect(reactConsume).toBeDefined(); - expect(reactConsume.react.requiredVersion).toBe('^17.0.0'); + const reactConsume = findShareConfig(consumes, 'react'); + expect(reactConsume?.requiredVersion).toBe('^17.0.0'); - // Second consume (longhand) - const lodashConsume = consumes.find((consume) => 'lodash' in consume); - expect(lodashConsume).toBeDefined(); - expect(lodashConsume.lodash.singleton).toBe(true); + const lodashConsume = findShareConfig(consumes, 'lodash'); + expect(lodashConsume?.singleton).toBe(true); - // @ts-ignore - const provides = plugin._provides; + const provides = plugin._provides as ShareConfigRecord[]; expect(provides.length).toBe(2); - - // Should create provides for both entries - const reactProvide = provides.find((provide) => 'react' in provide); - expect(reactProvide).toBeDefined(); - - const lodashProvide = provides.find((provide) => 'lodash' in provide); - expect(lodashProvide).toBeDefined(); - expect(lodashProvide.lodash.singleton).toBe(true); + expect(findShareConfig(provides, 'react')).toBeDefined(); + expect(findShareConfig(provides, 'lodash')?.singleton).toBe(true); }); it('should initialize with array shareScope', () => { const plugin = new SharePlugin({ - shareScope: shareScopes.array, + shareScope: shareScopesLocal.array, shared: { react: '^17.0.0', }, }); - // @ts-ignore accessing private properties for testing - expect(plugin._shareScope).toEqual(shareScopes.array); - - // @ts-ignore check consumes and provides - const consumes = plugin._consumes; - const provides = plugin._provides; - - // Check consume - const reactConsume = consumes.find((consume) => 'react' in consume); - expect(reactConsume).toBeDefined(); - - // Check provide - const reactProvide = provides.find((provide) => 'react' in provide); - expect(reactProvide).toBeDefined(); + expect(plugin._shareScope).toEqual(shareScopesLocal.array); + expect(findShareConfig(plugin._consumes, 'react')).toBeDefined(); + expect(findShareConfig(plugin._provides, 'react')).toBeDefined(); }); it('should handle mix of shareScope overrides', () => { const plugin = new SharePlugin({ - shareScope: shareScopes.string, + shareScope: shareScopesLocal.string, shared: { - // Uses default scope react: '^17.0.0', - // Override with string scope lodash: { shareScope: 'custom', requiredVersion: '^4.17.0', }, - // Override with array scope moment: { - shareScope: shareScopes.array, + shareScope: shareScopesLocal.array, requiredVersion: '^2.29.0', }, }, }); - // @ts-ignore accessing private properties for testing - expect(plugin._shareScope).toBe(shareScopes.string); - - // @ts-ignore check consumes - const consumes = plugin._consumes; - - // Default scope comes from plugin level, not set on item - const reactConsume = consumes.find((consume) => 'react' in consume); - expect(reactConsume).toBeDefined(); + expect(plugin._shareScope).toBe(shareScopesLocal.string); - // Custom string scope should be set on item - const lodashConsume = consumes.find((consume) => 'lodash' in consume); - expect(lodashConsume).toBeDefined(); - expect(lodashConsume.lodash.shareScope).toBe('custom'); - - // Array scope should be set on item - const momentConsume = consumes.find((consume) => 'moment' in consume); - expect(momentConsume).toBeDefined(); - expect(momentConsume.moment.shareScope).toEqual(shareScopes.array); - - // @ts-ignore check provides - const provides = plugin._provides; - - // Default scope comes from plugin level, not set on item - const reactProvide = provides.find((provide) => 'react' in provide); - expect(reactProvide).toBeDefined(); - - // Custom string scope should be set on item - const lodashProvide = provides.find((provide) => 'lodash' in provide); - expect(lodashProvide).toBeDefined(); - expect(lodashProvide.lodash.shareScope).toBe('custom'); + expect(findShareConfig(plugin._consumes, 'react')).toBeDefined(); + expect(findShareConfig(plugin._consumes, 'lodash')?.shareScope).toBe( + 'custom', + ); + expect(findShareConfig(plugin._consumes, 'moment')?.shareScope).toEqual( + shareScopesLocal.array, + ); - // Array scope should be set on item - const momentProvide = provides.find((provide) => 'moment' in provide); - expect(momentProvide).toBeDefined(); - expect(momentProvide.moment.shareScope).toEqual(shareScopes.array); + expect(findShareConfig(plugin._provides, 'lodash')?.shareScope).toBe( + 'custom', + ); + expect(findShareConfig(plugin._provides, 'moment')?.shareScope).toEqual( + shareScopesLocal.array, + ); }); it('should handle import false correctly', () => { const plugin = new SharePlugin({ - shareScope: shareScopes.string, + shareScope: shareScopesLocal.string, shared: { react: { - import: false, // No fallback + import: false, requiredVersion: '^17.0.0', }, }, }); - // @ts-ignore check provides - const provides = plugin._provides; - - // Should not create provides for import: false - expect(provides.length).toBe(0); - - // @ts-ignore check consumes - const consumes = plugin._consumes; - - // Should still create consume - const reactConsume = consumes.find((consume) => 'react' in consume); - expect(reactConsume).toBeDefined(); - expect(reactConsume.react.import).toBe(false); + expect(plugin._provides).toHaveLength(0); + expect(findShareConfig(plugin._consumes, 'react')?.import).toBe(false); }); }); @@ -246,24 +271,23 @@ describe('SharePlugin', () => { it('should store provides configurations', () => { expect(plugin._provides).toBeInstanceOf(Array); - expect(plugin._provides.length).toBe(2); // lodash excluded due to import: false + expect(plugin._provides.length).toBe(2); }); }); describe('apply', () => { - let mockCompiler; + let mockCompiler: any; beforeEach(() => { mockCompiler = createMockCompiler(); - - // Reset mocks before each test ConsumeSharedPluginMock.mockClear(); ProvideSharedPluginMock.mockClear(); + getWebpackPath.mockClear(); }); it('should apply both consume and provide plugins', () => { const plugin = new SharePlugin({ - shareScope: shareScopes.string, + shareScope: shareScopesLocal.string, shared: { react: '^17.0.0', }, @@ -271,25 +295,24 @@ describe('SharePlugin', () => { plugin.apply(mockCompiler); - // Should call getWebpackPath - expect(getWebpackPath).toHaveBeenCalled(); - - // Should create and apply ConsumeSharedPlugin + expect(process.env['FEDERATION_WEBPACK_PATH']).toBe( + 'mocked-webpack-path', + ); expect(ConsumeSharedPluginMock).toHaveBeenCalledTimes(1); + expect(ProvideSharedPluginMock).toHaveBeenCalledTimes(1); + const consumeOptions = ConsumeSharedPluginMock.mock.calls[0][0]; - expect(consumeOptions.shareScope).toBe(shareScopes.string); + expect(consumeOptions.shareScope).toBe(shareScopesLocal.string); expect(consumeOptions.consumes).toBeInstanceOf(Array); - // Should create and apply ProvideSharedPlugin - expect(ProvideSharedPluginMock).toHaveBeenCalledTimes(1); const provideOptions = ProvideSharedPluginMock.mock.calls[0][0]; - expect(provideOptions.shareScope).toBe(shareScopes.string); + expect(provideOptions.shareScope).toBe(shareScopesLocal.string); expect(provideOptions.provides).toBeInstanceOf(Array); }); it('should handle array shareScope when applying plugins', () => { const plugin = new SharePlugin({ - shareScope: shareScopes.array, + shareScope: shareScopesLocal.array, shared: { react: '^17.0.0', }, @@ -297,29 +320,20 @@ describe('SharePlugin', () => { plugin.apply(mockCompiler); - // Should create ConsumeSharedPlugin with array shareScope - expect(ConsumeSharedPluginMock).toHaveBeenCalledTimes(1); const consumeOptions = ConsumeSharedPluginMock.mock.calls[0][0]; - expect(consumeOptions.shareScope).toEqual(shareScopes.array); - expect(consumeOptions.consumes).toBeInstanceOf(Array); - - // Should create ProvideSharedPlugin with array shareScope - expect(ProvideSharedPluginMock).toHaveBeenCalledTimes(1); const provideOptions = ProvideSharedPluginMock.mock.calls[0][0]; - expect(provideOptions.shareScope).toEqual(shareScopes.array); - expect(provideOptions.provides).toBeInstanceOf(Array); + + expect(consumeOptions.shareScope).toEqual(shareScopesLocal.array); + expect(provideOptions.shareScope).toEqual(shareScopesLocal.array); }); it('should handle mixed shareScopes when applying plugins', () => { const plugin = new SharePlugin({ - // Default scope - shareScope: shareScopes.string, + shareScope: shareScopesLocal.string, shared: { - // Default scope react: '^17.0.0', - // Override scope lodash: { - shareScope: shareScopes.array, + shareScope: shareScopesLocal.array, requiredVersion: '^4.17.0', }, }, @@ -327,49 +341,517 @@ describe('SharePlugin', () => { plugin.apply(mockCompiler); - // Get ConsumeSharedPlugin options - expect(ConsumeSharedPluginMock).toHaveBeenCalledTimes(1); const consumeOptions = ConsumeSharedPluginMock.mock.calls[0][0]; + const provideOptions = ProvideSharedPluginMock.mock.calls[0][0]; - // Default scope should be string at the plugin level - expect(consumeOptions.shareScope).toBe(shareScopes.string); + expect(consumeOptions.shareScope).toBe(shareScopesLocal.string); + expect(provideOptions.shareScope).toBe(shareScopesLocal.string); + + const consumes = consumeOptions.consumes as ShareConfigRecord[]; + const provides = provideOptions.provides as ShareConfigRecord[]; - // Consumes should include both modules - const consumes = consumeOptions.consumes; expect(consumes.length).toBe(2); + expect(provides.length).toBe(2); - const reactConsume = consumes.find( - (consume) => Object.keys(consume)[0] === 'react', + expect(findShareConfig(consumes, 'lodash')?.shareScope).toEqual( + shareScopesLocal.array, ); - expect(reactConsume).toBeDefined(); - - const lodashConsume = consumes.find( - (consume) => Object.keys(consume)[0] === 'lodash', + expect(findShareConfig(provides, 'lodash')?.shareScope).toEqual( + shareScopesLocal.array, ); - expect(lodashConsume).toBeDefined(); - expect(lodashConsume.lodash.shareScope).toEqual(shareScopes.array); + }); + }); +}); - // Similarly check ProvideSharedPlugin - expect(ProvideSharedPluginMock).toHaveBeenCalledTimes(1); - const provideOptions = ProvideSharedPluginMock.mock.calls[0][0]; +describe('SharePlugin (integration)', () => { + let SharePlugin: any; - // Default scope should be string at the plugin level - expect(provideOptions.shareScope).toBe(shareScopes.string); + beforeEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + delete process.env['FEDERATION_WEBPACK_PATH']; + ({ SharePlugin } = loadRealSharePlugin()); + }); - // Provides should include both modules - const provides = provideOptions.provides; - expect(provides.length).toBe(2); + afterEach(() => { + jest.resetModules(); + }); - const reactProvide = provides.find( - (provide) => Object.keys(provide)[0] === 'react', - ); - expect(reactProvide).toBeDefined(); + const createRealWebpackCompiler = (): Compiler => { + const trackHook = | AsyncSeriesHook>( + hook: THook, + ): THook => { + const tapCalls: Array<{ name: string; fn: unknown }> = []; + const originalTap = hook.tap.bind(hook); + (hook as any).tap = (name: string, fn: any) => { + tapCalls.push({ name, fn }); + (hook as any).__tapCalls = tapCalls; + return originalTap(name, fn); + }; + + if ('tapAsync' in hook && typeof hook.tapAsync === 'function') { + const originalTapAsync = (hook.tapAsync as any).bind(hook); + (hook as any).tapAsync = (name: string, fn: any) => { + tapCalls.push({ name, fn }); + (hook as any).__tapCalls = tapCalls; + return originalTapAsync(name, fn); + }; + } + + if ('tapPromise' in hook && typeof hook.tapPromise === 'function') { + const originalTapPromise = (hook.tapPromise as any).bind(hook); + (hook as any).tapPromise = (name: string, fn: any) => { + tapCalls.push({ name, fn }); + (hook as any).__tapCalls = tapCalls; + return originalTapPromise(name, fn); + }; + } + + return hook; + }; + + const compiler = { + hooks: { + thisCompilation: trackHook( + new SyncHook<[unknown, unknown]>(['compilation', 'params']), + ), + compilation: trackHook( + new SyncHook<[unknown, unknown]>(['compilation', 'params']), + ), + finishMake: trackHook(new AsyncSeriesHook<[unknown]>(['compilation'])), + make: trackHook(new AsyncSeriesHook<[unknown]>(['compilation'])), + environment: trackHook(new SyncHook<[]>([])), + afterEnvironment: trackHook(new SyncHook<[]>([])), + afterPlugins: trackHook(new SyncHook<[unknown]>(['compiler'])), + afterResolvers: trackHook(new SyncHook<[unknown]>(['compiler'])), + }, + context: '/test-project', + options: { + context: '/test-project', + output: { + path: '/test-project/dist', + uniqueName: 'test-app', + }, + plugins: [], + resolve: { + alias: {}, + }, + }, + webpack: { + javascript: { + JavascriptModulesPlugin: { + getCompilationHooks: jest.fn(() => ({ + renderChunk: new SyncHook<[unknown, unknown]>([ + 'source', + 'renderContext', + ]), + render: new SyncHook<[unknown, unknown]>([ + 'source', + 'renderContext', + ]), + chunkHash: new SyncHook<[unknown, unknown, unknown]>([ + 'chunk', + 'hash', + 'context', + ]), + renderStartup: new SyncHook<[unknown, unknown, unknown]>([ + 'source', + 'module', + 'renderContext', + ]), + })), + }, + }, + }, + }; + + return compiler as unknown as Compiler; + }; + + const createMockCompilation = () => { + const runtimeRequirementInTreeHookMap = new HookMap< + SyncHook<[unknown, unknown, unknown]> + >( + () => + new SyncHook<[unknown, unknown, unknown]>(['chunk', 'set', 'context']), + ); + + return { + context: '/test-project', + compiler: { + context: '/test-project', + }, + dependencyFactories: new Map(), + hooks: { + additionalTreeRuntimeRequirements: { tap: jest.fn() }, + runtimeRequirementInTree: runtimeRequirementInTreeHookMap, + finishModules: { tap: jest.fn(), tapAsync: jest.fn() }, + seal: { tap: jest.fn() }, + }, + addRuntimeModule: jest.fn(), + contextDependencies: { addAll: jest.fn() }, + fileDependencies: { addAll: jest.fn() }, + missingDependencies: { addAll: jest.fn() }, + warnings: [], + errors: [], + addInclude: jest.fn(), + resolverFactory: { + get: jest.fn(() => ({ + resolve: jest.fn( + ( + _context: unknown, + path: string, + _request: string, + _resolveContext: unknown, + callback: (err: unknown, result?: string) => void, + ) => { + callback(null, path); + }, + ), + })), + }, + }; + }; + + type NormalModuleFactoryLike = { + hooks: { + module: { tap: jest.Mock }; + factorize: { tapPromise: jest.Mock }; + createModule: { tapPromise: jest.Mock }; + }; + }; + + const createMockNormalModuleFactory = (): NormalModuleFactoryLike => ({ + hooks: { + module: { tap: jest.fn() }, + factorize: { tapPromise: jest.fn() }, + createModule: { tapPromise: jest.fn() }, + }, + }); - const lodashProvide = provides.find( - (provide) => Object.keys(provide)[0] === 'lodash', - ); - expect(lodashProvide).toBeDefined(); - expect(lodashProvide.lodash.shareScope).toEqual(shareScopes.array); + const createCompilationParams = ( + normalModuleFactory: NormalModuleFactoryLike, + ) => ({ + normalModuleFactory, + contextModuleFactory: {} as Record, + }); + + describe('plugin integration', () => { + it('should integrate with webpack compiler for shared modules', () => { + const plugin = new SharePlugin({ + shareScope: 'default', + shared: { + react: '^17.0.0', + lodash: { + requiredVersion: '^4.17.21', + singleton: true, + eager: false, + }, + }, + }); + + const compiler = createRealWebpackCompiler(); + expect(() => plugin.apply(compiler)).not.toThrow(); + + expect( + (compiler.hooks.thisCompilation as any).__tapCalls?.length ?? 0, + ).toBeGreaterThan(0); + expect( + (compiler.hooks.compilation as any).__tapCalls?.length ?? 0, + ).toBeGreaterThan(0); + expect( + (compiler.hooks.finishMake as any).__tapCalls?.length ?? 0, + ).toBeGreaterThan(0); + }); + + it('should handle array shareScope configuration', () => { + const plugin = new SharePlugin({ + shareScope: ['default', 'custom'], + shared: { + react: '^17.0.0', + }, + }); + + const compiler = createRealWebpackCompiler(); + expect(() => plugin.apply(compiler)).not.toThrow(); + expect( + (compiler.hooks.thisCompilation as any).__tapCalls?.length ?? 0, + ).toBeGreaterThan(0); + }); + + it('should handle separate consumes and provides configurations', () => { + const plugin = new SharePlugin({ + shareScope: 'default', + shared: { + react: '^17.0.0', + 'external-lib': { + requiredVersion: '^1.0.0', + singleton: true, + }, + 'my-utils': { + version: '1.0.0', + shareKey: 'utils', + import: 'my-utils', + }, + 'my-components': { + version: '2.1.0', + import: 'my-components', + }, + }, + }); + + const compiler = createRealWebpackCompiler(); + expect(() => plugin.apply(compiler)).not.toThrow(); + expect( + (compiler.hooks.compilation as any).__tapCalls?.length ?? 0, + ).toBeGreaterThan(0); + }); + }); + + describe('webpack compilation integration', () => { + it('should execute compilation hooks without errors', () => { + const plugin = new SharePlugin({ + shareScope: 'default', + shared: { + react: '^17.0.0', + lodash: '^4.17.21', + }, + }); + + const compiler = createRealWebpackCompiler(); + plugin.apply(compiler); + + const compilation = createMockCompilation(); + const normalModuleFactory = createMockNormalModuleFactory(); + const thisCompilationParams = createCompilationParams( + normalModuleFactory, + ) as unknown as Parameters[1]; + const compilationParams = createCompilationParams( + normalModuleFactory, + ) as unknown as Parameters[1]; + + expect(() => + compiler.hooks.thisCompilation.call( + compilation as unknown as Compilation, + thisCompilationParams, + ), + ).not.toThrow(); + + expect(() => + compiler.hooks.compilation.call( + compilation as unknown as Compilation, + compilationParams, + ), + ).not.toThrow(); + }); + + it('should handle finishMake hook execution', async () => { + const plugin = new SharePlugin({ + shareScope: 'default', + shared: { + react: '^17.0.0', + }, + }); + + const compiler = createRealWebpackCompiler(); + plugin.apply(compiler); + + const compilation = createMockCompilation(); + + await expect( + compiler.hooks.finishMake.promise( + compilation as unknown as Compilation, + ), + ).resolves.toBeUndefined(); + }); + }); + + describe('configuration handling', () => { + it('should handle consumes-only configuration', () => { + const plugin = new SharePlugin({ + shareScope: 'default', + shared: { + react: '^17.0.0', + lodash: { + requiredVersion: '^4.17.21', + singleton: true, + }, + }, + }); + + const compiler = createRealWebpackCompiler(); + expect(() => plugin.apply(compiler)).not.toThrow(); + expect( + (compiler.hooks.thisCompilation as any).__tapCalls?.length ?? 0, + ).toBeGreaterThan(0); + }); + + it('should handle provides-only configuration', () => { + const plugin = new SharePlugin({ + shareScope: 'default', + shared: { + 'my-utils': { + version: '1.0.0', + import: 'my-utils', + }, + 'my-components': { + version: '2.0.0', + shareKey: 'components', + import: 'my-components', + }, + }, + }); + + const compiler = createRealWebpackCompiler(); + expect(() => plugin.apply(compiler)).not.toThrow(); + expect( + (compiler.hooks.compilation as any).__tapCalls?.length ?? 0, + ).toBeGreaterThan(0); + }); + + it('should handle complex shared module configurations', () => { + const plugin = new SharePlugin({ + shareScope: 'default', + shared: { + react: { + requiredVersion: '^17.0.0', + version: '17.0.2', + singleton: true, + eager: false, + shareKey: 'react', + shareScope: 'framework', + }, + lodash: '^4.17.21', + '@types/react': { + version: '^17.0.0', + singleton: false, + }, + }, + }); + + const compiler = createRealWebpackCompiler(); + expect(() => plugin.apply(compiler)).not.toThrow(); + expect( + (compiler.hooks.compilation as any).__tapCalls?.length ?? 0, + ).toBeGreaterThan(0); + }); + }); + + describe('edge cases and error handling', () => { + it('should handle empty shared configuration', () => { + expect(() => { + new SharePlugin({ + shareScope: 'default', + shared: {}, + }); + }).not.toThrow(); + }); + + it('should handle missing shareScope with default fallback', () => { + const plugin = new SharePlugin({ + shared: { + react: '^17.0.0', + }, + }); + + const compiler = createRealWebpackCompiler(); + expect(() => plugin.apply(compiler)).not.toThrow(); + }); + + it('should validate and apply comprehensive configuration', () => { + const plugin = new SharePlugin({ + shareScope: 'default', + shared: { + react: { + singleton: true, + requiredVersion: '^17.0.0', + }, + 'react-dom': { + singleton: true, + requiredVersion: '^17.0.0', + }, + lodash: '^4.17.21', + 'external-utils': { + shareScope: 'utils', + requiredVersion: '^1.0.0', + }, + 'internal-components': { + version: '2.0.0', + shareKey: 'components', + import: 'internal-components', + }, + }, + }); + + const compiler = createRealWebpackCompiler(); + expect(() => plugin.apply(compiler)).not.toThrow(); + expect( + (compiler.hooks.finishMake as any).__tapCalls?.length ?? 0, + ).toBeGreaterThan(0); + }); + }); + + describe('real-world usage scenarios', () => { + it('should support micro-frontend sharing patterns', () => { + const plugin = new SharePlugin({ + shareScope: 'mf', + shared: { + react: { + singleton: true, + requiredVersion: '^17.0.0', + }, + 'react-dom': { + singleton: true, + requiredVersion: '^17.0.0', + }, + lodash: { + singleton: false, + requiredVersion: '^4.17.0', + }, + 'design-system': { + version: '1.5.0', + shareKey: 'ds', + import: 'design-system', + }, + }, + }); + + const compiler = createRealWebpackCompiler(); + expect(() => plugin.apply(compiler)).not.toThrow(); + + const compilation = createMockCompilation(); + const normalModuleFactory = createMockNormalModuleFactory(); + const microFrontendParams = createCompilationParams( + normalModuleFactory, + ) as unknown as Parameters[1]; + + expect(() => + compiler.hooks.thisCompilation.call( + compilation as unknown as Compilation, + microFrontendParams, + ), + ).not.toThrow(); + }); + + it('should support development vs production sharing strategies', () => { + const isProduction = false; + + const plugin = new SharePlugin({ + shareScope: 'default', + shared: { + react: { + singleton: true, + requiredVersion: '^17.0.0', + strictVersion: !isProduction, + }, + 'dev-tools': { + ...(isProduction ? {} : { version: '1.0.0' }), + }, + }, + }); + + const compiler = createRealWebpackCompiler(); + expect(() => plugin.apply(compiler)).not.toThrow(); }); }); }); diff --git a/packages/enhanced/test/unit/sharing/ShareRuntimeModule.test.ts b/packages/enhanced/test/unit/sharing/ShareRuntimeModule.test.ts index 84a279d7fed..0aba2798a6b 100644 --- a/packages/enhanced/test/unit/sharing/ShareRuntimeModule.test.ts +++ b/packages/enhanced/test/unit/sharing/ShareRuntimeModule.test.ts @@ -87,7 +87,7 @@ describe('ShareRuntimeModule', () => { // Setup getData to return share-init data mockCompilation.codeGenerationResults.getData.mockImplementation( - (module, runtime, type) => { + (module: unknown, runtime: unknown, type: string) => { if (type === 'share-init') { return [ { @@ -147,7 +147,7 @@ describe('ShareRuntimeModule', () => { // Setup getData to return share-init data with array shareScope mockCompilation.codeGenerationResults.getData.mockImplementation( - (module, runtime, type) => { + (module: unknown, runtime: unknown, type: string) => { if (type === 'share-init') { return [ { @@ -210,7 +210,7 @@ describe('ShareRuntimeModule', () => { // Setup getData to return different share-init data for each module mockCompilation.codeGenerationResults.getData.mockImplementation( - (module, runtime, type) => { + (module: unknown, runtime: unknown, type: string) => { if (type === 'share-init') { if (module === mockModule1) { return [ @@ -298,7 +298,7 @@ describe('ShareRuntimeModule', () => { // Setup getData to return different versions for the same module mockCompilation.codeGenerationResults.getData.mockImplementation( - (module, runtime, type) => { + (module: unknown, runtime: unknown, type: string) => { if (type === 'share-init') { if (module === mockModule1) { return [ @@ -381,7 +381,7 @@ describe('ShareRuntimeModule', () => { // Setup getData to return same version but different layers mockCompilation.codeGenerationResults.getData.mockImplementation( - (module, runtime, type) => { + (module: unknown, runtime: unknown, type: string) => { if (type === 'share-init') { if (module === mockModule1) { return [ diff --git a/packages/enhanced/test/unit/sharing/plugin-test-utils.ts b/packages/enhanced/test/unit/sharing/plugin-test-utils.ts new file mode 100644 index 00000000000..91eceeebbd4 --- /dev/null +++ b/packages/enhanced/test/unit/sharing/plugin-test-utils.ts @@ -0,0 +1,252 @@ +import { + shareScopes, + createSharingTestEnvironment, + createMockFederationCompiler, + testModuleOptions, + createMockCompiler, + createMockCompilation, + createWebpackMock, + createModuleMock, +} from './utils'; + +export { + shareScopes, + createSharingTestEnvironment, + createMockFederationCompiler, + testModuleOptions, + createMockCompiler, + createMockCompilation, +}; + +jest.mock('@module-federation/sdk/normalize-webpack-path', () => ({ + normalizeWebpackPath: jest.fn((path: string) => path), + getWebpackPath: jest.fn(() => 'mocked-webpack-path'), +})); + +const federationRuntimePluginMock = jest.fn().mockImplementation(() => ({ + apply: jest.fn(), +})); + +const registerModuleVariant = (modulePath: string, factory: () => unknown) => { + jest.mock(modulePath, factory as any); + jest.mock(`${modulePath}.ts`, factory as any); +}; + +registerModuleVariant( + '../../../src/lib/container/runtime/FederationRuntimePlugin', + () => federationRuntimePluginMock, +); + +const mockConsumeSharedModule = jest + .fn() + .mockImplementation((contextOrOptions, options) => { + const actualOptions = options || contextOrOptions; + + return { + shareScope: actualOptions.shareScope, + name: actualOptions.name || 'default-name', + request: actualOptions.request || 'default-request', + eager: actualOptions.eager || false, + strictVersion: actualOptions.strictVersion || false, + singleton: actualOptions.singleton || false, + requiredVersion: + actualOptions.requiredVersion !== undefined + ? actualOptions.requiredVersion + : '1.0.0', + getVersion: jest + .fn() + .mockReturnValue( + actualOptions.requiredVersion !== undefined + ? actualOptions.requiredVersion + : '1.0.0', + ), + options: actualOptions, + build: jest.fn().mockImplementation((ctx, _c, _r, _f, callback) => { + callback && callback(); + }), + }; + }); + +registerModuleVariant( + '../../../src/lib/sharing/ConsumeSharedModule', + () => mockConsumeSharedModule, +); + +const mockConsumeSharedRuntimeModule = jest + .fn() + .mockImplementation(() => ({ name: 'ConsumeSharedRuntimeModule' })); + +const mockShareRuntimeModule = jest + .fn() + .mockImplementation(() => ({ name: 'ShareRuntimeModule' })); + +registerModuleVariant( + '../../../src/lib/sharing/ConsumeSharedRuntimeModule', + () => mockConsumeSharedRuntimeModule, +); + +registerModuleVariant( + '../../../src/lib/sharing/ShareRuntimeModule', + () => mockShareRuntimeModule, +); + +class MockConsumeSharedFallbackDependency { + constructor( + public fallbackRequest: string, + public shareScope: string, + public requiredVersion: string, + ) {} +} + +function consumeSharedFallbackFactory() { + return function ( + fallbackRequest: string, + shareScope: string, + requiredVersion: string, + ) { + return new MockConsumeSharedFallbackDependency( + fallbackRequest, + shareScope, + requiredVersion, + ); + }; +} + +jest.mock( + '../../../src/lib/sharing/ConsumeSharedFallbackDependency', + () => consumeSharedFallbackFactory(), + { virtual: true }, +); + +jest.mock( + '../../../src/lib/sharing/ConsumeSharedFallbackDependency.ts', + () => consumeSharedFallbackFactory(), + { virtual: true }, +); + +const resolveMatchedConfigs = jest.fn(); + +registerModuleVariant('../../../src/lib/sharing/resolveMatchedConfigs', () => ({ + resolveMatchedConfigs, +})); + +export const mockGetDescriptionFile = jest.fn(); + +jest.mock('../../../src/lib/sharing/utils.ts', () => ({ + ...jest.requireActual('../../../src/lib/sharing/utils.ts'), + getDescriptionFile: mockGetDescriptionFile, +})); + +export const ConsumeSharedPlugin = + require('../../../src/lib/sharing/ConsumeSharedPlugin').default; + +export function createTestConsumesConfig(consumes = {}) { + return { + shareScope: shareScopes.string, + consumes, + }; +} + +export function createMockResolver() { + return { + resolve: jest.fn(), + withOptions: jest.fn().mockReturnThis(), + }; +} + +export function resetAllMocks() { + jest.clearAllMocks(); + mockGetDescriptionFile.mockReset(); + resolveMatchedConfigs.mockReset(); + resolveMatchedConfigs.mockResolvedValue({ + resolved: new Map(), + unresolved: new Map(), + prefixed: new Map(), + }); + mockConsumeSharedModule.mockClear(); +} + +export { mockConsumeSharedModule, resolveMatchedConfigs }; + +const webpack = createWebpackMock(); +const Module = createModuleMock(webpack); + +export { webpack, Module }; + +class MockProvideSharedDependency { + constructor( + public request: string, + public shareScope: string | string[], + public version: string, + ) { + this._shareScope = shareScope; + this._version = version; + this._shareKey = request; + } + + _shareScope: string | string[]; + _version: string; + _shareKey: string; +} + +jest.mock( + '../../../src/lib/sharing/ProvideSharedDependency', + () => MockProvideSharedDependency, +); + +jest.mock('../../../src/lib/sharing/ProvideSharedModuleFactory', () => + jest.fn().mockImplementation(() => ({ + create: jest.fn(), + })), +); + +jest.mock('../../../src/lib/sharing/ProvideSharedModule', () => + jest.fn().mockImplementation((options) => ({ + _shareScope: options.shareScope, + _shareKey: options.shareKey || options.request, + _version: options.version, + _eager: options.eager || false, + options, + })), +); + +export const ProvideSharedPlugin = + require('../../../src/lib/sharing/ProvideSharedPlugin').default; + +export { MockProvideSharedDependency }; + +export const testProvides = { + react: { + shareKey: 'react', + shareScope: shareScopes.string, + version: '17.0.2', + eager: false, + }, + lodash: { + version: '4.17.21', + singleton: true, + }, + vue: { + shareKey: 'vue', + shareScope: shareScopes.array, + version: '3.2.37', + eager: true, + }, +}; + +export function createTestModule(overrides = {}) { + return { + ...testModuleOptions, + ...overrides, + }; +} + +export function createTestConfig( + provides = testProvides, + shareScope = shareScopes.string, +) { + return { + shareScope, + provides, + }; +} diff --git a/packages/enhanced/test/unit/sharing/resolveMatchedConfigs.improved.test.ts b/packages/enhanced/test/unit/sharing/resolveMatchedConfigs.improved.test.ts deleted file mode 100644 index c257c74111b..00000000000 --- a/packages/enhanced/test/unit/sharing/resolveMatchedConfigs.improved.test.ts +++ /dev/null @@ -1,664 +0,0 @@ -/* - * @jest-environment node - */ - -import { resolveMatchedConfigs } from '../../../src/lib/sharing/resolveMatchedConfigs'; -import type { ConsumeOptions } from '../../../src/declarations/plugins/sharing/ConsumeSharedModule'; -import { vol } from 'memfs'; - -// Mock file system only for controlled testing -jest.mock('fs', () => require('memfs').fs); -jest.mock('fs/promises', () => require('memfs').fs.promises); - -// Mock webpack paths minimally -jest.mock('@module-federation/sdk/normalize-webpack-path', () => ({ - normalizeWebpackPath: jest.fn((path) => path), - getWebpackPath: jest.fn(() => 'webpack'), -})); - -// Mock the webpack fs utilities that are used by getDescriptionFile -jest.mock('webpack/lib/util/fs', () => ({ - join: (fs: any, ...paths: string[]) => require('path').join(...paths), - dirname: (fs: any, filePath: string) => require('path').dirname(filePath), - readJson: (fs: any, filePath: string, callback: Function) => { - const memfs = require('memfs').fs; - memfs.readFile(filePath, 'utf8', (err: any, content: any) => { - if (err) return callback(err); - try { - const data = JSON.parse(content); - callback(null, data); - } catch (e) { - callback(e); - } - }); - }, -})); - -describe('resolveMatchedConfigs - Improved Quality Tests', () => { - beforeEach(() => { - vol.reset(); - jest.clearAllMocks(); - }); - - describe('Real module resolution scenarios', () => { - it('should resolve relative paths using real file system', async () => { - // Setup realistic project structure - vol.fromJSON({ - '/test-project/src/components/Button.js': - 'export const Button = () => {};', - '/test-project/src/utils/helpers.js': 'export const helper = () => {};', - '/test-project/lib/external.js': 'module.exports = {};', - }); - - const configs: [string, ConsumeOptions][] = [ - ['./src/components/Button', { shareScope: 'default' }], - ['./src/utils/helpers', { shareScope: 'utilities' }], - ['./lib/external', { shareScope: 'external' }], - ]; - - // Create realistic webpack compilation with real resolver behavior - const mockCompilation = { - resolverFactory: { - get: () => ({ - resolve: ( - context: string, - basePath: string, - request: string, - resolveContext: any, - callback: Function, - ) => { - const fs = require('fs'); - const path = require('path'); - - // Implement real-like path resolution - const fullPath = path.resolve(basePath, request); - - // Check if file exists - fs.access(fullPath + '.js', fs.constants.F_OK, (err: any) => { - if (err) { - callback(new Error(`Module not found: ${request}`), false); - } else { - callback(null, fullPath + '.js'); - } - }); - }, - }), - }, - compiler: { context: '/test-project' }, - contextDependencies: { addAll: jest.fn() }, - fileDependencies: { addAll: jest.fn() }, - missingDependencies: { addAll: jest.fn() }, - errors: [], - }; - - const result = await resolveMatchedConfigs( - mockCompilation as any, - configs, - ); - - // Verify successful resolution - expect(result.resolved.size).toBe(3); - expect( - result.resolved.has('/test-project/src/components/Button.js'), - ).toBe(true); - expect(result.resolved.has('/test-project/src/utils/helpers.js')).toBe( - true, - ); - expect(result.resolved.has('/test-project/lib/external.js')).toBe(true); - - // Verify configurations are preserved - expect( - result.resolved.get('/test-project/src/components/Button.js') - ?.shareScope, - ).toBe('default'); - expect( - result.resolved.get('/test-project/src/utils/helpers.js')?.shareScope, - ).toBe('utilities'); - expect( - result.resolved.get('/test-project/lib/external.js')?.shareScope, - ).toBe('external'); - - expect(result.unresolved.size).toBe(0); - expect(result.prefixed.size).toBe(0); - expect(mockCompilation.errors).toHaveLength(0); - }); - - it('should handle missing files with proper error reporting', async () => { - vol.fromJSON({ - '/test-project/src/existing.js': 'export default {};', - // missing.js doesn't exist - }); - - const configs: [string, ConsumeOptions][] = [ - ['./src/existing', { shareScope: 'default' }], - ['./src/missing', { shareScope: 'default' }], - ]; - - const mockCompilation = { - resolverFactory: { - get: () => ({ - resolve: ( - context: string, - basePath: string, - request: string, - resolveContext: any, - callback: Function, - ) => { - const fs = require('fs'); - const path = require('path'); - const fullPath = path.resolve(basePath, request); - - fs.access(fullPath + '.js', fs.constants.F_OK, (err: any) => { - if (err) { - callback(new Error(`Module not found: ${request}`), false); - } else { - callback(null, fullPath + '.js'); - } - }); - }, - }), - }, - compiler: { context: '/test-project' }, - contextDependencies: { addAll: jest.fn() }, - fileDependencies: { addAll: jest.fn() }, - missingDependencies: { addAll: jest.fn() }, - errors: [], - }; - - const result = await resolveMatchedConfigs( - mockCompilation as any, - configs, - ); - - // Should resolve existing file - expect(result.resolved.size).toBe(1); - expect(result.resolved.has('/test-project/src/existing.js')).toBe(true); - - // Should report error for missing file - expect(mockCompilation.errors).toHaveLength(1); - expect(mockCompilation.errors[0].message).toContain('Module not found'); - }); - - it('should handle absolute paths correctly', async () => { - vol.fromJSON({ - '/absolute/path/module.js': 'module.exports = {};', - '/another/absolute/lib.js': 'export default {};', - }); - - const configs: [string, ConsumeOptions][] = [ - ['/absolute/path/module.js', { shareScope: 'absolute1' }], - ['/another/absolute/lib.js', { shareScope: 'absolute2' }], - ['/nonexistent/path.js', { shareScope: 'missing' }], - ]; - - const mockCompilation = { - resolverFactory: { get: () => ({}) }, - compiler: { context: '/test-project' }, - contextDependencies: { addAll: jest.fn() }, - fileDependencies: { addAll: jest.fn() }, - missingDependencies: { addAll: jest.fn() }, - errors: [], - }; - - const result = await resolveMatchedConfigs( - mockCompilation as any, - configs, - ); - - // Absolute paths should be handled directly without resolution - expect(result.resolved.size).toBe(3); - expect(result.resolved.has('/absolute/path/module.js')).toBe(true); - expect(result.resolved.has('/another/absolute/lib.js')).toBe(true); - expect(result.resolved.has('/nonexistent/path.js')).toBe(true); - - expect(result.resolved.get('/absolute/path/module.js')?.shareScope).toBe( - 'absolute1', - ); - expect(result.resolved.get('/another/absolute/lib.js')?.shareScope).toBe( - 'absolute2', - ); - }); - - it('should handle prefix patterns correctly', async () => { - const configs: [string, ConsumeOptions][] = [ - ['@company/', { shareScope: 'company' }], - ['utils/', { shareScope: 'utilities' }], - ['components/', { shareScope: 'ui', issuerLayer: 'client' }], - ]; - - const mockCompilation = { - resolverFactory: { get: () => ({}) }, - compiler: { context: '/test-project' }, - contextDependencies: { addAll: jest.fn() }, - fileDependencies: { addAll: jest.fn() }, - missingDependencies: { addAll: jest.fn() }, - errors: [], - }; - - const result = await resolveMatchedConfigs( - mockCompilation as any, - configs, - ); - - expect(result.prefixed.size).toBe(3); - expect(result.prefixed.has('@company/')).toBe(true); - expect(result.prefixed.has('utils/')).toBe(true); - expect(result.prefixed.has('(client)components/')).toBe(true); - - expect(result.prefixed.get('@company/')?.shareScope).toBe('company'); - expect(result.prefixed.get('utils/')?.shareScope).toBe('utilities'); - expect(result.prefixed.get('(client)components/')?.shareScope).toBe('ui'); - expect(result.prefixed.get('(client)components/')?.issuerLayer).toBe( - 'client', - ); - }); - - it('should handle regular module names correctly', async () => { - const configs: [string, ConsumeOptions][] = [ - ['react', { shareScope: 'default' }], - ['lodash', { shareScope: 'utilities' }], - ['@babel/core', { shareScope: 'build', issuerLayer: 'build' }], - ]; - - const mockCompilation = { - resolverFactory: { get: () => ({}) }, - compiler: { context: '/test-project' }, - contextDependencies: { addAll: jest.fn() }, - fileDependencies: { addAll: jest.fn() }, - missingDependencies: { addAll: jest.fn() }, - errors: [], - }; - - const result = await resolveMatchedConfigs( - mockCompilation as any, - configs, - ); - - expect(result.unresolved.size).toBe(3); - expect(result.unresolved.has('react')).toBe(true); - expect(result.unresolved.has('lodash')).toBe(true); - expect(result.unresolved.has('(build)@babel/core')).toBe(true); - - expect(result.unresolved.get('react')?.shareScope).toBe('default'); - expect(result.unresolved.get('lodash')?.shareScope).toBe('utilities'); - expect(result.unresolved.get('(build)@babel/core')?.shareScope).toBe( - 'build', - ); - expect(result.unresolved.get('(build)@babel/core')?.issuerLayer).toBe( - 'build', - ); - }); - }); - - describe('Complex resolution scenarios', () => { - it('should handle mixed configuration types correctly', async () => { - vol.fromJSON({ - '/test-project/src/local.js': 'export default {};', - '/absolute/file.js': 'module.exports = {};', - }); - - const configs: [string, ConsumeOptions][] = [ - ['./src/local', { shareScope: 'local' }], // Relative path - ['/absolute/file.js', { shareScope: 'absolute' }], // Absolute path - ['@scoped/', { shareScope: 'scoped' }], // Prefix pattern - ['regular-module', { shareScope: 'regular' }], // Regular module - ]; - - const mockCompilation = { - resolverFactory: { - get: () => ({ - resolve: ( - context: string, - basePath: string, - request: string, - resolveContext: any, - callback: Function, - ) => { - const fs = require('fs'); - const path = require('path'); - const fullPath = path.resolve(basePath, request); - - fs.access(fullPath + '.js', fs.constants.F_OK, (err: any) => { - if (err) { - callback(new Error(`Module not found: ${request}`), false); - } else { - callback(null, fullPath + '.js'); - } - }); - }, - }), - }, - compiler: { context: '/test-project' }, - contextDependencies: { addAll: jest.fn() }, - fileDependencies: { addAll: jest.fn() }, - missingDependencies: { addAll: jest.fn() }, - errors: [], - }; - - const result = await resolveMatchedConfigs( - mockCompilation as any, - configs, - ); - - // Verify each type is handled correctly - expect(result.resolved.size).toBe(2); // Relative + absolute - expect(result.prefixed.size).toBe(1); // Prefix pattern - expect(result.unresolved.size).toBe(1); // Regular module - - expect(result.resolved.has('/test-project/src/local.js')).toBe(true); - expect(result.resolved.has('/absolute/file.js')).toBe(true); - expect(result.prefixed.has('@scoped/')).toBe(true); - expect(result.unresolved.has('regular-module')).toBe(true); - }); - - it('should handle custom request overrides', async () => { - vol.fromJSON({ - '/test-project/src/actual-file.js': 'export default {};', - }); - - const configs: [string, ConsumeOptions][] = [ - [ - 'alias-name', - { - shareScope: 'default', - request: './src/actual-file', // Custom request - }, - ], - [ - 'absolute-alias', - { - shareScope: 'absolute', - request: '/test-project/src/actual-file.js', // Absolute custom request - }, - ], - [ - 'prefix-alias', - { - shareScope: 'prefix', - request: 'utils/', // Prefix custom request - }, - ], - ]; - - const mockCompilation = { - resolverFactory: { - get: () => ({ - resolve: ( - context: string, - basePath: string, - request: string, - resolveContext: any, - callback: Function, - ) => { - const fs = require('fs'); - const path = require('path'); - const fullPath = path.resolve(basePath, request); - - fs.access(fullPath + '.js', fs.constants.F_OK, (err: any) => { - if (err) { - callback(new Error(`Module not found: ${request}`), false); - } else { - callback(null, fullPath + '.js'); - } - }); - }, - }), - }, - compiler: { context: '/test-project' }, - contextDependencies: { addAll: jest.fn() }, - fileDependencies: { addAll: jest.fn() }, - missingDependencies: { addAll: jest.fn() }, - errors: [], - }; - - const result = await resolveMatchedConfigs( - mockCompilation as any, - configs, - ); - - // Verify custom requests are used for resolution - // Both alias-name and absolute-alias resolve to the same path, so Map keeps only one - expect(result.resolved.size).toBe(1); - expect(result.prefixed.size).toBe(1); // One prefix - expect(result.unresolved.size).toBe(0); // None unresolved - - // Both resolve to the same path - expect(result.resolved.has('/test-project/src/actual-file.js')).toBe( - true, - ); - - // prefix-alias with prefix request goes to prefixed - expect(result.prefixed.has('utils/')).toBe(true); - - // Verify custom requests are preserved in configs - const resolvedConfig = result.resolved.get( - '/test-project/src/actual-file.js', - ); - expect(resolvedConfig).toBeDefined(); - // The config should have the custom request preserved - expect(resolvedConfig?.request).toBeDefined(); - }); - }); - - describe('Layer handling', () => { - it('should create proper composite keys for layered modules', async () => { - const configs: [string, ConsumeOptions][] = [ - ['react', { shareScope: 'default' }], // No layer - ['react', { shareScope: 'client', issuerLayer: 'client' }], // Client layer - ['express', { shareScope: 'server', issuerLayer: 'server' }], // Server layer - ['utils/', { shareScope: 'utilities', issuerLayer: 'shared' }], // Layered prefix - ]; - - const mockCompilation = { - resolverFactory: { get: () => ({}) }, - compiler: { context: '/test-project' }, - contextDependencies: { addAll: jest.fn() }, - fileDependencies: { addAll: jest.fn() }, - missingDependencies: { addAll: jest.fn() }, - errors: [], - }; - - const result = await resolveMatchedConfigs( - mockCompilation as any, - configs, - ); - - expect(result.unresolved.size).toBe(3); // All regular modules - expect(result.prefixed.size).toBe(1); // One prefix - - // Verify layer-based keys - expect(result.unresolved.has('react')).toBe(true); - expect(result.unresolved.has('(client)react')).toBe(true); - expect(result.unresolved.has('(server)express')).toBe(true); - expect(result.prefixed.has('(shared)utils/')).toBe(true); - - // Verify configurations - expect(result.unresolved.get('react')?.issuerLayer).toBeUndefined(); - expect(result.unresolved.get('(client)react')?.issuerLayer).toBe( - 'client', - ); - expect(result.unresolved.get('(server)express')?.issuerLayer).toBe( - 'server', - ); - expect(result.prefixed.get('(shared)utils/')?.issuerLayer).toBe('shared'); - }); - }); - - describe('Dependency tracking', () => { - it('should properly track file dependencies during resolution', async () => { - vol.fromJSON({ - '/test-project/src/component.js': 'export default {};', - }); - - const configs: [string, ConsumeOptions][] = [ - ['./src/component', { shareScope: 'default' }], - ]; - - const mockDependencies = { - contextDependencies: { addAll: jest.fn() }, - fileDependencies: { addAll: jest.fn() }, - missingDependencies: { addAll: jest.fn() }, - }; - - const mockCompilation = { - resolverFactory: { - get: () => ({ - resolve: ( - context: string, - basePath: string, - request: string, - resolveContext: any, - callback: Function, - ) => { - // Simulate dependency tracking during resolution - resolveContext.fileDependencies.add( - '/test-project/src/component.js', - ); - resolveContext.contextDependencies.add('/test-project/src'); - - const fs = require('fs'); - const path = require('path'); - const fullPath = path.resolve(basePath, request); - - fs.access(fullPath + '.js', fs.constants.F_OK, (err: any) => { - if (err) { - callback(new Error(`Module not found: ${request}`), false); - } else { - callback(null, fullPath + '.js'); - } - }); - }, - }), - }, - compiler: { context: '/test-project' }, - ...mockDependencies, - errors: [], - }; - - await resolveMatchedConfigs(mockCompilation as any, configs); - - // Verify dependency tracking was called - expect(mockDependencies.contextDependencies.addAll).toHaveBeenCalled(); - expect(mockDependencies.fileDependencies.addAll).toHaveBeenCalled(); - expect(mockDependencies.missingDependencies.addAll).toHaveBeenCalled(); - }); - }); - - describe('Edge cases and error handling', () => { - it('should handle empty configuration array', async () => { - const configs: [string, ConsumeOptions][] = []; - - const mockCompilation = { - resolverFactory: { get: () => ({}) }, - compiler: { context: '/test-project' }, - contextDependencies: { addAll: jest.fn() }, - fileDependencies: { addAll: jest.fn() }, - missingDependencies: { addAll: jest.fn() }, - errors: [], - }; - - const result = await resolveMatchedConfigs( - mockCompilation as any, - configs, - ); - - expect(result.resolved.size).toBe(0); - expect(result.unresolved.size).toBe(0); - expect(result.prefixed.size).toBe(0); - expect(mockCompilation.errors).toHaveLength(0); - }); - - it('should handle resolver factory errors gracefully', async () => { - const configs: [string, ConsumeOptions][] = [ - ['./src/component', { shareScope: 'default' }], - ]; - - const mockCompilation = { - resolverFactory: { - get: () => { - throw new Error('Resolver factory error'); - }, - }, - compiler: { context: '/test-project' }, - contextDependencies: { addAll: jest.fn() }, - fileDependencies: { addAll: jest.fn() }, - missingDependencies: { addAll: jest.fn() }, - errors: [], - }; - - await expect( - resolveMatchedConfigs(mockCompilation as any, configs), - ).rejects.toThrow('Resolver factory error'); - }); - - it('should handle concurrent resolution of multiple files', async () => { - vol.fromJSON({ - '/test-project/src/a.js': 'export default "a";', - '/test-project/src/b.js': 'export default "b";', - '/test-project/src/c.js': 'export default "c";', - '/test-project/src/d.js': 'export default "d";', - '/test-project/src/e.js': 'export default "e";', - }); - - const configs: [string, ConsumeOptions][] = [ - ['./src/a', { shareScope: 'a' }], - ['./src/b', { shareScope: 'b' }], - ['./src/c', { shareScope: 'c' }], - ['./src/d', { shareScope: 'd' }], - ['./src/e', { shareScope: 'e' }], - ]; - - const mockCompilation = { - resolverFactory: { - get: () => ({ - resolve: ( - context: string, - basePath: string, - request: string, - resolveContext: any, - callback: Function, - ) => { - const fs = require('fs'); - const path = require('path'); - const fullPath = path.resolve(basePath, request); - - // Add small delay to simulate real resolution - setTimeout(() => { - fs.access(fullPath + '.js', fs.constants.F_OK, (err: any) => { - if (err) { - callback(new Error(`Module not found: ${request}`), false); - } else { - callback(null, fullPath + '.js'); - } - }); - }, Math.random() * 10); - }, - }), - }, - compiler: { context: '/test-project' }, - contextDependencies: { addAll: jest.fn() }, - fileDependencies: { addAll: jest.fn() }, - missingDependencies: { addAll: jest.fn() }, - errors: [], - }; - - const result = await resolveMatchedConfigs( - mockCompilation as any, - configs, - ); - - expect(result.resolved.size).toBe(5); - expect(mockCompilation.errors).toHaveLength(0); - - // Verify all files were resolved correctly - ['a', 'b', 'c', 'd', 'e'].forEach((letter) => { - expect(result.resolved.has(`/test-project/src/${letter}.js`)).toBe( - true, - ); - expect( - result.resolved.get(`/test-project/src/${letter}.js`)?.shareScope, - ).toBe(letter); - }); - }); - }); -}); diff --git a/packages/enhanced/test/unit/sharing/resolveMatchedConfigs.test.ts b/packages/enhanced/test/unit/sharing/resolveMatchedConfigs.test.ts index 3bccc8b8563..3c1c69b46bd 100644 --- a/packages/enhanced/test/unit/sharing/resolveMatchedConfigs.test.ts +++ b/packages/enhanced/test/unit/sharing/resolveMatchedConfigs.test.ts @@ -1,27 +1,114 @@ +/* + * @jest-environment node + */ + /* * Comprehensive tests for resolveMatchedConfigs.ts * Testing all resolution paths: relative, absolute, prefix, and regular module requests */ +import type { Compilation } from 'webpack'; +// eslint-disable-next-line @typescript-eslint/no-restricted-imports import ModuleNotFoundError from 'webpack/lib/ModuleNotFoundError'; +// eslint-disable-next-line @typescript-eslint/no-restricted-imports import LazySet from 'webpack/lib/util/LazySet'; +import type { ResolveOptionsWithDependencyType } from 'webpack/lib/ResolverFactory'; import { resolveMatchedConfigs } from '../../../src/lib/sharing/resolveMatchedConfigs'; import type { ConsumeOptions } from '../../../src/declarations/plugins/sharing/ConsumeSharedModule'; +let vol: any; +try { + vol = require('memfs').vol; +} catch { + vol = { + reset: jest.fn(), + fromJSON: jest.fn(), + }; +} + +type PartialConsumeOptions = Partial & + Pick; + +const toConsumeOptionsArray = ( + configs: [string, PartialConsumeOptions][], +): [string, ConsumeOptions][] => + configs as unknown as [string, ConsumeOptions][]; + +type ResolveCallback = (error: Error | null, result?: string | false) => void; +type ResolverFunction = ( + context: string, + basePath: string, + request: string, + resolveContext: unknown, + callback: ResolveCallback, +) => void; +interface CompilationError { + message: string; +} + +interface MockResolver { + resolve: jest.MockedFunction; +} + +interface MockCompilation { + resolverFactory: { + get: jest.Mock; + }; + compiler: { context: string }; + errors: CompilationError[]; + contextDependencies: { + addAll: jest.Mock<(iterable: Iterable) => void>; + }; + fileDependencies: { addAll: jest.Mock<(iterable: Iterable) => void> }; + missingDependencies: { + addAll: jest.Mock<(iterable: Iterable) => void>; + }; +} + jest.mock('@module-federation/sdk/normalize-webpack-path', () => ({ normalizeWebpackPath: jest.fn((path) => path), + getWebpackPath: jest.fn(() => 'webpack'), +})); + +jest.mock('fs', () => require('memfs').fs); +jest.mock('fs/promises', () => require('memfs').fs.promises); + +jest.mock('webpack/lib/util/fs', () => ({ + join: (fs: any, ...paths: string[]) => require('path').join(...paths), + dirname: (fs: any, filePath: string) => require('path').dirname(filePath), + readJson: ( + fs: unknown, + filePath: string, + callback: (error: Error | null, data?: unknown) => void, + ) => { + const memfs = require('memfs').fs; + memfs.readFile(filePath, 'utf8', (err: any, content: any) => { + if (err) return callback(err); + try { + const data = JSON.parse(content); + callback(null, data); + } catch (e) { + const error = e instanceof Error ? e : new Error(String(e)); + callback(error); + } + }); + }, })); describe('resolveMatchedConfigs', () => { - let mockCompilation: any; - let mockResolver: any; + let mockCompilation: MockCompilation; + let mockResolver: MockResolver; + let compilation: Compilation; beforeEach(() => { jest.clearAllMocks(); mockResolver = { - resolve: jest.fn(), + resolve: jest.fn< + ReturnType, + Parameters + >() as jest.MockedFunction, }; mockCompilation = { @@ -36,22 +123,33 @@ describe('resolveMatchedConfigs', () => { fileDependencies: { addAll: jest.fn() }, missingDependencies: { addAll: jest.fn() }, }; + + compilation = mockCompilation as unknown as Compilation; }); describe('relative path resolution', () => { it('should resolve relative paths successfully', async () => { - const configs: [string, ConsumeOptions][] = [ + const configs: [string, PartialConsumeOptions][] = [ ['./relative-module', { shareScope: 'default' }], ]; mockResolver.resolve.mockImplementation( - (context, basePath, request, resolveContext, callback) => { + ( + context: string, + basePath: string, + request: string, + resolveContext: unknown, + callback: ResolveCallback, + ) => { expect(request).toBe('./relative-module'); callback(null, '/resolved/path/relative-module'); }, ); - const result = await resolveMatchedConfigs(mockCompilation, configs); + const result = await resolveMatchedConfigs( + compilation, + toConsumeOptionsArray(configs), + ); expect(result.resolved.has('/resolved/path/relative-module')).toBe(true); expect(result.resolved.get('/resolved/path/relative-module')).toEqual({ @@ -62,24 +160,39 @@ describe('resolveMatchedConfigs', () => { }); it('should handle relative path resolution with parent directory references', async () => { - const configs: [string, ConsumeOptions][] = [ + const configs: [string, PartialConsumeOptions][] = [ ['../parent-module', { shareScope: 'custom' }], ['../../grandparent-module', { shareScope: 'test' }], ]; mockResolver.resolve .mockImplementationOnce( - (context, basePath, request, resolveContext, callback) => { + ( + context: string, + basePath: string, + request: string, + resolveContext: unknown, + callback: ResolveCallback, + ) => { callback(null, '/resolved/parent-module'); }, ) .mockImplementationOnce( - (context, basePath, request, resolveContext, callback) => { + ( + context: string, + basePath: string, + request: string, + resolveContext: unknown, + callback: ResolveCallback, + ) => { callback(null, '/resolved/grandparent-module'); }, ); - const result = await resolveMatchedConfigs(mockCompilation, configs); + const result = await resolveMatchedConfigs( + compilation, + toConsumeOptionsArray(configs), + ); expect(result.resolved.size).toBe(2); expect(result.resolved.has('/resolved/parent-module')).toBe(true); @@ -87,18 +200,27 @@ describe('resolveMatchedConfigs', () => { }); it('should handle relative path resolution errors', async () => { - const configs: [string, ConsumeOptions][] = [ + const configs: [string, PartialConsumeOptions][] = [ ['./missing-module', { shareScope: 'default' }], ]; const resolveError = new Error('Module not found'); mockResolver.resolve.mockImplementation( - (context, basePath, request, resolveContext, callback) => { + ( + context: string, + basePath: string, + request: string, + resolveContext: unknown, + callback: ResolveCallback, + ) => { callback(resolveError, false); }, ); - const result = await resolveMatchedConfigs(mockCompilation, configs); + const result = await resolveMatchedConfigs( + compilation, + toConsumeOptionsArray(configs), + ); expect(result.resolved.size).toBe(0); expect(result.unresolved.size).toBe(0); @@ -116,17 +238,26 @@ describe('resolveMatchedConfigs', () => { }); it('should handle resolver returning false', async () => { - const configs: [string, ConsumeOptions][] = [ + const configs: [string, PartialConsumeOptions][] = [ ['./invalid-module', { shareScope: 'default' }], ]; mockResolver.resolve.mockImplementation( - (context, basePath, request, resolveContext, callback) => { + ( + context: string, + basePath: string, + request: string, + resolveContext: unknown, + callback: ResolveCallback, + ) => { callback(null, false); }, ); - const result = await resolveMatchedConfigs(mockCompilation, configs); + const result = await resolveMatchedConfigs( + compilation, + toConsumeOptionsArray(configs), + ); expect(result.resolved.size).toBe(0); expect(mockCompilation.errors).toHaveLength(1); @@ -143,7 +274,7 @@ describe('resolveMatchedConfigs', () => { }); it('should handle relative path resolution with custom request', async () => { - const configs: [string, ConsumeOptions][] = [ + const configs: [string, PartialConsumeOptions][] = [ [ 'module-alias', { shareScope: 'default', request: './actual-relative-module' }, @@ -151,13 +282,22 @@ describe('resolveMatchedConfigs', () => { ]; mockResolver.resolve.mockImplementation( - (context, basePath, request, resolveContext, callback) => { + ( + context: string, + basePath: string, + request: string, + resolveContext: unknown, + callback: ResolveCallback, + ) => { expect(request).toBe('./actual-relative-module'); callback(null, '/resolved/actual-module'); }, ); - const result = await resolveMatchedConfigs(mockCompilation, configs); + const result = await resolveMatchedConfigs( + compilation, + toConsumeOptionsArray(configs), + ); expect(result.resolved.has('/resolved/actual-module')).toBe(true); }); @@ -165,11 +305,14 @@ describe('resolveMatchedConfigs', () => { describe('absolute path resolution', () => { it('should handle absolute Unix paths', async () => { - const configs: [string, ConsumeOptions][] = [ + const configs: [string, PartialConsumeOptions][] = [ ['/absolute/unix/path', { shareScope: 'default' }], ]; - const result = await resolveMatchedConfigs(mockCompilation, configs); + const result = await resolveMatchedConfigs( + compilation, + toConsumeOptionsArray(configs), + ); expect(result.resolved.has('/absolute/unix/path')).toBe(true); expect(result.resolved.get('/absolute/unix/path')).toEqual({ @@ -179,12 +322,15 @@ describe('resolveMatchedConfigs', () => { }); it('should handle absolute Windows paths', async () => { - const configs: [string, ConsumeOptions][] = [ + const configs: [string, PartialConsumeOptions][] = [ ['C:\\Windows\\Path', { shareScope: 'windows' }], ['D:\\Drive\\Module', { shareScope: 'test' }], ]; - const result = await resolveMatchedConfigs(mockCompilation, configs); + const result = await resolveMatchedConfigs( + compilation, + toConsumeOptionsArray(configs), + ); expect(result.resolved.size).toBe(2); expect(result.resolved.has('C:\\Windows\\Path')).toBe(true); @@ -193,11 +339,14 @@ describe('resolveMatchedConfigs', () => { }); it('should handle UNC paths', async () => { - const configs: [string, ConsumeOptions][] = [ + const configs: [string, PartialConsumeOptions][] = [ ['\\\\server\\share\\module', { shareScope: 'unc' }], ]; - const result = await resolveMatchedConfigs(mockCompilation, configs); + const result = await resolveMatchedConfigs( + compilation, + toConsumeOptionsArray(configs), + ); expect(result.resolved.has('\\\\server\\share\\module')).toBe(true); expect(result.resolved.get('\\\\server\\share\\module')).toEqual({ @@ -206,14 +355,17 @@ describe('resolveMatchedConfigs', () => { }); it('should handle absolute paths with custom request override', async () => { - const configs: [string, ConsumeOptions][] = [ + const configs: [string, PartialConsumeOptions][] = [ [ 'module-name', { shareScope: 'default', request: '/absolute/override/path' }, ], ]; - const result = await resolveMatchedConfigs(mockCompilation, configs); + const result = await resolveMatchedConfigs( + compilation, + toConsumeOptionsArray(configs), + ); expect(result.resolved.has('/absolute/override/path')).toBe(true); expect(result.resolved.get('/absolute/override/path')).toEqual({ @@ -225,12 +377,15 @@ describe('resolveMatchedConfigs', () => { describe('prefix resolution', () => { it('should handle module prefix patterns', async () => { - const configs: [string, ConsumeOptions][] = [ + const configs: [string, PartialConsumeOptions][] = [ ['@company/', { shareScope: 'default' }], ['utils/', { shareScope: 'utilities' }], ]; - const result = await resolveMatchedConfigs(mockCompilation, configs); + const result = await resolveMatchedConfigs( + compilation, + toConsumeOptionsArray(configs), + ); expect(result.prefixed.size).toBe(2); expect(result.prefixed.has('@company/')).toBe(true); @@ -245,12 +400,15 @@ describe('resolveMatchedConfigs', () => { }); it('should handle prefix patterns with layers', async () => { - const configs: [string, ConsumeOptions][] = [ + const configs: [string, PartialConsumeOptions][] = [ ['@scoped/', { shareScope: 'default', issuerLayer: 'client' }], ['components/', { shareScope: 'ui', issuerLayer: 'server' }], ]; - const result = await resolveMatchedConfigs(mockCompilation, configs); + const result = await resolveMatchedConfigs( + compilation, + toConsumeOptionsArray(configs), + ); expect(result.prefixed.size).toBe(2); expect(result.prefixed.has('(client)@scoped/')).toBe(true); @@ -262,11 +420,14 @@ describe('resolveMatchedConfigs', () => { }); it('should handle prefix patterns with custom request', async () => { - const configs: [string, ConsumeOptions][] = [ + const configs: [string, PartialConsumeOptions][] = [ ['alias/', { shareScope: 'default', request: '@actual-scope/' }], ]; - const result = await resolveMatchedConfigs(mockCompilation, configs); + const result = await resolveMatchedConfigs( + compilation, + toConsumeOptionsArray(configs), + ); expect(result.prefixed.has('@actual-scope/')).toBe(true); expect(result.prefixed.get('@actual-scope/')).toEqual({ @@ -278,13 +439,16 @@ describe('resolveMatchedConfigs', () => { describe('regular module resolution', () => { it('should handle regular module requests', async () => { - const configs: [string, ConsumeOptions][] = [ + const configs: [string, PartialConsumeOptions][] = [ ['react', { shareScope: 'default' }], ['lodash', { shareScope: 'utilities' }], ['@babel/core', { shareScope: 'build' }], ]; - const result = await resolveMatchedConfigs(mockCompilation, configs); + const result = await resolveMatchedConfigs( + compilation, + toConsumeOptionsArray(configs), + ); expect(result.unresolved.size).toBe(3); expect(result.unresolved.has('react')).toBe(true); @@ -294,12 +458,15 @@ describe('resolveMatchedConfigs', () => { }); it('should handle regular modules with layers', async () => { - const configs: [string, ConsumeOptions][] = [ + const configs: [string, PartialConsumeOptions][] = [ ['react', { shareScope: 'default', issuerLayer: 'client' }], ['express', { shareScope: 'server', issuerLayer: 'server' }], ]; - const result = await resolveMatchedConfigs(mockCompilation, configs); + const result = await resolveMatchedConfigs( + compilation, + toConsumeOptionsArray(configs), + ); expect(result.unresolved.size).toBe(2); expect(result.unresolved.has('(client)react')).toBe(true); @@ -311,11 +478,14 @@ describe('resolveMatchedConfigs', () => { }); it('should handle regular modules with custom requests', async () => { - const configs: [string, ConsumeOptions][] = [ + const configs: [string, PartialConsumeOptions][] = [ ['alias', { shareScope: 'default', request: 'actual-module' }], ]; - const result = await resolveMatchedConfigs(mockCompilation, configs); + const result = await resolveMatchedConfigs( + compilation, + toConsumeOptionsArray(configs), + ); expect(result.unresolved.has('actual-module')).toBe(true); expect(result.unresolved.get('actual-module')).toEqual({ @@ -327,7 +497,7 @@ describe('resolveMatchedConfigs', () => { describe('mixed configuration scenarios', () => { it('should handle mixed configuration types', async () => { - const configs: [string, ConsumeOptions][] = [ + const configs: [string, PartialConsumeOptions][] = [ ['./relative', { shareScope: 'default' }], ['/absolute/path', { shareScope: 'abs' }], ['prefix/', { shareScope: 'prefix' }], @@ -335,12 +505,21 @@ describe('resolveMatchedConfigs', () => { ]; mockResolver.resolve.mockImplementation( - (context, basePath, request, resolveContext, callback) => { + ( + context: string, + basePath: string, + request: string, + resolveContext: unknown, + callback: ResolveCallback, + ) => { callback(null, '/resolved/relative'); }, ); - const result = await resolveMatchedConfigs(mockCompilation, configs); + const result = await resolveMatchedConfigs( + compilation, + toConsumeOptionsArray(configs), + ); expect(result.resolved.size).toBe(2); // relative + absolute expect(result.prefixed.size).toBe(1); @@ -353,7 +532,7 @@ describe('resolveMatchedConfigs', () => { }); it('should handle concurrent resolution with some failures', async () => { - const configs: [string, ConsumeOptions][] = [ + const configs: [string, PartialConsumeOptions][] = [ ['./success', { shareScope: 'default' }], ['./failure', { shareScope: 'default' }], ['/absolute', { shareScope: 'abs' }], @@ -361,17 +540,32 @@ describe('resolveMatchedConfigs', () => { mockResolver.resolve .mockImplementationOnce( - (context, basePath, request, resolveContext, callback) => { + ( + context: string, + basePath: string, + request: string, + resolveContext: unknown, + callback: ResolveCallback, + ) => { callback(null, '/resolved/success'); }, ) .mockImplementationOnce( - (context, basePath, request, resolveContext, callback) => { + ( + context: string, + basePath: string, + request: string, + resolveContext: unknown, + callback: ResolveCallback, + ) => { callback(new Error('Resolution failed'), false); }, ); - const result = await resolveMatchedConfigs(mockCompilation, configs); + const result = await resolveMatchedConfigs( + compilation, + toConsumeOptionsArray(configs), + ); expect(result.resolved.size).toBe(2); // success + absolute expect(result.resolved.has('/resolved/success')).toBe(true); @@ -382,34 +576,43 @@ describe('resolveMatchedConfigs', () => { describe('layer handling and composite keys', () => { it('should create composite keys without layers', async () => { - const configs: [string, ConsumeOptions][] = [ + const configs: [string, PartialConsumeOptions][] = [ ['react', { shareScope: 'default' }], ]; - const result = await resolveMatchedConfigs(mockCompilation, configs); + const result = await resolveMatchedConfigs( + compilation, + toConsumeOptionsArray(configs), + ); expect(result.unresolved.has('react')).toBe(true); }); it('should create composite keys with issuerLayer', async () => { - const configs: [string, ConsumeOptions][] = [ + const configs: [string, PartialConsumeOptions][] = [ ['react', { shareScope: 'default', issuerLayer: 'client' }], ]; - const result = await resolveMatchedConfigs(mockCompilation, configs); + const result = await resolveMatchedConfigs( + compilation, + toConsumeOptionsArray(configs), + ); expect(result.unresolved.has('(client)react')).toBe(true); expect(result.unresolved.has('react')).toBe(false); }); it('should handle complex layer scenarios', async () => { - const configs: [string, ConsumeOptions][] = [ + const configs: [string, PartialConsumeOptions][] = [ ['module', { shareScope: 'default' }], ['module', { shareScope: 'layered', issuerLayer: 'layer1' }], ['module', { shareScope: 'layered2', issuerLayer: 'layer2' }], ]; - const result = await resolveMatchedConfigs(mockCompilation, configs); + const result = await resolveMatchedConfigs( + compilation, + toConsumeOptionsArray(configs), + ); expect(result.unresolved.size).toBe(3); expect(result.unresolved.has('module')).toBe(true); @@ -420,21 +623,32 @@ describe('resolveMatchedConfigs', () => { describe('dependency tracking', () => { it('should track file dependencies from resolution', async () => { - const configs: [string, ConsumeOptions][] = [ + const configs: [string, PartialConsumeOptions][] = [ ['./relative', { shareScope: 'default' }], ]; mockResolver.resolve.mockImplementation( - (context, basePath, request, rc, callback) => { + ( + context: string, + basePath: string, + request: string, + resolveContext: unknown, + callback: ResolveCallback, + ) => { // Simulate adding dependencies during resolution - rc.fileDependencies.add('/some/file.js'); - rc.contextDependencies.add('/some/context'); - rc.missingDependencies.add('/missing/file'); + const typedContext = resolveContext as { + fileDependencies: Set; + contextDependencies: Set; + missingDependencies: Set; + }; + typedContext.fileDependencies.add('/some/file.js'); + typedContext.contextDependencies.add('/some/context'); + typedContext.missingDependencies.add('/missing/file'); callback(null, '/resolved/relative'); }, ); - await resolveMatchedConfigs(mockCompilation, configs); + await resolveMatchedConfigs(compilation, toConsumeOptionsArray(configs)); expect(mockCompilation.contextDependencies.addAll).toHaveBeenCalledTimes( 1, @@ -462,9 +676,12 @@ describe('resolveMatchedConfigs', () => { describe('edge cases and error scenarios', () => { it('should handle empty configuration array', async () => { - const configs: [string, ConsumeOptions][] = []; + const configs: [string, PartialConsumeOptions][] = []; - const result = await resolveMatchedConfigs(mockCompilation, configs); + const result = await resolveMatchedConfigs( + compilation, + toConsumeOptionsArray(configs), + ); expect(result.resolved.size).toBe(0); expect(result.unresolved.size).toBe(0); @@ -477,43 +694,677 @@ describe('resolveMatchedConfigs', () => { throw new Error('Resolver factory error'); }); - const configs: [string, ConsumeOptions][] = [ + const configs: [string, PartialConsumeOptions][] = [ ['./relative', { shareScope: 'default' }], ]; await expect( - resolveMatchedConfigs(mockCompilation, configs), + resolveMatchedConfigs(compilation, toConsumeOptionsArray(configs)), ).rejects.toThrow('Resolver factory error'); }); it('should handle configurations with undefined request', async () => { - const configs: [string, ConsumeOptions][] = [ + const configs: [string, PartialConsumeOptions][] = [ ['module-name', { shareScope: 'default', request: undefined }], ]; - const result = await resolveMatchedConfigs(mockCompilation, configs); + const result = await resolveMatchedConfigs( + compilation, + toConsumeOptionsArray(configs), + ); expect(result.unresolved.has('module-name')).toBe(true); }); it('should handle edge case path patterns', async () => { - const configs: [string, ConsumeOptions][] = [ + const configs: [string, PartialConsumeOptions][] = [ ['utils/', { shareScope: 'root' }], // Prefix ending with / ['./', { shareScope: 'current' }], // Current directory relative ['regular-module', { shareScope: 'regular' }], // Regular module ]; mockResolver.resolve.mockImplementation( - (context, basePath, request, resolveContext, callback) => { - callback(null, '/resolved/' + request); + ( + context: string, + basePath: string, + request: string, + resolveContext: unknown, + callback: ResolveCallback, + ) => { + callback(null, `/resolved/${request}`); }, ); - const result = await resolveMatchedConfigs(mockCompilation, configs); + const result = await resolveMatchedConfigs( + compilation, + toConsumeOptionsArray(configs), + ); expect(result.prefixed.has('utils/')).toBe(true); expect(result.resolved.has('/resolved/./')).toBe(true); expect(result.unresolved.has('regular-module')).toBe(true); }); }); + + describe('integration scenarios with memfs', () => { + beforeEach(() => { + vol.reset(); + jest.clearAllMocks(); + }); + + describe('real module resolution scenarios', () => { + it('should resolve relative paths using memfs-backed file system', async () => { + vol.fromJSON({ + '/test-project/src/components/Button.js': + 'export const Button = () => {};', + '/test-project/src/utils/helpers.js': + 'export const helper = () => {};', + '/test-project/lib/external.js': 'module.exports = {};', + }); + + const configs: [string, PartialConsumeOptions][] = [ + ['./src/components/Button', { shareScope: 'default' }], + ['./src/utils/helpers', { shareScope: 'utilities' }], + ['./lib/external', { shareScope: 'external' }], + ]; + + const mockCompilation = { + resolverFactory: { + get: () => ({ + resolve: ( + context: string, + basePath: string, + request: string, + resolveContext: unknown, + callback: ResolveCallback, + ) => { + const fs = require('fs'); + const path = require('path'); + + const fullPath = path.resolve(basePath, request); + + fs.access(fullPath + '.js', fs.constants.F_OK, (err: any) => { + if (err) { + callback(new Error(`Module not found: ${request}`), false); + } else { + callback(null, fullPath + '.js'); + } + }); + }, + }), + }, + compiler: { context: '/test-project' }, + contextDependencies: { addAll: jest.fn() }, + fileDependencies: { addAll: jest.fn() }, + missingDependencies: { addAll: jest.fn() }, + errors: [] as CompilationError[], + }; + + const result = await resolveMatchedConfigs( + mockCompilation as any, + toConsumeOptionsArray(configs), + ); + + expect(result.resolved.size).toBe(3); + expect( + result.resolved.has('/test-project/src/components/Button.js'), + ).toBe(true); + expect(result.resolved.has('/test-project/src/utils/helpers.js')).toBe( + true, + ); + expect(result.resolved.has('/test-project/lib/external.js')).toBe(true); + + expect( + result.resolved.get('/test-project/src/components/Button.js') + ?.shareScope, + ).toBe('default'); + expect( + result.resolved.get('/test-project/src/utils/helpers.js')?.shareScope, + ).toBe('utilities'); + expect( + result.resolved.get('/test-project/lib/external.js')?.shareScope, + ).toBe('external'); + + expect(result.unresolved.size).toBe(0); + expect(result.prefixed.size).toBe(0); + expect(mockCompilation.errors).toHaveLength(0); + }); + + it('should surface missing files via compilation errors when using memfs', async () => { + vol.fromJSON({ + '/test-project/src/existing.js': 'export default {};', + }); + + const configs: [string, PartialConsumeOptions][] = [ + ['./src/existing', { shareScope: 'default' }], + ['./src/missing', { shareScope: 'default' }], + ]; + + const mockCompilation = { + resolverFactory: { + get: () => ({ + resolve: ( + context: string, + basePath: string, + request: string, + resolveContext: unknown, + callback: ResolveCallback, + ) => { + const fs = require('fs'); + const path = require('path'); + const fullPath = path.resolve(basePath, request); + + fs.access(fullPath + '.js', fs.constants.F_OK, (err: any) => { + if (err) { + callback(new Error(`Module not found: ${request}`), false); + } else { + callback(null, fullPath + '.js'); + } + }); + }, + }), + }, + compiler: { context: '/test-project' }, + contextDependencies: { addAll: jest.fn() }, + fileDependencies: { addAll: jest.fn() }, + missingDependencies: { addAll: jest.fn() }, + errors: [] as CompilationError[], + }; + + const result = await resolveMatchedConfigs( + mockCompilation as any, + toConsumeOptionsArray(configs), + ); + + expect(result.resolved.size).toBe(1); + expect(result.resolved.has('/test-project/src/existing.js')).toBe(true); + expect(mockCompilation.errors).toHaveLength(1); + expect(mockCompilation.errors[0].message).toContain('Module not found'); + }); + + it('should accept absolute paths without resolver when using memfs', async () => { + vol.fromJSON({ + '/absolute/path/module.js': 'module.exports = {};', + '/another/absolute/lib.js': 'export default {};', + }); + + const configs: [string, PartialConsumeOptions][] = [ + ['/absolute/path/module.js', { shareScope: 'absolute1' }], + ['/another/absolute/lib.js', { shareScope: 'absolute2' }], + ['/nonexistent/path.js', { shareScope: 'missing' }], + ]; + + const mockCompilation = { + resolverFactory: { get: () => ({}) }, + compiler: { context: '/test-project' }, + contextDependencies: { addAll: jest.fn() }, + fileDependencies: { addAll: jest.fn() }, + missingDependencies: { addAll: jest.fn() }, + errors: [] as CompilationError[], + }; + + const result = await resolveMatchedConfigs( + mockCompilation as any, + toConsumeOptionsArray(configs), + ); + + expect(result.resolved.size).toBe(3); + expect(result.resolved.has('/absolute/path/module.js')).toBe(true); + expect(result.resolved.has('/another/absolute/lib.js')).toBe(true); + expect(result.resolved.has('/nonexistent/path.js')).toBe(true); + + expect( + result.resolved.get('/absolute/path/module.js')?.shareScope, + ).toBe('absolute1'); + expect( + result.resolved.get('/another/absolute/lib.js')?.shareScope, + ).toBe('absolute2'); + }); + + it('should treat prefix patterns as prefixed entries under memfs', async () => { + const configs: [string, PartialConsumeOptions][] = [ + ['@company/', { shareScope: 'company' }], + ['utils/', { shareScope: 'utilities' }], + ['components/', { shareScope: 'ui', issuerLayer: 'client' }], + ]; + + const mockCompilation = { + resolverFactory: { get: () => ({}) }, + compiler: { context: '/test-project' }, + contextDependencies: { addAll: jest.fn() }, + fileDependencies: { addAll: jest.fn() }, + missingDependencies: { addAll: jest.fn() }, + errors: [] as CompilationError[], + }; + + const result = await resolveMatchedConfigs( + mockCompilation as any, + toConsumeOptionsArray(configs), + ); + + expect(result.prefixed.size).toBe(3); + expect(result.prefixed.has('@company/')).toBe(true); + expect(result.prefixed.has('utils/')).toBe(true); + expect(result.prefixed.has('(client)components/')).toBe(true); + + expect(result.prefixed.get('@company/')?.shareScope).toBe('company'); + expect(result.prefixed.get('utils/')?.shareScope).toBe('utilities'); + expect(result.prefixed.get('(client)components/')?.shareScope).toBe( + 'ui', + ); + expect(result.prefixed.get('(client)components/')?.issuerLayer).toBe( + 'client', + ); + }); + + it('should record regular module names as unresolved under memfs setup', async () => { + const configs: [string, PartialConsumeOptions][] = [ + ['react', { shareScope: 'default' }], + ['lodash', { shareScope: 'utilities' }], + ['@babel/core', { shareScope: 'build', issuerLayer: 'build' }], + ]; + + const mockCompilation = { + resolverFactory: { get: () => ({}) }, + compiler: { context: '/test-project' }, + contextDependencies: { addAll: jest.fn() }, + fileDependencies: { addAll: jest.fn() }, + missingDependencies: { addAll: jest.fn() }, + errors: [], + }; + + const result = await resolveMatchedConfigs( + mockCompilation as any, + toConsumeOptionsArray(configs), + ); + + expect(result.unresolved.size).toBe(3); + expect(result.unresolved.has('react')).toBe(true); + expect(result.unresolved.has('lodash')).toBe(true); + expect(result.unresolved.has('(build)@babel/core')).toBe(true); + + expect(result.unresolved.get('react')?.shareScope).toBe('default'); + expect(result.unresolved.get('lodash')?.shareScope).toBe('utilities'); + expect(result.unresolved.get('(build)@babel/core')?.shareScope).toBe( + 'build', + ); + expect(result.unresolved.get('(build)@babel/core')?.issuerLayer).toBe( + 'build', + ); + }); + }); + + describe('complex resolution scenarios', () => { + it('should handle mixed configuration types with realistic resolution', async () => { + vol.fromJSON({ + '/test-project/src/local.js': 'export default {};', + '/absolute/file.js': 'module.exports = {};', + }); + + const configs: [string, PartialConsumeOptions][] = [ + ['./src/local', { shareScope: 'local' }], + ['/absolute/file.js', { shareScope: 'absolute' }], + ['@scoped/', { shareScope: 'scoped' }], + ['regular-module', { shareScope: 'regular' }], + ]; + + const mockCompilation = { + resolverFactory: { + get: () => ({ + resolve: ( + context: string, + basePath: string, + request: string, + resolveContext: unknown, + callback: ResolveCallback, + ) => { + const fs = require('fs'); + const path = require('path'); + const fullPath = path.resolve(basePath, request); + + fs.access(fullPath + '.js', fs.constants.F_OK, (err: any) => { + if (err) { + callback(new Error(`Module not found: ${request}`), false); + } else { + callback(null, fullPath + '.js'); + } + }); + }, + }), + }, + compiler: { context: '/test-project' }, + contextDependencies: { addAll: jest.fn() }, + fileDependencies: { addAll: jest.fn() }, + missingDependencies: { addAll: jest.fn() }, + errors: [] as CompilationError[], + }; + + const result = await resolveMatchedConfigs( + mockCompilation as any, + toConsumeOptionsArray(configs), + ); + + expect(result.resolved.size).toBe(2); + expect(result.prefixed.size).toBe(1); + expect(result.unresolved.size).toBe(1); + + expect(result.resolved.has('/test-project/src/local.js')).toBe(true); + expect(result.resolved.has('/absolute/file.js')).toBe(true); + expect(result.prefixed.has('@scoped/')).toBe(true); + expect(result.unresolved.has('regular-module')).toBe(true); + }); + + it('should respect custom request overrides during resolution', async () => { + vol.fromJSON({ + '/test-project/src/actual-file.js': 'export default {};', + }); + + const configs: [string, PartialConsumeOptions][] = [ + [ + 'alias-name', + { + shareScope: 'default', + request: './src/actual-file', + }, + ], + [ + 'absolute-alias', + { + shareScope: 'absolute', + request: '/test-project/src/actual-file.js', + }, + ], + [ + 'prefix-alias', + { + shareScope: 'prefix', + request: 'utils/', + }, + ], + ]; + + const mockCompilation = { + resolverFactory: { + get: () => ({ + resolve: ( + context: string, + basePath: string, + request: string, + resolveContext: unknown, + callback: ResolveCallback, + ) => { + const fs = require('fs'); + const path = require('path'); + const fullPath = path.resolve(basePath, request); + + fs.access(fullPath + '.js', fs.constants.F_OK, (err: any) => { + if (err) { + callback(new Error(`Module not found: ${request}`), false); + } else { + callback(null, fullPath + '.js'); + } + }); + }, + }), + }, + compiler: { context: '/test-project' }, + contextDependencies: { addAll: jest.fn() }, + fileDependencies: { addAll: jest.fn() }, + missingDependencies: { addAll: jest.fn() }, + errors: [] as CompilationError[], + }; + + const result = await resolveMatchedConfigs( + mockCompilation as any, + toConsumeOptionsArray(configs), + ); + + expect(result.resolved.size).toBe(1); + expect(result.prefixed.size).toBe(1); + expect(result.unresolved.size).toBe(0); + + expect(result.resolved.has('/test-project/src/actual-file.js')).toBe( + true, + ); + expect(result.prefixed.has('utils/')).toBe(true); + + const resolvedConfig = result.resolved.get( + '/test-project/src/actual-file.js', + ); + expect(resolvedConfig).toBeDefined(); + expect(resolvedConfig?.request).toBeDefined(); + }); + }); + + describe('layer handling with memfs', () => { + it('should build composite keys for layered modules and prefixes', async () => { + const configs: [string, PartialConsumeOptions][] = [ + ['react', { shareScope: 'default' }], + ['react', { shareScope: 'client', issuerLayer: 'client' }], + ['express', { shareScope: 'server', issuerLayer: 'server' }], + ['utils/', { shareScope: 'utilities', issuerLayer: 'shared' }], + ]; + + const mockCompilation = { + resolverFactory: { get: () => ({}) }, + compiler: { context: '/test-project' }, + contextDependencies: { addAll: jest.fn() }, + fileDependencies: { addAll: jest.fn() }, + missingDependencies: { addAll: jest.fn() }, + errors: [], + }; + + const result = await resolveMatchedConfigs( + mockCompilation as any, + toConsumeOptionsArray(configs), + ); + + expect(result.unresolved.size).toBe(3); + expect(result.prefixed.size).toBe(1); + + expect(result.unresolved.has('react')).toBe(true); + expect(result.unresolved.has('(client)react')).toBe(true); + expect(result.unresolved.has('(server)express')).toBe(true); + expect(result.prefixed.has('(shared)utils/')).toBe(true); + + expect(result.unresolved.get('react')?.issuerLayer).toBeUndefined(); + expect(result.unresolved.get('(client)react')?.issuerLayer).toBe( + 'client', + ); + expect(result.unresolved.get('(server)express')?.issuerLayer).toBe( + 'server', + ); + expect(result.prefixed.get('(shared)utils/')?.issuerLayer).toBe( + 'shared', + ); + }); + }); + + describe('dependency tracking with memfs', () => { + it('should forward resolver dependency sets to the compilation', async () => { + vol.fromJSON({ + '/test-project/src/component.js': 'export default {};', + }); + + const configs: [string, PartialConsumeOptions][] = [ + ['./src/component', { shareScope: 'default' }], + ]; + + const mockDependencies = { + contextDependencies: { addAll: jest.fn() }, + fileDependencies: { addAll: jest.fn() }, + missingDependencies: { addAll: jest.fn() }, + }; + + const mockCompilation = { + resolverFactory: { + get: () => ({ + resolve: ( + context: string, + basePath: string, + request: string, + resolveContext: unknown, + callback: ResolveCallback, + ) => { + const typedContext = resolveContext as { + fileDependencies: LazySet; + contextDependencies: LazySet; + }; + typedContext.fileDependencies.add( + '/test-project/src/component.js', + ); + typedContext.contextDependencies.add('/test-project/src'); + + const fs = require('fs'); + const path = require('path'); + const fullPath = path.resolve(basePath, request); + + fs.access(fullPath + '.js', fs.constants.F_OK, (err: any) => { + if (err) { + callback(new Error(`Module not found: ${request}`), false); + } else { + callback(null, fullPath + '.js'); + } + }); + }, + }), + }, + compiler: { context: '/test-project' }, + ...mockDependencies, + errors: [] as CompilationError[], + }; + + await resolveMatchedConfigs( + mockCompilation as any, + toConsumeOptionsArray(configs), + ); + + expect(mockDependencies.contextDependencies.addAll).toHaveBeenCalled(); + expect(mockDependencies.fileDependencies.addAll).toHaveBeenCalled(); + expect(mockDependencies.missingDependencies.addAll).toHaveBeenCalled(); + }); + }); + + describe('edge cases and concurrency with memfs', () => { + it('should handle an empty configuration array with memfs mocks', async () => { + const configs: [string, PartialConsumeOptions][] = []; + + const mockCompilation = { + resolverFactory: { get: () => ({}) }, + compiler: { context: '/test-project' }, + contextDependencies: { addAll: jest.fn() }, + fileDependencies: { addAll: jest.fn() }, + missingDependencies: { addAll: jest.fn() }, + errors: [], + }; + + const result = await resolveMatchedConfigs( + mockCompilation as any, + toConsumeOptionsArray(configs), + ); + + expect(result.resolved.size).toBe(0); + expect(result.unresolved.size).toBe(0); + expect(result.prefixed.size).toBe(0); + expect(mockCompilation.errors).toHaveLength(0); + }); + + it('should propagate resolver factory failures when using memfs', async () => { + const configs: [string, PartialConsumeOptions][] = [ + ['./src/component', { shareScope: 'default' }], + ]; + + const mockCompilation = { + resolverFactory: { + get: () => { + throw new Error('Resolver factory error'); + }, + }, + compiler: { context: '/test-project' }, + contextDependencies: { addAll: jest.fn() }, + fileDependencies: { addAll: jest.fn() }, + missingDependencies: { addAll: jest.fn() }, + errors: [] as CompilationError[], + }; + + await expect( + resolveMatchedConfigs( + mockCompilation as any, + toConsumeOptionsArray(configs), + ), + ).rejects.toThrow('Resolver factory error'); + }); + + it('should resolve multiple files concurrently without errors', async () => { + vol.fromJSON({ + '/test-project/src/a.js': 'export default "a";', + '/test-project/src/b.js': 'export default "b";', + '/test-project/src/c.js': 'export default "c";', + '/test-project/src/d.js': 'export default "d";', + '/test-project/src/e.js': 'export default "e";', + }); + + const configs: [string, PartialConsumeOptions][] = [ + ['./src/a', { shareScope: 'a' }], + ['./src/b', { shareScope: 'b' }], + ['./src/c', { shareScope: 'c' }], + ['./src/d', { shareScope: 'd' }], + ['./src/e', { shareScope: 'e' }], + ]; + + const mockCompilation = { + resolverFactory: { + get: () => ({ + resolve: ( + context: string, + basePath: string, + request: string, + resolveContext: unknown, + callback: ResolveCallback, + ) => { + const fs = require('fs'); + const path = require('path'); + const fullPath = path.resolve(basePath, request); + + setTimeout(() => { + fs.access(fullPath + '.js', fs.constants.F_OK, (err: any) => { + if (err) { + callback( + new Error(`Module not found: ${request}`), + false, + ); + } else { + callback(null, fullPath + '.js'); + } + }); + }, Math.random() * 10); + }, + }), + }, + compiler: { context: '/test-project' }, + contextDependencies: { addAll: jest.fn() }, + fileDependencies: { addAll: jest.fn() }, + missingDependencies: { addAll: jest.fn() }, + errors: [] as CompilationError[], + }; + + const result = await resolveMatchedConfigs( + mockCompilation as any, + toConsumeOptionsArray(configs), + ); + + expect(result.resolved.size).toBe(5); + expect(mockCompilation.errors).toHaveLength(0); + + ['a', 'b', 'c', 'd', 'e'].forEach((letter) => { + expect(result.resolved.has(`/test-project/src/${letter}.js`)).toBe( + true, + ); + expect( + result.resolved.get(`/test-project/src/${letter}.js`)?.shareScope, + ).toBe(letter); + }); + }); + }); + }); }); diff --git a/packages/enhanced/test/unit/sharing/test-types.ts b/packages/enhanced/test/unit/sharing/test-types.ts new file mode 100644 index 00000000000..5fb77dac11c --- /dev/null +++ b/packages/enhanced/test/unit/sharing/test-types.ts @@ -0,0 +1,44 @@ +import type { Compilation } from 'webpack'; +import type ConsumeSharedPlugin from '../../../src/lib/sharing/ConsumeSharedPlugin'; +import type { SemVerRange } from 'webpack/lib/util/semver'; + +export type ConsumeSharedPluginInstance = ConsumeSharedPlugin; + +export type ConsumeConfig = Parameters< + ConsumeSharedPluginInstance['createConsumeSharedModule'] +>[3]; + +// Infer the resolver signature from webpack's Compilation type so tests stay aligned +// with upstream changes. +type NormalResolver = ReturnType; + +type ResolveSignature = NormalResolver extends { + resolve: infer Fn; +} + ? Fn + : ( + context: Record, + lookupStartPath: string, + request: string, + resolveContext: Record, + callback: (err: Error | null, result?: string | null) => void, + ) => void; + +export type ResolveFunction = ResolveSignature; + +export type DescriptionFileResolver = ( + fs: Parameters[0], + dir: string, + files: string[], + callback: ( + err: Error | null, + result?: { data?: { name?: string; version?: string }; path?: string }, + ) => void, +) => void; + +export type ConsumeEntry = [ + string, + Partial & Record, +]; + +export type SemVerRangeLike = SemVerRange | string; diff --git a/packages/enhanced/test/unit/sharing/utils.ts b/packages/enhanced/test/unit/sharing/utils.ts index bdf1734b068..501ce5fffab 100644 --- a/packages/enhanced/test/unit/sharing/utils.ts +++ b/packages/enhanced/test/unit/sharing/utils.ts @@ -254,13 +254,17 @@ export const createMockCompilation = () => { export const createTapableHook = (name: string) => { const hook = { name, - tap: jest.fn().mockImplementation((pluginName, callback) => { - hook.callback = callback; - }), + tap: jest + .fn() + .mockImplementation( + (pluginName: string, callback: (...args: unknown[]) => unknown) => { + hook.callback = callback; + }, + ), tapPromise: jest.fn(), call: jest.fn(), promise: jest.fn(), - callback: null, + callback: null as ((...args: unknown[]) => unknown) | null, }; return hook; }; @@ -420,13 +424,17 @@ export const createSharingTestEnvironment = () => { }; // Set up the compilation hook callback to invoke with our mocks - compiler.hooks.compilation.tap.mockImplementation((name, callback) => { - compiler.hooks.compilation.callback = callback; - }); + compiler.hooks.compilation.tap.mockImplementation( + (name: string, callback: (...args: unknown[]) => unknown) => { + compiler.hooks.compilation.callback = callback; + }, + ); - compiler.hooks.thisCompilation.tap.mockImplementation((name, callback) => { - compiler.hooks.thisCompilation.callback = callback; - }); + compiler.hooks.thisCompilation.tap.mockImplementation( + (name: string, callback: (...args: unknown[]) => unknown) => { + compiler.hooks.thisCompilation.callback = callback; + }, + ); // Function to simulate the compilation phase const simulateCompilation = () => { diff --git a/packages/enhanced/tsconfig.spec.json b/packages/enhanced/tsconfig.spec.json index 9b2a121d114..7ba1db8e567 100644 --- a/packages/enhanced/tsconfig.spec.json +++ b/packages/enhanced/tsconfig.spec.json @@ -3,12 +3,19 @@ "compilerOptions": { "outDir": "../../dist/out-tsc", "module": "commonjs", - "types": ["jest", "node"] + "types": ["jest", "node"], + "noEmit": true, + "baseUrl": ".", + "paths": { + "webpack": ["../../webpack/types.d.ts"], + "webpack/*": ["../../webpack/lib/*.d.ts", "../../webpack/*"] + } }, "include": [ "jest.config.ts", - "src/**/*.test.ts", - "src/**/*.spec.ts", + "test/**/*.test.ts", + "test/**/*.spec.ts", + "test/**/*.d.ts", "src/**/*.d.ts" ] }