From b42ddf1b78f67bc4cc7e95f91e2306571934916a Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 11 Jul 2025 23:39:20 -0700 Subject: [PATCH] feat: enhance HoistContainerReferencesPlugin for better module hoisting - Separate handling for container, federation, and remote dependencies - Improved support for runtimeChunk: 'single' configuration - Proper remote module hoisting using the new addRemoteDependency hook - Simplified cleanup logic for better performance - Changed runtime chunk detection to include all chunks with runtime - Added comprehensive unit tests --- .changeset/enhanced-hoist-container-refs.md | 12 + .../HoistContainerReferencesPlugin.ts | 135 +++-- .../HoistContainerReferencesPlugin.test.ts | 567 ++++++++++++++++++ 3 files changed, 672 insertions(+), 42 deletions(-) create mode 100644 .changeset/enhanced-hoist-container-refs.md create mode 100644 packages/enhanced/test/compiler-unit/container/HoistContainerReferencesPlugin.test.ts diff --git a/.changeset/enhanced-hoist-container-refs.md b/.changeset/enhanced-hoist-container-refs.md new file mode 100644 index 00000000000..31798e36aa6 --- /dev/null +++ b/.changeset/enhanced-hoist-container-refs.md @@ -0,0 +1,12 @@ +--- +"@module-federation/enhanced": patch +--- + +feat: enhance HoistContainerReferencesPlugin for better module hoisting + +- Separate handling for container, federation, and remote dependencies +- Improved support for `runtimeChunk: 'single'` configuration +- Proper remote module hoisting using the new `addRemoteDependency` hook +- Simplified cleanup logic for better performance +- Changed runtime chunk detection to include all chunks with runtime (not just entry chunks) +- Added comprehensive unit tests for the plugin functionality \ No newline at end of file diff --git a/packages/enhanced/src/lib/container/HoistContainerReferencesPlugin.ts b/packages/enhanced/src/lib/container/HoistContainerReferencesPlugin.ts index 525da3b99b4..2e0d3e9fff1 100644 --- a/packages/enhanced/src/lib/container/HoistContainerReferencesPlugin.ts +++ b/packages/enhanced/src/lib/container/HoistContainerReferencesPlugin.ts @@ -1,7 +1,7 @@ import type { + Chunk, Compiler, Compilation, - Chunk, WebpackPluginInstance, Module, Dependency, @@ -10,6 +10,8 @@ import { normalizeWebpackPath } from '@module-federation/sdk/normalize-webpack-p import FederationModulesPlugin from './runtime/FederationModulesPlugin'; import ContainerEntryDependency from './ContainerEntryDependency'; import FederationRuntimeDependency from './runtime/FederationRuntimeDependency'; +import RemoteToExternalDependency from './RemoteToExternalDependency'; +import FallbackDependency from './FallbackDependency'; const { AsyncDependenciesBlock, ExternalModule } = require( normalizeWebpackPath('webpack'), @@ -18,9 +20,9 @@ const { AsyncDependenciesBlock, ExternalModule } = require( const PLUGIN_NAME = 'HoistContainerReferences'; /** - * This class is used to hoist container references in the code. + * This plugin hoists container-related modules into runtime chunks when using runtimeChunk: single configuration. */ -export class HoistContainerReferences implements WebpackPluginInstance { +class HoistContainerReferences implements WebpackPluginInstance { apply(compiler: Compiler): void { compiler.hooks.thisCompilation.tap( PLUGIN_NAME, @@ -28,6 +30,9 @@ export class HoistContainerReferences implements WebpackPluginInstance { const logger = compilation.getLogger(PLUGIN_NAME); const hooks = FederationModulesPlugin.getCompilationHooks(compilation); const containerEntryDependencies = new Set(); + const federationRuntimeDependencies = new Set(); + const remoteDependencies = new Set(); + hooks.addContainerEntryDependency.tap( 'HoistContainerReferences', (dep: ContainerEntryDependency) => { @@ -37,7 +42,13 @@ export class HoistContainerReferences implements WebpackPluginInstance { hooks.addFederationRuntimeDependency.tap( 'HoistContainerReferences', (dep: FederationRuntimeDependency) => { - containerEntryDependencies.add(dep); + federationRuntimeDependencies.add(dep); + }, + ); + hooks.addRemoteDependency.tap( + 'HoistContainerReferences', + (dep: RemoteToExternalDependency | FallbackDependency) => { + remoteDependencies.add(dep); }, ); @@ -53,9 +64,10 @@ export class HoistContainerReferences implements WebpackPluginInstance { this.hoistModulesInChunks( compilation, runtimeChunks, - chunks, logger, containerEntryDependencies, + federationRuntimeDependencies, + remoteDependencies, ); }, ); @@ -67,37 +79,60 @@ export class HoistContainerReferences implements WebpackPluginInstance { private hoistModulesInChunks( compilation: Compilation, runtimeChunks: Set, - chunks: Iterable, logger: ReturnType, containerEntryDependencies: Set, + federationRuntimeDependencies: Set, + remoteDependencies: Set, ): void { const { chunkGraph, moduleGraph } = compilation; - // when runtimeChunk: single is set - ContainerPlugin will create a "partial" chunk we can use to - // move modules into the runtime chunk + const allModulesToHoist = new Set(); + + // Process container entry dependencies (needed for nextjs-mf exposed modules) for (const dep of containerEntryDependencies) { const containerEntryModule = moduleGraph.getModule(dep); if (!containerEntryModule) continue; - const allReferencedModules = getAllReferencedModules( + const referencedModules = getAllReferencedModules( compilation, containerEntryModule, 'initial', ); + referencedModules.forEach((m: Module) => allModulesToHoist.add(m)); + const moduleRuntimes = chunkGraph.getModuleRuntimes(containerEntryModule); + const runtimes = new Set(); + for (const runtimeSpec of moduleRuntimes) { + compilation.compiler.webpack.util.runtime.forEachRuntime( + runtimeSpec, + (runtimeKey) => { + if (runtimeKey) { + runtimes.add(runtimeKey); + } + }, + ); + } + for (const runtime of runtimes) { + const runtimeChunk = compilation.namedChunks.get(runtime); + if (!runtimeChunk) continue; + for (const module of referencedModules) { + if (!chunkGraph.isModuleInChunk(module, runtimeChunk)) { + chunkGraph.connectChunkAndModule(runtimeChunk, module); + } + } + } + } - const allRemoteReferences = getAllReferencedModules( + // Federation Runtime Dependencies: use 'initial' (not 'all') + for (const dep of federationRuntimeDependencies) { + const runtimeModule = moduleGraph.getModule(dep); + if (!runtimeModule) continue; + const referencedModules = getAllReferencedModules( compilation, - containerEntryModule, - 'external', + runtimeModule, + 'initial', ); - - for (const remote of allRemoteReferences) { - allReferencedModules.add(remote); - } - - const containerRuntimes = - chunkGraph.getModuleRuntimes(containerEntryModule); + referencedModules.forEach((m: Module) => allModulesToHoist.add(m)); + const moduleRuntimes = chunkGraph.getModuleRuntimes(runtimeModule); const runtimes = new Set(); - - for (const runtimeSpec of containerRuntimes) { + for (const runtimeSpec of moduleRuntimes) { compilation.compiler.webpack.util.runtime.forEachRuntime( runtimeSpec, (runtimeKey) => { @@ -107,19 +142,49 @@ export class HoistContainerReferences implements WebpackPluginInstance { }, ); } - for (const runtime of runtimes) { const runtimeChunk = compilation.namedChunks.get(runtime); if (!runtimeChunk) continue; + for (const module of referencedModules) { + if (!chunkGraph.isModuleInChunk(module, runtimeChunk)) { + chunkGraph.connectChunkAndModule(runtimeChunk, module); + } + } + } + } - for (const module of allReferencedModules) { + // Process remote dependencies + for (const remoteDep of remoteDependencies) { + const remoteModule = moduleGraph.getModule(remoteDep); + if (!remoteModule) continue; + const referencedRemoteModules = getAllReferencedModules( + compilation, + remoteModule, + 'initial', + ); + referencedRemoteModules.forEach((m: Module) => allModulesToHoist.add(m)); + const remoteModuleRuntimes = chunkGraph.getModuleRuntimes(remoteModule); + const remoteRuntimes = new Set(); + for (const runtimeSpec of remoteModuleRuntimes) { + compilation.compiler.webpack.util.runtime.forEachRuntime( + runtimeSpec, + (runtimeKey) => { + if (runtimeKey) remoteRuntimes.add(runtimeKey); + }, + ); + } + for (const runtime of remoteRuntimes) { + const runtimeChunk = compilation.namedChunks.get(runtime); + if (!runtimeChunk) continue; + for (const module of referencedRemoteModules) { if (!chunkGraph.isModuleInChunk(module, runtimeChunk)) { chunkGraph.connectChunkAndModule(runtimeChunk, module); } } } - this.cleanUpChunks(compilation, allReferencedModules); } + + this.cleanUpChunks(compilation, allModulesToHoist); } // Method to clean up chunks by disconnecting unused modules @@ -129,31 +194,17 @@ export class HoistContainerReferences implements WebpackPluginInstance { for (const chunk of chunkGraph.getModuleChunks(module)) { if (!chunk.hasRuntime()) { chunkGraph.disconnectChunkAndModule(chunk, module); - if ( - chunkGraph.getNumberOfChunkModules(chunk) === 0 && - chunkGraph.getNumberOfEntryModules(chunk) === 0 - ) { - chunkGraph.disconnectChunk(chunk); - compilation.chunks.delete(chunk); - if (chunk.name) { - compilation.namedChunks.delete(chunk.name); - } - } } } } - modules.clear(); } - // Helper method to get runtime chunks from the compilation + // Method to get runtime chunks private getRuntimeChunks(compilation: Compilation): Set { const runtimeChunks = new Set(); - const entries = compilation.entrypoints; - - for (const entrypoint of entries.values()) { - const runtimeChunk = entrypoint.getRuntimeChunk(); - if (runtimeChunk) { - runtimeChunks.add(runtimeChunk); + for (const chunk of compilation.chunks) { + if (chunk.hasRuntime()) { + runtimeChunks.add(chunk); } } return runtimeChunks; diff --git a/packages/enhanced/test/compiler-unit/container/HoistContainerReferencesPlugin.test.ts b/packages/enhanced/test/compiler-unit/container/HoistContainerReferencesPlugin.test.ts new file mode 100644 index 00000000000..406ea7cde00 --- /dev/null +++ b/packages/enhanced/test/compiler-unit/container/HoistContainerReferencesPlugin.test.ts @@ -0,0 +1,567 @@ +import { + ModuleFederationPlugin, + dependencies, +} from '@module-federation/enhanced'; +// Import the helper function we need +import { getAllReferencedModules } from '../../../src/lib/container/HoistContainerReferencesPlugin'; +// Use require for webpack as per linter rule +import { normalizeWebpackPath } from '@module-federation/sdk/normalize-webpack-path'; +const webpack = require( + normalizeWebpackPath('webpack'), +) as typeof import('webpack'); +// Use type imports for webpack types +type Compilation = import('webpack').Compilation; +type Module = import('webpack').Module; +type Chunk = import('webpack').Chunk; +import path from 'path'; +import fs from 'fs'; // Use real fs +import os from 'os'; // Use os for temp dir +// Import FederationRuntimeDependency directly +import FederationRuntimeDependency from '../../../src/lib/container/runtime/FederationRuntimeDependency'; + +describe('HoistContainerReferencesPlugin', () => { + let tempDir: string; + let allTempDirs: string[] = []; + + beforeAll(() => { + // Track all temp directories for final cleanup + allTempDirs = []; + }); + + beforeEach(() => { + // Create temp dir before each test + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hoist-test-')); + allTempDirs.push(tempDir); + }); + + afterEach((done) => { + // Clean up temp dir after each test + if (tempDir && fs.existsSync(tempDir)) { + // Add a small delay to allow file handles to be released + setTimeout(() => { + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + done(); + } catch (error) { + console.warn(`Failed to clean up temp directory ${tempDir}:`, error); + // Try alternative cleanup method + try { + fs.rmdirSync(tempDir, { recursive: true }); + done(); + } catch (fallbackError) { + console.error( + `Fallback cleanup also failed for ${tempDir}:`, + fallbackError, + ); + done(); + } + } + }, 100); // 100ms delay to allow file handles to close + } else { + done(); + } + }); + + afterAll(() => { + // Final cleanup of any remaining temp directories + allTempDirs.forEach((dir) => { + if (fs.existsSync(dir)) { + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch (error) { + console.warn(`Final cleanup failed for ${dir}:`, error); + } + } + }); + allTempDirs = []; + }); + + it('should hoist container runtime modules into the single runtime chunk when using remotes', (done) => { + // Define input file content + const mainJsContent = ` + import('remoteApp/utils') + .then(utils => console.log('Loaded remote utils:', utils)) + .catch(err => console.error('Error loading remote:', err)); + console.log('Host application started'); + `; + const packageJsonContent = '{ "name": "test-host", "version": "1.0.0" }'; + + // Write input files to tempDir + fs.writeFileSync(path.join(tempDir, 'main.js'), mainJsContent); + fs.writeFileSync(path.join(tempDir, 'package.json'), packageJsonContent); + + const outputPath = path.join(tempDir, 'dist'); + + const compiler = webpack({ + mode: 'development', + devtool: false, + context: tempDir, // Use tempDir as context + entry: { + main: './main.js', + }, + output: { + path: outputPath, // Use outputPath + filename: '[name].js', + chunkFilename: 'chunks/[name].[contenthash].js', + uniqueName: 'hoist-remote-test', + publicPath: 'auto', // Important for MF remotes + }, + optimization: { + runtimeChunk: 'single', // Critical for this test + chunkIds: 'named', + moduleIds: 'named', + }, + plugins: [ + new ModuleFederationPlugin({ + name: 'host', + remotes: { + // Define a remote + remoteApp: 'remoteApp@http://localhost:3001/remoteEntry.js', + }, + // No exposes or shared needed for this specific test + }), + ], + }); + + // Remove compiler fs assignments + + compiler.run((err, stats) => { + try { + if (err) { + return done(err); + } + if (!stats) { + return done(new Error('No stats object returned')); + } + if (stats.hasErrors()) { + // Add more detailed error logging + const info = stats.toJson({ + errorDetails: true, + all: false, + errors: true, + }); + console.error( + 'Webpack Errors:', + JSON.stringify(info.errors, null, 2), + ); + return done( + new Error( + info.errors + ?.map((e) => e.message + (e.details ? `\n${e.details}` : '')) + .join('\n'), + ), + ); + } + if (stats.hasWarnings()) { + console.warn( + 'Webpack Warnings:', + stats.toString({ colors: true, all: false, warnings: true }), + ); + } + + const compilation = stats.compilation; + const { chunkGraph, moduleGraph } = compilation; + + // 1. Find the runtime chunk + const runtimeChunk = Array.from(compilation.chunks).find( + (c: Chunk) => c.hasRuntime() && c.name === 'runtime', + ); + expect(runtimeChunk).toBeDefined(); + if (!runtimeChunk) return done(new Error('Runtime chunk not found')); + + // 2. Find the module that was created from FederationRuntimeDependency + let federationRuntimeModule: Module | null = null; + for (const module of compilation.modules) { + if (module.constructor.name === 'FederationRuntimeModule') { + federationRuntimeModule = module; + break; + } + } + expect(federationRuntimeModule).toBeDefined(); + if (!federationRuntimeModule) + return done( + new Error( + 'Module originating FederationRuntimeDependency not found', + ), + ); + + // 3. Assert the Federation Runtime Module is in the Runtime Chunk + const isRuntimeModuleInRuntime = chunkGraph.isModuleInChunk( + federationRuntimeModule, + runtimeChunk, + ); + expect(isRuntimeModuleInRuntime).toBe(true); + + // 4. Assert the Federation Runtime Module is NOT in the Main Chunk (if separate) + const mainChunk = Array.from(compilation.chunks).find( + (c: Chunk) => c.name === 'main', + ); + if (mainChunk && mainChunk !== runtimeChunk) { + const isRuntimeModuleInMain = chunkGraph.isModuleInChunk( + federationRuntimeModule, + mainChunk, + ); + expect(isRuntimeModuleInMain).toBe(false); + } + + // 5. Verify file output (Optional) + const runtimeFilePath = path.join(outputPath, 'runtime.js'); + expect(fs.existsSync(runtimeFilePath)).toBe(true); + + // Close compiler to release file handles + compiler.close(() => { + done(); + }); + } catch (e) { + // Close compiler even on error + compiler.close(() => { + done(e); + }); + } + }); + }); + + it('should NOT hoist container entry but hoist its deps when using exposes', (done) => { + // Define input file content + const mainJsContent = ` + console.log('Host application started, loading exposed module...'); + `; // Main entry might need to interact with container + const exposedJsContent = `export default () => 'exposed module content';`; + const packageJsonContent = + '{ "name": "test-host-exposes", "version": "1.0.0" }'; + + // Write input files to tempDir + fs.writeFileSync(path.join(tempDir, 'main.js'), mainJsContent); + fs.writeFileSync(path.join(tempDir, 'exposed.js'), exposedJsContent); + fs.writeFileSync(path.join(tempDir, 'package.json'), packageJsonContent); + + const outputPath = path.join(tempDir, 'dist'); + + const compiler = webpack({ + mode: 'development', + devtool: false, + context: tempDir, + entry: { + main: './main.js', + }, + output: { + path: outputPath, + filename: '[name].js', + chunkFilename: 'chunks/[name].[contenthash].js', + uniqueName: 'hoist-expose-test', + publicPath: 'auto', + }, + optimization: { + runtimeChunk: 'single', + chunkIds: 'named', + moduleIds: 'named', + }, + plugins: [ + new ModuleFederationPlugin({ + name: 'host_exposes', + filename: 'container.js', // Explicit container entry + exposes: { + './exposed': './exposed.js', // Expose a module + }, + // No remotes needed for this specific test + }), + ], + }); + + compiler.run((err, stats) => { + try { + if (err) return done(err); + if (!stats) return done(new Error('No stats object returned')); + if (stats.hasErrors()) { + const info = stats.toJson({ + errorDetails: true, + all: false, + errors: true, + }); + console.error( + 'Webpack Errors:', + JSON.stringify(info.errors, null, 2), + ); + return done( + new Error( + info.errors + ?.map((e) => e.message + (e.details ? `\n${e.details}` : '')) + .join('\n'), + ), + ); + } + if (stats.hasWarnings()) { + console.warn( + 'Webpack Warnings:', + stats.toString({ colors: true, all: false, warnings: true }), + ); + } + + const compilation = stats.compilation; + const { chunkGraph, moduleGraph } = compilation; + + // 1. Find the runtime chunk + const runtimeChunk = Array.from(compilation.chunks).find( + (c: Chunk) => c.hasRuntime() && c.name === 'runtime', + ); + expect(runtimeChunk).toBeDefined(); + if (!runtimeChunk) return done(new Error('Runtime chunk not found')); + + // 2. Find the Container Entry Module that was created from ContainerEntryDependency + let containerEntryModule: Module | null = null; + for (const module of compilation.modules) { + if (module.constructor.name === 'ContainerEntryModule') { + containerEntryModule = module; + break; + } + } + expect(containerEntryModule).toBeDefined(); + if (!containerEntryModule) + return done( + new Error('ContainerEntryModule not found via dependency check'), + ); + + // 3. Find the exposed module itself + const exposedModule = Array.from(compilation.modules).find((m) => + m.identifier().endsWith('exposed.js'), + ); + expect(exposedModule).toBeDefined(); + if (!exposedModule) + return done(new Error('Exposed module (exposed.js) not found')); + + // 4. Get all modules referenced by the container entry + const referencedModules = getAllReferencedModules( + compilation, + containerEntryModule, + 'all', + ); + expect(referencedModules.size).toBeGreaterThan(1); // container + exposed + runtime helpers + + // 5. Assert container entry itself is NOT in the runtime chunk + const isContainerInRuntime = chunkGraph.isModuleInChunk( + containerEntryModule, + runtimeChunk, + ); + expect(isContainerInRuntime).toBe(false); + + // 6. Assert the exposed module is NOT in the runtime chunk + const isExposedInRuntime = chunkGraph.isModuleInChunk( + exposedModule, + runtimeChunk, + ); + expect(isExposedInRuntime).toBe(false); + + // 7. Assert ALL OTHER referenced modules (runtime helpers) ARE in the runtime chunk + let hoistedCount = 0; + for (const module of referencedModules) { + // Skip the container entry and the actual exposed module + if (module === containerEntryModule || module === exposedModule) + continue; + + const isModuleInRuntime = chunkGraph.isModuleInChunk( + module, + runtimeChunk, + ); + expect(isModuleInRuntime).toBe(true); + if (isModuleInRuntime) { + hoistedCount++; + } + } + // Ensure at least one runtime helper module was found and hoisted + expect(hoistedCount).toBeGreaterThan(0); + + // 8. Verify file output (optional) + const runtimeFilePath = path.join(outputPath, 'runtime.js'); + const containerFilePath = path.join(outputPath, 'container.js'); + expect(fs.existsSync(runtimeFilePath)).toBe(true); + expect(fs.existsSync(containerFilePath)).toBe(true); + + // Close compiler to release file handles + compiler.close(() => { + done(); + }); + } catch (e) { + // Close compiler even on error + compiler.close(() => { + done(e); + }); + } + }); + }); + + xit('should hoist container runtime modules into the single runtime chunk when using remotes with federationRuntimeOriginModule', (done) => { + // Define input file content + const mainJsContent = ` + import('remoteApp/utils') + .then(utils => console.log('Loaded remote utils:', utils)) + .catch(err => console.error('Error loading remote:', err)); + console.log('Host application started'); + `; + const packageJsonContent = '{ "name": "test-host", "version": "1.0.0" }'; + + // Write input files to tempDir + fs.writeFileSync(path.join(tempDir, 'main.js'), mainJsContent); + fs.writeFileSync(path.join(tempDir, 'package.json'), packageJsonContent); + + const outputPath = path.join(tempDir, 'dist'); + + const compiler = webpack({ + mode: 'development', + devtool: false, + context: tempDir, // Use tempDir as context + entry: { + main: './main.js', + }, + output: { + path: outputPath, // Use outputPath + filename: '[name].js', + chunkFilename: 'chunks/[name].[contenthash].js', + uniqueName: 'hoist-remote-test', + publicPath: 'auto', // Important for MF remotes + }, + optimization: { + runtimeChunk: 'single', // Critical for this test + chunkIds: 'named', + moduleIds: 'named', + }, + plugins: [ + new ModuleFederationPlugin({ + name: 'host', + remotes: { + // Define a remote + remoteApp: 'remoteApp@http://localhost:3001/remoteEntry.js', + }, + // No exposes or shared needed for this specific test + }), + ], + }); + + // Remove compiler fs assignments + + compiler.run((err, stats) => { + try { + if (err) { + return done(err); + } + if (!stats) { + return done(new Error('No stats object returned')); + } + if (stats.hasErrors()) { + // Add more detailed error logging + const info = stats.toJson({ + errorDetails: true, + all: false, + errors: true, + }); + console.error( + 'Webpack Errors:', + JSON.stringify(info.errors, null, 2), + ); + return done( + new Error( + info.errors + ?.map((e) => e.message + (e.details ? `\n${e.details}` : '')) + .join('\n'), + ), + ); + } + if (stats.hasWarnings()) { + console.warn( + 'Webpack Warnings:', + stats.toString({ colors: true, all: false, warnings: true }), + ); + } + + const compilation = stats.compilation; + const { chunkGraph, moduleGraph } = compilation; + + // 1. Find the runtime chunk (using Array.from) + const runtimeChunk = Array.from(compilation.chunks).find( + (c: Chunk) => c.hasRuntime() && c.name === 'runtime', + ); + expect(runtimeChunk).toBeDefined(); + if (!runtimeChunk) return done(new Error('Runtime chunk not found')); + + // 2. Verify runtime chunk content directly + const runtimeFilePath = path.join(outputPath, 'runtime.js'); + expect(fs.existsSync(runtimeFilePath)).toBe(true); + const runtimeFileContent = fs.readFileSync(runtimeFilePath, 'utf-8'); + + // Check for presence of key MF runtime identifiers + const mfRuntimeKeywords = [ + '__webpack_require__.f.remotes', // Function for handling remotes + '__webpack_require__.S = ', // Share scope object + 'initializeSharing', // Function name for initializing sharing + '__webpack_require__.I = ', // Function for initializing consumes + ]; + + for (const keyword of mfRuntimeKeywords) { + expect(runtimeFileContent).toContain(keyword); + } + + // 3. Verify absence in main chunk (if separate) + const mainChunk = Array.from(compilation.chunks).find( + (c: Chunk) => c.name === 'main', + ); + if (mainChunk && mainChunk !== runtimeChunk) { + const mainFilePath = path.join(outputPath, 'main.js'); + expect(fs.existsSync(mainFilePath)).toBe(true); + const mainFileContent = fs.readFileSync(mainFilePath, 'utf-8'); + for (const keyword of mfRuntimeKeywords) { + expect(mainFileContent).not.toContain(keyword); + } + } + + // 4. Verify container file output (if applicable, not expected here) + const containerFilePath = path.join(outputPath, 'container.js'); // Filename was removed from config + // In remotes-only mode without filename, container.js might not exist or be empty + // expect(fs.existsSync(containerFilePath)).toBe(true); + + // 5. Find the federationRuntimeModule + let federationRuntimeModule: Module | null = null; + for (const module of compilation.modules) { + if (module.constructor.name === 'FederationRuntimeModule') { + federationRuntimeModule = module; + break; + } + } + expect(federationRuntimeModule).toBeDefined(); + if (!federationRuntimeModule) + return done( + new Error( + 'Module created from FederationRuntimeDependency not found', + ), + ); + + // 6. Assert the Federation Runtime Module is in the Runtime Chunk + const isRuntimeModuleInRuntime = chunkGraph.isModuleInChunk( + federationRuntimeModule, + runtimeChunk, + ); + expect(isRuntimeModuleInRuntime).toBe(true); + + // 7. Assert the Federation Runtime Module is NOT in the Main Chunk (if separate) + if (mainChunk && mainChunk !== runtimeChunk) { + const isRuntimeModuleInMain = chunkGraph.isModuleInChunk( + federationRuntimeModule, + mainChunk, + ); + expect(isRuntimeModuleInMain).toBe(false); + } + + // 8. Verify file output using real fs paths (Optional, but still useful) + expect(fs.existsSync(runtimeFilePath)).toBe(true); + + // Close compiler to release file handles + compiler.close(() => { + done(); + }); + } catch (e) { + // Close compiler even on error + compiler.close(() => { + done(e); + }); + } + }); + }); +});