nx/e2e/react/src/react-module-federation.rspack.test.ts
Colum Ferry 2bc7d4e6e9
feat(react): add module federation static server (#27802)
<!-- Please make sure you have read the submission guidelines before
posting an PR -->
<!--
https://github.com/nrwl/nx/blob/master/CONTRIBUTING.md#-submitting-a-pr
-->

<!-- Please make sure that your commit message follows our format -->
<!-- Example: `fix(nx): must begin with lowercase` -->

<!-- If this is a particularly complex change or feature addition, you
can request a dedicated Nx release for this pull request branch. Mention
someone from the Nx team or the `@nrwl/nx-pipelines-reviewers` and they
will confirm if the PR warrants its own release for testing purposes,
and generate it for you if appropriate. -->

## Current Behavior
<!-- This is the behavior we have today -->
We can serve each application in a module federation setup statically,
but only individually.


## Expected Behavior
<!-- This is the behavior we should expect with the changes in this PR
-->
Add a serve static executor for module federation hosts which will also
spin up the remotes statically

## Related Issue(s)
<!-- Please link the issue being fixed so it gets closed when this is
merged. -->

Fixes #
2024-09-09 12:50:52 -04:00

1219 lines
38 KiB
TypeScript

import { Tree, stripIndents } from '@nx/devkit';
import {
checkFilesExist,
cleanupProject,
fileExists,
killPorts,
killProcessAndPorts,
newProject,
readJson,
runCLI,
runCLIAsync,
runCommandUntil,
runE2ETests,
tmpProjPath,
uniq,
updateFile,
updateJson,
} from '@nx/e2e/utils';
import { join } from 'path';
import { createTreeWithEmptyWorkspace } from 'nx/src/devkit-testing-exports';
describe('React Rspack Module Federation', () => {
describe('Default Configuration', () => {
beforeAll(() => {
newProject({ packages: ['@nx/react'] });
});
afterAll(() => cleanupProject());
it.each`
js
${false}
${true}
`(
'should generate host and remote apps with "--js=$js"',
async ({ js }) => {
const shell = uniq('shell');
const remote1 = uniq('remote1');
const remote2 = uniq('remote2');
const remote3 = uniq('remote3');
runCLI(
`generate @nx/react:host ${shell} --remotes=${remote1},${remote2},${remote3} --bundler=rspack --e2eTestRunner=cypress --style=css --no-interactive --skipFormat --js=${js}`
);
checkFilesExist(
`apps/${shell}/module-federation.config.${js ? 'js' : 'ts'}`
);
checkFilesExist(
`apps/${remote1}/module-federation.config.${js ? 'js' : 'ts'}`
);
checkFilesExist(
`apps/${remote2}/module-federation.config.${js ? 'js' : 'ts'}`
);
checkFilesExist(
`apps/${remote3}/module-federation.config.${js ? 'js' : 'ts'}`
);
await expect(runCLIAsync(`test ${shell}`)).resolves.toMatchObject({
combinedOutput: expect.stringContaining(
'Test Suites: 1 passed, 1 total'
),
});
updateFile(
`apps/${shell}-e2e/src/integration/app.spec.${js ? 'js' : 'ts'}`,
stripIndents`
import { getGreeting } from '../support/app.po';
describe('shell app', () => {
it('should display welcome message', () => {
cy.visit('/')
getGreeting().contains('Welcome ${shell}');
});
it('should load remote 1', () => {
cy.visit('/${remote1}')
getGreeting().contains('Welcome ${remote1}');
});
it('should load remote 2', () => {
cy.visit('/${remote2}')
getGreeting().contains('Welcome ${remote2}');
});
it('should load remote 3', () => {
cy.visit('/${remote3}')
getGreeting().contains('Welcome ${remote3}');
});
});
`
);
[shell, remote1, remote2, remote3].forEach((app) => {
['development', 'production'].forEach(async (configuration) => {
const cliOutput = runCLI(`run ${app}:build:${configuration}`);
expect(cliOutput).toContain('Successfully ran target');
});
});
const serveResult = await runCommandUntil(`serve ${shell}`, (output) =>
output.includes(`http://localhost:${readPort(shell)}`)
);
await killProcessAndPorts(serveResult.pid, readPort(shell));
if (runE2ETests()) {
const e2eResultsSwc = await runCommandUntil(
`e2e ${shell}-e2e --no-watch --verbose`,
(output) => output.includes('All specs passed!')
);
await killProcessAndPorts(e2eResultsSwc.pid, readPort(shell));
const e2eResultsTsNode = await runCommandUntil(
`e2e ${shell}-e2e --no-watch --verbose`,
(output) =>
output.includes('Successfully ran target e2e for project'),
{
env: { NX_PREFER_TS_NODE: 'true' },
}
);
await killProcessAndPorts(e2eResultsTsNode.pid, readPort(shell));
}
},
500_000
);
it('should generate host and remote apps and use playwright for e2es', async () => {
const shell = uniq('shell');
const remote1 = uniq('remote1');
const remote2 = uniq('remote2');
const remote3 = uniq('remote3');
runCLI(
`generate @nx/react:host ${shell} --remotes=${remote1},${remote2},${remote3} --bundler=rspack --e2eTestRunner=playwright --style=css --no-interactive --skipFormat`
);
checkFilesExist(`apps/${shell}/module-federation.config.ts`);
checkFilesExist(`apps/${remote1}/module-federation.config.ts`);
checkFilesExist(`apps/${remote2}/module-federation.config.ts`);
checkFilesExist(`apps/${remote3}/module-federation.config.ts`);
await expect(runCLIAsync(`test ${shell}`)).resolves.toMatchObject({
combinedOutput: expect.stringContaining(
'Test Suites: 1 passed, 1 total'
),
});
updateFile(
`apps/${shell}-e2e/src/example.spec.ts`,
stripIndents`
import { test, expect } from '@playwright/test';
test('should display welcome message', async ({page}) => {
await page.goto("/");
expect(await page.locator('h1').innerText()).toContain('Welcome');
});
test('should load remote 1', async ({page}) => {
await page.goto("/${remote1}");
expect(await page.locator('h1').innerText()).toContain('${remote1}');
});
test('should load remote 2', async ({page}) => {
await page.goto("/${remote2}");
expect(await page.locator('h1').innerText()).toContain('${remote2}');
});
test('should load remote 3', async ({page}) => {
await page.goto("/${remote3}");
expect(await page.locator('h1').innerText()).toContain('${remote3}');
});
`
);
if (runE2ETests()) {
const e2eResultsSwc = await runCommandUntil(
`e2e ${shell}-e2e`,
(output) => output.includes('Successfully ran target e2e for project')
);
await killProcessAndPorts(e2eResultsSwc.pid, readPort(shell));
const e2eResultsTsNode = await runCommandUntil(
`e2e ${shell}-e2e`,
(output) =>
output.includes('Successfully ran target e2e for project'),
{
env: { NX_PREFER_TS_NODE: 'true' },
}
);
await killProcessAndPorts(e2eResultsTsNode.pid, readPort(shell));
}
}, 500_000);
it('should have interop between webpack host and rspack remote', async () => {
const shell = uniq('shell');
const remote1 = uniq('remote1');
const remote2 = uniq('remote2');
runCLI(
`generate @nx/react:host ${shell} --remotes=${remote1} --bundler=webpack --e2eTestRunner=cypress --style=css --no-interactive --skipFormat`
);
runCLI(
`generate @nx/react:remote ${remote2} --host=${shell} --bundler=rspack --style=css --no-interactive --skipFormat`
);
updateFile(
`apps/${shell}-e2e/src/integration/app.spec.ts`,
stripIndents`
import { getGreeting } from '../support/app.po';
describe('shell app', () => {
it('should display welcome message', () => {
cy.visit('/')
getGreeting().contains('Welcome ${shell}');
});
it('should load remote 1', () => {
cy.visit('/${remote1}')
getGreeting().contains('Welcome ${remote1}');
});
it('should load remote 2', () => {
cy.visit('/${remote2}')
getGreeting().contains('Welcome ${remote2}');
});
});
`
);
[shell, remote1, remote2].forEach((app) => {
['development', 'production'].forEach(async (configuration) => {
const cliOutput = runCLI(`run ${app}:build:${configuration}`);
expect(cliOutput).toContain('Successfully ran target');
});
});
const serveResult = await runCommandUntil(`serve ${shell}`, (output) =>
output.includes(`http://localhost:${readPort(shell)}`)
);
await killProcessAndPorts(serveResult.pid, readPort(shell));
if (runE2ETests()) {
const e2eResultsSwc = await runCommandUntil(
`e2e ${shell}-e2e --no-watch --verbose`,
(output) => output.includes('All specs passed!')
);
await killProcessAndPorts(e2eResultsSwc.pid, readPort(shell));
const e2eResultsTsNode = await runCommandUntil(
`e2e ${shell}-e2e --no-watch --verbose`,
(output) =>
output.includes('Successfully ran target e2e for project'),
{
env: { NX_PREFER_TS_NODE: 'true' },
}
);
await killProcessAndPorts(e2eResultsTsNode.pid, readPort(shell));
}
}, 500_000);
it('should have interop between rspack host and webpack remote', async () => {
const shell = uniq('shell');
const remote1 = uniq('remote1');
const remote2 = uniq('remote2');
runCLI(
`generate @nx/react:host ${shell} --remotes=${remote1} --bundler=rspack --e2eTestRunner=cypress --style=css --no-interactive --skipFormat`
);
runCLI(
`generate @nx/react:remote ${remote2} --host=${shell} --bundler=webpack --style=css --no-interactive --skipFormat`
);
updateFile(
`apps/${shell}-e2e/src/integration/app.spec.ts`,
stripIndents`
import { getGreeting } from '../support/app.po';
describe('shell app', () => {
it('should display welcome message', () => {
cy.visit('/')
getGreeting().contains('Welcome ${shell}');
});
it('should load remote 1', () => {
cy.visit('/${remote1}')
getGreeting().contains('Welcome ${remote1}');
});
it('should load remote 2', () => {
cy.visit('/${remote2}')
getGreeting().contains('Welcome ${remote2}');
});
});
`
);
if (runE2ETests()) {
const e2eResultsSwc = await runCommandUntil(
`e2e ${shell}-e2e --no-watch --verbose`,
(output) => output.includes('All specs passed!')
);
await killProcessAndPorts(e2eResultsSwc.pid, readPort(shell));
const e2eResultsTsNode = await runCommandUntil(
`e2e ${shell}-e2e --no-watch --verbose`,
(output) =>
output.includes('Successfully ran target e2e for project'),
{
env: { NX_PREFER_TS_NODE: 'true' },
}
);
await killProcessAndPorts(e2eResultsTsNode.pid, readPort(shell));
}
}, 500_000);
describe('ssr', () => {
it('should generate host and remote apps with ssr', async () => {
const shell = uniq('shell');
const remote1 = uniq('remote1');
const remote2 = uniq('remote2');
const remote3 = uniq('remote3');
await runCLIAsync(
`generate @nx/react:host ${shell} --ssr --remotes=${remote1},${remote2},${remote3} --bundler=rspack --style=css --no-interactive --projectNameAndRootFormat=derived --skipFormat`
);
expect(readPort(shell)).toEqual(4200);
expect(readPort(remote1)).toEqual(4201);
expect(readPort(remote2)).toEqual(4202);
expect(readPort(remote3)).toEqual(4203);
[shell, remote1, remote2, remote3].forEach((app) => {
checkFilesExist(
`apps/${app}/module-federation.config.ts`,
`apps/${app}/module-federation.server.config.ts`
);
['build', 'server'].forEach((target) => {
['development', 'production'].forEach(async (configuration) => {
const cliOutput = runCLI(`run ${app}:${target}:${configuration}`);
expect(cliOutput).toContain('Successfully ran target');
await killPorts(readPort(app));
});
});
});
}, 500_000);
it('should serve remotes as static when running the host by default', async () => {
const shell = uniq('shell');
const remote1 = uniq('remote1');
const remote2 = uniq('remote2');
const remote3 = uniq('remote3');
await runCLIAsync(
`generate @nx/react:host ${shell} --ssr --remotes=${remote1},${remote2},${remote3} --bundler=rspack --style=css --e2eTestRunner=cypress --no-interactive --projectNameAndRootFormat=derived --skipFormat`
);
const serveResult = await runCommandUntil(`serve ${shell}`, (output) =>
output.includes(`Nx SSR Static remotes proxies started successfully`)
);
await killProcessAndPorts(serveResult.pid);
}, 500_000);
it('should serve remotes as static and they should be able to be accessed from the host', async () => {
const shell = uniq('shell');
const remote1 = uniq('remote1');
const remote2 = uniq('remote2');
const remote3 = uniq('remote3');
await runCLIAsync(
`generate @nx/react:host ${shell} --ssr --remotes=${remote1},${remote2},${remote3} --bundler=rspack --style=css --e2eTestRunner=cypress --no-interactive --projectNameAndRootFormat=derived --skipFormat`
);
const capitalize = (s: string) =>
s.charAt(0).toUpperCase() + s.slice(1);
updateFile(`apps/${shell}-e2e/src/e2e/app.cy.ts`, (content) => {
return `
describe('${shell}-e2e', () => {
beforeEach(() => cy.visit('/'));
it('should display welcome message', () => {
expect(cy.get('ul li').should('have.length', 4));
expect(cy.get('ul li').eq(0).should('have.text', 'Home'));
expect(cy.get('ul li').eq(1).should('have.text', '${capitalize(
remote1
)}'));
expect(cy.get('ul li').eq(2).should('have.text', '${capitalize(
remote2
)}'));
expect(cy.get('ul li').eq(3).should('have.text', '${capitalize(
remote3
)}'));
});
});
`;
});
if (runE2ETests()) {
const hostE2eResults = await runCommandUntil(
`e2e ${shell}-e2e --no-watch --verbose`,
(output) => output.includes('All specs passed!')
);
await killProcessAndPorts(hostE2eResults.pid);
}
}, 600_000);
});
it('should should support generating host and remote apps with the new name and root format', async () => {
const shell = uniq('shell');
const remote = uniq('remote');
runCLI(
`generate @nx/react:host ${shell} --project-name-and-root-format=as-provided --no-interactive --skipFormat`
);
runCLI(
`generate @nx/react:remote ${remote} --host=${shell} --bundler=rspack --project-name-and-root-format=as-provided --no-interactive --skipFormat`
);
const shellPort = readPort(shell);
const remotePort = readPort(remote);
// check files are generated without the layout directory ("apps/") and
// using the project name as the directory when no directory is provided
checkFilesExist(`${shell}/module-federation.config.ts`);
checkFilesExist(`${remote}/module-federation.config.ts`);
// check default generated host is built successfully
const buildOutputSwc = runCLI(`run ${shell}:build:development`);
expect(buildOutputSwc).toContain('Successfully ran target build');
const buildOutputTsNode = runCLI(`run ${shell}:build:development`, {
env: { NX_PREFER_TS_NODE: 'true' },
});
expect(buildOutputTsNode).toContain('Successfully ran target build');
// check serves devRemotes ok
const shellProcessSwc = await runCommandUntil(
`serve ${shell} --devRemotes=${remote} --verbose`,
(output) => {
return output.includes(
`All remotes started, server ready at http://localhost:${shellPort}`
);
}
);
await killProcessAndPorts(
shellProcessSwc.pid,
shellPort,
remotePort + 1,
remotePort
);
const shellProcessTsNode = await runCommandUntil(
`serve ${shell} --devRemotes=${remote} --verbose`,
(output) => {
return output.includes(
`All remotes started, server ready at http://localhost:${shellPort}`
);
},
{
env: { NX_PREFER_TS_NODE: 'true' },
}
);
await killProcessAndPorts(
shellProcessTsNode.pid,
shellPort,
remotePort + 1,
remotePort
);
}, 500_000);
});
// Federate Module
describe('Federate Module', () => {
let proj: string;
let tree: Tree;
beforeAll(() => {
tree = createTreeWithEmptyWorkspace();
proj = newProject();
});
afterAll(() => cleanupProject());
it('should federate a module from a library and update an existing remote', async () => {
const lib = uniq('lib');
const remote = uniq('remote');
const module = uniq('module');
const host = uniq('host');
runCLI(
`generate @nx/react:host ${host} --remotes=${remote} --bundler=rspack --e2eTestRunner=cypress --no-interactive --projectNameAndRootFormat=as-provided --skipFormat`
);
runCLI(
`generate @nx/js:lib ${lib} --no-interactive --projectNameAndRootFormat=as-provided --skipFormat`
);
// Federate Module
runCLI(
`generate @nx/react:federate-module ${lib}/src/index.ts --name=${module} --remote=${remote} --bundler=rspack --no-interactive --skipFormat`
);
updateFile(
`${lib}/src/index.ts`,
`export { default } from './lib/${lib}';`
);
updateFile(
`${lib}/src/lib/${lib}.ts`,
`export default function lib() { return 'Hello from ${lib}'; };`
);
// Update Host to use the module
updateFile(
`${host}/src/app/app.tsx`,
`
import * as React from 'react';
import NxWelcome from './nx-welcome';
import { Link, Route, Routes } from 'react-router-dom';
import myLib from '${remote}/${module}';
export function App() {
return (
<React.Suspense fallback={null}>
<div className='remote'>
My Remote Library: { myLib() }
</div>
<ul>
<li>
<Link to="/">Home</Link>
</li>
</ul>
<Routes>
<Route path="/" element={<NxWelcome title="Host" />} />
</Routes>
</React.Suspense>
);
}
export default App;
`
);
// Update e2e test to check the module
updateFile(
`${host}-e2e/src/e2e/app.cy.ts`,
`
describe('${host}', () => {
beforeEach(() => cy.visit('/'));
it('should display contain the remote library', () => {
expect(cy.get('div.remote')).to.exist;
expect(cy.get('div.remote').contains('My Remote Library: Hello from ${lib}'));
});
});
`
);
const hostPort = readPort(host);
const remotePort = readPort(remote);
// Build host and remote
const buildOutput = runCLI(`build ${host}`);
const remoteOutput = runCLI(`build ${remote}`);
expect(buildOutput).toContain('Successfully ran target build');
expect(remoteOutput).toContain('Successfully ran target build');
if (runE2ETests()) {
const hostE2eResults = await runCommandUntil(
`e2e ${host}-e2e --no-watch --verbose`,
(output) => output.includes('All specs passed!')
);
await killProcessAndPorts(
hostE2eResults.pid,
hostPort,
hostPort + 1,
remotePort
);
}
}, 500_000);
it('should federate a module from a library and create a remote and serve it recursively', async () => {
const lib = uniq('lib');
const remote = uniq('remote');
const childRemote = uniq('childremote');
const module = uniq('module');
const host = uniq('host');
runCLI(
`generate @nx/react:host ${host} --remotes=${remote} --bundler=rspack --e2eTestRunner=cypress --no-interactive --projectNameAndRootFormat=as-provided --skipFormat`
);
runCLI(
`generate @nx/js:lib ${lib} --no-interactive --projectNameAndRootFormat=as-provided --skipFormat`
);
// Federate Module
runCLI(
`generate @nx/react:federate-module ${lib}/src/index.ts --name=${module} --remote=${childRemote} --bundler=rspack --no-interactive --skipFormat`
);
updateFile(
`${lib}/src/index.ts`,
`export { default } from './lib/${lib}';`
);
updateFile(
`${lib}/src/lib/${lib}.ts`,
`export default function lib() { return 'Hello from ${lib}'; };`
);
// Update Host to use the module
updateFile(
`${remote}/src/app/app.tsx`,
`
import * as React from 'react';
import NxWelcome from './nx-welcome';
import myLib from '${childRemote}/${module}';
export function App() {
return (
<React.Suspense fallback={null}>
<div className='remote'>
My Remote Library: { myLib() }
</div>
<NxWelcome title="Host" />
</React.Suspense>
);
}
export default App;
`
);
// Update e2e test to check the module
updateFile(
`${host}-e2e/src/e2e/app.cy.ts`,
`
describe('${host}', () => {
beforeEach(() => cy.visit('/${remote}'));
it('should display contain the remote library', () => {
expect(cy.get('div.remote')).to.exist;
expect(cy.get('div.remote').contains('My Remote Library: Hello from ${lib}'));
});
});
`
);
const hostPort = readPort(host);
const remotePort = readPort(remote);
const childRemotePort = readPort(childRemote);
// Build host and remote
const buildOutput = runCLI(`build ${host}`);
const remoteOutput = runCLI(`build ${remote}`);
expect(buildOutput).toContain('Successfully ran target build');
expect(remoteOutput).toContain('Successfully ran target build');
if (runE2ETests()) {
const hostE2eResults = await runCommandUntil(
`e2e ${host}-e2e --no-watch --verbose`,
(output) => output.includes('All specs passed!')
);
await killProcessAndPorts(
hostE2eResults.pid,
hostPort,
hostPort + 1,
remotePort,
childRemotePort
);
}
}, 500_000);
});
describe('Independent Deployability', () => {
let proj: string;
let tree: Tree;
beforeAll(() => {
process.env.NX_ADD_PLUGINS = 'false';
tree = createTreeWithEmptyWorkspace();
proj = newProject();
});
afterAll(() => {
cleanupProject();
delete process.env.NX_ADD_PLUGINS;
});
it('should support promised based remotes', async () => {
const remote = uniq('remote');
const host = uniq('host');
runCLI(
`generate @nx/react:host ${host} --remotes=${remote} --bundler=rspack --e2eTestRunner=cypress --no-interactive --projectNameAndRootFormat=as-provided --typescriptConfiguration=false --skipFormat`
);
// Update remote to be loaded via script
updateFile(
`${remote}/module-federation.config.js`,
stripIndents`
module.exports = {
name: '${remote}',
library: { type: 'var', name: '${remote}' },
exposes: {
'./Module': './src/remote-entry.ts',
},
};
`
);
updateFile(
`${remote}/webpack.config.prod.js`,
`module.exports = require('./webpack.config');`
);
// Update host to use promise based remote
updateFile(
`${host}/module-federation.config.js`,
`module.exports = {
name: '${host}',
library: { type: 'var', name: '${host}' },
remotes: [
[
'${remote}',
\`promise new Promise(resolve => {
const remoteUrl = 'http://localhost:4201/remoteEntry.js';
const script = document.createElement('script');
script.src = remoteUrl;
script.onload = () => {
const proxy = {
get: (request) => window.${remote}.get(request),
init: (arg) => {
try {
window.${remote}.init(arg);
} catch (e) {
console.log('Remote container already initialized');
}
}
};
resolve(proxy);
}
document.head.appendChild(script);
})\`,
],
],
};
`
);
updateFile(
`${host}/webpack.config.prod.js`,
`module.exports = require('./webpack.config');`
);
// Update e2e project.json
updateJson(`${host}-e2e/project.json`, (json) => {
return {
...json,
targets: {
...json.targets,
e2e: {
...json.targets.e2e,
options: {
...json.targets.e2e.options,
devServerTarget: `${host}:serve-static:production`,
},
},
},
};
});
// update e2e
updateFile(
`${host}-e2e/src/e2e/app.cy.ts`,
`
import { getGreeting } from '../support/app.po';
describe('${host}', () => {
beforeEach(() => cy.visit('/'));
it('should display welcome message', () => {
getGreeting().contains('Welcome ${host}');
});
it('should navigate to /${remote} from /', () => {
cy.get('a').contains('${remote[0].toUpperCase()}${remote.slice(
1
)}').click();
cy.url().should('include', '/${remote}');
getGreeting().contains('Welcome ${remote}');
});
});
`
);
const hostPort = readPort(host);
const remotePort = readPort(remote);
// Build host and remote
const buildOutput = runCLI(`build ${host}`);
const remoteOutput = runCLI(`build ${remote}`);
expect(buildOutput).toContain('Successfully ran target build');
expect(remoteOutput).toContain('Successfully ran target build');
if (runE2ETests()) {
const remoteProcess = await runCommandUntil(
`serve-static ${remote} --no-watch --verbose`,
() => {
return true;
}
);
const hostE2eResults = await runCommandUntil(
`e2e ${host}-e2e --no-watch --verbose`,
(output) => output.includes('All specs passed!')
);
await killProcessAndPorts(hostE2eResults.pid, hostPort, hostPort + 1);
await killProcessAndPorts(remoteProcess.pid, remotePort);
}
}, 500_000);
it('should support different versions workspace libs for host and remote', async () => {
const shell = uniq('shell');
const remote = uniq('remote');
const lib = uniq('lib');
runCLI(
`generate @nx/react:host ${shell} --remotes=${remote} --bundler=rspack --e2eTestRunner=cypress --no-interactive --projectNameAndRootFormat=as-provided --skipFormat`
);
runCLI(
`generate @nx/js:lib ${lib} --importPath=@acme/${lib} --publishable=true --no-interactive --projectNameAndRootFormat=as-provided --skipFormat`
);
const shellPort = readPort(shell);
const remotePort = readPort(remote);
updateFile(
`${lib}/src/lib/${lib}.ts`,
stripIndents`
export const version = '0.0.1';
`
);
updateJson(`${lib}/package.json`, (json) => {
return {
...json,
version: '0.0.1',
};
});
// Update host to use the lib
updateFile(
`${shell}/src/app/app.tsx`,
`
import * as React from 'react';
import NxWelcome from './nx-welcome';
import { version } from '@acme/${lib}';
import { Link, Route, Routes } from 'react-router-dom';
const About = React.lazy(() => import('${remote}/Module'));
export function App() {
return (
<React.Suspense fallback={null}>
<div className="home">
Lib version: { version }
</div>
<ul>
<li>
<Link to="/">Home</Link>
</li>
<li>
<Link to="/About">About</Link>
</li>
</ul>
<Routes>
<Route path="/" element={<NxWelcome title="home" />} />
<Route path="/About" element={<About />} />
</Routes>
</React.Suspense>
);
}
export default App;`
);
// Update remote to use the lib
updateFile(
`${remote}/src/app/app.tsx`,
`// eslint-disable-next-line @typescript-eslint/no-unused-vars
import styles from './app.module.css';
import { version } from '@acme/${lib}';
import NxWelcome from './nx-welcome';
export function App() {
return (
<div className='remote'>
Lib version: { version }
<NxWelcome title="${remote}" />
</div>
);
}
export default App;`
);
// update remote e2e test to check the version
updateFile(
`${remote}-e2e/src/e2e/app.cy.ts`,
`describe('${remote}', () => {
beforeEach(() => cy.visit('/'));
it('should check the lib version', () => {
cy.get('div.remote').contains('Lib version: 0.0.1');
});
});
`
);
// update shell e2e test to check the version
updateFile(
`${shell}-e2e/src/e2e/app.cy.ts`,
`
describe('${shell}', () => {
beforeEach(() => cy.visit('/'));
it('should check the lib version', () => {
cy.get('div.home').contains('Lib version: 0.0.1');
});
});
`
);
if (runE2ETests()) {
// test remote e2e
const remoteE2eResults = await runCommandUntil(
`e2e ${remote}-e2e --no-watch --verbose`,
(output) => output.includes('All specs passed!')
);
await killProcessAndPorts(remoteE2eResults.pid, remotePort);
// test shell e2e
// serve remote first
const remoteProcess = await runCommandUntil(
`serve ${remote} --no-watch --verbose`,
(output) => {
return output.includes(`Loopback: http://localhost:${remotePort}/`);
}
);
await killProcessAndPorts(remoteProcess.pid, remotePort);
const shellE2eResults = await runCommandUntil(
`e2e ${shell}-e2e --no-watch --verbose`,
(output) => output.includes('All specs passed!')
);
await killProcessAndPorts(
shellE2eResults.pid,
shellPort,
shellPort + 1,
remotePort
);
}
}, 500_000);
it('should support host and remote with library type var', async () => {
const shell = uniq('shell');
const remote = uniq('remote');
runCLI(
`generate @nx/react:host ${shell} --remotes=${remote} --bundler=rspack --e2eTestRunner=cypress --project-name-and-root-format=as-provided --no-interactive --skipFormat`
);
const shellPort = readPort(shell);
const remotePort = readPort(remote);
// update host and remote to use library type var
updateFile(
`${shell}/module-federation.config.ts`,
stripIndents`
import { ModuleFederationConfig } from '@nx/webpack';
const config: ModuleFederationConfig = {
name: '${shell}',
library: { type: 'var', name: '${shell}' },
remotes: ['${remote}'],
};
export default config;
`
);
updateFile(
`${shell}/webpack.config.prod.ts`,
`export { default } from './webpack.config';`
);
updateFile(
`${remote}/module-federation.config.ts`,
stripIndents`
import { ModuleFederationConfig } from '@nx/webpack';
const config: ModuleFederationConfig = {
name: '${remote}',
library: { type: 'var', name: '${remote}' },
exposes: {
'./Module': './src/remote-entry.ts',
},
};
export default config;
`
);
updateFile(
`${remote}/webpack.config.prod.ts`,
`export { default } from './webpack.config';`
);
// Update host e2e test to check that the remote works with library type var via navigation
updateFile(
`${shell}-e2e/src/e2e/app.cy.ts`,
`
import { getGreeting } from '../support/app.po';
describe('${shell}', () => {
beforeEach(() => cy.visit('/'));
it('should display welcome message', () => {
getGreeting().contains('Welcome ${shell}');
});
it('should navigate to /about from /', () => {
cy.get('a').contains('${remote[0].toUpperCase()}${remote.slice(
1
)}').click();
cy.url().should('include', '/${remote}');
getGreeting().contains('Welcome ${remote}');
});
});
`
);
// Build host and remote
const buildOutput = runCLI(`build ${shell}`);
const remoteOutput = runCLI(`build ${remote}`);
expect(buildOutput).toContain('Successfully ran target build');
expect(remoteOutput).toContain('Successfully ran target build');
if (runE2ETests()) {
const hostE2eResultsSwc = await runCommandUntil(
`e2e ${shell}-e2e --no-watch --verbose`,
(output) => output.includes('All specs passed!')
);
await killProcessAndPorts(
hostE2eResultsSwc.pid,
shellPort,
shellPort + 1,
remotePort
);
const remoteE2eResultsSwc = await runCommandUntil(
`e2e ${remote}-e2e --no-watch --verbose`,
(output) => output.includes('All specs passed!')
);
await killProcessAndPorts(remoteE2eResultsSwc.pid, remotePort);
const hostE2eResultsTsNode = await runCommandUntil(
`e2e ${shell}-e2e --no-watch --verbose`,
(output) => output.includes('All specs passed!'),
{ env: { NX_PREFER_TS_NODE: 'true' } }
);
await killProcessAndPorts(
hostE2eResultsTsNode.pid,
shellPort,
shellPort + 1,
remotePort
);
const remoteE2eResultsTsNode = await runCommandUntil(
`e2e ${remote}-e2e --no-watch --verbose`,
(output) => output.includes('All specs passed!'),
{ env: { NX_PREFER_TS_NODE: 'true' } }
);
await killProcessAndPorts(remoteE2eResultsTsNode.pid, remotePort);
}
}, 500_000);
});
describe('Dynamic Module Federation', () => {
beforeAll(() => {
newProject({ packages: ['@nx/react'] });
});
afterAll(() => cleanupProject());
it('should load remote dynamic module', async () => {
const shell = uniq('shell');
const remote = uniq('remote');
const remotePort = 4205;
runCLI(
`generate @nx/react:host ${shell} --remotes=${remote} --bundler=rspack --e2eTestRunner=cypress --dynamic=true --project-name-and-root-format=as-provided --no-interactive --skipFormat`
);
updateJson(`${remote}/project.json`, (project) => {
project.targets.serve.options.port = remotePort;
return project;
});
// Webpack prod config should not exists when loading dynamic modules
expect(
fileExists(`${tmpProjPath()}/${shell}/webpack.config.prod.ts`)
).toBeFalsy();
expect(
fileExists(
`${tmpProjPath()}/${shell}/src/assets/module-federation.manifest.json`
)
).toBeTruthy();
updateJson(
`${shell}/src/assets/module-federation.manifest.json`,
(json) => {
return {
[remote]: `http://localhost:${remotePort}`,
};
}
);
const manifest = readJson(
`${shell}/src/assets/module-federation.manifest.json`
);
expect(manifest[remote]).toBeDefined();
expect(manifest[remote]).toEqual('http://localhost:4205');
// update e2e
updateFile(
`${shell}-e2e/src/e2e/app.cy.ts`,
`
import { getGreeting } from '../support/app.po';
describe('${shell}', () => {
beforeEach(() => cy.visit('/'));
it('should display welcome message', () => {
getGreeting().contains('Welcome ${shell}');
});
it('should navigate to /${remote} from /', () => {
cy.get('a').contains('${remote[0].toUpperCase()}${remote.slice(
1
)}').click();
cy.url().should('include', '/${remote}');
getGreeting().contains('Welcome ${remote}');
});
});
`
);
// Build host and remote
const buildOutput = runCLI(`build ${shell}`);
const remoteOutput = runCLI(`build ${remote}`);
expect(buildOutput).toContain('Successfully ran target build');
expect(remoteOutput).toContain('Successfully ran target build');
const shellPort = readPort(shell);
if (runE2ETests()) {
// Serve Remote since it is dynamic and won't be started with the host
const remoteProcess = await runCommandUntil(
`serve-static ${remote} --no-watch --verbose`,
() => {
return true;
}
);
const hostE2eResultsSwc = await runCommandUntil(
`e2e ${shell}-e2e --no-watch --verbose`,
(output) => output.includes('All specs passed!')
);
await killProcessAndPorts(remoteProcess.pid, remotePort);
await killProcessAndPorts(hostE2eResultsSwc.pid, shellPort);
}
}, 500_000);
});
});
function readPort(appName: string): number {
let config;
try {
config = readJson(join('apps', appName, 'project.json'));
} catch {
config = readJson(join(appName, 'project.json'));
}
return config.targets.serve.options.port;
}