diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 140692718..8d5e18e74 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -137,6 +137,10 @@ jobs: - name: Run Examples Test run: pnpm run test:examples + - name: Run Federation Example Test + if: runner.os == 'macOS' + run: pnpm run test:federation + # ======== e2e ======== e2e: needs: [prepare, lint, ut] diff --git a/e2e/dom/fixtures/test/handledError.test.tsx b/e2e/dom/fixtures/test/handledError.test.tsx index 479da8abf..2df1db2a7 100644 --- a/e2e/dom/fixtures/test/handledError.test.tsx +++ b/e2e/dom/fixtures/test/handledError.test.tsx @@ -8,9 +8,21 @@ test('should handle click error', async () => { const element = screen.getByText('Rsbuild with React'); - window.addEventListener('error', (event) => { - expect(event.message).toBe('click error'); + await new Promise((resolve) => { + window.addEventListener( + 'error', + (event) => { + expect(event.message).toBe('click error'); + event.preventDefault(); + // Some DOM implementations (e.g. happy-dom) don't reflect preventDefault() + // via `defaultPrevented`; this ensures frameworks can treat it as handled. + (event as any).returnValue = false; + resolve(); + }, + { once: true }, + ); + element.click(); }); - element.click(); + // Ensure the assertion above is reached before the test completes. }); diff --git a/examples/federation/.gitignore b/examples/federation/.gitignore new file mode 100644 index 000000000..45507cc5a --- /dev/null +++ b/examples/federation/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +dist/ +dist-*/ +.rstest-* +.rstest-temp/ +.rstest-mf-node-remote.lock +.DS_Store + diff --git a/examples/federation/README.md b/examples/federation/README.md new file mode 100644 index 000000000..ef1401534 --- /dev/null +++ b/examples/federation/README.md @@ -0,0 +1,32 @@ +# react-webpack-MF + +[中文](./README_zh-cn.md) + +A complete Webpack Module Federation Case with React. + +# project directory + +## lib-app + +Removed in this simplified example. + +## component-app + +It exposes UI components to `main-app` via Module Federation. + +It is a pure `remote`. + +## main-app + +The top-level app, which depends on `component-app`. + +It is a pure host. + +# how to use + +- `pnpm install` +- `pnpm run start` + +After running these commands, open your browser at `http://localhost:3002` and open the DevTools network tab to see resource loading details. + +[Federation Playwright example test](./e2e/checkApplication.spec.ts) diff --git a/examples/federation/README_zh-cn.md b/examples/federation/README_zh-cn.md new file mode 100644 index 000000000..52f0bceaf --- /dev/null +++ b/examples/federation/README_zh-cn.md @@ -0,0 +1,30 @@ +# react-webpack-MF + +[English](./README.md) + +一个相对完整的应用`Webpack Module Federation`的 React 项目案例 + +# 目录结构 + +## lib-app + +该示例已简化,不再包含 `lib-app`。 + +## component-app + +组件层 App,通过 Module Federation 暴露组件给 `main-app` 使用。 + +它是一个纯粹的 `remote`。 + +## main-app + +上层 App,依赖 `component-app` 应用。它也是一个纯粹的 `host`。 + +# 如何使用 + +- `pnpm install` +- `pnpm run start` + +执行完上述命令,打开浏览器,输入 `http://localhost:3002` 查看页面结果。 + +[Federation Playwright 示例测试](./e2e/checkApplication.spec.ts) diff --git a/examples/federation/component-app/App.jsx b/examples/federation/component-app/App.jsx new file mode 100644 index 000000000..86cac9a17 --- /dev/null +++ b/examples/federation/component-app/App.jsx @@ -0,0 +1,43 @@ +import React from 'react'; +import Button from './src/Button'; +import Dialog from './src/Dialog'; +import Logo from './src/Logo'; +export default class App extends React.Component { + constructor(props) { + super(props); + this.state = { + dialogVisible: false, + }; + this.handleClick = this.handleClick.bind(this); + this.handleSwitchVisible = this.handleSwitchVisible.bind(this); + } + handleClick(ev) { + console.log(ev); + this.setState({ + dialogVisible: true, + }); + } + handleSwitchVisible(visible) { + this.setState({ + dialogVisible: visible, + }); + } + render() { + return ( +
+ +
+ + +
+ ); + } +} diff --git a/examples/federation/component-app/bootstrap.js b/examples/federation/component-app/bootstrap.js new file mode 100644 index 000000000..a22f17594 --- /dev/null +++ b/examples/federation/component-app/bootstrap.js @@ -0,0 +1,5 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import App from './App'; + +ReactDOM.render(, document.getElementById('app')); diff --git a/examples/federation/component-app/index.js b/examples/federation/component-app/index.js new file mode 100644 index 000000000..d390836af --- /dev/null +++ b/examples/federation/component-app/index.js @@ -0,0 +1 @@ +import('./bootstrap.js'); diff --git a/examples/federation/component-app/package.json b/examples/federation/component-app/package.json new file mode 100644 index 000000000..5eefdfd12 --- /dev/null +++ b/examples/federation/component-app/package.json @@ -0,0 +1,33 @@ +{ + "name": "@examples/federation-component-app", + "version": "1.0.0", + "description": "", + "keywords": [], + "license": "ISC", + "author": "", + "main": "index.js", + "scripts": { + "build": "rspack build --config rspack.browser.config.js ", + "build:node": "rspack build --config rspack.node.config.js", + "serve": "serve dist -p 3001", + "serve:node": "serve dist-node -p 3001", + "start": "concurrently \"npm run build\" \"npm run serve\"", + "pretest": "pnpm -C ../node-local-remote build:node", + "test": "rstest --reporter=verbose" + }, + "dependencies": { + "@module-federation/node": "2.7.32", + "react": "^19.2.6", + "react-dom": "^19.2.6" + }, + "devDependencies": { + "@module-federation/enhanced": "https://pkg.pr.new/module-federation/core/@module-federation/enhanced@3fe0a1a3b6b5eab3d2d8eba2e4fd554fc1bda214", + "@module-federation/rstest": "https://pkg.pr.new/module-federation/core/@module-federation/rstest@517741e", + "@rsbuild/plugin-react": "^2.0.1", + "@rspack/cli": "~2.0.5", + "@rspack/core": "~2.0.5", + "@rstest/core": "workspace:*", + "concurrently": "8.2.2", + "serve": "14.2.3" + } +} diff --git a/examples/federation/component-app/public/index.html b/examples/federation/component-app/public/index.html new file mode 100644 index 000000000..432d5b45b --- /dev/null +++ b/examples/federation/component-app/public/index.html @@ -0,0 +1,12 @@ + + + + + + + Document + + +
+ + diff --git a/examples/federation/component-app/rspack.browser.config.js b/examples/federation/component-app/rspack.browser.config.js new file mode 100644 index 000000000..494ee7474 --- /dev/null +++ b/examples/federation/component-app/rspack.browser.config.js @@ -0,0 +1,62 @@ +const { HtmlRspackPlugin } = require('@rspack/core'); +const { + ModuleFederationPlugin, +} = require('@module-federation/enhanced/rspack'); + +module.exports = { + entry: './index.js', + mode: 'development', + devtool: 'hidden-source-map', + output: { + publicPath: 'http://localhost:3001/', + clean: true, + }, + resolve: { + extensions: ['.jsx', '.js', '.json', '.wasm'], + }, + experiments: { + css: true, + }, + module: { + rules: [ + { test: /\.(jpg|png|gif|jpeg)$/, type: 'asset/resource' }, + { + test: /\.css$/, + type: 'css/auto', + }, + { + test: /\.(js|jsx)$/, + use: { + loader: 'builtin:swc-loader', + options: { + jsc: { + parser: { syntax: 'ecmascript', jsx: true }, + transform: { react: { runtime: 'automatic' } }, + }, + }, + }, + }, + ], + }, + plugins: [ + new ModuleFederationPlugin({ + name: 'component_app', + experiments: { + asyncStartup: true, + }, + filename: 'remoteEntry.js', + library: { type: 'var', name: 'component_app' }, + exposes: { + './Button': './src/Button.jsx', + './Dialog': './src/Dialog.jsx', + './Logo': './src/Logo.jsx', + './ToolTip': './src/ToolTip.jsx', + }, + shared: { + react: { singleton: true, requiredVersion: '19.2.4' }, + 'react-dom': { singleton: true, requiredVersion: '19.2.4' }, + }, + }), + new HtmlRspackPlugin({ template: './public/index.html' }), + ], +}; diff --git a/examples/federation/component-app/rspack.node.config.js b/examples/federation/component-app/rspack.node.config.js new file mode 100644 index 000000000..87ed8fc98 --- /dev/null +++ b/examples/federation/component-app/rspack.node.config.js @@ -0,0 +1,88 @@ +const { + ModuleFederationPlugin, +} = require('@module-federation/enhanced/rspack'); +const path = require('node:path'); + +/** + * Node-targeted remote build used by Rstest's Module Federation test. + * This differs from the browser build (rspack.config.js): + * - target: async-node + * - includes a Node runtime plugin so remote chunks can be loaded over HTTP + * - outputs to dist-node (served on a separate port from the browser remote) + */ +module.exports = { + entry: './index.js', + mode: 'development', + devtool: 'hidden-source-map', + target: 'async-node', + output: { + // The Node remote runtime resolves chunk URLs relative to the remoteEntry URL + // when publicPath is explicitly set. `auto` can throw in non-browser contexts. + publicPath: 'http://localhost:3001/', + clean: true, + path: path.resolve(__dirname, 'dist-node'), + }, + resolve: { + extensions: ['.jsx', '.js', '.json', '.wasm'], + }, + experiments: { + css: true, + }, + module: { + rules: [ + { + test: /\.(jpg|png|gif|jpeg)$/, + type: 'asset/resource', + }, + { + test: /\.css$/, + type: 'css/auto', + }, + { + test: /\.(js|jsx)$/, + use: { + loader: 'builtin:swc-loader', + options: { + jsc: { + parser: { + syntax: 'ecmascript', + jsx: true, + }, + transform: { + react: { + runtime: 'automatic', + }, + }, + }, + }, + }, + }, + ], + }, + plugins: [ + new ModuleFederationPlugin({ + name: 'component_app', + experiments: { + asyncStartup: true, + }, + filename: 'remoteEntry.js', + // This remote is consumed by Rstest using `remoteType: 'script'` while tests + // run under JSDOM. We force the MF runtime to use its Node loader (vm eval), + // so the remoteEntry must export through CommonJS for the loader to return + // the container interface (get/init). + library: { type: 'commonjs-module', name: 'component_app' }, + // Required for async-node remotes that load chunks over HTTP in Node. + runtimePlugins: ['@module-federation/node/runtimePlugin'], + exposes: { + './Button': './src/Button.jsx', + './Dialog': './src/Dialog.jsx', + './Logo': './src/Logo.jsx', + './ToolTip': './src/ToolTip.jsx', + }, + shared: { + react: { singleton: true, requiredVersion: '19.2.4' }, + 'react-dom': { singleton: true, requiredVersion: '19.2.4' }, + }, + }), + ], +}; diff --git a/examples/federation/component-app/rstest.config.ts b/examples/federation/component-app/rstest.config.ts new file mode 100644 index 000000000..c566e0d3d --- /dev/null +++ b/examples/federation/component-app/rstest.config.ts @@ -0,0 +1,32 @@ +import path from 'node:path'; +import { federation } from '@module-federation/rstest'; +import { pluginReact } from '@rsbuild/plugin-react'; +import { defineConfig } from '@rstest/core'; + +export default defineConfig({ + globalSetup: ['./scripts/rstestGlobalSetup.ts'], + setupFiles: ['./scripts/rstest.setup.ts'], + testEnvironment: 'node', + plugins: [ + pluginReact(), + federation({ + name: 'component_app_node_test', + remoteType: 'commonjs', + remotes: { + 'node-local-remote': `commonjs ${path.resolve( + __dirname, + '../node-local-remote/dist-node/remoteEntry.js', + )}`, + }, + shared: { + react: { singleton: true, requiredVersion: '19.2.4' }, + 'react-dom': { + singleton: true, + requiredVersion: '19.2.4', + }, + }, + }), + ], + testTimeout: 15000, + federation: true, +}); diff --git a/examples/federation/component-app/scripts/rstest.setup.ts b/examples/federation/component-app/scripts/rstest.setup.ts new file mode 100644 index 000000000..477e48dec --- /dev/null +++ b/examples/federation/component-app/scripts/rstest.setup.ts @@ -0,0 +1,8 @@ +import vm from 'node:vm'; + +(globalThis as Record).ENV_TARGET = 'node'; +try { + vm.runInThisContext("var ENV_TARGET = 'node'"); +} catch { + // best effort +} diff --git a/examples/federation/component-app/scripts/rstestGlobalSetup.ts b/examples/federation/component-app/scripts/rstestGlobalSetup.ts new file mode 100644 index 000000000..98ca763e1 --- /dev/null +++ b/examples/federation/component-app/scripts/rstestGlobalSetup.ts @@ -0,0 +1,36 @@ +// Build the node-local remote once before running tests. +// +// The test runner executes projects directly (it does not run `pnpm test` for each +// example package), so package.json `pretest` hooks won't run in CI. +// Without this step, Module Federation's Node runtime can't load the local remoteEntry. +import { spawn } from 'node:child_process'; +import path from 'node:path'; + +const run = async (cwd: string, cmd: string, args: string[]) => { + // On Windows, pnpm is typically a `.cmd` shim, which isn't directly executable + // via CreateProcess (spawn) unless run through a shell. + const child = spawn(cmd, args, { + cwd, + stdio: 'inherit', + env: process.env, + shell: process.platform === 'win32', + }); + await new Promise((resolve, reject) => { + child.once('exit', (code) => { + code === 0 + ? resolve() + : reject(new Error(`${cmd} ${args.join(' ')} exited ${code}`)); + }); + child.once('error', (err) => reject(err)); + }); +}; + +export async function setup() { + const federationRoot = path.resolve(__dirname, '..', '..'); + const nodeLocalDir = path.resolve(federationRoot, 'node-local-remote'); + await run(nodeLocalDir, 'pnpm', ['build:node']); +} + +export async function teardown() { + // no-op +} diff --git a/examples/federation/component-app/src/Button.jsx b/examples/federation/component-app/src/Button.jsx new file mode 100644 index 000000000..a448fa150 --- /dev/null +++ b/examples/federation/component-app/src/Button.jsx @@ -0,0 +1,38 @@ +import React from 'react'; + +const styleMapping = { + primary: { + marginLeft: '10px', + color: '#fff', + backgroundColor: '#409eff', + borderColor: '#409eff', + padding: '12px 20px', + fontSize: '14px', + borderRadius: '4px', + outline: 'none', + border: '1px solid #dcdfe6', + cursor: 'pointer', + }, + warning: { + marginLeft: '10px', + color: '#fff', + backgroundColor: '#e6a23c', + borderColor: '#e6a23c', + padding: '12px 20px', + fontSize: '14px', + borderRadius: '4px', + outline: 'none', + border: '1px solid #dcdfe6', + cursor: 'pointer', + }, +}; +export default class Button extends React.Component { + render() { + var type = this.props.type || 'primary'; + return ( + + ); + } +} diff --git a/examples/federation/component-app/src/Dialog.jsx b/examples/federation/component-app/src/Dialog.jsx new file mode 100644 index 000000000..687e440ae --- /dev/null +++ b/examples/federation/component-app/src/Dialog.jsx @@ -0,0 +1,44 @@ +import React from 'react'; + +const wrapperStyle = { + position: 'fixed', + top: 0, + right: 0, + bottom: 0, + left: 0, + zIndex: 2000, + height: '100%', + backgroundColor: 'rgba(0,0,0,.5)', + overflow: 'auto', +}; +const boxStyle = { + width: '30%', + margin: '0 auto 50px', + marginTop: '15vh', + padding: '20px', + backgroundColor: '#fff', +}; +export default class Dialog extends React.Component { + render() { + if (this.props.visible) { + return ( +
+
+
+

What is your name ?

+ +
+ +
+
+ ); + } + return null; + } +} diff --git a/examples/federation/component-app/src/Logo.jsx b/examples/federation/component-app/src/Logo.jsx new file mode 100644 index 000000000..0a4e5616e --- /dev/null +++ b/examples/federation/component-app/src/Logo.jsx @@ -0,0 +1,11 @@ +import React from 'react'; +import pictureData from './MF.jpeg'; +export default function () { + return ( + Module Federation logo + ); +} diff --git a/examples/federation/component-app/src/MF.jpeg b/examples/federation/component-app/src/MF.jpeg new file mode 100644 index 000000000..b8563c588 Binary files /dev/null and b/examples/federation/component-app/src/MF.jpeg differ diff --git a/examples/federation/component-app/src/ToolTip.jsx b/examples/federation/component-app/src/ToolTip.jsx new file mode 100644 index 000000000..a0d27866e --- /dev/null +++ b/examples/federation/component-app/src/ToolTip.jsx @@ -0,0 +1,11 @@ +import React from 'react'; +import './tool-tip.css'; +export default class ToolTip extends React.Component { + render() { + return ( +
+ {this.props.content} +
+ ); + } +} diff --git a/examples/federation/component-app/src/tool-tip.css b/examples/federation/component-app/src/tool-tip.css new file mode 100644 index 000000000..2dbed66a5 --- /dev/null +++ b/examples/federation/component-app/src/tool-tip.css @@ -0,0 +1,40 @@ +.tool-tip { + background-color: #fff; + padding: 10px 16px; + border: 1px solid #dcdfe6; + display: inline-block; + cursor: pointer; + border-radius: 4px; + position: relative; +} +/* content-box */ +.tool-tip::before { + content: attr(data-content); + max-width: 100%; + box-sizing: border-box; + position: absolute; + background-color: #303133; + color: #fff; + font-size: 12px; + border-radius: 4px; + padding: 10px; + left: 50%; + bottom: 100%; + transform: translate(-50%, -10px); + display: none; +} +/* arrow-box */ +.tool-tip::after { + display: none; + content: ''; + border: 6px solid transparent; + border-top-color: #303133; + position: absolute; + left: 50%; + bottom: 100%; + transform: translate(-50%, 2px); +} +.tool-tip:hover::after, +.tool-tip:hover::before { + display: block; +} diff --git a/examples/federation/component-app/test/Button.ssr.test.ts b/examples/federation/component-app/test/Button.ssr.test.ts new file mode 100644 index 000000000..d1c408b2f --- /dev/null +++ b/examples/federation/component-app/test/Button.ssr.test.ts @@ -0,0 +1,10 @@ +import { expect, test } from '@rstest/core'; +import React from 'react'; +import { renderToString } from 'react-dom/server'; +import Button from '../src/Button.jsx'; + +test('SSR: Button renders primary', () => { + const html = renderToString(React.createElement(Button, { type: 'primary' })); + const normalized = html.replace(//g, ''); + expect(normalized).toContain('primary Button'); +}); diff --git a/examples/federation/component-app/test/Dialog.ssr.test.ts b/examples/federation/component-app/test/Dialog.ssr.test.ts new file mode 100644 index 000000000..90ab97786 --- /dev/null +++ b/examples/federation/component-app/test/Dialog.ssr.test.ts @@ -0,0 +1,11 @@ +import { expect, test } from '@rstest/core'; +import React from 'react'; +import { renderToString } from 'react-dom/server'; +import Dialog from '../src/Dialog.jsx'; + +test('SSR: Dialog renders when visible', () => { + const html = renderToString( + React.createElement(Dialog, { visible: true, switchVisible: () => {} }), + ); + expect(html).toContain('What is your name'); +}); diff --git a/examples/federation/component-app/test/NodeLocalRemote.container.test.ts b/examples/federation/component-app/test/NodeLocalRemote.container.test.ts new file mode 100644 index 000000000..031258747 --- /dev/null +++ b/examples/federation/component-app/test/NodeLocalRemote.container.test.ts @@ -0,0 +1,8 @@ +import { expect, test } from '@rstest/core'; + +test('node-local-remote federated import returns expected value', async () => { + // IMPORTANT: Do not `require()` the remoteEntry directly. + // Always go through Module Federation's runtime via the federated specifier. + const mod = await import('node-local-remote/test'); + expect(mod?.default ?? mod).toBe('module from node-local-remote'); +}); diff --git a/examples/federation/component-app/test/ToolTip.ssr.test.ts b/examples/federation/component-app/test/ToolTip.ssr.test.ts new file mode 100644 index 000000000..496422a9f --- /dev/null +++ b/examples/federation/component-app/test/ToolTip.ssr.test.ts @@ -0,0 +1,15 @@ +import { expect, test } from '@rstest/core'; +import React from 'react'; +import { renderToString } from 'react-dom/server'; +import ToolTip from '../src/ToolTip.jsx'; + +test('SSR: ToolTip renders content/message', () => { + const html = renderToString( + React.createElement(ToolTip, { + content: 'hover me please', + message: 'Hello,world!', + }), + ); + expect(html).toContain('hover me please'); + expect(html).toContain('data-content="Hello,world!"'); +}); diff --git a/examples/federation/e2e/checkApplication.spec.ts b/examples/federation/e2e/checkApplication.spec.ts new file mode 100644 index 000000000..5a1440b3a --- /dev/null +++ b/examples/federation/e2e/checkApplication.spec.ts @@ -0,0 +1,94 @@ +import { expect, test } from '@playwright/test'; + +// Hardcoded expectations for this example app. This avoids relying on shared +// fixtures that may not exist when running the example standalone. +const APP_TEXT = { + header: 'Open Dev Tool And Focus On Network,checkout resources details', + paragraphs: { + firstStrong: 'main-app', + secondStrong: 'component-app', + }, + h4: { + buttons: 'Buttons:', + dialog: 'Dialog:', + hoverTitle: 'hover me please!', + }, + tooltip: { + content: 'hover me please', + }, + buttons: { + primaryButton: 'primary Button', + warningButton: 'warning Button', + openDialogButton: 'click me to open Dialog', + closeButton: 'close It!', + }, + dialog: { + nameMessage: 'What is your name ?', + inputValue: 'rstest', + }, +} as const; + +const COLORS = { + primaryButtonBg: 'rgb(64, 158, 255)', // #409eff + warningButtonBg: 'rgb(230, 162, 60)', // #e6a23c +} as const; + +test.describe('Complete React case', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + }); + + test('Check App build and running', async ({ page }) => { + await expect(page.locator('h1')).toHaveText(APP_TEXT.header); + await expect(page.locator('strong').nth(0)).toHaveText( + APP_TEXT.paragraphs.firstStrong, + ); + await expect(page.locator('strong').nth(1)).toHaveText( + APP_TEXT.paragraphs.secondStrong, + ); + await expect( + page.locator('h4').filter({ hasText: APP_TEXT.h4.buttons }), + ).toBeVisible(); + await expect( + page.locator('h4').filter({ hasText: APP_TEXT.h4.dialog }), + ).toBeVisible(); + await expect( + page.locator('h4').filter({ hasText: APP_TEXT.h4.hoverTitle }), + ).toBeVisible(); + await expect(page.locator('.tool-tip')).toHaveText( + APP_TEXT.tooltip.content, + ); + }); + + test('Check App buttons', async ({ page }) => { + await expect( + page.getByRole('button', { name: APP_TEXT.buttons.primaryButton }), + ).toBeVisible(); + await expect( + page.getByRole('button', { name: APP_TEXT.buttons.warningButton }), + ).toBeVisible(); + await expect( + page.getByRole('button', { name: APP_TEXT.buttons.openDialogButton }), + ).toBeVisible(); + await expect( + page.getByRole('button', { name: APP_TEXT.buttons.primaryButton }), + ).toHaveCSS('background-color', COLORS.primaryButtonBg); + await expect( + page.getByRole('button', { name: APP_TEXT.buttons.warningButton }), + ).toHaveCSS('background-color', COLORS.warningButtonBg); + }); + + test('Check App Dialog popup', async ({ page }) => { + await page + .getByRole('button', { name: APP_TEXT.buttons.openDialogButton }) + .click(); + await expect( + page.getByRole('button', { name: APP_TEXT.buttons.closeButton }), + ).toBeVisible(); + await expect(page.getByText(APP_TEXT.dialog.nameMessage)).toBeVisible(); + await page.fill('input', APP_TEXT.dialog.inputValue); + await page + .getByRole('button', { name: APP_TEXT.buttons.closeButton }) + .click(); + }); +}); diff --git a/examples/federation/main-app/App.jsx b/examples/federation/main-app/App.jsx new file mode 100644 index 000000000..e5b44f531 --- /dev/null +++ b/examples/federation/main-app/App.jsx @@ -0,0 +1,55 @@ +import Button from 'component-app/Button'; +import Dialog from 'component-app/Dialog'; +import ToolTip from 'component-app/ToolTip'; +import NodeLocal from 'node-local-remote/test'; +import React from 'react'; +export default class App extends React.Component { + constructor(props) { + super(props); + this.state = { + dialogVisible: false, + nodeLocalContent: String(NodeLocal || ''), + }; + this.handleClick = this.handleClick.bind(this); + this.handleSwitchVisible = this.handleSwitchVisible.bind(this); + } + handleClick(ev) { + console.log(ev); + this.setState({ + dialogVisible: true, + }); + } + handleSwitchVisible(visible) { + this.setState({ + dialogVisible: visible, + }); + } + render() { + return ( +
+

Open Dev Tool And Focus On Network,checkout resources details

+

+ react、react-dom js files hosted on main-app +

+

+ components hosted on component-app +

+

Buttons:

+ + +

hover me please!

+ +

Node-local remote:

+

{this.state.nodeLocalContent}

+
+ ); + } +} diff --git a/examples/federation/main-app/bootstrap.js b/examples/federation/main-app/bootstrap.js new file mode 100644 index 000000000..d0b09c068 --- /dev/null +++ b/examples/federation/main-app/bootstrap.js @@ -0,0 +1,5 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import App from './App.jsx'; + +ReactDOM.render(, document.getElementById('app')); diff --git a/examples/federation/main-app/index.js b/examples/federation/main-app/index.js new file mode 100644 index 000000000..d390836af --- /dev/null +++ b/examples/federation/main-app/index.js @@ -0,0 +1 @@ +import('./bootstrap.js'); diff --git a/examples/federation/main-app/package.json b/examples/federation/main-app/package.json new file mode 100644 index 000000000..1100a1a7e --- /dev/null +++ b/examples/federation/main-app/package.json @@ -0,0 +1,32 @@ +{ + "name": "@examples/federation-main-app", + "version": "1.0.0", + "description": "", + "keywords": [], + "license": "ISC", + "author": "", + "main": "index.js", + "scripts": { + "build": "rspack build --config rspack.browser.config.js", + "serve": "serve dist -p 3002", + "start": "concurrently \"npm run build\" \"npm run serve\"", + "test": "rstest --reporter=verbose" + }, + "dependencies": { + "react": "^19.2.6", + "react-dom": "^19.2.6" + }, + "devDependencies": { + "@module-federation/enhanced": "https://pkg.pr.new/module-federation/core/@module-federation/enhanced@3fe0a1a3b6b5eab3d2d8eba2e4fd554fc1bda214", + "@module-federation/rstest": "https://pkg.pr.new/module-federation/core/@module-federation/rstest@517741e", + "@rsbuild/plugin-react": "^2.0.1", + "@rspack/cli": "~2.0.5", + "@rspack/core": "~2.0.5", + "@rstest/core": "workspace:*", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "concurrently": "8.2.2", + "kill-port": "^2.0.1", + "serve": "14.2.3" + } +} diff --git a/examples/federation/main-app/public/index.html b/examples/federation/main-app/public/index.html new file mode 100644 index 000000000..432d5b45b --- /dev/null +++ b/examples/federation/main-app/public/index.html @@ -0,0 +1,12 @@ + + + + + + + Document + + +
+ + diff --git a/examples/federation/main-app/rspack.browser.config.js b/examples/federation/main-app/rspack.browser.config.js new file mode 100644 index 000000000..4e685eafd --- /dev/null +++ b/examples/federation/main-app/rspack.browser.config.js @@ -0,0 +1,50 @@ +const { HtmlRspackPlugin } = require('@rspack/core'); +const { + ModuleFederationPlugin, +} = require('@module-federation/enhanced/rspack'); + +module.exports = { + entry: './index.js', + mode: 'development', + devtool: 'hidden-source-map', + output: { + publicPath: 'http://localhost:3002/', + clean: true, + }, + module: { + rules: [ + { test: /\.(jpg|png|gif|jpeg)$/, type: 'asset/resource' }, + { + test: /\.(js|jsx)$/, + use: { + loader: 'builtin:swc-loader', + options: { + jsc: { + parser: { syntax: 'ecmascript', jsx: true }, + transform: { react: { runtime: 'automatic' } }, + }, + }, + }, + }, + ], + }, + plugins: [ + new ModuleFederationPlugin({ + name: 'main_app', + experiments: { + asyncStartup: true, + }, + remoteType: 'script', + remotes: { + 'component-app': 'component_app@http://localhost:3001/remoteEntry.js', + 'node-local-remote': + 'node_local_remote@http://localhost:3004/remoteEntry.js', + }, + shared: { + react: { singleton: true, requiredVersion: '19.2.4' }, + 'react-dom': { singleton: true, requiredVersion: '19.2.4' }, + }, + }), + new HtmlRspackPlugin({ template: './public/index.html' }), + ], +}; diff --git a/examples/federation/main-app/rspack.node.config.js b/examples/federation/main-app/rspack.node.config.js new file mode 100644 index 000000000..092eca898 --- /dev/null +++ b/examples/federation/main-app/rspack.node.config.js @@ -0,0 +1,54 @@ +const { + ModuleFederationPlugin, +} = require('@module-federation/enhanced/rspack'); + +module.exports = { + entry: './index.js', + mode: 'development', + devtool: 'hidden-source-map', + target: 'async-node', + output: { + publicPath: 'http://localhost:3002/', + clean: true, + }, + module: { + rules: [ + { + test: /\.(jpg|png|gif|jpeg)$/, + type: 'asset/resource', + }, + { + test: /\.(js|jsx)$/, + use: { + loader: 'builtin:swc-loader', + options: { + jsc: { + parser: { syntax: 'ecmascript', jsx: true }, + transform: { react: { runtime: 'automatic' } }, + }, + }, + }, + }, + ], + }, + plugins: [ + new ModuleFederationPlugin({ + name: 'main_app', + experiments: { + asyncStartup: true, + }, + library: { type: 'commonjs-module', name: 'main_app_web' }, + remoteType: 'script', + remotes: { + 'component-app': 'component_app@http://localhost:3003/remoteEntry.js', + 'node-local-remote': + 'commonjs ../../node-local-remote/dist-node/remoteEntry.js', + }, + runtimePlugins: ['@module-federation/node/runtimePlugin'], + shared: { + react: { singleton: true, requiredVersion: '19.2.4' }, + 'react-dom': { singleton: true, requiredVersion: '19.2.4' }, + }, + }), + ], +}; diff --git a/examples/federation/main-app/rstest.config.ts b/examples/federation/main-app/rstest.config.ts new file mode 100644 index 000000000..0fb886216 --- /dev/null +++ b/examples/federation/main-app/rstest.config.ts @@ -0,0 +1,32 @@ +import path from 'node:path'; +import { federation } from '@module-federation/rstest'; +import { pluginReact } from '@rsbuild/plugin-react'; +import { defineConfig } from '@rstest/core'; +export default defineConfig({ + globalSetup: ['./scripts/rstestGlobalSetup.ts'], + setupFiles: ['./scripts/rstest.setup.ts'], + testTimeout: 30_000, + testEnvironment: 'jsdom', + plugins: [ + pluginReact(), + federation({ + name: 'main_app_web', + remoteType: 'script', + remotes: { + 'component-app': 'component_app@http://localhost:3001/remoteEntry.js', + 'node-local-remote': `commonjs ${path.resolve( + __dirname, + '../node-local-remote/dist-node/remoteEntry.js', + )}`, + }, + shared: { + react: { singleton: true, requiredVersion: '19.2.4' }, + 'react-dom': { + singleton: true, + requiredVersion: '19.2.4', + }, + }, + }), + ], + federation: true, +}); diff --git a/examples/federation/main-app/scripts/rstest.setup.ts b/examples/federation/main-app/scripts/rstest.setup.ts new file mode 100644 index 000000000..bb0a419a5 --- /dev/null +++ b/examples/federation/main-app/scripts/rstest.setup.ts @@ -0,0 +1,23 @@ +import vm from 'node:vm'; +import { expect } from '@rstest/core'; +import { + toBeInTheDocument, + toHaveAttribute, +} from '@testing-library/jest-dom/matchers'; + +expect.extend({ + toBeInTheDocument, + toHaveAttribute, +}); + +// Force Module Federation runtime to use the node-like loader in JSDOM so remoteEntry +// executes via fetch + vm instead of DOM script injection. +// +// MF SDK checks `ENV_TARGET` as an unscoped identifier (not `globalThis.ENV_TARGET`), +// so define it via `vm.runInThisContext` to ensure it is visible. +(globalThis as any).ENV_TARGET = 'node'; +try { + vm.runInThisContext("var ENV_TARGET = 'node'"); +} catch { + // best effort +} diff --git a/examples/federation/main-app/scripts/rstestGlobalSetup.ts b/examples/federation/main-app/scripts/rstestGlobalSetup.ts new file mode 100644 index 000000000..b50909eff --- /dev/null +++ b/examples/federation/main-app/scripts/rstestGlobalSetup.ts @@ -0,0 +1,12 @@ +// Run once in the main process before any tests start. +// This is the stable place to boot a Module Federation node remote server; +// `setupFiles` run per test-entry and can execute concurrently across workers. +import { cleanupNodeRemote, ensureNodeRemote } from './server.setup'; + +export async function setup() { + await ensureNodeRemote(); +} + +export async function teardown() { + await cleanupNodeRemote(); +} diff --git a/examples/federation/main-app/scripts/server.setup.ts b/examples/federation/main-app/scripts/server.setup.ts new file mode 100644 index 000000000..a28dcb69e --- /dev/null +++ b/examples/federation/main-app/scripts/server.setup.ts @@ -0,0 +1,166 @@ +import { spawn } from 'node:child_process'; +import { rmSync } from 'node:fs'; +import { connect } from 'node:net'; +import { resolve } from 'node:path'; +import killPort from 'kill-port'; + +type TrackedChild = { name: string; child: ReturnType }; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +const isUrlReachable = async (url: string, timeoutMs = 500) => { + const ctrl = + typeof AbortController !== 'undefined' ? new AbortController() : null; + const timer = setTimeout(() => ctrl?.abort(), timeoutMs); + try { + if (typeof fetch === 'function') { + const res = await fetch(url, { signal: ctrl?.signal }); + return res.ok; + } + } catch { + } finally { + clearTimeout(timer); + } + return false; +}; + +const waitForUrl = async ( + url: string, + timeoutMs = 30_000, + intervalMs = 250, +) => { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (await isUrlReachable(url, 500)) return; + await sleep(intervalMs); + } + throw new Error(`Timed out waiting for ${url}`); +}; + +const isPortInUse = async ( + port: number, + host = '127.0.0.1', + timeoutMs = 200, +) => { + return await new Promise((resolve) => { + const socket = connect({ port, host }); + const timer = setTimeout(() => { + socket.destroy(); + resolve(false); + }, timeoutMs); + socket.on('connect', () => { + clearTimeout(timer); + socket.end(); + resolve(true); + }); + socket.on('error', () => { + clearTimeout(timer); + resolve(false); + }); + }); +}; + +const workspaceRoot = resolve(__dirname, '..', '..'); +const componentAppDir = resolve(workspaceRoot, 'component-app'); +const lockFile = resolve(workspaceRoot, '.rstest-mf-node-remote.lock'); +const remoteEntryUrl = 'http://localhost:3001/remoteEntry.js'; + +const workerEnv = { + ...process.env, + PATH: [ + process.env.PATH, + // Windows runners typically rely on the `.cmd` shims for package managers. + // Keep it on PATH and run through a shell so `spawn('pnpm')` works. + ...(process.platform === 'win32' ? ['C:\\Windows\\System32'] : []), + '/usr/local/bin', + '/opt/homebrew/bin', + `${process.env.HOME}/.local/bin`, + ] + .filter(Boolean) + .join(process.platform === 'win32' ? ';' : ':'), +}; + +const start = (name: string, cwd: string, cmd: string, args: string[]) => { + const child = spawn(cmd, args, { + cwd, + stdio: 'inherit', + env: workerEnv, + shell: process.platform === 'win32', + }); + child.on('error', (err) => { + console.error(`[Federation Setup] Error in ${name}:`, err); + }); + child.on('exit', (code, signal) => { + if (code !== null) + console.log(`[Federation Setup] ${name} exited with code ${code}`); + else if (signal !== null) + console.log( + `[Federation Setup] ${name} terminated with signal ${signal}`, + ); + }); + return { name, child }; +}; + +const run = async (cwd: string, cmd: string, args: string[]) => { + const child = spawn(cmd, args, { + cwd, + stdio: 'inherit', + env: workerEnv, + shell: process.platform === 'win32', + }); + await new Promise((resolveRun, rejectRun) => { + child.once('exit', (code) => { + code === 0 + ? resolveRun() + : rejectRun(new Error(`${cmd} ${args.join(' ')} exited ${code}`)); + }); + child.once('error', (err) => rejectRun(err)); + }); +}; + +declare global { + // eslint-disable-next-line no-var + var __RSTEST_MF_CHILDREN__: TrackedChild[] | undefined; +} + +export const cleanupNodeRemote = async () => { + try { + rmSync(lockFile, { force: true }); + } catch {} + const inUse = await isPortInUse(3001); + if (inUse) { + await killPort(3001).catch(() => {}); + console.log('[Federation Setup] Killed process on port 3001'); + } + for (const { child } of globalThis.__RSTEST_MF_CHILDREN__ ?? []) { + try { + child.kill('SIGTERM'); + } catch {} + } + globalThis.__RSTEST_MF_CHILDREN__ = []; +}; + +export const ensureNodeRemote = async () => { + globalThis.__RSTEST_MF_CHILDREN__ ??= []; + + // Kill port 3001 if it's already in use before starting the server + const inUse = await isPortInUse(3001); + if (inUse) { + await killPort(3001).catch(() => {}); + console.log('[Federation Setup] Killed existing process on port 3001'); + } + + // In federation mode, the host is built for Node execution (async-node) even if tests + // run under JSDOM. Serve the *node* remoteEntry on 3001 so the MF node loader can + // evaluate it via fetch + vm and obtain the container interface (get/init). + await run(componentAppDir, 'pnpm', ['build:node']); + const server = start('component-app(node)', componentAppDir, 'pnpm', [ + 'serve:node', + ]); + globalThis.__RSTEST_MF_CHILDREN__!.push(server); + await waitForUrl(remoteEntryUrl, 30_000); + + // Also build node-local-remote for path-based consumption. + const nodeLocalDir = resolve(workspaceRoot, 'node-local-remote'); + await run(nodeLocalDir, 'pnpm', ['build:node']); +}; diff --git a/examples/federation/main-app/test/App.rtl.test.jsx b/examples/federation/main-app/test/App.rtl.test.jsx new file mode 100644 index 000000000..07f8eb735 --- /dev/null +++ b/examples/federation/main-app/test/App.rtl.test.jsx @@ -0,0 +1,24 @@ +import { expect, test } from '@rstest/core'; +import { render, screen } from '@testing-library/react'; +import App from '../App.jsx'; + +test('renders main-app with federated remotes', async () => { + render(); + + expect( + screen.getByText( + 'Open Dev Tool And Focus On Network,checkout resources details', + ), + ).toBeInTheDocument(); + + expect( + await screen.findByRole('button', { name: /primary Button/i }), + ).toBeInTheDocument(); + + // Both a heading ("hover me please!") and the tooltip trigger ("hover me please") + // exist; assert on the actual tooltip trigger element to avoid ambiguity. + expect(screen.getByText('hover me please')).toBeInTheDocument(); + + expect(screen.getByText('Node-local remote:')).toBeInTheDocument(); + expect(screen.getByText('module from node-local-remote')).toBeInTheDocument(); +}); diff --git a/examples/federation/main-app/test/ComponentRemotes.dynamic.test.tsx b/examples/federation/main-app/test/ComponentRemotes.dynamic.test.tsx new file mode 100644 index 000000000..a193354e9 --- /dev/null +++ b/examples/federation/main-app/test/ComponentRemotes.dynamic.test.tsx @@ -0,0 +1,24 @@ +import { expect, test } from '@rstest/core'; +import { render, screen } from '@testing-library/react'; +import Button from 'component-app/Button'; +import Dialog from 'component-app/Dialog'; +import ToolTip from 'component-app/ToolTip'; + +test('federated Button renders', () => { + render(