Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/calm-cats-test.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@module-federation/rstest': patch
---

Add the Rstest federation plugin and automatically enable Rstest's Node compatibility mode when its exposed configuration API is available.
1 change: 1 addition & 0 deletions .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"@module-federation/modern-js-v3",
"@module-federation/retry-plugin",
"@module-federation/rsbuild-plugin",
"@module-federation/rstest",
"@module-federation/error-codes",
"@module-federation/inject-external-runtime-core-plugin",
"@module-federation/runtime-core",
Expand Down
5 changes: 5 additions & 0 deletions .changeset/tidy-ssr-library-name.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@module-federation/rsbuild-plugin': patch
---

Preserve bundler-derived library names in Node SSR builds so CommonJS containers expose the expected interface.
2 changes: 1 addition & 1 deletion .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"root": true,
"overrides": [
{
"files": ["*.ts", "*.tsx"],
"files": ["*.ts", "*.tsx", "*.mts", "*.cts"],
"extends": ["plugin:@typescript-eslint/recommended"],
"parser": "@typescript-eslint/parser",
"parserOptions": { "ecmaVersion": 2020, "sourceType": "module" },
Expand Down
4 changes: 4 additions & 0 deletions apps/website-new/docs/en/_nav.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@
"text": "Rslib",
"link": "/integrations/build-tool/rslib"
},
{
"text": "Rstest",
"link": "/integrations/build-tool/rstest"
},
{
"text": "Vite",
"link": "/integrations/build-tool/vite"
Expand Down
5 changes: 5 additions & 0 deletions apps/website-new/docs/en/integrations/build-tool/_meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@
"name": "rslib",
"label": "Rslib"
},
{
"type": "file",
"name": "rstest",
"label": "Rstest"
},
{
"type": "file",
"name": "vite",
Expand Down
184 changes: 184 additions & 0 deletions apps/website-new/docs/en/integrations/build-tool/rstest.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
# Rstest

`@module-federation/rstest` enables Module Federation inside
[Rstest](https://rstest.rs/) test builds. Use it when your tests should load
real federated remotes instead of replacing the federation boundary with mocks.

## Supports

- Rstest Node and JSDOM test environments.
- Rstest browser mode.
- URL remotes such as `remote@http://localhost:3001/remoteEntry.js`.
- `commonjs ...` path remotes for Node-based integration tests.
- Automatic Rstest federation compatibility mode for Node-targeted test builds.

## Requirements

:::tip

Use `@rstest/core@0.11.4` or newer. Rstest's federation support shipped in
`0.11.4`.

:::

Rstest versions before `0.11.4` do not include the `federation` compatibility
mode this plugin enables for Node test builds.

## Quick Start

### Installation

import { PackageManagerTabs } from '@theme';

<PackageManagerTabs
command={{
npm: 'npm add @module-federation/rstest @rstest/core --save-dev',
yarn: 'yarn add @module-federation/rstest @rstest/core --dev',
pnpm: 'pnpm add @module-federation/rstest @rstest/core --save-dev',
bun: 'bun add @module-federation/rstest @rstest/core --dev',
}}
/>

### Register Plugin

```ts title='rstest.config.ts'
import { federation } from '@module-federation/rstest';
import { defineConfig } from '@rstest/core';

export default defineConfig({
plugins: [
federation({
name: 'host',
remotes: {
'component-app': 'component_app@http://localhost:3001/remoteEntry.js',
},
shared: {
react: { singleton: true },
'react-dom': { singleton: true },
},
}),
],
});
```

:::warning

Do not set `federation: true` in `rstest.config.ts` when using this plugin. For
Node and JSDOM test environments, `@module-federation/rstest` enables Rstest's
federation compatibility mode automatically.

:::

Run your tests with Rstest as usual:

```bash
npx rstest run
```

## Test a Federated Remote

Build or start the remote in Rstest's `globalSetup`, then import exposed modules
the same way application code does:

```ts title='remote.test.ts'
import { expect, it } from '@rstest/core';
import Button from 'component-app/Button';

it('loads a remote through a static import', () => {
expect(Button).toBeDefined();
});

it('loads a remote through a dynamic import', async () => {
const remote = await import('component-app/Button');
expect(remote.default).toBeDefined();
});
```

The official
[Rstest federation example](https://github.com/web-infra-dev/rstest/tree/v0.11.4/examples/federation)
combines an HTTP component remote with a locally built `commonjs ...` remote
and runs both Node and JSDOM projects.

## How It Works

For both Node and browser targets, `dts`, `manifest`, and `dev` default to
`false`; explicit values are preserved.

### Node Test Defaults

For Node-targeted Rstest builds, the plugin applies Module Federation defaults
that match the Node runtime:

- `target: async-node`
- `experiments.asyncStartup = true`
- CommonJS container output; `module` and `modern-module` library types are
normalized
- `@module-federation/node/runtimePlugin`
- Node-targeted federation optimization
- Script remote transport by default

Standard URL remotes use `remoteType: 'script'`. Inline transport prefixes such
as `commonjs ...` override that default.

### Browser Mode

Rstest browser mode is detected automatically from its resolved
`browser.enabled` configuration:

Install `@rstest/browser` and a Playwright browser first. See
[Rstest's Browser Mode setup](https://rstest.rs/guide/browser-testing/getting-started).

```ts title='rstest.config.ts'
import { federation } from '@module-federation/rstest';
import { defineConfig } from '@rstest/core';

export default defineConfig({
browser: {
enabled: true,
provider: 'playwright',
},
plugins: [
federation({
name: 'browser_host',
remotes: {
app2: 'app2@http://localhost:3001/remoteEntry.js',
},
}),
],
});
```

Browser mode avoids node-only defaults while enabling
`experiments.asyncStartup` and applying the shared defaults above. Pass an
explicit `target` only to override the detected mode.

## Configuration

`federation(options, rstestOptions)` accepts the standard
[Module Federation configuration](/configure/index.html) as the first argument.

```ts
federation(moduleFederationOptions, {
target: 'node', // or 'browser'
});
```

### moduleFederationOptions

[Module Federation Configuration](/configure/index.html)

### rstestOptions

- Type: `{ target?: 'node' | 'browser' }`
- Default: auto-detected from Rstest's resolved `browser.enabled`
configuration. Browser Mode defaults to `'browser'`; other builds default to
`'node'`.

## Producer Builds

Use `@module-federation/rsbuild-plugin` or another Module Federation build
plugin to build the remote application. Use `@module-federation/rstest` only in
the Rstest test project that consumes those remotes.

For Rsbuild producers, continue with the
[Rsbuild plugin guide](/integrations/build-tool/rsbuild.html).
3 changes: 2 additions & 1 deletion apps/website-new/rspress.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const siteIcon = '/svg.svg';
const socialImageUrl = `${siteOrigin}/module-federation-social.svg`;
const socialImageAlt = 'Module Federation icon';
const googleAnalyticsMeasurementId = 'G-DRPXW0EEVT';
const enableZephyr = Boolean(process.env.CI || process.env.ZE_SECRET_TOKEN);

export default defineConfig({
root: path.join(__dirname, 'docs'),
Expand Down Expand Up @@ -86,7 +87,7 @@ export default defineConfig({
// wordsMapPath: 'words-map.json',
// }),
pluginModuleFederation(mfConfig),
withZephyr(),
...(enableZephyr ? [withZephyr()] : []),
],
builderConfig: {
html: {
Expand Down
28 changes: 13 additions & 15 deletions packages/rsbuild-plugin/src/utils/ssr.spec.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { describe, it, expect, rs } from '@rstest/core';
import { afterEach, describe, it, expect, rs } from '@rstest/core';
import { createSSRMFConfig, patchSSRRspackConfig, SSR_DIR } from './ssr';
import type { Rspack } from '@rsbuild/core';
import type { moduleFederationPlugin } from '@module-federation/sdk';

const RECORD_DYNAMIC_REMOTE_ENTRY_HASH_PLUGIN_PATTERN =
/record(?:-dynamic-remote-entry-hash-plugin|DynamicRemoteEntryHashPlugin)(\.js)?$/;

afterEach(() => {
rs.unstubAllEnvs();
});

describe('createSSRMFConfig', () => {
const baseMFConfig: moduleFederationPlugin.ModuleFederationPluginOptions = {
name: 'testApp',
Expand All @@ -15,48 +19,45 @@ describe('createSSRMFConfig', () => {
const ssrMFConfig = createSSRMFConfig(baseMFConfig);
expect(ssrMFConfig.name).toBe('testApp');
expect(ssrMFConfig.library?.type).toBe('commonjs-module');
expect(ssrMFConfig.library?.name).toBeUndefined();
expect(ssrMFConfig.dts).toBe(false);
expect(ssrMFConfig.dev).toBe(false);
expect(ssrMFConfig.runtimePlugins).toHaveLength(1);
expect(ssrMFConfig.runtimePlugins?.[0]).toMatch(/runtimePlugin(\.js)?$/);
});

it('should preserve library.type if already defined', () => {
it('should preserve a preconfigured library', () => {
const mfConfigWithLibraryType: moduleFederationPlugin.ModuleFederationPluginOptions =
{
...baseMFConfig,
library: {
name: 'testApp',
name: 'customLibrary',
type: 'umd',
},
};
const ssrMFConfig = createSSRMFConfig(mfConfigWithLibraryType);
expect(ssrMFConfig.library?.type).toBe('umd');
expect(ssrMFConfig.library?.name).toBe('customLibrary');
});

it('should add record-dynamic-remote-entry-hash-plugin in development', () => {
const originalNodeEnv = process.env.NODE_ENV;
process.env.NODE_ENV = 'development';
rs.stubEnv('NODE_ENV', 'development');
const ssrMFConfig = createSSRMFConfig(baseMFConfig);
expect(ssrMFConfig.runtimePlugins?.[0]).toMatch(/runtimePlugin(\.js)?$/);
expect(ssrMFConfig.runtimePlugins?.[1]).toMatch(
RECORD_DYNAMIC_REMOTE_ENTRY_HASH_PLUGIN_PATTERN,
);
process.env.NODE_ENV = originalNodeEnv; // Restore original NODE_ENV
});

it('should not add record-dynamic-remote-entry-hash-plugin in production', () => {
const originalNodeEnv = process.env.NODE_ENV;
process.env.NODE_ENV = 'production';
rs.stubEnv('NODE_ENV', 'production');
const ssrMFConfig = createSSRMFConfig(baseMFConfig);
expect(ssrMFConfig.runtimePlugins).toHaveLength(1);
expect(ssrMFConfig.runtimePlugins?.[0]).toMatch(/runtimePlugin(\.js)?$/);
process.env.NODE_ENV = originalNodeEnv; // Restore original NODE_ENV
});

it('should initialize runtimePlugins if it is undefined', () => {
const originalNodeEnv = process.env.NODE_ENV;
process.env.NODE_ENV = 'production';
rs.stubEnv('NODE_ENV', 'production');
const mfConfigWithoutRuntimePlugins: moduleFederationPlugin.ModuleFederationPluginOptions =
{
name: 'testApp',
Expand All @@ -65,7 +66,6 @@ describe('createSSRMFConfig', () => {
const ssrMFConfig = createSSRMFConfig(mfConfigWithoutRuntimePlugins);
expect(ssrMFConfig.runtimePlugins).toHaveLength(1);
expect(ssrMFConfig.runtimePlugins?.[0]).toMatch(/runtimePlugin(\.js)?$/);
process.env.NODE_ENV = originalNodeEnv;
});
});

Expand Down Expand Up @@ -111,16 +111,14 @@ describe('patchSSRRspackConfig', () => {
});

it('should add UniverseEntryChunkTrackerPlugin to plugins', () => {
const env = process.env.NODE_ENV;
process.env.NODE_ENV = 'development';
rs.stubEnv('NODE_ENV', 'development');
const config = JSON.parse(JSON.stringify(baseConfig));
const patchedConfig = patchSSRRspackConfig(config, baseMfConfig, 'ssr');
expect(patchedConfig.plugins).toHaveLength(1);
// @ts-expect-error default is a class
expect(patchedConfig.plugins?.[0].constructor.name).toBe(
'UniverseEntryChunkTrackerPlugin',
);
process.env.NODE_ENV = env;
});

describe('chunkFilename modification', () => {
Expand Down
10 changes: 9 additions & 1 deletion packages/rsbuild-plugin/src/utils/ssr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,15 @@ export function createSSRREnvConfig(
return ssrEnvConfig;
}

/**
* Node-target MF defaults for Rsbuild SSR builds.
*
* `@module-federation/rstest` has a sibling helper (`withNodeDefaults` in
* packages/rstest/src/node-defaults.ts). Both default remotes to script
* transport and container output to CommonJS. Rstest additionally forces
* `library.name` to the container name so test workers can resolve it; Rsbuild
* keeps the bundler-derived name.
*/
export function patchNodeMFConfig(
mfConfig: moduleFederationPlugin.ModuleFederationPluginOptions,
) {
Expand All @@ -193,7 +202,6 @@ export function patchNodeMFConfig(
mfConfig.exposes = { ...mfConfig.exposes };
mfConfig.library = {
...mfConfig.library,
name: mfConfig.name,
type: mfConfig.library?.type ?? 'commonjs-module',
};
mfConfig.runtimePlugins = [...(mfConfig.runtimePlugins || [])];
Expand Down
21 changes: 21 additions & 0 deletions packages/rstest/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2024-present Bytedance, Inc. and its affiliates.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Loading
Loading