<!-- 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 --> ## Expected Behavior <!-- This is the behavior we should expect with the changes in this PR --> ## Related Issue(s) <!-- Please link the issue being fixed so it gets closed when this is merged. --> Fixes # --------- Co-authored-by: Colum Ferry <cferry09@gmail.com> Co-authored-by: Nicholas Cunningham <ndcunningham@gmail.com>
448 lines
14 KiB
TypeScript
448 lines
14 KiB
TypeScript
import { names } from '@nx/devkit';
|
|
import {
|
|
checkFilesExist,
|
|
cleanupProject,
|
|
killPorts,
|
|
killProcessAndPorts,
|
|
newProject,
|
|
readJson,
|
|
runCLI,
|
|
runCommandUntil,
|
|
runE2ETests,
|
|
uniq,
|
|
updateFile,
|
|
updateJson,
|
|
} from '@nx/e2e/utils';
|
|
import { join } from 'path';
|
|
|
|
describe('Angular Module Federation', () => {
|
|
let proj: string;
|
|
let oldVerboseLoggingValue: string;
|
|
|
|
beforeAll(() => {
|
|
proj = newProject({ packages: ['@nx/angular'] });
|
|
oldVerboseLoggingValue = process.env.NX_E2E_VERBOSE_LOGGING;
|
|
process.env.NX_E2E_VERBOSE_LOGGING = 'true';
|
|
});
|
|
afterAll(() => {
|
|
cleanupProject();
|
|
process.env.NX_E2E_VERBOSE_LOGGING = oldVerboseLoggingValue;
|
|
});
|
|
|
|
it('should generate valid host and remote apps', async () => {
|
|
const hostApp = uniq('app');
|
|
const remoteApp1 = uniq('remote');
|
|
const sharedLib = uniq('shared-lib');
|
|
const wildcardLib = uniq('wildcard-lib');
|
|
const secondaryEntry = uniq('secondary');
|
|
const hostPort = 4300;
|
|
const remotePort = 4301;
|
|
|
|
// generate host app
|
|
runCLI(
|
|
`generate @nx/angular:host ${hostApp} --style=css --no-standalone --no-interactive`
|
|
);
|
|
// generate remote app
|
|
runCLI(
|
|
`generate @nx/angular:remote ${remoteApp1} --host=${hostApp} --port=${remotePort} --style=css --no-standalone --no-interactive`
|
|
);
|
|
|
|
// check files are generated without the layout directory ("apps/")
|
|
checkFilesExist(
|
|
`${hostApp}/src/app/app.module.ts`,
|
|
`${remoteApp1}/src/app/app.module.ts`
|
|
);
|
|
|
|
// check default generated host is built successfully
|
|
const buildOutput = runCLI(`build ${hostApp}`);
|
|
expect(buildOutput).toContain('Successfully ran target build');
|
|
|
|
// generate a shared lib with a seconary entry point
|
|
runCLI(
|
|
`generate @nx/angular:library ${sharedLib} --buildable --no-standalone --no-interactive`
|
|
);
|
|
runCLI(
|
|
`generate @nx/angular:library-secondary-entry-point --library=${sharedLib} --name=${secondaryEntry} --no-interactive`
|
|
);
|
|
|
|
// Add a library that will be accessed via a wildcard in tspath mappings
|
|
runCLI(
|
|
`generate @nx/angular:library ${wildcardLib} --buildable --no-standalone --no-interactive`
|
|
);
|
|
|
|
updateJson('tsconfig.base.json', (json) => {
|
|
delete json.compilerOptions.paths[`@${proj}/${wildcardLib}`];
|
|
json.compilerOptions.paths[`@${proj}/${wildcardLib}/*`] = [
|
|
`${wildcardLib}/src/lib/*`,
|
|
];
|
|
return json;
|
|
});
|
|
|
|
// update host & remote files to use shared library
|
|
updateFile(
|
|
`${hostApp}/src/app/app.module.ts`,
|
|
`import { NgModule } from '@angular/core';
|
|
import { BrowserModule } from '@angular/platform-browser';
|
|
import { ${
|
|
names(wildcardLib).className
|
|
}Module } from '@${proj}/${wildcardLib}/${
|
|
names(secondaryEntry).fileName
|
|
}.module';
|
|
import { ${
|
|
names(sharedLib).className
|
|
}Module } from '@${proj}/${sharedLib}';
|
|
import { ${
|
|
names(secondaryEntry).className
|
|
}Module } from '@${proj}/${sharedLib}/${secondaryEntry}';
|
|
import { AppComponent } from './app.component';
|
|
import { NxWelcomeComponent } from './nx-welcome.component';
|
|
import { RouterModule } from '@angular/router';
|
|
|
|
@NgModule({
|
|
declarations: [AppComponent, NxWelcomeComponent],
|
|
imports: [
|
|
BrowserModule,
|
|
${names(sharedLib).className}Module,
|
|
${names(wildcardLib).className}Module,
|
|
RouterModule.forRoot(
|
|
[
|
|
{
|
|
path: '${remoteApp1}',
|
|
loadChildren: () =>
|
|
import('${remoteApp1}/Module').then(
|
|
(m) => m.RemoteEntryModule
|
|
),
|
|
},
|
|
],
|
|
{ initialNavigation: 'enabledBlocking' }
|
|
),
|
|
],
|
|
providers: [],
|
|
bootstrap: [AppComponent],
|
|
})
|
|
export class AppModule {}
|
|
`
|
|
);
|
|
updateFile(
|
|
`${remoteApp1}/src/app/remote-entry/entry.module.ts`,
|
|
`import { NgModule } from '@angular/core';
|
|
import { CommonModule } from '@angular/common';
|
|
import { RouterModule } from '@angular/router';
|
|
import { ${names(sharedLib).className}Module } from '@${proj}/${sharedLib}';
|
|
import { ${
|
|
names(secondaryEntry).className
|
|
}Module } from '@${proj}/${sharedLib}/${secondaryEntry}';
|
|
import { RemoteEntryComponent } from './entry.component';
|
|
import { NxWelcomeComponent } from './nx-welcome.component';
|
|
|
|
@NgModule({
|
|
declarations: [RemoteEntryComponent, NxWelcomeComponent],
|
|
imports: [
|
|
CommonModule,
|
|
${names(sharedLib).className}Module,
|
|
RouterModule.forChild([
|
|
{
|
|
path: '',
|
|
component: RemoteEntryComponent,
|
|
},
|
|
]),
|
|
],
|
|
providers: [],
|
|
})
|
|
export class RemoteEntryModule {}
|
|
`
|
|
);
|
|
|
|
const processSwc = await runCommandUntil(
|
|
`serve ${hostApp} --port=${hostPort} --dev-remotes=${remoteApp1}`,
|
|
(output) =>
|
|
!output.includes(`Remote '${remoteApp1}' failed to serve correctly`) &&
|
|
output.includes(`listening on localhost:${hostPort}`)
|
|
);
|
|
await killProcessAndPorts(processSwc.pid, hostPort, remotePort);
|
|
|
|
const processTsNode = await runCommandUntil(
|
|
`serve ${hostApp} --port=${hostPort} --dev-remotes=${remoteApp1}`,
|
|
(output) =>
|
|
!output.includes(`Remote '${remoteApp1}' failed to serve correctly`) &&
|
|
output.includes(`listening on localhost:${hostPort}`),
|
|
{ env: { NX_PREFER_TS_NODE: 'true' } }
|
|
);
|
|
|
|
await killProcessAndPorts(processTsNode.pid, hostPort, remotePort);
|
|
}, 20_000_000);
|
|
|
|
it('should convert apps to MF successfully', async () => {
|
|
const app1 = uniq('app1');
|
|
const app2 = uniq('app2');
|
|
const app1Port = 4400;
|
|
const app2Port = 4401;
|
|
|
|
// generate apps
|
|
runCLI(
|
|
`generate @nx/angular:application ${app1} --routing --bundler=webpack --no-interactive`
|
|
);
|
|
runCLI(
|
|
`generate @nx/angular:application ${app2} --bundler=webpack --no-interactive`
|
|
);
|
|
|
|
// convert apps
|
|
runCLI(
|
|
`generate @nx/angular:setup-mf ${app1} --mfType=host --port=${app1Port} --no-interactive`
|
|
);
|
|
runCLI(
|
|
`generate @nx/angular:setup-mf ${app2} --mfType=remote --host=${app1} --port=${app2Port} --no-interactive`
|
|
);
|
|
|
|
const processSwc = await runCommandUntil(
|
|
`serve ${app1} --dev-remotes=${app2}`,
|
|
(output) =>
|
|
!output.includes(`Remote '${app2}' failed to serve correctly`) &&
|
|
output.includes(`listening on localhost:${app1Port}`)
|
|
);
|
|
|
|
await killProcessAndPorts(processSwc.pid, app1Port, app2Port);
|
|
|
|
const processTsNode = await runCommandUntil(
|
|
`serve ${app1} --dev-remotes=${app2}`,
|
|
(output) =>
|
|
!output.includes(`Remote '${app2}' failed to serve correctly`) &&
|
|
output.includes(`listening on localhost:${app1Port}`),
|
|
{ env: { NX_PREFER_TS_NODE: 'true' } }
|
|
);
|
|
|
|
await killProcessAndPorts(processTsNode.pid, app1Port, app2Port);
|
|
}, 20_000_000);
|
|
|
|
it('should scaffold MF + SSR setup successfully', async () => {
|
|
const host = uniq('host');
|
|
const remote1 = uniq('remote1');
|
|
const remote2 = uniq('remote2');
|
|
|
|
// generate remote apps
|
|
runCLI(
|
|
`generate @nx/angular:host ${host} --ssr --remotes=${remote1},${remote2} --no-interactive`
|
|
);
|
|
|
|
// ports
|
|
const hostPort = 4500;
|
|
const remote1Port = readJson(join(remote1, 'project.json')).targets.serve
|
|
.options.port;
|
|
const remote2Port = readJson(join(remote2, 'project.json')).targets.serve
|
|
.options.port;
|
|
|
|
[host, remote1, remote2].forEach((app) => {
|
|
checkFilesExist(
|
|
`${app}/module-federation.config.ts`,
|
|
`${app}/webpack.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));
|
|
});
|
|
});
|
|
});
|
|
|
|
const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
|
|
|
|
updateFile(
|
|
`${host}-e2e/src/example.spec.ts`,
|
|
(_) => `import { test, expect } from '@playwright/test';
|
|
test('renders remotes', async ({ page }) => {
|
|
await page.goto('/');
|
|
|
|
// Expect the page to contain a specific text.
|
|
// get ul li text
|
|
const items = page.locator('ul li');
|
|
|
|
await items.nth(2).waitFor()
|
|
expect(await items.count()).toEqual(3);
|
|
expect(await items.nth(0).innerText()).toContain('Home');
|
|
expect(await items.nth(1).innerText()).toContain('${capitalize(remote1)}');
|
|
expect(await items.nth(2).innerText()).toContain('${capitalize(remote2)}');
|
|
});`
|
|
);
|
|
if (runE2ETests()) {
|
|
const e2eProcess = await runCommandUntil(`e2e ${host}-e2e`, (output) =>
|
|
output.includes(`Successfully ran target e2e for project ${host}-e2e`)
|
|
);
|
|
await killProcessAndPorts(e2eProcess.pid);
|
|
}
|
|
}, 20_000_000);
|
|
|
|
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');
|
|
|
|
const hostPort = 4200;
|
|
|
|
runCLI(
|
|
`generate @nx/angular:host ${host} --remotes=${remote} --e2eTestRunner=cypress --no-interactive`
|
|
);
|
|
|
|
runCLI(`generate @nx/js:lib ${lib} --no-interactive`);
|
|
|
|
// Federate Module
|
|
runCLI(
|
|
`generate @nx/angular:federate-module ${lib}/src/index.ts --name=${module} --remote=${remote} --no-interactive`
|
|
);
|
|
|
|
updateFile(`${lib}/src/index.ts`, `export { isEven } from './lib/${lib}';`);
|
|
updateFile(
|
|
`${lib}/src/lib/${lib}.ts`,
|
|
`export function isEven(num: number) { return num % 2 === 0; }`
|
|
);
|
|
|
|
// Update Host to use the module
|
|
updateFile(
|
|
`${host}/src/app/app.component.ts`,
|
|
`
|
|
import { Component } from '@angular/core';
|
|
import { isEven } from '${remote}/${module}';
|
|
|
|
@Component({
|
|
selector: 'app-root',
|
|
template: \`<div class="host">{{title}}</div>\`,
|
|
standalone: true
|
|
})
|
|
export class AppComponent {
|
|
title = \`shell is \${isEven(2) ? 'even' : 'odd'}\`;
|
|
}`
|
|
);
|
|
|
|
// 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.host')).to.exist;
|
|
expect(cy.get('div.host').contains('shell is even'));
|
|
});
|
|
});
|
|
|
|
`
|
|
);
|
|
|
|
// Build host and remote
|
|
const buildHostOutput = runCLI(`build ${host}`);
|
|
expect(buildHostOutput).toContain('Successfully ran target build');
|
|
const buildRemoteOutput = runCLI(`build ${remote}`);
|
|
expect(buildRemoteOutput).toContain('Successfully ran target build');
|
|
|
|
if (runE2ETests('cypress')) {
|
|
const e2eProcess = await runCommandUntil(`e2e ${host}-e2e`, (output) =>
|
|
output.includes('All specs passed!')
|
|
);
|
|
await killProcessAndPorts(e2eProcess.pid, hostPort, hostPort + 1);
|
|
}
|
|
}, 500_000);
|
|
|
|
it('should federate a module from a library and create a remote that is served recursively', async () => {
|
|
const lib = uniq('lib');
|
|
const remote = uniq('remote');
|
|
const childRemote = uniq('childremote');
|
|
const module = uniq('module');
|
|
const host = uniq('host');
|
|
const hostPort = 4200;
|
|
|
|
runCLI(
|
|
`generate @nx/angular:host ${host} --remotes=${remote} --e2eTestRunner=cypress --no-interactive`
|
|
);
|
|
|
|
runCLI(`generate @nx/js:lib ${lib} --no-interactive`);
|
|
|
|
// Federate Module
|
|
runCLI(
|
|
`generate @nx/angular:federate-module ${lib}/src/index.ts --name=${module} --remote=${childRemote} --remoteDirectory=${childRemote} --no-interactive`
|
|
);
|
|
|
|
updateFile(`${lib}/src/index.ts`, `export { isEven } from './lib/${lib}';`);
|
|
updateFile(
|
|
`${lib}/src/lib/${lib}.ts`,
|
|
`export function isEven(num: number) { return num % 2 === 0; }`
|
|
);
|
|
|
|
// Update Host to use the module
|
|
updateFile(
|
|
`${remote}/src/app/remote-entry/entry.component.ts`,
|
|
`
|
|
import { Component } from '@angular/core';
|
|
import { isEven } from '${childRemote}/${module}';
|
|
|
|
@Component({
|
|
selector: 'app-${remote}-entry',
|
|
template: \`<div class="childremote">{{title}}</div>\`,
|
|
standalone: true
|
|
})
|
|
export class RemoteEntryComponent {
|
|
title = \`shell is \${isEven(2) ? 'even' : 'odd'}\`;
|
|
}`
|
|
);
|
|
|
|
updateFile(
|
|
`${remote}/module-federation.config.ts`,
|
|
`
|
|
import { ModuleFederationConfig } from '@nx/webpack';
|
|
|
|
const config: ModuleFederationConfig = {
|
|
name: '${remote}',
|
|
remotes: ['${childRemote}'],
|
|
exposes: {
|
|
'./Routes': '${remote}/src/app/remote-entry/entry.routes.ts',
|
|
'./Module': '${remote}/src/app/remote-entry/entry.component.ts',
|
|
},
|
|
};
|
|
|
|
export default config;`
|
|
);
|
|
|
|
// 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.childremote')).to.exist;
|
|
expect(cy.get('div.childremote').contains('shell is even'));
|
|
});
|
|
});
|
|
|
|
`
|
|
);
|
|
|
|
// Build host and remote
|
|
const buildHostOutput = runCLI(`build ${host}`);
|
|
expect(buildHostOutput).toContain('Successfully ran target build');
|
|
const buildRemoteOutput = runCLI(`build ${remote}`);
|
|
expect(buildRemoteOutput).toContain('Successfully ran target build');
|
|
|
|
if (runE2ETests('cypress')) {
|
|
const e2eProcess = await runCommandUntil(`e2e ${host}-e2e`, (output) =>
|
|
output.includes('All specs passed!')
|
|
);
|
|
await killProcessAndPorts(e2eProcess.pid, hostPort, hostPort + 1);
|
|
}
|
|
}, 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;
|
|
}
|