diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml new file mode 100644 index 00000000..5748ef0b --- /dev/null +++ b/.github/workflows/integration-test.yml @@ -0,0 +1,45 @@ +name: Integration Test + +on: + pull_request: + branches: + - main + +permissions: + pull-requests: read + +jobs: + integration-test: + name: Integration (vite ${{ matrix.vite-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # This plugin does not yet correctly support version above 7.1.7 + vite-version: ['5', '6', '7.1.7'] + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: PNPM Install + uses: pnpm/action-setup@v4 + with: + version: 10.28.2 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 24 + cache: 'pnpm' + registry-url: https://registry.npmjs.org/ + + - run: corepack enable + + - name: Install NPM Dependencies + run: pnpm install --frozen-lockfile + + - name: Override vite version + run: pnpm add -Dw vite@${{ matrix.vite-version }} + + - name: Run Integration Tests + run: pnpm test:integration diff --git a/integration/build.test.ts b/integration/build.test.ts new file mode 100644 index 00000000..0f170586 --- /dev/null +++ b/integration/build.test.ts @@ -0,0 +1,46 @@ +import { resolve } from 'path'; +import { describe, expect, it } from 'vitest'; +import { buildFixture, FIXTURES } from './helpers/build'; +import { findAsset, findChunk, getAllChunkCode, getChunkNames } from './helpers/matchers'; + +const BASIC_REMOTE_MF_OPTIONS = { + exposes: { + './exposed': resolve(FIXTURES, 'basic-remote', 'exposed-module.js'), + }, +}; + +describe('build', () => { + describe('remote', () => { + it('produces a remoteEntry chunk', async () => { + const output = await buildFixture({ mfOptions: BASIC_REMOTE_MF_OPTIONS }); + const chunks = getChunkNames(output); + expect(chunks.some((name) => name.includes('remoteEntry'))).toBe(true); + }); + + it('remoteEntry contains federation runtime init with correct name', async () => { + const output = await buildFixture({ mfOptions: BASIC_REMOTE_MF_OPTIONS }); + const remoteEntry = findChunk(output, 'remoteEntry'); + expect(remoteEntry).toBeDefined(); + expect(remoteEntry!.code).toContain('basicRemote'); + expect(remoteEntry!.code).toContain('moduleCache'); + }); + + it('exposed module content is included in output', async () => { + const output = await buildFixture({ mfOptions: BASIC_REMOTE_MF_OPTIONS }); + const allCode = getAllChunkCode(output); + expect(allCode).toContain('Hello'); + }); + + it('generates mf-manifest.json when manifest is enabled', async () => { + const manifestOutput = await buildFixture({ + mfOptions: { ...BASIC_REMOTE_MF_OPTIONS, manifest: true }, + }); + + const manifest = findAsset(manifestOutput, 'mf-manifest.json'); + expect(manifest).toBeDefined(); + + const parsed = JSON.parse(manifest!.source as string); + expect(parsed).toHaveProperty('exposes'); + }); + }); +}); diff --git a/integration/css-manifest.test.ts b/integration/css-manifest.test.ts new file mode 100644 index 00000000..3a7ea36b --- /dev/null +++ b/integration/css-manifest.test.ts @@ -0,0 +1,73 @@ +import { resolve } from 'path'; +import { describe, expect, it } from 'vitest'; +import type { ModuleFederationOptions } from '../src/utils/normalizeModuleFederationOptions'; +import { buildFixture, FIXTURES } from './helpers/build'; +import { parseManifest } from './helpers/matchers'; + +const CSS_BASE_MF_OPTIONS = { + name: 'cssRemote', + filename: 'remoteEntry.js', + exposes: { + './widget': resolve(FIXTURES, 'css-remote', 'exposed-module.js'), + }, + manifest: true, + dts: false, +} satisfies Partial; + +interface ManifestExpose { + id: string; + name: string; + path: string; + assets: { + js: { sync: string[]; async: string[] }; + css: { sync: string[]; async: string[] }; + }; +} + +describe('css manifest', () => { + it('tracks CSS and JS assets under the correct expose', async () => { + const output = await buildFixture({ + fixture: 'css-remote', + mfOptions: CSS_BASE_MF_OPTIONS, + }); + const manifest = parseManifest(output) as Record; + expect(manifest).toBeDefined(); + expect(manifest).toHaveProperty('exposes'); + + const exposes = manifest.exposes as ManifestExpose[]; + const widget = exposes.find((e) => e.name === 'widget'); + expect(widget).toBeDefined(); + + const allCssFiles = [...widget!.assets.css.sync, ...widget!.assets.css.async]; + expect(allCssFiles.length).toBeGreaterThanOrEqual(1); + for (const file of allCssFiles) { + expect(file).toMatch(/\.css$/); + } + + const allJsFiles = [...widget!.assets.js.sync, ...widget!.assets.js.async]; + expect(allJsFiles.length).toBeGreaterThanOrEqual(1); + for (const file of allJsFiles) { + expect(file).toMatch(/\.js$/); + } + }); + + it('adds CSS to all exposes when bundleAllCSS is enabled', async () => { + const output = await buildFixture({ + fixture: 'css-remote', + mfOptions: { ...CSS_BASE_MF_OPTIONS, bundleAllCSS: true }, + }); + const manifest = parseManifest(output) as Record; + expect(manifest).toBeDefined(); + + const exposes = manifest.exposes as ManifestExpose[]; + expect(exposes.length).toBeGreaterThanOrEqual(1); + + for (const expose of exposes) { + const cssCount = expose.assets.css.sync.length + expose.assets.css.async.length; + expect( + cssCount, + `expose "${expose.name}" should have at least one CSS asset` + ).toBeGreaterThanOrEqual(1); + } + }); +}); diff --git a/integration/fixtures/basic-host/entry.js b/integration/fixtures/basic-host/entry.js new file mode 100644 index 00000000..e6a3b738 --- /dev/null +++ b/integration/fixtures/basic-host/entry.js @@ -0,0 +1,2 @@ +const mod = import('remote1/Module'); +mod.then((m) => console.log(m)); diff --git a/integration/fixtures/basic-host/index.html b/integration/fixtures/basic-host/index.html new file mode 100644 index 00000000..330490e2 --- /dev/null +++ b/integration/fixtures/basic-host/index.html @@ -0,0 +1,8 @@ + + + + +
+ + + diff --git a/integration/fixtures/basic-remote/entry.js b/integration/fixtures/basic-remote/entry.js new file mode 100644 index 00000000..604610a1 --- /dev/null +++ b/integration/fixtures/basic-remote/entry.js @@ -0,0 +1,2 @@ +// Minimal app entry — just needs to exist for vite to build +console.log('hello world'); diff --git a/integration/fixtures/basic-remote/exposed-module.js b/integration/fixtures/basic-remote/exposed-module.js new file mode 100644 index 00000000..a1065073 --- /dev/null +++ b/integration/fixtures/basic-remote/exposed-module.js @@ -0,0 +1,3 @@ +export function greet(name) { + return `Hello, ${name}!`; +} diff --git a/integration/fixtures/basic-remote/index.html b/integration/fixtures/basic-remote/index.html new file mode 100644 index 00000000..9c3437e2 --- /dev/null +++ b/integration/fixtures/basic-remote/index.html @@ -0,0 +1,7 @@ + + + +
+ + + diff --git a/integration/fixtures/css-remote/entry.js b/integration/fixtures/css-remote/entry.js new file mode 100644 index 00000000..629397ac --- /dev/null +++ b/integration/fixtures/css-remote/entry.js @@ -0,0 +1 @@ +console.log('css remote entry'); diff --git a/integration/fixtures/css-remote/exposed-module.js b/integration/fixtures/css-remote/exposed-module.js new file mode 100644 index 00000000..b839a47c --- /dev/null +++ b/integration/fixtures/css-remote/exposed-module.js @@ -0,0 +1,5 @@ +import './styles.css'; + +export function Widget() { + return '
Widget
'; +} diff --git a/integration/fixtures/css-remote/index.html b/integration/fixtures/css-remote/index.html new file mode 100644 index 00000000..9c3437e2 --- /dev/null +++ b/integration/fixtures/css-remote/index.html @@ -0,0 +1,7 @@ + + + +
+ + + diff --git a/integration/fixtures/css-remote/styles.css b/integration/fixtures/css-remote/styles.css new file mode 100644 index 00000000..bbdf45f5 --- /dev/null +++ b/integration/fixtures/css-remote/styles.css @@ -0,0 +1,4 @@ +.widget { + color: red; + padding: 8px; +} diff --git a/integration/fixtures/shared-remote/entry.js b/integration/fixtures/shared-remote/entry.js new file mode 100644 index 00000000..8f3556a9 --- /dev/null +++ b/integration/fixtures/shared-remote/entry.js @@ -0,0 +1 @@ +console.log('shared remote entry'); diff --git a/integration/fixtures/shared-remote/exposed-module.js b/integration/fixtures/shared-remote/exposed-module.js new file mode 100644 index 00000000..88cc3c26 --- /dev/null +++ b/integration/fixtures/shared-remote/exposed-module.js @@ -0,0 +1,5 @@ +import defu from 'defu'; + +export function merge(a, b) { + return defu(a, b); +} diff --git a/integration/fixtures/shared-remote/index.html b/integration/fixtures/shared-remote/index.html new file mode 100644 index 00000000..9c3437e2 --- /dev/null +++ b/integration/fixtures/shared-remote/index.html @@ -0,0 +1,7 @@ + + + +
+ + + diff --git a/integration/helpers/assertions.ts b/integration/helpers/assertions.ts new file mode 100644 index 00000000..76e3c536 --- /dev/null +++ b/integration/helpers/assertions.ts @@ -0,0 +1,7 @@ +import type { OutputAsset, OutputChunk, RollupOutput } from 'rollup'; + +export const isRollupChunk = (output: RollupOutput['output'][number]): output is OutputChunk => + output.type === 'chunk'; + +export const isRollupAsset = (output: RollupOutput['output'][number]): output is OutputAsset => + output.type === 'asset'; diff --git a/integration/helpers/build.ts b/integration/helpers/build.ts new file mode 100644 index 00000000..5b8ddec5 --- /dev/null +++ b/integration/helpers/build.ts @@ -0,0 +1,54 @@ +import defu from 'defu'; +import { resolve } from 'path'; +import { build, Rollup, UserConfig as ViteUserConfig } from 'vite'; +import { expect } from 'vitest'; +import { federation } from '../../src/index'; +import type { ModuleFederationOptions } from '../../src/utils/normalizeModuleFederationOptions'; + +export const FIXTURES = resolve(__dirname, '../fixtures'); + +export interface BuildFixtureOptions { + /** + * @default 'basic-remote' + */ + fixture?: string; + mfOptions?: Partial; + viteConfig?: Partial; +} + +export async function buildFixture(opts?: BuildFixtureOptions): Promise { + const { fixture = 'basic-remote', mfOptions, viteConfig } = opts ?? {}; + + const defaultMfOptions = { + name: 'basicRemote', + filename: 'remoteEntry.js', + exposes: {}, + shared: {}, + dts: false, + } satisfies Parameters[0]; + + // defu(overrides, defaults) — first arg wins for any key it provides + const mergedMfOptions = defu(mfOptions, defaultMfOptions); + + const defaultViteConfig: ViteUserConfig = { + root: resolve(FIXTURES, fixture), + logLevel: 'silent', + build: { + write: false, + minify: false, + target: 'chrome89', + }, + }; + + const mergedViteConfig = defu(viteConfig, defaultViteConfig); + + const result = await build({ + ...mergedViteConfig, + plugins: [federation(mergedMfOptions)], + }); + + // Vite returns RollupOutput[] only with multiple rollupOptions.output entries. + // Our test configs should never produce that — fail fast if they do. + expect(Array.isArray(result), 'Expected a single RollupOutput, not an array').toBe(false); + return result as Rollup.RollupOutput; +} diff --git a/integration/helpers/matchers.ts b/integration/helpers/matchers.ts new file mode 100644 index 00000000..daaad68a --- /dev/null +++ b/integration/helpers/matchers.ts @@ -0,0 +1,39 @@ +import type { Rollup } from 'vite'; +import { isRollupAsset, isRollupChunk } from './assertions'; + +export function getChunkNames(output: Rollup.RollupOutput) { + return output.output.filter(isRollupChunk).map((c) => c.fileName); +} + +export function findChunk( + output: Rollup.RollupOutput, + test: string | RegExp +): Rollup.OutputChunk | undefined { + return output.output + .filter(isRollupChunk) + .find((o) => (typeof test === 'string' ? o.fileName.includes(test) : test.test(o.fileName))); +} + +export function findAsset( + output: Rollup.RollupOutput, + test: string +): Rollup.OutputAsset | undefined { + return output.output.filter(isRollupAsset).find((o) => o.fileName.includes(test)); +} + +export function getAllChunkCode(output: Rollup.RollupOutput): string { + return output.output + .filter(isRollupChunk) + .map((c) => c.code) + .join('\n'); +} + +export function getHtmlAsset(output: Rollup.RollupOutput): Rollup.OutputAsset | undefined { + return output.output.filter(isRollupAsset).find((o) => o.fileName.endsWith('.html')); +} + +export function parseManifest(output: Rollup.RollupOutput): object | undefined { + const asset = findAsset(output, 'mf-manifest.json'); + if (!asset) return undefined; + return JSON.parse(asset.source as string); +} diff --git a/integration/host-build.test.ts b/integration/host-build.test.ts new file mode 100644 index 00000000..4963cd36 --- /dev/null +++ b/integration/host-build.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import type { ModuleFederationOptions } from '../src/utils/normalizeModuleFederationOptions'; +import { buildFixture } from './helpers/build'; +import { findChunk, getAllChunkCode, getChunkNames, getHtmlAsset } from './helpers/matchers'; + +const HOST_BASE_MF_OPTIONS = { + name: 'hostApp', + filename: 'remoteEntry.js', + remotes: { + remote1: { + name: 'remote1', + entry: 'http://localhost:3001/remoteEntry.js', + type: 'module', + }, + }, + dts: false, +} satisfies Partial; + +const hostInitChunkRegex = //; + +describe('host build', () => { + it('transforms remote module imports into federation loadRemote() calls', async () => { + const output = await buildFixture({ + fixture: 'basic-host', + mfOptions: HOST_BASE_MF_OPTIONS, + }); + const allCode = getAllChunkCode(output); + expect(allCode).toContain('loadRemote'); + expect(allCode).toContain('remote1/Module'); + }); + + it('adds federation bootstrap script to HTML when hostInitInjectLocation is html', async () => { + const output = await buildFixture({ + fixture: 'basic-host', + mfOptions: { ...HOST_BASE_MF_OPTIONS, hostInitInjectLocation: 'html' }, + }); + const htmlAsset = getHtmlAsset(output); + expect(htmlAsset).toBeDefined(); + // pluginAddEntry.generateBundle injects a