diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f217355b..86d0d05d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,11 @@ jobs: node-version: 24 cache: 'npm' - run: npm ci + + # Install Playwright browsers + - name: Install Playwright Browsers + run: npx playwright install --with-deps chromium + - uses: nrwl/nx-set-shas@v4 - run: npx nx format:check diff --git a/.gitignore b/.gitignore index b8038c2c..ed813ead 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,6 @@ migrations.json .nx .cursor/rules/nx-rules.mdc .github/instructions/nx.instructions.md + +vite.config.*.timestamp* +vitest.config.*.timestamp* \ No newline at end of file diff --git a/libs/native-federation-node/src/lib/node/init-node-federation.ts b/libs/native-federation-node/src/lib/node/init-node-federation.ts index 3ec78d31..b96eb2ee 100644 --- a/libs/native-federation-node/src/lib/node/init-node-federation.ts +++ b/libs/native-federation-node/src/lib/node/init-node-federation.ts @@ -1,15 +1,14 @@ -import { register } from 'node:module'; -import { pathToFileURL } from 'node:url'; import * as fs from 'node:fs/promises'; +import { register } from 'node:module'; import * as path from 'node:path'; +import { pathToFileURL } from 'node:url'; import { FederationInfo, + fetchAndRegisterRemotes, ImportMap, - InitFederationOptions, mergeImportMaps, processHostInfo, - processRemoteInfos, } from '@softarc/native-federation-runtime'; import { IMPORT_MAP_FILE_NAME } from '../utils/import-map-loader'; import { resolver } from '../utils/loader-as-data-url'; @@ -54,7 +53,7 @@ async function createNodeImportMap( const hostInfo = await loadFsFederationInfo(relBundlePath); const hostImportMap = await processHostInfo(hostInfo, './' + relBundlePath); - const remotesImportMap = await processRemoteInfos(remotes, { + const remotesImportMap = await fetchAndRegisterRemotes(remotes, { throwIfRemoteNotFound: options.throwIfRemoteNotFound, cacheTag: options.cacheTag, }); diff --git a/libs/native-federation-runtime/.eslintrc.json b/libs/native-federation-runtime/.eslintrc.json index ca310329..f987fa8f 100644 --- a/libs/native-federation-runtime/.eslintrc.json +++ b/libs/native-federation-runtime/.eslintrc.json @@ -1,6 +1,11 @@ { "extends": ["../../.eslintrc.json", "../../.eslintrc.base.json"], - "ignorePatterns": ["!**/*"], + "ignorePatterns": [ + "!**/*", + "**/*.spec.ts", + "**/*.integration.spec.ts", + "__test-helpers__/**/*" + ], "overrides": [ { "files": ["*.ts"], diff --git a/libs/native-federation-runtime/jest.config.ts b/libs/native-federation-runtime/jest.config.ts deleted file mode 100644 index 339acd94..00000000 --- a/libs/native-federation-runtime/jest.config.ts +++ /dev/null @@ -1,22 +0,0 @@ -/* eslint-disable */ -export default { - displayName: 'native-federation-runtime', - preset: '../../jest.preset.js', - setupFilesAfterEnv: ['/src/test-setup.ts'], - coverageDirectory: '../../coverage/libs/native-federation-runtime', - transform: { - '^.+\\.(ts|mjs|js|html)$': [ - 'jest-preset-angular', - { - tsconfig: '/tsconfig.spec.json', - stringifyContentPathRegex: '\\.(html|svg)$', - }, - ], - }, - transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], - snapshotSerializers: [ - 'jest-preset-angular/build/serializers/no-ng-attributes', - 'jest-preset-angular/build/serializers/ng-snapshot', - 'jest-preset-angular/build/serializers/html-comment', - ], -}; diff --git a/libs/native-federation-runtime/package.json b/libs/native-federation-runtime/package.json index 56c7181a..da31d9e1 100644 --- a/libs/native-federation-runtime/package.json +++ b/libs/native-federation-runtime/package.json @@ -5,6 +5,21 @@ "tslib": "^2.3.0" }, "devDependencies": { - "@types/node": "^22.5.4" + "@types/node": "^22.5.4", + "vitest": "^3.0.0", + "@vitest/ui": "^3.0.0" + }, + "scripts": { + "test": "vitest run", + "test:watch": "vitest", + "test:ui": "vitest --ui", + "test:coverage": "vitest run --coverage", + "test:unit": "vitest run --project=unit", + "test:integration": "vitest run --project=integration" + }, + "msw": { + "workerDirectory": [ + "public" + ] } } diff --git a/libs/native-federation-runtime/project.json b/libs/native-federation-runtime/project.json index 58caf167..68f4eea0 100644 --- a/libs/native-federation-runtime/project.json +++ b/libs/native-federation-runtime/project.json @@ -34,6 +34,25 @@ "options": { "command": "node tools/scripts/publish.mjs native-federation-runtime verdaccio {args.ver}" } + }, + "test": { + "executor": "@nx/vite:test", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "passWithNoTests": true, + "reportsDirectory": "../../coverage/libs/native-federation-runtime", + "configFile": "libs/native-federation-runtime/vitest.config.mts" + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": [ + "libs/native-federation-runtime/**/*.ts", + "libs/native-federation-runtime/package.json" + ] + } } }, "tags": ["org:softarc", "scope:nf"] diff --git a/libs/native-federation-runtime/public/mockServiceWorker.js b/libs/native-federation-runtime/public/mockServiceWorker.js new file mode 100644 index 00000000..30600c86 --- /dev/null +++ b/libs/native-federation-runtime/public/mockServiceWorker.js @@ -0,0 +1,349 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker. + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + */ + +const PACKAGE_VERSION = '2.12.3'; +const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'; +const IS_MOCKED_RESPONSE = Symbol('isMockedResponse'); +const activeClientIds = new Set(); + +addEventListener('install', function () { + self.skipWaiting(); +}); + +addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()); +}); + +addEventListener('message', async function (event) { + const clientId = Reflect.get(event.source || {}, 'id'); + + if (!clientId || !self.clients) { + return; + } + + const client = await self.clients.get(clientId); + + if (!client) { + return; + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }); + + switch (event.data) { + case 'KEEPALIVE_REQUEST': { + sendToClient(client, { + type: 'KEEPALIVE_RESPONSE', + }); + break; + } + + case 'INTEGRITY_CHECK_REQUEST': { + sendToClient(client, { + type: 'INTEGRITY_CHECK_RESPONSE', + payload: { + packageVersion: PACKAGE_VERSION, + checksum: INTEGRITY_CHECKSUM, + }, + }); + break; + } + + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId); + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: { + client: { + id: client.id, + frameType: client.frameType, + }, + }, + }); + break; + } + + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId); + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId; + }); + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister(); + } + + break; + } + } +}); + +addEventListener('fetch', function (event) { + const requestInterceptedAt = Date.now(); + + // Bypass navigation requests. + if (event.request.mode === 'navigate') { + return; + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if ( + event.request.cache === 'only-if-cached' && + event.request.mode !== 'same-origin' + ) { + return; + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been terminated (still remains active until the next reload). + if (activeClientIds.size === 0) { + return; + } + + const requestId = crypto.randomUUID(); + event.respondWith(handleRequest(event, requestId, requestInterceptedAt)); +}); + +/** + * @param {FetchEvent} event + * @param {string} requestId + * @param {number} requestInterceptedAt + */ +async function handleRequest(event, requestId, requestInterceptedAt) { + const client = await resolveMainClient(event); + const requestCloneForEvents = event.request.clone(); + const response = await getResponse( + event, + client, + requestId, + requestInterceptedAt, + ); + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + const serializedRequest = await serializeRequest(requestCloneForEvents); + + // Clone the response so both the client and the library could consume it. + const responseClone = response.clone(); + + sendToClient( + client, + { + type: 'RESPONSE', + payload: { + isMockedResponse: IS_MOCKED_RESPONSE in response, + request: { + id: requestId, + ...serializedRequest, + }, + response: { + type: responseClone.type, + status: responseClone.status, + statusText: responseClone.statusText, + headers: Object.fromEntries(responseClone.headers.entries()), + body: responseClone.body, + }, + }, + }, + responseClone.body ? [serializedRequest.body, responseClone.body] : [], + ); + } + + return response; +} + +/** + * Resolve the main client for the given event. + * Client that issues a request doesn't necessarily equal the client + * that registered the worker. It's with the latter the worker should + * communicate with during the response resolving phase. + * @param {FetchEvent} event + * @returns {Promise} + */ +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId); + + if (activeClientIds.has(event.clientId)) { + return client; + } + + if (client?.frameType === 'top-level') { + return client; + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }); + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible'; + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id); + }); +} + +/** + * @param {FetchEvent} event + * @param {Client | undefined} client + * @param {string} requestId + * @param {number} requestInterceptedAt + * @returns {Promise} + */ +async function getResponse(event, client, requestId, requestInterceptedAt) { + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const requestClone = event.request.clone(); + + function passthrough() { + // Cast the request headers to a new Headers instance + // so the headers can be manipulated with. + const headers = new Headers(requestClone.headers); + + // Remove the "accept" header value that marked this request as passthrough. + // This prevents request alteration and also keeps it compliant with the + // user-defined CORS policies. + const acceptHeader = headers.get('accept'); + if (acceptHeader) { + const values = acceptHeader.split(',').map((value) => value.trim()); + const filteredValues = values.filter( + (value) => value !== 'msw/passthrough', + ); + + if (filteredValues.length > 0) { + headers.set('accept', filteredValues.join(', ')); + } else { + headers.delete('accept'); + } + } + + return fetch(requestClone, { headers }); + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough(); + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough(); + } + + // Notify the client that a request has been intercepted. + const serializedRequest = await serializeRequest(event.request); + const clientMessage = await sendToClient( + client, + { + type: 'REQUEST', + payload: { + id: requestId, + interceptedAt: requestInterceptedAt, + ...serializedRequest, + }, + }, + [serializedRequest.body], + ); + + switch (clientMessage.type) { + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data); + } + + case 'PASSTHROUGH': { + return passthrough(); + } + } + + return passthrough(); +} + +/** + * @param {Client} client + * @param {any} message + * @param {Array} transferrables + * @returns {Promise} + */ +function sendToClient(client, message, transferrables = []) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel(); + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error); + } + + resolve(event.data); + }; + + client.postMessage(message, [ + channel.port2, + ...transferrables.filter(Boolean), + ]); + }); +} + +/** + * @param {Response} response + * @returns {Response} + */ +function respondWithMock(response) { + // Setting response status code to 0 is a no-op. + // However, when responding with a "Response.error()", the produced Response + // instance will have status code set to 0. Since it's not possible to create + // a Response instance with status code 0, handle that use-case separately. + if (response.status === 0) { + return Response.error(); + } + + const mockedResponse = new Response(response.body, response); + + Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { + value: true, + enumerable: true, + }); + + return mockedResponse; +} + +/** + * @param {Request} request + */ +async function serializeRequest(request) { + return { + url: request.url, + mode: request.mode, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: await request.arrayBuffer(), + keepalive: request.keepalive, + }; +} diff --git a/libs/native-federation-runtime/src/index.ts b/libs/native-federation-runtime/src/index.ts index cbb978c4..fc985c35 100644 --- a/libs/native-federation-runtime/src/index.ts +++ b/libs/native-federation-runtime/src/index.ts @@ -1,4 +1,3 @@ -export * from './lib/get-shared'; export * from './lib/init-federation'; export * from './lib/load-remote-module'; export * from './lib/model/build-notifications-options'; diff --git a/libs/native-federation-runtime/src/lib/__test-helpers__/dom-helpers.ts b/libs/native-federation-runtime/src/lib/__test-helpers__/dom-helpers.ts new file mode 100644 index 00000000..344eedc9 --- /dev/null +++ b/libs/native-federation-runtime/src/lib/__test-helpers__/dom-helpers.ts @@ -0,0 +1,62 @@ +/** + * DOM test helpers for federation tests + */ + +/** + * Gets all importmap-shim scripts from document head + */ +export const getImportMapScripts = (): NodeListOf => { + return document.querySelectorAll('script[type="importmap-shim"]'); +}; + +/** + * Removes all importmap-shim scripts from document head + */ +export const clearImportMapScripts = (): void => { + const scripts = getImportMapScripts(); + scripts.forEach((script) => script.remove()); +}; + +/** + * Gets the parsed content of the first importmap-shim script + */ +export const getImportMapContent = (): { + imports: Record; + scopes: Record>; +} | null => { + const scripts = getImportMapScripts(); + if (scripts.length === 0) { + return null; + } + + try { + return JSON.parse(scripts[0].innerHTML); + } catch { + return null; + } +}; + +/** + * Asserts that an importmap script exists in the DOM + */ +export const assertImportMapExists = (): void => { + const scripts = getImportMapScripts(); + if (scripts.length === 0) { + throw new Error('Expected importmap-shim script to exist in document head'); + } +}; + +/** + * Gets the count of importmap scripts in the document + */ +export const getImportMapScriptCount = (): number => { + return getImportMapScripts().length; +}; + +/** + * Clears all DOM side effects from federation initialization + */ +export const clearFederationDOMEffects = (): void => { + clearImportMapScripts(); + // Add more cleanup here if federation adds other DOM elements +}; diff --git a/libs/native-federation-runtime/src/lib/__test-helpers__/federation-fixtures.ts b/libs/native-federation-runtime/src/lib/__test-helpers__/federation-fixtures.ts new file mode 100644 index 00000000..565c9936 --- /dev/null +++ b/libs/native-federation-runtime/src/lib/__test-helpers__/federation-fixtures.ts @@ -0,0 +1,109 @@ +import type { FederationInfo } from '../model/federation-info'; + +/** + * Test fixture builder for FederationInfo objects + */ +export const createFederationInfo = ( + overrides?: Partial, +): FederationInfo => ({ + name: 'default-host', + exposes: [], + shared: [], + ...overrides, +}); + +/** + * Creates a host federation info with shared dependencies + */ +export const createHostInfo = (name = 'host'): FederationInfo => ({ + name, + exposes: [], + shared: [ + { + singleton: true, + strictVersion: true, + requiredVersion: '^18.0.0', + packageName: 'angular', + outFileName: 'angular.js', + }, + { + singleton: true, + strictVersion: false, + requiredVersion: '^18.2.0', + packageName: 'rxjs', + outFileName: 'rxjs.js', + }, + ], +}); + +/** + * Creates a remote MFE federation info with exposes and shared + */ +export const createRemoteInfo = ( + name = 'mfe1', + exposes: Array<{ key: string; outFileName: string }> = [], +): FederationInfo => ({ + name, + exposes: + exposes.length > 0 + ? exposes + : [ + { + key: './Component', + outFileName: 'Component.js', + }, + ], + shared: [ + { + singleton: true, + strictVersion: false, + requiredVersion: '^4.0.0', + packageName: 'lodash', + outFileName: 'lodash.js', + }, + ], +}); + +/** + * Creates a minimal remote info without dependencies + */ +export const createMinimalRemoteInfo = ( + name = 'minimal-mfe', +): FederationInfo => ({ + name, + exposes: [ + { + key: './Module', + outFileName: 'Module.js', + }, + ], + shared: [], +}); + +/** + * Test URLs constants + */ +export const TEST_URLS = { + HOST_REMOTE_ENTRY: './remoteEntry.json', + MFE1_BASE: 'http://localhost:3000/mfe1', + MFE1_REMOTE_ENTRY: 'http://localhost:3000/mfe1/remoteEntry.json', + MFE2_BASE: 'http://localhost:4000/mfe2', + MFE2_REMOTE_ENTRY: 'http://localhost:4000/mfe2/remoteEntry.json', + INVALID_URL: + 'http://invalid-domain-that-does-not-exist.test/remoteEntry.json', +} as const; + +/** + * Creates a remote config object for initFederation + */ +export const createRemoteConfig = ( + ...remotes: Array<{ name: string; url: string }> +): Record => { + return remotes.reduce( + (acc, { name, url }) => { + acc[name] = url; + return acc; + }, + {} as Record, + ); +}; diff --git a/libs/native-federation-runtime/src/lib/__test-helpers__/module-fixtures.ts b/libs/native-federation-runtime/src/lib/__test-helpers__/module-fixtures.ts new file mode 100644 index 00000000..7f40bff9 --- /dev/null +++ b/libs/native-federation-runtime/src/lib/__test-helpers__/module-fixtures.ts @@ -0,0 +1,72 @@ +/** + * Test fixtures for loadRemoteModule tests + */ + +/** + * Creates a mock ES module with exports + */ +export const createMockModule = (exports: T): T => exports; + +/** + * Common mock modules for testing + */ +export const MOCK_MODULES = { + Component: createMockModule({ + default: class MockComponent { + name = 'MockComponent'; + }, + namedExport: 'test-value', + }), + + Button: createMockModule({ + default: class MockButton { + name = 'MockButton'; + click() { + return 'clicked'; + } + }, + }), + + Service: createMockModule({ + DataService: class DataService { + getData() { + return { data: 'test' }; + } + }, + ApiService: class ApiService { + fetch() { + return Promise.resolve({ ok: true }); + } + }, + }), + + EmptyModule: createMockModule({}), + + SimpleValue: createMockModule({ + value: 42, + message: 'Hello from remote', + }), +}; + +/** + * Creates a mock module URL for testing + */ +export const createModuleUrl = (baseUrl: string, fileName: string): string => { + return `${baseUrl}/${fileName}`; +}; + +/** + * Fallback components for testing + */ +export const FALLBACK_COMPONENTS = { + DefaultComponent: class DefaultComponent { + name = 'DefaultComponent'; + }, + + ErrorComponent: class ErrorComponent { + name = 'ErrorComponent'; + error = true; + }, + + NullFallback: null, +}; diff --git a/libs/native-federation-runtime/src/lib/__test-helpers__/msw-handlers.ts b/libs/native-federation-runtime/src/lib/__test-helpers__/msw-handlers.ts new file mode 100644 index 00000000..687cc9d0 --- /dev/null +++ b/libs/native-federation-runtime/src/lib/__test-helpers__/msw-handlers.ts @@ -0,0 +1,164 @@ +import { delay, http, HttpResponse } from 'msw'; +import type { FederationInfo } from '../model/federation-info'; +import { + createHostInfo, + createRemoteInfo, + TEST_URLS, +} from './federation-fixtures'; + +/** + * MSW request handlers for federation tests + */ + +/** + * Creates a handler that returns the host remoteEntry.json + */ +export const hostRemoteEntryHandler = ( + info: FederationInfo = createHostInfo(), + options?: { delay?: number }, +) => { + return http.get(TEST_URLS.HOST_REMOTE_ENTRY, async () => { + if (options?.delay) { + await delay(options.delay); + } + return HttpResponse.json(info); + }); +}; + +/** + * Creates a handler for a remote MFE remoteEntry.json + */ +export const remoteEntryHandler = ( + url: string, + info: FederationInfo, + options?: { delay?: number; status?: number }, +) => { + return http.get(url, async () => { + if (options?.delay) { + await delay(options.delay); + } + + if (options?.status && options.status !== 200) { + return new HttpResponse(null, { status: options.status }); + } + + return HttpResponse.json(info); + }); +}; + +/** + * Creates a handler that returns 404 for any remoteEntry.json + */ +export const notFoundHandler = (url: string) => { + return http.get(url, () => { + return new HttpResponse(null, { status: 404 }); + }); +}; + +/** + * Creates a handler that returns network error + */ +export const networkErrorHandler = (url: string) => { + return http.get(url, () => { + return HttpResponse.error(); + }); +}; + +/** + * Creates a handler that returns malformed JSON + */ +export const malformedJsonHandler = (url: string) => { + return http.get(url, () => { + return new HttpResponse('{ invalid json', { + headers: { + 'Content-Type': 'application/json', + }, + }); + }); +}; + +/** + * Creates a handler that times out + */ +export const timeoutHandler = (url: string, timeoutMs = 5000) => { + return http.get(url, async () => { + await delay(timeoutMs); + return HttpResponse.json({}); + }); +}; + +/** + * Default handlers for common scenarios + */ +export const defaultHandlers = [ + hostRemoteEntryHandler(), + remoteEntryHandler(TEST_URLS.MFE1_REMOTE_ENTRY, createRemoteInfo('mfe1')), + remoteEntryHandler( + TEST_URLS.MFE2_REMOTE_ENTRY, + createRemoteInfo('mfe2', [{ key: './Button', outFileName: 'Button.js' }]), + ), +]; + +/** + * Creates handlers for a complete federation scenario + */ +export const createFederationHandlers = (config: { + host?: FederationInfo; + remotes?: Array<{ url: string; info: FederationInfo }>; +}) => { + const handlers = [hostRemoteEntryHandler(config.host || createHostInfo())]; + + if (config.remotes) { + config.remotes.forEach(({ url, info }) => { + handlers.push(remoteEntryHandler(url, info)); + }); + } + + return handlers; +}; + +/** + * Creates a handler that returns a JavaScript module + */ +export const moduleHandler = (url: string, moduleContent: any) => { + return http.get(url, () => { + // Return JavaScript module as text + const moduleCode = ` + export default ${JSON.stringify(moduleContent.default || {})}; + ${Object.entries(moduleContent) + .filter(([key]) => key !== 'default') + .map( + ([key, value]) => `export const ${key} = ${JSON.stringify(value)};`, + ) + .join('\n')} + `; + + return new HttpResponse(moduleCode, { + headers: { + 'Content-Type': 'application/javascript', + }, + }); + }); +}; + +/** + * Creates a handler that returns a 404 for a module + */ +export const moduleNotFoundHandler = (url: string) => { + return http.get(url, () => { + return new HttpResponse(null, { status: 404 }); + }); +}; + +/** + * Creates a handler that returns invalid JavaScript + */ +export const invalidModuleHandler = (url: string) => { + return http.get(url, () => { + return new HttpResponse('this is not valid javascript {{{', { + headers: { + 'Content-Type': 'application/javascript', + }, + }); + }); +}; diff --git a/libs/native-federation-runtime/src/lib/get-shared.ts b/libs/native-federation-runtime/src/lib/get-shared.ts index 6c7c9695..9692df82 100644 --- a/libs/native-federation-runtime/src/lib/get-shared.ts +++ b/libs/native-federation-runtime/src/lib/get-shared.ts @@ -55,6 +55,7 @@ export function getShared(options = defaultShareOptions) { const shareObj: ShareObject = { version, get: async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any const lib = await (window as any).importShim(path); return () => lib; }, diff --git a/libs/native-federation-runtime/src/lib/init-federation.integration.spec.ts b/libs/native-federation-runtime/src/lib/init-federation.integration.spec.ts new file mode 100644 index 00000000..6c520781 --- /dev/null +++ b/libs/native-federation-runtime/src/lib/init-federation.integration.spec.ts @@ -0,0 +1,763 @@ +import { http, HttpResponse } from 'msw'; +import { setupWorker } from 'msw/browser'; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, +} from 'vitest'; +import { + clearFederationDOMEffects, + getImportMapContent, + getImportMapScriptCount, + getImportMapScripts, +} from './__test-helpers__/dom-helpers'; +import { + createFederationInfo, + createHostInfo, + createMinimalRemoteInfo, + createRemoteConfig, + createRemoteInfo, + TEST_URLS, +} from './__test-helpers__/federation-fixtures'; +import { + createFederationHandlers, + hostRemoteEntryHandler, + malformedJsonHandler, + networkErrorHandler, + notFoundHandler, + remoteEntryHandler, +} from './__test-helpers__/msw-handlers'; +import { initFederation } from './init-federation'; + +// ============================================================================ +// TEST UTILITIES +// ============================================================================ + +/** + * Helper to capture console errors during test execution. + * Returns a cleanup function that restores the original console.error. + * + * @param onError - Optional callback to capture error messages + * @returns Cleanup function to restore console.error + */ +function captureConsoleErrors(onError?: (...args: any[]) => void): () => void { + const originalError = console.error; + console.error = onError || (() => {}); + return () => { + console.error = originalError; + }; +} + +/** + * Helper to capture console error messages in an array. + * Returns both the messages array and a cleanup function. + * + * @returns Tuple of [messages array, cleanup function] + */ +function captureConsoleErrorMessages(): [string[], () => void] { + const messages: string[] = []; + const cleanup = captureConsoleErrors((...args: any[]) => { + messages.push(args.join(' ')); + }); + return [messages, cleanup]; +} + +/** + * Integration tests for initFederation using MSW for network mocking + */ +describe('initFederation - Browser Integration Test', () => { + const worker = setupWorker(); + + beforeAll(async () => { + await worker.start({ + onUnhandledRequest: 'error', + quiet: false, + }); + }); + + afterAll(() => worker.stop()); + + beforeEach(() => { + clearFederationDOMEffects(); + }); + + afterEach(() => { + worker.resetHandlers(); + }); + + // ========================================================================== + // BASIC INITIALIZATION TESTS + // ========================================================================== + // These tests verify the fundamental initialization flows: + // - Host-only setup (no remotes) + // - Single remote integration + // - Multiple remotes working together + describe('Basic Initialization', () => { + it('should initialize federation without remotes', async () => { + const hostInfo = createHostInfo(); + worker.use(hostRemoteEntryHandler(hostInfo)); + + const result = await initFederation({}); + + expect(result).toEqual( + expect.objectContaining({ + imports: expect.objectContaining({ + angular: './angular.js', + rxjs: './rxjs.js', + }), + scopes: {}, + }), + ); + }); + + it('should initialize federation with empty host info', async () => { + const hostInfo = createFederationInfo({ name: 'empty-host' }); + worker.use(hostRemoteEntryHandler(hostInfo)); + + const result = await initFederation({}); + + expect(result).toEqual({ + imports: {}, + scopes: {}, + }); + }); + + it('should initialize federation with one remote', async () => { + const hostInfo = createFederationInfo({ name: 'host' }); + const remoteInfo = createRemoteInfo('mfe1'); + + worker.use( + hostRemoteEntryHandler(hostInfo), + remoteEntryHandler(TEST_URLS.MFE1_REMOTE_ENTRY, remoteInfo), + ); + + const result = await initFederation( + createRemoteConfig({ name: 'mfe1', url: TEST_URLS.MFE1_REMOTE_ENTRY }), + ); + + expect(result.imports).toEqual( + expect.objectContaining({ + 'mfe1/Component': `${TEST_URLS.MFE1_BASE}/Component.js`, + }), + ); + expect(result.scopes).toHaveProperty(`${TEST_URLS.MFE1_BASE}/`); + expect(result.scopes[`${TEST_URLS.MFE1_BASE}/`]).toEqual( + expect.objectContaining({ + lodash: `${TEST_URLS.MFE1_BASE}/lodash.js`, + }), + ); + }); + + it('should initialize federation with multiple remotes', async () => { + const hostInfo = createFederationInfo({ name: 'host' }); + const mfe1Info = createRemoteInfo('mfe1', [ + { key: './Component', outFileName: 'Component.js' }, + ]); + const mfe2Info = createRemoteInfo('mfe2', [ + { key: './Button', outFileName: 'Button.js' }, + ]); + + worker.use( + ...createFederationHandlers({ + host: hostInfo, + remotes: [ + { url: TEST_URLS.MFE1_REMOTE_ENTRY, info: mfe1Info }, + { url: TEST_URLS.MFE2_REMOTE_ENTRY, info: mfe2Info }, + ], + }), + ); + + const result = await initFederation( + createRemoteConfig( + { name: 'mfe1', url: TEST_URLS.MFE1_REMOTE_ENTRY }, + { name: 'mfe2', url: TEST_URLS.MFE2_REMOTE_ENTRY }, + ), + ); + + expect(result.imports).toEqual( + expect.objectContaining({ + 'mfe1/Component': `${TEST_URLS.MFE1_BASE}/Component.js`, + 'mfe2/Button': `${TEST_URLS.MFE2_BASE}/Button.js`, + }), + ); + expect(result.scopes).toHaveProperty(`${TEST_URLS.MFE1_BASE}/`); + expect(result.scopes).toHaveProperty(`${TEST_URLS.MFE2_BASE}/`); + }); + }); + + // ========================================================================== + // DOM MANIPULATION TESTS + // ========================================================================== + // These tests verify that initFederation correctly manipulates the DOM: + // - Script tag injection + // - Importmap structure and content + // - Multiple initialization calls + describe('DOM Manipulation', () => { + it('should append importmap-shim script to document head', async () => { + const hostInfo = createFederationInfo({ name: 'host' }); + worker.use(hostRemoteEntryHandler(hostInfo)); + + await initFederation({}); + + const scripts = getImportMapScripts(); + expect(scripts.length).toBe(1); + expect(scripts[0].type).toBe('importmap-shim'); + }); + + it('should create importmap with correct structure', async () => { + const hostInfo = createHostInfo(); + worker.use(hostRemoteEntryHandler(hostInfo)); + + await initFederation({}); + + const importMapContent = getImportMapContent(); + expect(importMapContent).not.toBeNull(); + expect(importMapContent).toHaveProperty('imports'); + expect(importMapContent).toHaveProperty('scopes'); + expect(importMapContent!.imports).toEqual( + expect.objectContaining({ + angular: './angular.js', + rxjs: './rxjs.js', + }), + ); + }); + + it('should handle multiple calls without creating duplicate scripts', async () => { + const hostInfo = createFederationInfo({ name: 'host' }); + worker.use(hostRemoteEntryHandler(hostInfo)); + + await initFederation({}); + const firstCount = getImportMapScriptCount(); + + await initFederation({}); + const secondCount = getImportMapScriptCount(); + + // Each call adds a new script (this is the actual behavior) + expect(secondCount).toBe(firstCount + 1); + }); + }); + + // ========================================================================== + // CACHE TAG HANDLING TESTS + // ========================================================================== + // These tests verify cache busting functionality: + // - CacheTag applied to host requests + // - CacheTag applied to all remote requests + // - Correct parameter separator (? or &) based on existing query params + describe('Cache Tag Handling', () => { + it('should apply cacheTag to host request', async () => { + const hostInfo = createFederationInfo({ name: 'host' }); + let capturedUrl = ''; + + worker.use( + http.get('./remoteEntry.json', ({ request }) => { + capturedUrl = request.url; + return HttpResponse.json(hostInfo); + }), + ); + + await initFederation({}, { cacheTag: 'v1.0.0' }); + + expect(capturedUrl).toContain('t=v1.0.0'); + }); + + it('should apply cacheTag to all remote requests', async () => { + const hostInfo = createFederationInfo({ name: 'host' }); + const remoteInfo = createRemoteInfo('mfe1'); + const capturedUrls: string[] = []; + + worker.use( + http.get('./remoteEntry.json', ({ request }) => { + capturedUrls.push(request.url); + return HttpResponse.json(hostInfo); + }), + http.get(TEST_URLS.MFE1_REMOTE_ENTRY, ({ request }) => { + capturedUrls.push(request.url); + return HttpResponse.json(remoteInfo); + }), + ); + + await initFederation( + createRemoteConfig({ name: 'mfe1', url: TEST_URLS.MFE1_REMOTE_ENTRY }), + { cacheTag: 'v2.5.1' }, + ); + + expect(capturedUrls.length).toBe(2); + expect(capturedUrls.every((url) => url.includes('t=v2.5.1'))).toBe(true); + }); + + it('should append cacheTag with & when URL has existing query params', async () => { + const hostInfo = createFederationInfo({ name: 'host' }); + const remoteInfo = createRemoteInfo('mfe1'); + const urlWithParams = `${TEST_URLS.MFE1_REMOTE_ENTRY}?env=prod`; + let capturedRemoteUrl = ''; + + worker.use( + hostRemoteEntryHandler(hostInfo), + http.get(TEST_URLS.MFE1_REMOTE_ENTRY, ({ request }) => { + capturedRemoteUrl = request.url; + return HttpResponse.json(remoteInfo); + }), + ); + + await initFederation( + createRemoteConfig({ name: 'mfe1', url: urlWithParams }), + { cacheTag: 'v1.0.0' }, + ); + + expect(capturedRemoteUrl).toContain('env=prod'); + expect(capturedRemoteUrl).toContain('&t=v1.0.0'); + }); + }); + + // ========================================================================== + // IMPORT MAP MERGING TESTS + // ========================================================================== + // These tests verify that import maps from different sources merge correctly: + // - Host shared deps in root imports + // - Remote exposed modules in root imports + // - Remote shared deps in scoped imports + // - Handling overlapping dependencies between remotes + describe('Import Map Merging', () => { + it('should merge host and remote import maps correctly', async () => { + const hostInfo = createHostInfo(); + const remoteInfo = createRemoteInfo('mfe1', [ + { key: './Button', outFileName: 'Button.js' }, + ]); + + worker.use( + ...createFederationHandlers({ + host: hostInfo, + remotes: [{ url: TEST_URLS.MFE1_REMOTE_ENTRY, info: remoteInfo }], + }), + ); + + const result = await initFederation( + createRemoteConfig({ name: 'mfe1', url: TEST_URLS.MFE1_REMOTE_ENTRY }), + ); + + // Host shared dependencies in root imports + expect(result.imports['angular']).toBe('./angular.js'); + expect(result.imports['rxjs']).toBe('./rxjs.js'); + + // Remote exposed modules in root imports + expect(result.imports['mfe1/Button']).toBe( + `${TEST_URLS.MFE1_BASE}/Button.js`, + ); + + // Remote shared dependencies in scopes + expect(result.scopes[`${TEST_URLS.MFE1_BASE}/`]['lodash']).toBe( + `${TEST_URLS.MFE1_BASE}/lodash.js`, + ); + }); + + it('should handle multiple remotes with overlapping shared dependencies', async () => { + const hostInfo = createFederationInfo({ name: 'host' }); + const sharedDep = { + singleton: true, + strictVersion: false, + requiredVersion: '^4.0.0', + packageName: 'lodash', + outFileName: 'lodash.js', + }; + + const mfe1Info = createFederationInfo({ + name: 'mfe1', + exposes: [{ key: './Component', outFileName: 'Component.js' }], + shared: [sharedDep], + }); + + const mfe2Info = createFederationInfo({ + name: 'mfe2', + exposes: [{ key: './Service', outFileName: 'Service.js' }], + shared: [sharedDep], + }); + + worker.use( + ...createFederationHandlers({ + host: hostInfo, + remotes: [ + { url: TEST_URLS.MFE1_REMOTE_ENTRY, info: mfe1Info }, + { url: TEST_URLS.MFE2_REMOTE_ENTRY, info: mfe2Info }, + ], + }), + ); + + const result = await initFederation( + createRemoteConfig( + { name: 'mfe1', url: TEST_URLS.MFE1_REMOTE_ENTRY }, + { name: 'mfe2', url: TEST_URLS.MFE2_REMOTE_ENTRY }, + ), + ); + + // Both remotes should have lodash in their scopes + expect(result.scopes[`${TEST_URLS.MFE1_BASE}/`]['lodash']).toBeDefined(); + expect(result.scopes[`${TEST_URLS.MFE2_BASE}/`]['lodash']).toBeDefined(); + }); + }); + + // ========================================================================== + // ERROR HANDLING TESTS + // ========================================================================== + // These tests verify resilient error handling: + // - 404 responses handled gracefully + // - Network errors don't crash the app + // - Malformed JSON handled properly + // - Host failures are fatal (must have host) + // - Partial success when some remotes fail + describe('Error Handling', () => { + it('should handle 404 response for remote gracefully when throwIfRemoteNotFound is false', async () => { + const hostInfo = createFederationInfo({ name: 'host' }); + worker.use( + hostRemoteEntryHandler(hostInfo), + notFoundHandler(TEST_URLS.MFE1_REMOTE_ENTRY), + ); + + const [errorMessages, cleanup] = captureConsoleErrorMessages(); + + const result = await initFederation( + createRemoteConfig({ name: 'mfe1', url: TEST_URLS.MFE1_REMOTE_ENTRY }), + ); + + expect(result.imports).toEqual({}); + expect(result.scopes).toEqual({}); + expect( + errorMessages.some((msg) => + msg.includes('Error loading remote entry for mfe1'), + ), + ).toBe(true); + + cleanup(); + }); + + it('should throw error when remote not found and throwIfRemoteNotFound is true', async () => { + const hostInfo = createFederationInfo({ name: 'host' }); + worker.use( + hostRemoteEntryHandler(hostInfo), + notFoundHandler(TEST_URLS.MFE1_REMOTE_ENTRY), + ); + + // Note: throwIfRemoteNotFound is not exposed in InitFederationOptions, + // but it's used internally. We test the default behavior (false) above. + // To properly test this, we would need to expose it in the public API + // or test processRemoteInfos directly. + + // For now, this test documents the expected behavior if the option were exposed + const { fetchAndRegisterRemotes } = await import('./init-federation'); + + await expect( + fetchAndRegisterRemotes( + createRemoteConfig({ + name: 'mfe1', + url: TEST_URLS.MFE1_REMOTE_ENTRY, + }), + { throwIfRemoteNotFound: true }, + ), + ).rejects.toThrow('Error loading remote entry for mfe1'); + }); + + it('should handle network errors gracefully', async () => { + const hostInfo = createFederationInfo({ name: 'host' }); + worker.use( + hostRemoteEntryHandler(hostInfo), + networkErrorHandler(TEST_URLS.MFE1_REMOTE_ENTRY), + ); + + let errorCalled = false; + const cleanup = captureConsoleErrors(() => { + errorCalled = true; + }); + + const result = await initFederation( + createRemoteConfig({ name: 'mfe1', url: TEST_URLS.MFE1_REMOTE_ENTRY }), + ); + + expect(result.imports).toEqual({}); + expect(result.scopes).toEqual({}); + expect(errorCalled).toBe(true); + + cleanup(); + }); + + it('should handle malformed JSON response', async () => { + const hostInfo = createFederationInfo({ name: 'host' }); + worker.use( + hostRemoteEntryHandler(hostInfo), + malformedJsonHandler(TEST_URLS.MFE1_REMOTE_ENTRY), + ); + + let errorCalled = false; + const cleanup = captureConsoleErrors(() => { + errorCalled = true; + }); + + const result = await initFederation( + createRemoteConfig({ name: 'mfe1', url: TEST_URLS.MFE1_REMOTE_ENTRY }), + ); + + expect(result.imports).toEqual({}); + expect(result.scopes).toEqual({}); + expect(errorCalled).toBe(true); + + cleanup(); + }); + + it('should handle host remoteEntry.json failure', async () => { + worker.use(notFoundHandler(TEST_URLS.HOST_REMOTE_ENTRY)); + + await expect(initFederation({})).rejects.toThrow(); + }); + + it('should continue with successful remotes when some fail', async () => { + const hostInfo = createFederationInfo({ name: 'host' }); + const mfe2Info = createRemoteInfo('mfe2'); + + worker.use( + hostRemoteEntryHandler(hostInfo), + notFoundHandler(TEST_URLS.MFE1_REMOTE_ENTRY), + remoteEntryHandler(TEST_URLS.MFE2_REMOTE_ENTRY, mfe2Info), + ); + + const [errorMessages, cleanup] = captureConsoleErrorMessages(); + + const result = await initFederation( + createRemoteConfig( + { name: 'mfe1', url: TEST_URLS.MFE1_REMOTE_ENTRY }, + { name: 'mfe2', url: TEST_URLS.MFE2_REMOTE_ENTRY }, + ), + ); + + // mfe2 should be loaded successfully + expect(result.imports['mfe2/Component']).toBeDefined(); + expect(result.scopes[`${TEST_URLS.MFE2_BASE}/`]).toBeDefined(); + + // mfe1 should not be in the result + expect(result.imports['mfe1/Component']).toBeUndefined(); + + expect( + errorMessages.some((msg) => + msg.includes('Error loading remote entry for mfe1'), + ), + ).toBe(true); + + cleanup(); + }); + }); + + // ========================================================================== + // EDGE CASES TESTS + // ========================================================================== + // These tests verify boundary conditions: + // - Remotes with no exposed modules + // - Remotes with no shared dependencies + // - Empty remotes configuration + // - Special characters in names + // - URL formatting edge cases + describe('Edge Cases', () => { + it('should handle remote with no exposes', async () => { + const hostInfo = createFederationInfo({ name: 'host' }); + const remoteInfo = createFederationInfo({ + name: 'mfe1', + exposes: [], + shared: [ + { + singleton: true, + strictVersion: false, + requiredVersion: '^4.0.0', + packageName: 'lodash', + outFileName: 'lodash.js', + }, + ], + }); + + worker.use( + ...createFederationHandlers({ + host: hostInfo, + remotes: [{ url: TEST_URLS.MFE1_REMOTE_ENTRY, info: remoteInfo }], + }), + ); + + const result = await initFederation( + createRemoteConfig({ name: 'mfe1', url: TEST_URLS.MFE1_REMOTE_ENTRY }), + ); + + expect(Object.keys(result.imports)).toHaveLength(0); + expect(result.scopes[`${TEST_URLS.MFE1_BASE}/`]['lodash']).toBeDefined(); + }); + + it('should handle remote with no shared dependencies', async () => { + const hostInfo = createFederationInfo({ name: 'host' }); + const remoteInfo = createMinimalRemoteInfo('mfe1'); + + worker.use( + ...createFederationHandlers({ + host: hostInfo, + remotes: [{ url: TEST_URLS.MFE1_REMOTE_ENTRY, info: remoteInfo }], + }), + ); + + const result = await initFederation( + createRemoteConfig({ name: 'mfe1', url: TEST_URLS.MFE1_REMOTE_ENTRY }), + ); + + expect(result.imports['mfe1/Module']).toBeDefined(); + expect(result.scopes[`${TEST_URLS.MFE1_BASE}/`]).toEqual({}); + }); + + it('should handle empty remotes object', async () => { + const hostInfo = createHostInfo(); + worker.use(hostRemoteEntryHandler(hostInfo)); + + const result = await initFederation({}); + + expect(result.imports).toEqual({ + angular: './angular.js', + rxjs: './rxjs.js', + }); + expect(result.scopes).toEqual({}); + }); + + it('should handle special characters in remote names', async () => { + const hostInfo = createFederationInfo({ name: 'host' }); + const remoteInfo = createRemoteInfo('my-mfe-1', [ + { key: './Component', outFileName: 'Component.js' }, + ]); + + worker.use( + hostRemoteEntryHandler(hostInfo), + remoteEntryHandler(TEST_URLS.MFE1_REMOTE_ENTRY, remoteInfo), + ); + + const result = await initFederation( + createRemoteConfig({ + name: 'my-mfe-1', + url: TEST_URLS.MFE1_REMOTE_ENTRY, + }), + ); + + expect(result.imports['my-mfe-1/Component']).toBeDefined(); + }); + + it('should handle URLs with trailing slashes', async () => { + const hostInfo = createFederationInfo({ name: 'host' }); + const remoteInfo = createRemoteInfo('mfe1'); + const urlWithTrailingSlash = `${TEST_URLS.MFE1_BASE}/remoteEntry.json`; + + worker.use( + hostRemoteEntryHandler(hostInfo), + remoteEntryHandler(urlWithTrailingSlash, remoteInfo), + ); + + const result = await initFederation( + createRemoteConfig({ name: 'mfe1', url: urlWithTrailingSlash }), + ); + + expect(result.imports['mfe1/Component']).toBeDefined(); + }); + + it('should handle remote with multiple exposed modules', async () => { + const hostInfo = createFederationInfo({ name: 'host' }); + const remoteInfo = createRemoteInfo('mfe1', [ + { key: './Component', outFileName: 'Component.js' }, + { key: './Button', outFileName: 'Button.js' }, + { key: './Service', outFileName: 'Service.js' }, + ]); + + worker.use( + hostRemoteEntryHandler(hostInfo), + remoteEntryHandler(TEST_URLS.MFE1_REMOTE_ENTRY, remoteInfo), + ); + + const result = await initFederation( + createRemoteConfig({ name: 'mfe1', url: TEST_URLS.MFE1_REMOTE_ENTRY }), + ); + + expect(result.imports['mfe1/Component']).toBe( + `${TEST_URLS.MFE1_BASE}/Component.js`, + ); + expect(result.imports['mfe1/Button']).toBe( + `${TEST_URLS.MFE1_BASE}/Button.js`, + ); + expect(result.imports['mfe1/Service']).toBe( + `${TEST_URLS.MFE1_BASE}/Service.js`, + ); + }); + + it('should handle remote with nested exposed paths', async () => { + const hostInfo = createFederationInfo({ name: 'host' }); + const remoteInfo = createRemoteInfo('mfe1', [ + { key: './components/Button', outFileName: 'components-Button.js' }, + { + key: './services/api/DataService', + outFileName: 'services-api-DataService.js', + }, + ]); + + worker.use( + hostRemoteEntryHandler(hostInfo), + remoteEntryHandler(TEST_URLS.MFE1_REMOTE_ENTRY, remoteInfo), + ); + + const result = await initFederation( + createRemoteConfig({ name: 'mfe1', url: TEST_URLS.MFE1_REMOTE_ENTRY }), + ); + + expect(result.imports['mfe1/components/Button']).toBe( + `${TEST_URLS.MFE1_BASE}/components-Button.js`, + ); + expect(result.imports['mfe1/services/api/DataService']).toBe( + `${TEST_URLS.MFE1_BASE}/services-api-DataService.js`, + ); + }); + }); + + // ========================================================================== + // MANIFEST LOADING TESTS + // ========================================================================== + // These tests verify manifest-based configuration: + // - Loading remotes from a manifest.json file + // - Cache busting applied to manifest URL + describe('Manifest Loading', () => { + it('should load remotes from manifest URL', async () => { + const hostInfo = createFederationInfo({ name: 'host' }); + const remoteInfo = createRemoteInfo('mfe1'); + const manifestUrl = 'http://localhost:3000/federation-manifest.json'; + const manifest = { + mfe1: TEST_URLS.MFE1_REMOTE_ENTRY, + }; + + worker.use( + http.get(manifestUrl, () => HttpResponse.json(manifest)), + hostRemoteEntryHandler(hostInfo), + remoteEntryHandler(TEST_URLS.MFE1_REMOTE_ENTRY, remoteInfo), + ); + + const result = await initFederation(manifestUrl); + + expect(result.imports['mfe1/Component']).toBeDefined(); + }); + + it('should apply cacheTag to manifest URL', async () => { + const hostInfo = createFederationInfo({ name: 'host' }); + const manifestUrl = 'http://localhost:3000/federation-manifest.json'; + const manifest = {}; + let capturedUrl = ''; + + worker.use( + http.get(manifestUrl, ({ request }) => { + capturedUrl = request.url; + return HttpResponse.json(manifest); + }), + hostRemoteEntryHandler(hostInfo), + ); + + await initFederation(manifestUrl, { cacheTag: 'v1.0.0' }); + + expect(capturedUrl).toContain('t=v1.0.0'); + }); + }); +}); diff --git a/libs/native-federation-runtime/src/lib/init-federation.ts b/libs/native-federation-runtime/src/lib/init-federation.ts index cc5d4b7c..f86f6de2 100644 --- a/libs/native-federation-runtime/src/lib/init-federation.ts +++ b/libs/native-federation-runtime/src/lib/init-federation.ts @@ -16,67 +16,174 @@ import { getDirectory, joinPaths } from './utils/path-utils'; import { watchFederationBuildCompletion } from './watch-federation-build'; /** - * Initialize the federation runtime - * @param remotesOrManifestUrl - * @param options The cacheTag allows you to invalidate the cache of the remoteEntry.json files, pass a new value with every release (f.ex. the version number) + * Initializes the Module Federation runtime for the host application. + * + * This is the main entry point for setting up federation. It performs the following: + * 1. Loads the host's remoteEntry.json to discover shared dependencies + * 2. Loads each remote's remoteEntry.json to discover exposed modules + * 3. Creates an ES Module import map with proper scoping + * 4. Injects the import map into the DOM as a appendImportMap(importMap); return importMap; } -async function loadManifest(remotes: string): Promise> { - return (await fetch(remotes).then((r) => r.json())) as Record; +/** + * Loads a federation manifest file (JSON) from the given URL. + * + * The manifest should map remote names to their remoteEntry.json URLs. + * + * @param manifestUrl - The URL to the manifest.json file. + * @returns A promise resolving to an object mapping remote names to their remoteEntry.json URLs. + */ +async function loadManifest( + manifestUrl: string, +): Promise> { + const manifest = (await fetch(manifestUrl).then((r) => r.json())) as Record< + string, + string + >; + return manifest; +} + +/** + * Adds cache busting query parameter to a URL if cacheTag is provided. + */ +function applyCacheTag(url: string, cacheTag?: string): string { + if (!cacheTag) return url; + + const separator = url.includes('?') ? '&' : '?'; + return `${url}${separator}t=${cacheTag}`; } -export async function processRemoteInfos( +/** + * Handles errors when loading a remote entry. + * Either throws or logs based on options. + */ +function handleRemoteLoadError( + remoteName: string, + remoteUrl: string, + options: ProcessRemoteInfoOptions, + originalError: Error, +): null { + const errorMessage = `Error loading remote entry for ${remoteName} from file ${remoteUrl}`; + + if (options.throwIfRemoteNotFound) { + throw new Error(errorMessage); + } + + console.error(errorMessage); + console.error(originalError); + return null; +} + +/** + * Fetches and registers multiple remote applications in parallel and merges their import maps. + * + * This function is the orchestrator for loading all remotes. It: + * 1. Creates a promise for each remote to load its remoteEntry.json + * 2. Applies cache busting to each remote URL + * 3. Handles errors gracefully (logs or throws based on options) + * 4. Merges all successful remote import maps into one + * + * Each remote contributes: + * - Its exposed modules to the root imports + * - Its shared dependencies to scoped imports + * + * @param remotes - Record of remote names to their remoteEntry.json URLs + * @param options - Processing options including: + * - throwIfRemoteNotFound: Whether to throw or log on remote load failure + * - cacheTag: Cache busting tag to append to URLs + * + * @returns Merged import map containing all remotes' contributions + * + */ + +export async function fetchAndRegisterRemotes( remotes: Record, options: ProcessRemoteInfoOptions = { throwIfRemoteNotFound: false }, ): Promise { - const processRemoteInfoPromises = Object.keys(remotes).map( - async (remoteName) => { + // Create an array of promises, one for each remote + // Each promise will independently fetch and process its remoteEntry.json + const fetchAndRegisterRemotePromises = Object.entries(remotes).map( + async ([remoteName, remoteUrl]): Promise => { try { - let url = remotes[remoteName]; - if (options.cacheTag) { - const addAppend = remotes[remoteName].includes('?') ? '&' : '?'; - url += `${addAppend}t=${options.cacheTag}`; - } + // Apply cache busting if cacheTag is provided + const urlWithCache = applyCacheTag(remoteUrl, options.cacheTag); - return await processRemoteInfo(url, remoteName); + // Fetch and register this specific remote + return await fetchAndRegisterRemote(urlWithCache, remoteName); } catch (e) { - const error = `Error loading remote entry for ${remoteName} from file ${remotes[remoteName]}`; - - if (options.throwIfRemoteNotFound) { - throw new Error(error); - } - - console.error(error); - return null; + return handleRemoteLoadError( + remoteName, + remoteUrl, + options, + e as Error, + ); } }, ); - const remoteImportMaps = await Promise.all(processRemoteInfoPromises); + // Wait for all remotes to load in parallel + const remoteImportMaps = await Promise.all(fetchAndRegisterRemotePromises); + // Filter out failed remotes (null values) and merge successful ones const importMap = remoteImportMaps.reduce( (acc, remoteImportMap) => remoteImportMap ? mergeImportMaps(acc, remoteImportMap) : acc, @@ -86,29 +193,81 @@ export async function processRemoteInfos( return importMap; } -export async function processRemoteInfo( +/** + * Fetches a single remote application's remoteEntry.json file and registers it in the system (global registry). + * + * This function handles everything needed to integrate one remote: + * 1. Fetches the remote's remoteEntry.json file + * 2. Extracts the base URL from the remoteEntry path + * 3. Creates import map entries for exposed modules and shared deps + * 4. Registers the remote in the global remotes registry + * 5. Sets up hot reload watching if configured (development mode) + * + * @param federationInfoUrl - Full URL to the remote's remoteEntry.json + * @param remoteName - Name to use for this remote (optional, uses info.name if not provided) + * + * @returns Import map containing this remote's exposed modules and shared dependencies + * + * @example + * ```typescript + * const importMap = await fetchAndRegisterRemote( + * 'http://localhost:3000/mfe1/remoteEntry.json', + * 'mfe1' + * ); + * // Result: { + * // imports: { 'mfe1/Component': 'http://localhost:3000/mfe1/Component.js' }, + * // scopes: { 'http://localhost:3000/mfe1/': { 'lodash': '...' } } + * // } + * ``` + */ +export async function fetchAndRegisterRemote( federationInfoUrl: string, remoteName?: string, ): Promise { const baseUrl = getDirectory(federationInfoUrl); + const remoteInfo = await loadFederationInfo(federationInfoUrl); + // Uses the name from the remote's remoteEntry.json if not explicitly provided if (!remoteName) { remoteName = remoteInfo.name; } + // Setup hot reload watching for development mode and in case it has a build notifications endpoint if (remoteInfo.buildNotificationsEndpoint) { watchFederationBuildCompletion( baseUrl + remoteInfo.buildNotificationsEndpoint, ); } + // Create the import map entries for this remote const importMap = createRemoteImportMap(remoteInfo, remoteName, baseUrl); + + // Register this remote in the global registry for later lookup addRemote(remoteName, { ...remoteInfo, baseUrl }); return importMap; } +/** + * Creates an import map for a remote application. + * + * The import map has two parts: + * 1. Imports (root level): Maps remote's exposed modules + * Example: "mfe1/Component" -> "http://localhost:3000/mfe1/Component.js" + * + * 2. Scopes: Maps remote's shared dependencies within its scope + * Example: "http://localhost:3000/mfe1/": { "lodash": "http://localhost:3000/mfe1/lodash.js" } + * + * Scoping ensures that when a module from this remote imports 'lodash', + * it gets the version from this remote's bundle, not another version. + * + * @param remoteInfo - Federation info from the remote's remoteEntry.json + * @param remoteName - Name used to prefix exposed module keys + * @param baseUrl - Base URL where the remote is hosted + * + * @returns Import map with imports and scopes for this remote + */ function createRemoteImportMap( remoteInfo: FederationInfo, remoteName: string, @@ -116,14 +275,54 @@ function createRemoteImportMap( ): ImportMap { const imports = processExposed(remoteInfo, remoteName, baseUrl); const scopes = processRemoteImports(remoteInfo, baseUrl); + return { imports, scopes }; } -async function loadFederationInfo(url: string): Promise { - const info = (await fetch(url).then((r) => r.json())) as FederationInfo; +/** + * Fetches and parses a remoteEntry.json file. + * + * The remoteEntry.json contains metadata about a federated module: + * - name: The application name + * - exposes: Array of modules this app exposes to others + * - shared: Array of dependencies this app shares + * - buildNotificationsEndpoint: Optional SSE endpoint for hot reload (development mode) + * + * @param remoteEntryUrl - URL to the remoteEntry.json file (can be relative or absolute) + * @returns Parsed federation info object + */ +async function loadFederationInfo( + remoteEntryUrl: string, +): Promise { + const info = (await fetch(remoteEntryUrl).then((r) => + r.json(), + )) as FederationInfo; return info; } +/** + * Processes a remote's shared dependencies into scoped import map entries. + * + * Shared dependencies need to be scoped to avoid version conflicts. + * When a module from "http://localhost:3000/mfe1/" imports "lodash", + * the import map scope ensures it gets the correct version. + * + * Scope structure: + * { + * "http://localhost:3000/mfe1/": { + * "lodash": "http://localhost:3000/mfe1/lodash.js", + * "rxjs": "http://localhost:3000/mfe1/rxjs.js" + * } + * } + * + * This function also manages external URLs - if a shared dependency + * has already been loaded from another location, it can reuse that URL. + * + * @param remoteInfo - Federation info containing shared dependencies + * @param baseUrl - Base URL of the remote (used as the scope key) + * + * @returns Scopes object mapping baseUrl to its shared dependencies + */ function processRemoteImports( remoteInfo: FederationInfo, baseUrl: string, @@ -131,17 +330,48 @@ function processRemoteImports( const scopes: Scopes = {}; const scopedImports: Imports = {}; + // Process each shared dependency for (const shared of remoteInfo.shared) { + // Check if this dependency already has an external URL registered + // If not, construct the URL from the base path and output filename const outFileName = getExternalUrl(shared) ?? joinPaths(baseUrl, shared.outFileName); + + // Register this URL as the external location for this shared dependency + // This allows other remotes to potentially reuse this version setExternalUrl(shared, outFileName); + + // Add to the scoped imports: package name -> full URL scopedImports[shared.packageName] = outFileName; } + // Create the scope entry: baseUrl + '/' -> imports scopes[baseUrl + '/'] = scopedImports; + return scopes; } +/** + * Processes a remote's exposed modules into root-level import map entries. + * + * Exposed modules are what the remote makes available to other applications. + * They go in the root imports (not scoped) so any app can import them. + * + * Example exposed module: + * - Remote 'mfe1' exposes './Component' from file 'Component.js' + * - Results in: "mfe1/Component" -> "http://localhost:3000/mfe1/Component.js" + * + * This allows other apps to do: + * ```typescript + * import { Component } from 'mfe1/Component'; + * ``` + * + * @param remoteInfo - Federation info containing exposed modules + * @param remoteName - Name to prefix the exposed keys with + * @param baseUrl - Base URL where the remote's files are hosted + * + * @returns Imports object mapping remote module keys to their URLs + */ function processExposed( remoteInfo: FederationInfo, remoteName: string, @@ -149,19 +379,43 @@ function processExposed( ): Imports { const imports: Imports = {}; + // Process each exposed module for (const exposed of remoteInfo.exposes) { + // Create the import key by joining remote name with the exposed key + // Example: 'mfe1' + './Component' -> 'mfe1/Component' const key = joinPaths(remoteName, exposed.key); + + // Create the full URL to the exposed module's output file + // Example: 'http://localhost:3000/mfe1' + 'Component.js' -> 'http://localhost:3000/mfe1/Component.js' const value = joinPaths(baseUrl, exposed.outFileName); + imports[key] = value; } return imports; } +/** + * Processes the host application's federation info into an import map. + * + * The host app typically doesn't expose modules (it's the consumer), + * but it does share dependencies that should be available to remotes. + * + * Host shared dependencies go in root-level imports (not scoped) because: + * 1. The host loads first and establishes the base environment + * 2. Remotes should prefer host versions to avoid duplication + * + * @param hostInfo - Federation info from the host's remoteEntry.json + * @param relBundlesPath - Relative path to the host's bundle directory (default: './') + * + * @returns Import map with host's shared dependencies in root imports + */ export async function processHostInfo( hostInfo: FederationInfo, relBundlesPath = './', ): Promise { + // Transform shared array into imports object + // Each shared dep becomes: packageName -> relative path to output file const imports = hostInfo.shared.reduce( (acc, cur) => ({ ...acc, @@ -170,8 +424,12 @@ export async function processHostInfo( {}, ) as Imports; + // Register external URLs for host's shared dependencies + // This allows remotes to discover and potentially reuse these versions for (const shared of hostInfo.shared) { setExternalUrl(shared, relBundlesPath + shared.outFileName); } + + // Host doesn't have scopes - its shared deps are at root level return { imports, scopes: {} }; } diff --git a/libs/native-federation-runtime/src/lib/load-remote-module.integration.spec.ts b/libs/native-federation-runtime/src/lib/load-remote-module.integration.spec.ts new file mode 100644 index 00000000..7e1e8e61 --- /dev/null +++ b/libs/native-federation-runtime/src/lib/load-remote-module.integration.spec.ts @@ -0,0 +1,758 @@ +import { http, HttpResponse } from 'msw'; +import { setupWorker } from 'msw/browser'; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, +} from 'vitest'; +import { + clearFederationDOMEffects, + getImportMapContent, +} from './__test-helpers__/dom-helpers'; +import { + createHostInfo, + createRemoteConfig, + createRemoteInfo, + TEST_URLS, +} from './__test-helpers__/federation-fixtures'; +import { FALLBACK_COMPONENTS } from './__test-helpers__/module-fixtures'; +import { + createFederationHandlers, + hostRemoteEntryHandler, + remoteEntryHandler, +} from './__test-helpers__/msw-handlers'; +import { initFederation } from './init-federation'; +import { loadRemoteModule } from './load-remote-module'; +import { addRemote } from './model/remotes'; + +// ============================================================================ +// TEST UTILITIES +// ============================================================================ + +/** + * Helper to setup a basic federation environment with one remote + */ +async function setupFederationWithRemote( + worker: ReturnType, + remoteName = 'mfe1', +) { + const hostInfo = createHostInfo(); + const remoteInfo = createRemoteInfo(remoteName); + + worker.use( + ...createFederationHandlers({ + host: hostInfo, + remotes: [{ url: TEST_URLS.MFE1_REMOTE_ENTRY, info: remoteInfo }], + }), + ); + + await initFederation( + createRemoteConfig({ name: remoteName, url: TEST_URLS.MFE1_REMOTE_ENTRY }), + ); + + return { hostInfo, remoteInfo }; +} + +/** + * Helper to manually register a remote without calling initFederation + */ +function registerRemoteManually( + remoteName: string, + baseUrl: string, + exposes: Array<{ key: string; outFileName: string }>, +) { + addRemote(remoteName, { + name: remoteName, + baseUrl, + exposes, + shared: [], + }); +} + +describe('loadRemoteModule - Browser Integration Test', () => { + const worker = setupWorker(); + + beforeAll(async () => { + await worker.start({ + onUnhandledRequest: 'bypass', // Changed to bypass to avoid warnings + quiet: true, + }); + }); + + afterAll(() => worker.stop()); + + beforeEach(() => { + clearFederationDOMEffects(); + // Reset handlers before each test to ensure clean state + worker.resetHandlers(); + }); + + afterEach(() => { + // Additional cleanup + worker.resetHandlers(); + }); + + describe('Basic Loading - Success Case', () => { + it('should successfully load and execute remote module', async () => { + await setupFederationWithRemote(worker); + + // Serve a valid JavaScript module with multiple exports + worker.use( + http.get(`${TEST_URLS.MFE1_BASE}/Component.js`, () => { + const moduleCode = ` + // Default export - a class + export default class Component { + constructor() { + this.name = 'RemoteComponent'; + this.type = 'MFE1'; + } + + render() { + return 'Rendered: ' + this.name; + } + } + + // Named exports + export const version = '1.0.0'; + export const ComponentName = 'MFE1Component'; + + export function render() { + return 'Rendered from MFE1'; + } + + export const utils = { + validate: () => true, + format: (val) => String(val).toUpperCase() + }; + `; + return new HttpResponse(moduleCode, { + headers: { 'Content-Type': 'application/javascript' }, + }); + }), + ); + + // Test with options object + const module = await loadRemoteModule({ + remoteName: 'mfe1', + exposedModule: './Component', + }); + + // Verify module was loaded and all exports work + expect(module).toBeDefined(); + + // Test default export (class) + expect(module.default).toBeDefined(); + const instance = new module.default(); + expect(instance.name).toBe('RemoteComponent'); + expect(instance.type).toBe('MFE1'); + expect(instance.render()).toBe('Rendered: RemoteComponent'); + + // Test named exports + expect(module.version).toBe('1.0.0'); + expect(module.ComponentName).toBe('MFE1Component'); + expect(module.render).toBeTypeOf('function'); + expect(module.render()).toBe('Rendered from MFE1'); + + // Test exported object with methods + expect(module.utils).toBeDefined(); + expect(module.utils.validate()).toBe(true); + expect(module.utils.format('test')).toBe('TEST'); + }); + }); + + describe('Basic Loading - Additional Success Cases', () => { + it('should load module using positional arguments', async () => { + // Setup remote with Button module + const hostInfo = createHostInfo(); + const remoteInfo = createRemoteInfo('mfe1', [ + { key: './Component', outFileName: 'Component.js' }, + { key: './Button', outFileName: 'Button.js' }, + ]); + + worker.use( + ...createFederationHandlers({ + host: hostInfo, + remotes: [{ url: TEST_URLS.MFE1_REMOTE_ENTRY, info: remoteInfo }], + }), + ); + + await initFederation( + createRemoteConfig({ name: 'mfe1', url: TEST_URLS.MFE1_REMOTE_ENTRY }), + ); + + // Serve Button module + worker.use( + http.get(`${TEST_URLS.MFE1_BASE}/Button.js`, () => { + const moduleCode = ` + export default class Button { + constructor() { + this.type = 'button'; + } + click() { + return 'Button clicked!'; + } + } + export const buttonType = 'primary'; + `; + return new HttpResponse(moduleCode, { + headers: { 'Content-Type': 'application/javascript' }, + }); + }), + ); + + // Load using positional arguments + const module = await loadRemoteModule('mfe1', './Button'); + + expect(module).toBeDefined(); + expect(module.default).toBeDefined(); + expect(module.buttonType).toBe('primary'); + + const btn = new module.default(); + expect(btn.type).toBe('button'); + expect(btn.click()).toBe('Button clicked!'); + }); + + it('should load module with only named exports', async () => { + // Setup remote with Service module + const hostInfo = createHostInfo(); + const remoteInfo = createRemoteInfo('mfe1', [ + { key: './Component', outFileName: 'Component.js' }, + { key: './Service', outFileName: 'Service.js' }, + ]); + + worker.use( + ...createFederationHandlers({ + host: hostInfo, + remotes: [{ url: TEST_URLS.MFE1_REMOTE_ENTRY, info: remoteInfo }], + }), + ); + + await initFederation( + createRemoteConfig({ name: 'mfe1', url: TEST_URLS.MFE1_REMOTE_ENTRY }), + ); + + // Serve Service module with only named exports + worker.use( + http.get(`${TEST_URLS.MFE1_BASE}/Service.js`, () => { + const moduleCode = ` + export const API_URL = 'https://api.example.com'; + + export function fetchData() { + return Promise.resolve({ data: 'test' }); + } + + export class DataService { + getData() { + return 'service data'; + } + } + `; + return new HttpResponse(moduleCode, { + headers: { 'Content-Type': 'application/javascript' }, + }); + }), + ); + + const module = await loadRemoteModule('mfe1', './Service'); + + expect(module.API_URL).toBe('https://api.example.com'); + expect(module.fetchData).toBeTypeOf('function'); + expect(module.DataService).toBeDefined(); + + const service = new module.DataService(); + expect(service.getData()).toBe('service data'); + }); + }); + + // ========================================================================== + // BASIC LOADING TESTS - FAILURE CASES + // ========================================================================== + // These tests verify error handling when modules fail to load + // Using different module names to avoid caching conflicts + describe('Basic Loading - Failure Cases', () => { + it('should throw when module returns 404', async () => { + // Setup remote with NotFound module + const hostInfo = createHostInfo(); + const remoteInfo = createRemoteInfo('mfe1', [ + { key: './Component', outFileName: 'Component.js' }, + { key: './NotFound', outFileName: 'NotFound.js' }, + ]); + + worker.use( + ...createFederationHandlers({ + host: hostInfo, + remotes: [{ url: TEST_URLS.MFE1_REMOTE_ENTRY, info: remoteInfo }], + }), + ); + + await initFederation( + createRemoteConfig({ name: 'mfe1', url: TEST_URLS.MFE1_REMOTE_ENTRY }), + ); + + worker.use( + http.get(`${TEST_URLS.MFE1_BASE}/NotFound.js`, () => { + return new HttpResponse(null, { status: 404 }); + }), + ); + + await expect( + loadRemoteModule({ + remoteName: 'mfe1', + exposedModule: './NotFound', + }), + ).rejects.toThrow(); + }); + + it('should throw when module has syntax error', async () => { + // Setup remote with Invalid module + const hostInfo = createHostInfo(); + const remoteInfo = createRemoteInfo('mfe1', [ + { key: './Component', outFileName: 'Component.js' }, + { key: './Invalid', outFileName: 'Invalid.js' }, + ]); + + worker.use( + ...createFederationHandlers({ + host: hostInfo, + remotes: [{ url: TEST_URLS.MFE1_REMOTE_ENTRY, info: remoteInfo }], + }), + ); + + await initFederation( + createRemoteConfig({ name: 'mfe1', url: TEST_URLS.MFE1_REMOTE_ENTRY }), + ); + + worker.use( + http.get(`${TEST_URLS.MFE1_BASE}/Invalid.js`, () => { + const invalidCode = ` + export default class { + // Missing closing brace + `; + return new HttpResponse(invalidCode, { + headers: { 'Content-Type': 'application/javascript' }, + }); + }), + ); + + await expect(loadRemoteModule('mfe1', './Invalid')).rejects.toThrow(); + }); + }); + + describe('Lazy Loading', () => { + it('should support remoteEntry option for lazy loading', async () => { + // Initialize only host, no remotes + const hostInfo = createHostInfo(); + worker.use(hostRemoteEntryHandler(hostInfo)); + await initFederation({}); + + const remoteInfo = createRemoteInfo('mfe2', [ + { key: './LazyModule', outFileName: 'LazyModule.js' }, + ]); + + worker.use( + remoteEntryHandler(TEST_URLS.MFE2_REMOTE_ENTRY, remoteInfo), + http.get(`${TEST_URLS.MFE2_BASE}/LazyModule.js`, () => { + const moduleCode = ` + export default class LazyModule { + constructor() { + this.loaded = 'lazy'; + } + } + `; + return new HttpResponse(moduleCode, { + headers: { 'Content-Type': 'application/javascript' }, + }); + }), + ); + + // Load module with remoteEntry - should fetch and register the remote + const module = await loadRemoteModule({ + remoteEntry: TEST_URLS.MFE2_REMOTE_ENTRY, + exposedModule: './LazyModule', + }); + + expect(module).toBeDefined(); + expect(module.default).toBeDefined(); + const instance = new module.default(); + expect(instance.loaded).toBe('lazy'); + }); + + it('should not refetch remote if already initialized', async () => { + const hostInfo = createHostInfo(); + const remoteInfo = createRemoteInfo('mfe1', [ + { key: './Component', outFileName: 'Component.js' }, + { key: './CachedModule', outFileName: 'CachedModule.js' }, + ]); + + worker.use( + ...createFederationHandlers({ + host: hostInfo, + remotes: [{ url: TEST_URLS.MFE1_REMOTE_ENTRY, info: remoteInfo }], + }), + ); + + await initFederation( + createRemoteConfig({ name: 'mfe1', url: TEST_URLS.MFE1_REMOTE_ENTRY }), + ); + + let fetchCount = 0; + worker.use( + http.get(TEST_URLS.MFE1_REMOTE_ENTRY, () => { + fetchCount++; + return HttpResponse.json(remoteInfo); + }), + http.get(`${TEST_URLS.MFE1_BASE}/CachedModule.js`, () => { + const moduleCode = `export const cached = true;`; + return new HttpResponse(moduleCode, { + headers: { 'Content-Type': 'application/javascript' }, + }); + }), + ); + + // Load with remoteEntry even though it's already initialized + const module = await loadRemoteModule({ + remoteEntry: TEST_URLS.MFE1_REMOTE_ENTRY, + exposedModule: './CachedModule', + }); + + // Should not fetch remoteEntry again since remote is already initialized + expect(fetchCount).toBe(0); + expect(module.cached).toBe(true); + }); + + it('should determine remote name from remoteEntry URL', async () => { + const hostInfo = createHostInfo(); + const remoteInfo = createRemoteInfo('mfe1', [ + { key: './ResolvedModule', outFileName: 'ResolvedModule.js' }, + ]); + + worker.use( + hostRemoteEntryHandler(hostInfo), + remoteEntryHandler(TEST_URLS.MFE1_REMOTE_ENTRY, remoteInfo), + http.get(`${TEST_URLS.MFE1_BASE}/ResolvedModule.js`, () => { + const moduleCode = `export const resolved = 'by-url';`; + return new HttpResponse(moduleCode, { + headers: { 'Content-Type': 'application/javascript' }, + }); + }), + ); + + await initFederation({}); + + // Manually register the remote so we can test name resolution + registerRemoteManually(TEST_URLS.MFE1_BASE, TEST_URLS.MFE1_BASE, [ + { key: './ResolvedModule', outFileName: 'ResolvedModule.js' }, + ]); + + // Load without remoteName, should resolve from remoteEntry + const module = await loadRemoteModule({ + remoteEntry: TEST_URLS.MFE1_REMOTE_ENTRY, + exposedModule: './ResolvedModule', + }); + + expect(module.resolved).toBe('by-url'); + }); + }); + + describe('Error Handling', () => { + it('should throw error when remote is not found and no fallback', async () => { + await setupFederationWithRemote(worker); + + await expect( + loadRemoteModule('unknown-remote', './Component'), + ).rejects.toThrow('unknown remote unknown-remote'); + }); + + it('should return fallback when remote is not found', async () => { + await setupFederationWithRemote(worker); + + const result = await loadRemoteModule({ + remoteName: 'unknown-remote', + exposedModule: './Component', + fallback: FALLBACK_COMPONENTS.DefaultComponent, + }); + + expect(result).toBe(FALLBACK_COMPONENTS.DefaultComponent); + }); + + it('should throw error when exposed module is not found and no fallback', async () => { + await setupFederationWithRemote(worker); + + await expect(loadRemoteModule('mfe1', './UnknownModule')).rejects.toThrow( + 'Unknown exposed module ./UnknownModule in remote mfe1', + ); + }); + + it('should return fallback when exposed module is not found', async () => { + await setupFederationWithRemote(worker); + + const result = await loadRemoteModule({ + remoteName: 'mfe1', + exposedModule: './UnknownModule', + fallback: FALLBACK_COMPONENTS.DefaultComponent, + }); + + expect(result).toBe(FALLBACK_COMPONENTS.DefaultComponent); + }); + + it('should throw error when module import fails and no fallback', async () => { + const hostInfo = createHostInfo(); + const remoteInfo = createRemoteInfo('mfe1', [ + { key: './Component', outFileName: 'Component.js' }, + { key: './FailModule', outFileName: 'FailModule.js' }, + ]); + + worker.use( + ...createFederationHandlers({ + host: hostInfo, + remotes: [{ url: TEST_URLS.MFE1_REMOTE_ENTRY, info: remoteInfo }], + }), + ); + + await initFederation( + createRemoteConfig({ name: 'mfe1', url: TEST_URLS.MFE1_REMOTE_ENTRY }), + ); + + worker.use( + http.get(`${TEST_URLS.MFE1_BASE}/FailModule.js`, () => { + return new HttpResponse(null, { status: 500 }); + }), + ); + + await expect(loadRemoteModule('mfe1', './FailModule')).rejects.toThrow(); + }); + + it('should throw error when module import fails even with fallback', async () => { + const hostInfo = createHostInfo(); + const remoteInfo = createRemoteInfo('mfe1', [ + { key: './Component', outFileName: 'Component.js' }, + { key: './ErrorModule', outFileName: 'ErrorModule.js' }, + ]); + + worker.use( + ...createFederationHandlers({ + host: hostInfo, + remotes: [{ url: TEST_URLS.MFE1_REMOTE_ENTRY, info: remoteInfo }], + }), + ); + + await initFederation( + createRemoteConfig({ name: 'mfe1', url: TEST_URLS.MFE1_REMOTE_ENTRY }), + ); + + // Mock the module file to return 404 + worker.use( + http.get(`${TEST_URLS.MFE1_BASE}/ErrorModule.js`, () => { + return new HttpResponse(null, { status: 404 }); + }), + ); + + // In browser mode, dynamic import failures throw before the catch block + // This is expected behavior - the fallback can't catch import errors + await expect( + loadRemoteModule({ + remoteName: 'mfe1', + exposedModule: './ErrorModule', + fallback: FALLBACK_COMPONENTS.ErrorComponent, + }), + ).rejects.toThrow(); + }); + + it('should handle null fallback gracefully', async () => { + await setupFederationWithRemote(worker); + + // null is not considered a valid fallback, so it should throw + await expect( + loadRemoteModule({ + remoteName: 'unknown-remote', + exposedModule: './Component', + fallback: null, + }), + ).rejects.toThrow('unknown remote unknown-remote'); + }); + + it('should log error in browser environment when using fallback', async () => { + await setupFederationWithRemote(worker); + + const [errorMessages, cleanup] = captureConsoleErrorMessages(); + + await loadRemoteModule({ + remoteName: 'unknown-remote', + exposedModule: './Component', + fallback: FALLBACK_COMPONENTS.DefaultComponent, + }); + + expect(errorMessages.length).toBeGreaterThan(0); + expect(errorMessages[0]).toContain('unknown remote'); + + cleanup(); + }); + }); + + describe('Argument Validation', () => { + it('should throw error when neither remoteName nor remoteEntry is provided', async () => { + await setupFederationWithRemote(worker); + + await expect( + loadRemoteModule({ + exposedModule: './Component', + } as any), + ).rejects.toThrow('Please pass remoteName or remoteEntry'); + }); + + it('should throw error with invalid argument combination', async () => { + await setupFederationWithRemote(worker); + + await expect( + loadRemoteModule('mfe1' as any, undefined as any), + ).rejects.toThrow( + 'please pass options or a remoteName/exposedModule-pair', + ); + }); + + it('should throw error when exposedModule is missing in options', async () => { + await setupFederationWithRemote(worker); + + await expect( + loadRemoteModule({ + remoteName: 'mfe1', + } as any), + ).rejects.toThrow(); + }); + }); + + describe('Edge Cases', () => { + it('should handle multiple modules from same remote', async () => { + // Setup remote with multiple exposed modules + const hostInfo = createHostInfo(); + const remoteInfo = createRemoteInfo('mfe1', [ + { key: './Component', outFileName: 'Component.js' }, + { key: './Button', outFileName: 'Button.js' }, + { key: './Service', outFileName: 'Service.js' }, + ]); + + worker.use( + ...createFederationHandlers({ + host: hostInfo, + remotes: [{ url: TEST_URLS.MFE1_REMOTE_ENTRY, info: remoteInfo }], + }), + ); + + await initFederation( + createRemoteConfig({ name: 'mfe1', url: TEST_URLS.MFE1_REMOTE_ENTRY }), + ); + + // Verify all modules are exposed in import map + const importMapContent = getImportMapContent(); + expect(importMapContent).toBeDefined(); + expect(importMapContent!.imports['mfe1/Component']).toBeDefined(); + expect(importMapContent!.imports['mfe1/Button']).toBeDefined(); + expect(importMapContent!.imports['mfe1/Service']).toBeDefined(); + }); + + it('should handle nested module paths', async () => { + const hostInfo = createHostInfo(); + const remoteInfo = createRemoteInfo('mfe1', [ + { + key: './components/Button', + outFileName: 'components-Button.js', + }, + ]); + + worker.use( + ...createFederationHandlers({ + host: hostInfo, + remotes: [{ url: TEST_URLS.MFE1_REMOTE_ENTRY, info: remoteInfo }], + }), + ); + + await initFederation( + createRemoteConfig({ name: 'mfe1', url: TEST_URLS.MFE1_REMOTE_ENTRY }), + ); + + // Verify nested path is in import map + const importMapContent = getImportMapContent(); + expect(importMapContent?.imports['mfe1/components/Button']).toBe( + `${TEST_URLS.MFE1_BASE}/components-Button.js`, + ); + }); + + it('should handle special characters in remote names', async () => { + const hostInfo = createHostInfo(); + const remoteInfo = createRemoteInfo('my-mfe-1', [ + { key: './Component', outFileName: 'Component.js' }, + ]); + + worker.use( + hostRemoteEntryHandler(hostInfo), + remoteEntryHandler(TEST_URLS.MFE1_REMOTE_ENTRY, remoteInfo), + ); + + await initFederation( + createRemoteConfig({ + name: 'my-mfe-1', + url: TEST_URLS.MFE1_REMOTE_ENTRY, + }), + ); + + // Verify remote with special characters is registered + const importMapContent = getImportMapContent(); + expect(importMapContent?.imports['my-mfe-1/Component']).toBeDefined(); + }); + + it('should work with remotes from different base URLs', async () => { + const hostInfo = createHostInfo(); + const mfe1Info = createRemoteInfo('mfe1'); + const mfe2Info = createRemoteInfo('mfe2'); + + worker.use( + ...createFederationHandlers({ + host: hostInfo, + remotes: [ + { url: TEST_URLS.MFE1_REMOTE_ENTRY, info: mfe1Info }, + { url: TEST_URLS.MFE2_REMOTE_ENTRY, info: mfe2Info }, + ], + }), + ); + + await initFederation( + createRemoteConfig( + { name: 'mfe1', url: TEST_URLS.MFE1_REMOTE_ENTRY }, + { name: 'mfe2', url: TEST_URLS.MFE2_REMOTE_ENTRY }, + ), + ); + + // Verify both remotes are registered with different base URLs + const importMapContent = getImportMapContent(); + expect(importMapContent?.imports['mfe1/Component']).toContain( + 'localhost:3000', + ); + expect(importMapContent?.imports['mfe2/Component']).toContain( + 'localhost:4000', + ); + }); + }); +}); + +// ============================================================================ +// HELPER FUNCTIONS +// ============================================================================ + +/** + * Helper to capture console error messages + */ +function captureConsoleErrorMessages(): [string[], () => void] { + const messages: string[] = []; + const originalError = console.error; + console.error = (...args: any[]) => { + messages.push(args.join(' ')); + }; + const cleanup = () => { + console.error = originalError; + }; + return [messages, cleanup]; +} diff --git a/libs/native-federation-runtime/src/lib/load-remote-module.ts b/libs/native-federation-runtime/src/lib/load-remote-module.ts index 3a9ee215..1ebe3f47 100644 --- a/libs/native-federation-runtime/src/lib/load-remote-module.ts +++ b/libs/native-federation-runtime/src/lib/load-remote-module.ts @@ -1,15 +1,36 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { appendImportMap } from './utils/add-import-map'; -import { processRemoteInfo } from './init-federation'; +import { fetchAndRegisterRemote } from './init-federation'; import { getRemote, getRemoteNameByBaseUrl, isRemoteInitialized, } from './model/remotes'; +import { appendImportMap } from './utils/add-import-map'; import { getDirectory, joinPaths } from './utils/path-utils'; declare function importShim(url: string): T; +/** + * Options for loading a remote module. + * + * @template T - The expected type of the module's exports + * + * @property remoteEntry - Optional URL to the remote's remoteEntry.json file. + * Used for lazy-loading remotes that weren't registered during initFederation + * Example: 'http://localhost:3000/remoteEntry.json' + * + * @property remoteName - Optional name of the remote application. + * Should match the name used during initFederation or the name in remoteEntry.json. + * Example: 'mfe1' + * + * @property exposedModule - The key of the exposed module to load (required). + * Must match the key defined in the remote's federation config. + * Example: './Component' or './Dashboard' + * + * @property fallback - Optional fallback value to return if the remote or module cannot be loaded. + * Prevents throwing errors and provides graceful degradation. + * Example: A default component or null + */ export type LoadRemoteModuleOptions = { remoteEntry?: string; remoteName?: string; @@ -17,6 +38,59 @@ export type LoadRemoteModuleOptions = { fallback?: T; }; +/** + * Dynamically loads a remote module at runtime from a federated application. + * + * This is the primary API for consuming remote modules after federation has been initialized. + * It supports two calling patterns: + * + * **Pattern 1: Using options object** + * ```typescript + * const module = await loadRemoteModule({ + * remoteName: 'mfe1', + * exposedModule: './Component', + * fallback: DefaultComponent + * }); + * ``` + * + * **Pattern 2: Using positional arguments** + * ```typescript + * const module = await loadRemoteModule('mfe1', './Component'); + * ``` + * + * ## Loading Process + * + * 1. **Normalize Options**: Converts arguments into a standard options object + * 2. **Ensure Remote Initialized**: If remoteEntry is provided and remote isn't loaded, + * fetches and registers it dynamically + * 3. **Resolve Remote Name**: Determines remote name from options or remoteEntry URL + * 4. **Validate Remote**: Checks if remote exists in the registry + * 5. **Validate Exposed Module**: Verifies the requested module is exposed by the remote + * 6. **Import Module**: Uses dynamic import or import-shim to load the module + * 7. **Handle Errors**: Returns fallback if provided, otherwise throws + * + * ## Lazy Loading Support + * + * If you provide a `remoteEntry` URL for a remote that wasn't initialized during + * `initFederation()`, this function will automatically: + * - Fetch the remote's remoteEntry.json + * - Register it in the global registry + * - Update the import map + * - Then load the requested module + * + * This enables on-demand loading of remotes based on user interactions. + * + * + * @template T - The expected type of the module's exports + * + * @param options - Configuration object for loading the remote module + * @returns Promise resolving to the loaded module or fallback value + * + * @throws Error if remote is not found and no fallback is provided + * @throws Error if exposed module doesn't exist and no fallback is provided + * @throws Error if module import fails and no fallback is provided + * + */ export async function loadRemoteModule( options: LoadRemoteModuleOptions, ): Promise; @@ -28,42 +102,49 @@ export async function loadRemoteModule( optionsOrRemoteName: LoadRemoteModuleOptions | string, exposedModule?: string, ): Promise { + // Step 1: Normalize the input arguments into a standard options object const options = normalizeOptions(optionsOrRemoteName, exposedModule); + // Step 2: Ensure the remote is initialized (fetch and register if needed) await ensureRemoteInitialized(options); + // Step 3: Resolve the remote name from the provided options const remoteName = getRemoteNameByOptions(options); + + // Step 4: Retrieve the remote from the global registry const remote = getRemote(remoteName); const fallback = options.fallback; + // Handles errors when the remote is missing const remoteError = !remote ? 'unknown remote ' + remoteName : ''; - - if (!remote && !fallback) { - throw new Error(remoteError); - } else if (!remote) { + if (!remote && !fallback) throw new Error(remoteError); + if (!remote) { logClientError(remoteError); return Promise.resolve(fallback); } + // Step 5: Find the requested exposed module in the remote's exposes array const exposed = remote.exposes.find((e) => e.key === options.exposedModule); + // Handles errors when the exposed module is missing const exposedError = !exposed ? `Unknown exposed module ${options.exposedModule} in remote ${remoteName}` : ''; - - if (!exposed && !fallback) { - throw new Error(exposedError); - } else if (!exposed) { + if (!exposed && !fallback) throw new Error(exposedError); + if (!exposed) { logClientError(exposedError); return Promise.resolve(fallback); } - const url = joinPaths(remote.baseUrl, exposed.outFileName); + // Step 6: Construct the full URL to the exposed module's output file + const moduleUrl = joinPaths(remote.baseUrl, exposed.outFileName); + // Step 7: Dynamically import the module try { - const module = _import(url); + const module = _import(moduleUrl); return module; } catch (e) { + // Handles errors when the module import fails if (fallback) { console.error('error loading remote module', e); return fallback; @@ -72,14 +153,35 @@ export async function loadRemoteModule( } } -function _import(url: string) { +/** + * Internal helper function to perform the dynamic import. + * + * @template T - The expected type of the module's exports + * @param moduleUrl - Full URL to the module file to import + * @returns Promise resolving to the imported module + */ +function _import(moduleUrl: string) { return typeof importShim !== 'undefined' - ? importShim(url) - : (import(/* @vite-ignore */ url) as T); + ? importShim(moduleUrl) + : (import(/* @vite-ignore */ moduleUrl) as T); } +/** + * Resolves the remote name from the provided options. + * + * The remote name can be determined in two ways: + * 1. If options.remoteName is provided, use it directly + * 2. If only remoteEntry is provided, extract the baseUrl + * and look up the remote name from the registry using that baseUrl + * + * @param options - Load options containing remoteName and/or remoteEntry + * @returns The resolved remote name + * + * @throws Error if neither remoteName nor remoteEntry is provided + * @throws Error if the remote name cannot be determined + */ function getRemoteNameByOptions(options: LoadRemoteModuleOptions) { - let remoteName; + let remoteName: string | undefined; if (options.remoteName) { remoteName = options.remoteName; @@ -98,6 +200,23 @@ function getRemoteNameByOptions(options: LoadRemoteModuleOptions) { return remoteName; } +/** + * Ensures that the remote is initialized before attempting to load a module from it. + * + * This function enables lazy-loading of remotes that weren't registered during + * the initial `initFederation()` call. It checks if: + * 1. A remoteEntry URL is provided in the options + * 2. The remote at that URL hasn't been initialized yet + * + * If both conditions are true, it: + * 1. Fetches the remote's remoteEntry.json file + * 2. Registers the remote in the global registry + * 3. Creates and appends the remote's import map to the DOM + * + * @param options - Load options containing optional remoteEntry URL + * @returns Promise that resolves when the remote is initialized (or immediately if already initialized) + * + */ async function ensureRemoteInitialized( options: LoadRemoteModuleOptions, ): Promise { @@ -105,11 +224,23 @@ async function ensureRemoteInitialized( options.remoteEntry && !isRemoteInitialized(getDirectory(options.remoteEntry)) ) { - const importMap = await processRemoteInfo(options.remoteEntry); + const importMap = await fetchAndRegisterRemote(options.remoteEntry); appendImportMap(importMap); } } +/** + * Normalizes the function arguments into a standard LoadRemoteModuleOptions object. + * + * The function detects which pattern is being used and converts it to the + * standard options object format for consistent internal processing. + * + * @param optionsOrRemoteName - Either an options object or the remote name string + * @param exposedModule - The exposed module key + * @returns Normalized options object + * + * @throws Error if arguments don't match either supported pattern + */ function normalizeOptions( optionsOrRemoteName: string | LoadRemoteModuleOptions, exposedModule: string | undefined, @@ -131,6 +262,12 @@ function normalizeOptions( return options; } +/** + * Logs an error message to the console, but only in browser environments. + * + * @param error - The error message to log + * + */ function logClientError(error: string): void { if (typeof window !== 'undefined') { console.error(error); diff --git a/libs/native-federation-runtime/src/lib/model/externals.spec.ts b/libs/native-federation-runtime/src/lib/model/externals.spec.ts new file mode 100644 index 00000000..1189dd27 --- /dev/null +++ b/libs/native-federation-runtime/src/lib/model/externals.spec.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { getExternalUrl, setExternalUrl } from './externals'; +import { SharedInfo } from './federation-info'; +import { globalCache } from './global-cache'; + +describe('externals', () => { + const fakeSharedInfo: SharedInfo = { + singleton: true, + strictVersion: false, + requiredVersion: '^15.0.0', + version: '15.0.0', + packageName: '@angular/core', + outFileName: 'angular-core.js', + dev: { + entryPoint: '@angular/core', + }, + }; + + const fakeSharedInfoWithoutVersion: SharedInfo = { + singleton: false, + strictVersion: true, + requiredVersion: '^4.0.0', + packageName: 'lodash', + outFileName: 'lodash.js', + }; + + beforeEach(() => { + globalCache.externals.clear(); + }); + + describe('setExternalUrl', () => { + it('stores external URL with correct key format', () => { + const url = 'http://localhost:4200/angular-core.js'; + + setExternalUrl(fakeSharedInfo, url); + + expect(getExternalUrl(fakeSharedInfo)).toBe(url); + }); + + it('handles package without version', () => { + const url = 'http://localhost:4200/lodash.js'; + + setExternalUrl(fakeSharedInfoWithoutVersion, url); + + expect(getExternalUrl(fakeSharedInfoWithoutVersion)).toBe(url); + }); + }); + + describe('getExternalUrl', () => { + it('returns stored URL for existing external', () => { + const url = 'http://localhost:4200/angular-core.js'; + setExternalUrl(fakeSharedInfo, url); + + const retrievedUrl = getExternalUrl(fakeSharedInfo); + expect(retrievedUrl).toBe(url); + }); + + it('returns undefined for non-existing external', () => { + const nonExistingSharedInfo: SharedInfo = { + singleton: false, + strictVersion: false, + requiredVersion: '^1.0.0', + version: '1.0.0', + packageName: 'non-existing-package', + outFileName: 'non-existing.js', + }; + + const retrievedUrl = getExternalUrl(nonExistingSharedInfo); + expect(retrievedUrl).toBeUndefined(); + }); + }); +}); diff --git a/libs/native-federation-runtime/src/lib/model/import-map.spec.ts b/libs/native-federation-runtime/src/lib/model/import-map.spec.ts new file mode 100644 index 00000000..2ab4e269 --- /dev/null +++ b/libs/native-federation-runtime/src/lib/model/import-map.spec.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import { mergeImportMaps, ImportMap } from './import-map'; + +describe('import-map', () => { + const fakeImportMap1: ImportMap = { + imports: { + '@angular/core': 'http://localhost:4200/angular-core.js', + lodash: 'http://localhost:4200/lodash.js', + }, + scopes: { + 'http://localhost:4200/': { + react: 'http://localhost:4200/react.js', + }, + }, + }; + + const fakeImportMap2: ImportMap = { + imports: { + '@angular/common': 'http://localhost:4201/angular-common.js', + lodash: 'http://localhost:4201/lodash.js', // This should override the first one + }, + scopes: { + 'http://localhost:4201/': { + vue: 'http://localhost:4201/vue.js', + }, + }, + }; + + describe('mergeImportMaps', () => { + it('merges two import maps correctly', () => { + const result = mergeImportMaps(fakeImportMap1, fakeImportMap2); + + expect(result.imports['@angular/core']).toBe( + 'http://localhost:4200/angular-core.js', + ); + expect(result.imports['@angular/common']).toBe( + 'http://localhost:4201/angular-common.js', + ); + expect(result.imports['lodash']).toBe('http://localhost:4201/lodash.js'); // Second map wins + }); + + it('merges scopes from both maps', () => { + const result = mergeImportMaps(fakeImportMap1, fakeImportMap2); + + expect(result.scopes['http://localhost:4200/']['react']).toBe( + 'http://localhost:4200/react.js', + ); + expect(result.scopes['http://localhost:4201/']['vue']).toBe( + 'http://localhost:4201/vue.js', + ); + }); + + it('handles empty import maps', () => { + const emptyMap: ImportMap = { imports: {}, scopes: {} }; + + const result = mergeImportMaps(fakeImportMap1, emptyMap); + + expect(result).toEqual(fakeImportMap1); + }); + }); +}); diff --git a/libs/native-federation-runtime/src/lib/model/remotes.spec.ts b/libs/native-federation-runtime/src/lib/model/remotes.spec.ts new file mode 100644 index 00000000..6f2de70b --- /dev/null +++ b/libs/native-federation-runtime/src/lib/model/remotes.spec.ts @@ -0,0 +1,99 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { globalCache } from './global-cache'; +import { + addRemote, + getRemote, + getRemoteNameByBaseUrl, + hasRemote, + isRemoteInitialized, + Remote, +} from './remotes'; + +describe('remotes', () => { + const fakeRemote: Remote = { + name: 'shell-app', + baseUrl: 'http://localhost:4200', + exposes: [ + { + key: './Component', + outFileName: 'component.js', + }, + ], + shared: [ + { + singleton: true, + strictVersion: false, + requiredVersion: '^15.0.0', + version: '15.0.0', + packageName: '@angular/core', + outFileName: 'angular-core.js', + }, + ], + }; + + beforeEach(() => { + globalCache.remoteNamesToRemote.clear(); + globalCache.baseUrlToRemoteNames.clear(); + }); + + describe('addRemote', () => { + it('stores remote and creates bidirectional mapping', () => { + addRemote('shell-app', fakeRemote); + + expect(getRemote('shell-app')).toEqual(fakeRemote); + expect(getRemoteNameByBaseUrl('http://localhost:4200')).toBe('shell-app'); + }); + }); + + describe('getRemoteNameByBaseUrl', () => { + it('returns remote name for existing baseUrl', () => { + addRemote('shell-app', fakeRemote); + + const remoteName = getRemoteNameByBaseUrl('http://localhost:4200'); + expect(remoteName).toBe('shell-app'); + }); + + it('returns undefined for non-existing baseUrl', () => { + const remoteName = getRemoteNameByBaseUrl('http://localhost:9999'); + expect(remoteName).toBeUndefined(); + }); + }); + + describe('isRemoteInitialized', () => { + it('returns true for initialized remote', () => { + addRemote('shell-app', fakeRemote); + + expect(isRemoteInitialized('http://localhost:4200')).toBe(true); + }); + + it('returns false for non-initialized remote', () => { + expect(isRemoteInitialized('http://localhost:9999')).toBe(false); + }); + }); + + describe('getRemote', () => { + it('returns stored remote for existing name', () => { + addRemote('shell-app', fakeRemote); + + const remote = getRemote('shell-app'); + expect(remote).toEqual(fakeRemote); + }); + + it('returns undefined for non-existing remote name', () => { + const remote = getRemote('non-existing-remote'); + expect(remote).toBeUndefined(); + }); + }); + + describe('hasRemote', () => { + it('returns true for existing remote', () => { + addRemote('shell-app', fakeRemote); + + expect(hasRemote('shell-app')).toBe(true); + }); + + it('returns false for non-existing remote', () => { + expect(hasRemote('non-existing-remote')).toBe(false); + }); + }); +}); diff --git a/libs/native-federation-runtime/src/lib/utils/add-import-map.spec.ts b/libs/native-federation-runtime/src/lib/utils/add-import-map.spec.ts new file mode 100644 index 00000000..4df946c7 --- /dev/null +++ b/libs/native-federation-runtime/src/lib/utils/add-import-map.spec.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { appendImportMap } from './add-import-map'; + +describe('appendImportMap', () => { + it('appends a script tag with importmap-shim type and innerHTML', () => { + const fakeImportMap = { + imports: { + foo: '/bar/baz.js', + }, + scopes: {}, + }; + + // Execute + appendImportMap(fakeImportMap); + + // Assert - check that a new script element was added + const scriptElements = document.head.querySelectorAll( + 'script[type="importmap-shim"]', + ); + expect(scriptElements).toHaveLength(1); + + // Get the last added script element + const addedScript = scriptElements[ + scriptElements.length - 1 + ] as HTMLScriptElement; + expect(addedScript.type).toBe('importmap-shim'); + expect(addedScript.innerHTML).toBe(JSON.stringify(fakeImportMap)); + + // Cleanup + document.head.removeChild(addedScript); + }); +}); diff --git a/libs/native-federation-runtime/src/lib/utils/path-utils.spec.ts b/libs/native-federation-runtime/src/lib/utils/path-utils.spec.ts new file mode 100644 index 00000000..f9cce39b --- /dev/null +++ b/libs/native-federation-runtime/src/lib/utils/path-utils.spec.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { getDirectory, joinPaths } from './path-utils'; + +describe('getDirectory', () => { + it('returns the directory for a simple path', () => { + expect(getDirectory('/foo/baz.txt')).toBe('/foo'); + }); + + it('returns the full path of the parents directory', () => { + expect(getDirectory('/foo/anotherFoo/file.txt')).toBe('/foo/anotherFoo'); + }); + + it('returns empty string for a path without a slash', () => { + expect(getDirectory('file.txt')).toBe(''); + }); +}); + +describe('joinPaths', () => { + it('joins two normal paths', () => { + expect(joinPaths('/foo/bar', 'baz.txt')).toBe('/foo/bar/baz.txt'); + }); + + it('handles trailing slash in the first path', () => { + expect(joinPaths('/foo/bar/', 'baz.txt')).toBe('/foo/bar/baz.txt'); + }); + + it('handles multiple trailing slashes in the first path', () => { + expect(joinPaths('/foo/bar///', 'baz.txt')).toBe('/foo/bar/baz.txt'); + }); + + it('normalizes "./" in the second path', () => { + expect(joinPaths('/foo/bar', './baz.txt')).toBe('/foo/bar/baz.txt'); + }); + + it('combines empty first path', () => { + expect(joinPaths('', 'baz.txt')).toBe('/baz.txt'); + }); + + it('joins paths correctly if second path is just "./"', () => { + expect(joinPaths('/foo/bar', './')).toBe('/foo/bar/'); + }); +}); diff --git a/libs/native-federation-runtime/src/lib/utils/path-utils.ts b/libs/native-federation-runtime/src/lib/utils/path-utils.ts index 077a49a7..270d9cd4 100644 --- a/libs/native-federation-runtime/src/lib/utils/path-utils.ts +++ b/libs/native-federation-runtime/src/lib/utils/path-utils.ts @@ -1,10 +1,21 @@ +/** + * Returns the full directory of a given path. + * @param url - The path to get the directory of. + * @returns The full directory of the path. + */ export function getDirectory(url: string) { const parts = url.split('/'); parts.pop(); return parts.join('/'); } -export function joinPaths(path1: string, path2: string): string { +/** + * Joins two paths together taking into account trailing slashes and "./" prefixes. + * @param path1 - The first path to join. + * @param path2 - The second path to join. + * @returns The joined path. + */ +export function joinPaths(path1: string, path2: string) { while (path1.endsWith('/')) { path1 = path1.substring(0, path1.length - 1); } diff --git a/libs/native-federation-runtime/src/lib/watch-federation-build.spec.ts b/libs/native-federation-runtime/src/lib/watch-federation-build.spec.ts new file mode 100644 index 00000000..4568fbed --- /dev/null +++ b/libs/native-federation-runtime/src/lib/watch-federation-build.spec.ts @@ -0,0 +1,70 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { BuildNotificationType } from './model/build-notifications-options'; +import { watchFederationBuildCompletion } from './watch-federation-build'; + +describe('watch-federation-build', () => { + let fakeReload: ReturnType; + let mockConsoleLog: any; + let mockConsoleWarn: any; + let eventSourceInstance: any; + + beforeEach(() => { + fakeReload = vi.fn(); + vi.stubGlobal('window', { location: { reload: fakeReload } }); + + mockConsoleLog = vi.spyOn(console, 'log').mockImplementation(() => {}); + mockConsoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + eventSourceInstance = { + onmessage: null, + onerror: null, + }; + vi.stubGlobal( + 'EventSource', + vi.fn(() => eventSourceInstance), + ); + }); + + describe('watchFederationBuildCompletion', () => { + it('reloads page when build completion is received', () => { + watchFederationBuildCompletion( + 'http://localhost:4200/build-notifications', + ); + + eventSourceInstance.onmessage({ + data: JSON.stringify({ type: BuildNotificationType.COMPLETED }), + }); + + expect(mockConsoleLog).toHaveBeenCalledWith( + '[Federation] Rebuild completed, reloading...', + ); + expect(fakeReload).toHaveBeenCalled(); + }); + + it('does not reload page for non-completion messages', () => { + watchFederationBuildCompletion( + 'http://localhost:4200/build-notifications', + ); + + eventSourceInstance.onmessage({ + data: JSON.stringify({ type: BuildNotificationType.ERROR }), + }); + + expect(fakeReload).not.toHaveBeenCalled(); + }); + + it('logs warning on SSE connection error', () => { + watchFederationBuildCompletion( + 'http://localhost:4200/build-notifications', + ); + + const errorEvent = {}; + eventSourceInstance.onerror(errorEvent); + + expect(mockConsoleWarn).toHaveBeenCalledWith( + '[Federation] SSE connection error:', + errorEvent, + ); + }); + }); +}); diff --git a/libs/native-federation-runtime/tsconfig.lib.json b/libs/native-federation-runtime/tsconfig.lib.json index 063e5257..23401dbe 100644 --- a/libs/native-federation-runtime/tsconfig.lib.json +++ b/libs/native-federation-runtime/tsconfig.lib.json @@ -10,7 +10,7 @@ "exclude": [ "src/**/*.spec.ts", "src/test-setup.ts", - "jest.config.ts", + "vitest.config.mts", "src/**/*.test.ts" ], "include": ["src/**/*.ts"] diff --git a/libs/native-federation-runtime/tsconfig.spec.json b/libs/native-federation-runtime/tsconfig.spec.json index 53fbfcdc..eb0aaec4 100644 --- a/libs/native-federation-runtime/tsconfig.spec.json +++ b/libs/native-federation-runtime/tsconfig.spec.json @@ -2,15 +2,9 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "../../dist/out-tsc", - "module": "commonjs", - "target": "es2016", - "types": ["jest", "node"] + "module": "esnext", + "types": ["vitest/globals", "node"] }, "files": ["src/test-setup.ts"], - "include": [ - "jest.config.ts", - "src/**/*.test.ts", - "src/**/*.spec.ts", - "src/**/*.d.ts" - ] + "include": ["**/*.spec.ts", "**/*.test.ts", "**/*.d.ts"] } diff --git a/libs/native-federation-runtime/vite.config.mts b/libs/native-federation-runtime/vite.config.mts new file mode 100644 index 00000000..0b6fae14 --- /dev/null +++ b/libs/native-federation-runtime/vite.config.mts @@ -0,0 +1,14 @@ +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; +import { sep } from 'node:path'; +import { join } from 'node:path/posix'; +import { defineConfig } from 'vite'; + +const projectName = __dirname.split(sep).pop() ?? 'native-federation-runtime'; + +export default defineConfig({ + root: __dirname, + cacheDir: join('../../node_modules/.vite/tests', projectName), + plugins: [nxViteTsPaths()], + // Serve static files for MSW Service Worker + publicDir: 'public', +}); diff --git a/libs/native-federation-runtime/vitest.config.mts b/libs/native-federation-runtime/vitest.config.mts new file mode 100644 index 00000000..7f6840bc --- /dev/null +++ b/libs/native-federation-runtime/vitest.config.mts @@ -0,0 +1,54 @@ +import { defineConfig } from 'vitest/config'; + +const testPatterns = ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}']; +const integrationTestPatterns = ['src/**/*.integration.spec.ts']; + +export default defineConfig({ + test: { + globals: true, + setupFiles: ['src/test-setup.ts'], + reporters: ['default'], + coverage: { + reportsDirectory: '../../coverage/libs/native-federation-runtime', + provider: 'v8', + }, + watch: false, + pool: 'threads', + exclude: ['node_modules', 'dist', '.angular'], + projects: [ + { + test: { + name: 'unit', + environment: 'jsdom', + include: testPatterns, + exclude: [ + ...integrationTestPatterns, + 'node_modules', + 'dist', + '.angular', + ], + }, + }, + { + test: { + name: 'integration', + include: integrationTestPatterns, + browser: { + enabled: true, + provider: 'playwright', + headless: process.env.CI === 'true', + instances: [ + { + browser: 'chromium', + }, + ], + // Serve static assets for MSW + api: { + host: '127.0.0.1', + }, + }, + }, + }, + ], + }, +}); diff --git a/nx.json b/nx.json index c874f761..ce3027ec 100644 --- a/nx.json +++ b/nx.json @@ -58,6 +58,20 @@ "targetName": "e2e", "componentTestingTargetName": "component-test" } + }, + { + "plugin": "@nx/vite/plugin", + "options": { + "buildTargetName": "build", + "testTargetName": "test", + "serveTargetName": "serve", + "devTargetName": "dev", + "previewTargetName": "preview", + "serveStaticTargetName": "serve-static", + "typecheckTargetName": "typecheck", + "buildDepsTargetName": "build-deps", + "watchDepsTargetName": "watch-deps" + } } ], "generators": { diff --git a/package-lock.json b/package-lock.json index 16f5622f..f78feec4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,8 +46,11 @@ "@nx/js": "22.0.1", "@nx/node": "22.0.1", "@nx/plugin": "22.0.1", + "@nx/vite": "22.0.1", + "@nx/web": "22.0.1", "@nx/webpack": "22.0.1", "@nx/workspace": "22.0.1", + "@playwright/test": "^1.56.1", "@rollup/plugin-commonjs": "^28.0.3", "@rollup/plugin-json": "^6.0.0", "@rollup/plugin-node-resolve": "^16.0.1", @@ -64,6 +67,8 @@ "@typescript-eslint/eslint-plugin": "^8.46.0", "@typescript-eslint/parser": "^8.46.0", "@typescript-eslint/utils": "^8.46.0", + "@vitest/browser": "^3.2.4", + "@vitest/ui": "^3.0.0", "acorn": "^8.8.1", "autoprefixer": "^10.4.0", "browser-sync": "^3.0.2", @@ -84,6 +89,7 @@ "json5": "^2.2.0", "jsonc-eslint-parser": "^2.1.0", "mrmime": "^2.0.1", + "msw": "^2.12.3", "ng-packagr": "^20.3.0", "node-fetch": "^3.3.2", "node-watch": "^0.7.3", @@ -103,6 +109,8 @@ "tslib": "^2.3.0", "typescript": "5.9.3", "verdaccio": "^6.2.1", + "vite": "^6.0.0", + "vitest": "^3.0.0", "word-wrap": "^1.2.5" }, "peerDependencies": { @@ -2137,6 +2145,98 @@ "node": ">=10" } }, + "node_modules/@angular/build/node_modules/vite": { + "version": "7.1.11", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.11.tgz", + "integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/@angular/build/node_modules/vite/node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, "node_modules/@angular/cli": { "version": "20.3.12", "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-20.3.12.tgz", @@ -2207,7 +2307,6 @@ "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-20.3.14.tgz", "integrity": "sha512-KFbfPPAbclzGDujCVruflCD9j4Zwwxvrg7Y4C9GJYs3LZ85t+BfIMDDnvpBUM07ZLnfY4TO4gQdHmJAcaGGXDQ==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -6327,9 +6426,9 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", - "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6339,7 +6438,7 @@ "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", + "js-yaml": "^4.1.1", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" }, @@ -9916,7 +10015,6 @@ "integrity": "sha512-/u4f+GYRZfHpSvdt5n40lMCS9Cmve7N3JlDreaFXz8xrWDNOp2wvMgiVGpndo5J4iQdtLjpavWStahGQ05B2cQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@module-federation/enhanced": "0.21.6", "@module-federation/runtime": "0.21.6", @@ -10177,6 +10275,24 @@ "win32" ] }, + "node_modules/@mswjs/interceptors": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.40.0.tgz", + "integrity": "sha512-EFd6cVbHsgLa6wa4RljGj6Wk75qoHxUSyc5asLyyPSyuhIcdS2Q3Phw6ImS1q+CkALthJRShiYfKANcQMuMqsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@napi-rs/magic-string": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/@napi-rs/magic-string/-/magic-string-0.3.4.tgz", @@ -13196,6 +13312,41 @@ "tslib": "^2.3.0" } }, + "node_modules/@nx/vite": { + "version": "22.0.1", + "resolved": "https://registry.npmjs.org/@nx/vite/-/vite-22.0.1.tgz", + "integrity": "sha512-2rzMmvqB3qtBrXHL/sasMMJw5Id86+jElLDn2bbt08NMqg0+u9jlkzmqYB802QDMfvZfs+GLih+42sL2Jikjfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nx/devkit": "22.0.1", + "@nx/js": "22.0.1", + "@phenomnomnominal/tsquery": "~5.0.1", + "ajv": "^8.0.0", + "enquirer": "~2.3.6", + "picomatch": "4.0.2", + "semver": "^7.6.3", + "tsconfig-paths": "^4.1.2", + "tslib": "^2.3.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0", + "vitest": "^1.3.1 || ^2.0.0 || ^3.0.0" + } + }, + "node_modules/@nx/vite/node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/@nx/web": { "version": "22.0.1", "resolved": "https://registry.npmjs.org/@nx/web/-/web-22.0.1.tgz", @@ -13587,6 +13738,31 @@ "node": ">=8" } }, + "node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true, + "license": "MIT" + }, "node_modules/@oxc-parser/binding-darwin-arm64": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.8.0.tgz", @@ -14334,6 +14510,29 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@playwright/test": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz", + "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, "node_modules/@rollup/plugin-commonjs": { "version": "28.0.9", "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-28.0.9.tgz", @@ -15697,6 +15896,72 @@ "node": ">=14.16" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@testing-library/dom/node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@testing-library/dom/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@tokenizer/inflate": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.2.7.tgz", @@ -15821,6 +16086,13 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -15964,6 +16236,17 @@ "node": ">=8.10.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -15995,6 +16278,13 @@ "@types/node": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/eslint": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", @@ -16348,6 +16638,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/statuses": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", + "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/tough-cookie": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", @@ -17860,6 +18157,212 @@ "vite": "^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/browser": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-3.2.4.tgz", + "integrity": "sha512-tJxiPrWmzH8a+w9nLKlQMzAKX/7VjFs50MWgcAj7p9XQ7AQ9/35fByFYptgPELyLw+0aixTnC4pUWV+APcZ/kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@testing-library/dom": "^10.4.0", + "@testing-library/user-event": "^14.6.1", + "@vitest/mocker": "3.2.4", + "@vitest/utils": "3.2.4", + "magic-string": "^0.30.17", + "sirv": "^3.0.1", + "tinyrainbow": "^2.0.0", + "ws": "^8.18.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "playwright": "*", + "vitest": "3.2.4", + "webdriverio": "^7.0.0 || ^8.0.0 || ^9.0.0" + }, + "peerDependenciesMeta": { + "playwright": { + "optional": true + }, + "safaridriver": { + "optional": true + }, + "webdriverio": { + "optional": true + } + } + }, + "node_modules/@vitest/browser/node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/ui": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-3.2.4.tgz", + "integrity": "sha512-hGISOaP18plkzbWEcP/QvtRW1xDXF2+96HbEX6byqQhAUbiS5oH6/9JwW+QsQCIYON2bI6QZBF+2PvOmrRZ9wA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@vitest/utils": "3.2.4", + "fflate": "^0.8.2", + "flatted": "^3.3.3", + "pathe": "^2.0.3", + "sirv": "^3.0.1", + "tinyglobby": "^0.2.14", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "3.2.4" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@webassemblyjs/ast": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", @@ -18691,6 +19194,16 @@ "node": ">=0.8" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/astral-regex": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", @@ -19143,9 +19656,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.8.31", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.31.tgz", - "integrity": "sha512-a28v2eWrrRWPpJSzxc+mKwm0ZtVx/G8SepdQZDArnXYU/XS+IF6mp8aB/4E+hH1tyGCoDo3KlUCdlSxGDsRkAw==", + "version": "2.8.32", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.32.tgz", + "integrity": "sha512-OPz5aBThlyLFgxyhdwf/s2+8ab3OvT7AdTNvKHBwpXomIYeXqpUUuT8LrdtxZSsWJ4R4CU1un4XGh5Ez3nlTpw==", "devOptional": true, "license": "Apache-2.0", "bin": { @@ -19861,6 +20374,16 @@ "node": ">= 0.8" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/cacache": { "version": "19.0.1", "resolved": "https://registry.npmjs.org/cacache/-/cacache-19.0.1.tgz", @@ -20068,6 +20591,23 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", @@ -20098,6 +20638,16 @@ "dev": true, "license": "MIT" }, + "node_modules/check-error": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", + "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/check-more-types": { "version": "2.24.0", "resolved": "https://registry.npmjs.org/check-more-types/-/check-more-types-2.24.0.tgz", @@ -20114,6 +20664,7 @@ "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "readdirp": "^4.0.1" }, @@ -22677,6 +23228,16 @@ } } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/deep-equal": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.0.1.tgz", @@ -22800,6 +23361,16 @@ "node": ">=4" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/destroy": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", @@ -22915,6 +23486,13 @@ "node": ">=6" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -24222,6 +24800,16 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/expect-type": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", + "integrity": "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/expect/node_modules/@jest/schemas": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", @@ -26233,6 +26821,16 @@ "dev": true, "license": "MIT" }, + "node_modules/graphql": { + "version": "16.12.0", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.12.0.tgz", + "integrity": "sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, "node_modules/gunzip-maybe": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/gunzip-maybe/-/gunzip-maybe-1.4.2.tgz", @@ -26372,6 +26970,13 @@ "he": "bin/he" } }, + "node_modules/headers-polyfill": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz", + "integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/homedir-polyfill": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz", @@ -27238,6 +27843,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "dev": true, + "license": "MIT" + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -33475,6 +34087,13 @@ "integrity": "sha512-BFRuQUqc7x2NWxfJBCyUrN8iYUYznzL9JROmRz1gZ6KlOIgmoD+njPVbb+VNn2nGMKggMsK79iUNErillsrx7w==", "license": "MIT" }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lowdb": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/lowdb/-/lowdb-1.0.0.tgz", @@ -33541,6 +34160,16 @@ "node": ">=12" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.17", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", @@ -34068,6 +34697,133 @@ "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" } }, + "node_modules/msw": { + "version": "2.12.3", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.12.3.tgz", + "integrity": "sha512-/5rpGC0eK8LlFqsHaBmL19/PVKxu/CCt8pO1vzp9X6SDLsRDh/Ccudkf3Ur5lyaKxJz9ndAx+LaThdv0ySqB6A==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@inquirer/confirm": "^5.0.0", + "@mswjs/interceptors": "^0.40.0", + "@open-draft/deferred-promise": "^2.2.0", + "@types/statuses": "^2.0.6", + "cookie": "^1.0.2", + "graphql": "^16.12.0", + "headers-polyfill": "^4.0.2", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "path-to-regexp": "^6.3.0", + "picocolors": "^1.1.1", + "rettime": "^0.7.0", + "statuses": "^2.0.2", + "strict-event-emitter": "^0.5.1", + "tough-cookie": "^6.0.0", + "type-fest": "^5.2.0", + "until-async": "^3.0.2", + "yargs": "^17.7.2" + }, + "bin": { + "msw": "cli/index.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mswjs" + }, + "peerDependencies": { + "typescript": ">= 4.8.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/msw/node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/msw/node_modules/tldts": { + "version": "7.0.19", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.19.tgz", + "integrity": "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.19" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/msw/node_modules/tldts-core": { + "version": "7.0.19", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.19.tgz", + "integrity": "sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==", + "dev": true, + "license": "MIT" + }, + "node_modules/msw/node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/msw/node_modules/type-fest": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.2.0.tgz", + "integrity": "sha512-xxCJm+Bckc6kQBknN7i9fnP/xobQRsRQxR01CztFkp/h++yfVxUUcmMgfR2HttJx/dpWjS9ubVuyspJv24Q9DA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/msw/node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/multicast-dns": { "version": "7.2.5", "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz", @@ -35447,6 +36203,13 @@ "dev": true, "license": "MIT" }, + "node_modules/outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "dev": true, + "license": "MIT" + }, "node_modules/oxc-parser": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.8.0.tgz", @@ -35894,15 +36657,11 @@ "license": "ISC" }, "node_modules/path-to-regexp": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", - "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", "dev": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } + "license": "MIT" }, "node_modules/path-type": { "version": "4.0.0", @@ -35921,6 +36680,16 @@ "dev": true, "license": "MIT" }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/peek-stream": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/peek-stream/-/peek-stream-1.1.3.tgz", @@ -36209,6 +36978,53 @@ "node": ">=8" } }, + "node_modules/playwright": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", + "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", + "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/portfinder": { "version": "1.0.38", "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.38.tgz", @@ -38667,6 +39483,13 @@ "node": ">= 4" } }, + "node_modules/rettime": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.7.0.tgz", + "integrity": "sha512-LPRKoHnLKd/r3dVxcwO7vhCW+orkOGj9ViueosEBK6ie89CijnfRlhaDhHq/3Hxu4CkWQtxwlBG0mzTQY6uQjw==", + "dev": true, + "license": "MIT" + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -38820,6 +39643,17 @@ "node": ">= 18" } }, + "node_modules/router/node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/rslog": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/rslog/-/rslog-1.3.2.tgz", @@ -38924,7 +39758,6 @@ "integrity": "sha512-9GUyuksjw70uNpb1MTYWsH9MQHOHY6kwfnkafC24+7aOMZn9+rVMBxRbLvw756mrBFbIsFg6Xw9IkR2Fnn3k+Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", @@ -39991,6 +40824,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -40022,6 +40862,21 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -40591,6 +41446,13 @@ "node": ">=8" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/stackframe": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", @@ -40607,6 +41469,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/stdin-discarder": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", @@ -40719,6 +41588,13 @@ "text-decoder": "^1.1.0" } }, + "node_modules/strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "dev": true, + "license": "MIT" + }, "node_modules/string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", @@ -40872,6 +41748,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/strtok3": { "version": "10.3.4", "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.4.tgz", @@ -41063,6 +41959,19 @@ "url": "https://opencollective.com/synckit" } }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tapable": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", @@ -41219,7 +42128,6 @@ "integrity": "sha512-+6erLbBm0+LROX2sPXlUYx/ux5PyE9K/a92Wrt6oA+WDAoFTdpHE5tCYCI5PNzq2y8df4rA+QgHLJuR4jNymsg==", "devOptional": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.14.0", @@ -41423,6 +42331,20 @@ "dev": true, "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.14", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", @@ -41440,6 +42362,36 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tldts": { "version": "6.1.86", "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", @@ -41518,6 +42470,16 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/tough-cookie": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", @@ -42357,6 +43319,16 @@ "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" } }, + "node_modules/until-async": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz", + "integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/kettanaito" + } + }, "node_modules/untildify": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", @@ -43459,24 +44431,25 @@ "license": "MIT" }, "node_modules/vite": { - "version": "7.1.11", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.11.tgz", - "integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==", + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" @@ -43485,14 +44458,14 @@ "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", - "less": "^4.0.0", + "less": "*", "lightningcss": "^1.21.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" @@ -43533,21 +44506,101 @@ } } }, - "node_modules/vite/node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", "dev": true, "license": "MIT", "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" }, "engines": { - "node": ">=12.0.0" + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" }, "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } } }, "node_modules/w3c-xmlserializer": { @@ -44072,9 +45125,9 @@ } }, "node_modules/webpack-dev-server/node_modules/ipaddr.js": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz", - "integrity": "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", + "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==", "dev": true, "license": "MIT", "engines": { @@ -44427,6 +45480,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wildcard": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz", diff --git a/package.json b/package.json index d9818bf2..03f21e22 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "build:nf": "nx run-many -t build --projects=tag:scope:nf* && nx run native-federation:post-build", "lint": "nx workspace-lint && ng lint", "e2e": "ng e2e", + "test:e2e:runtime": "cd libs/native-federation-runtime && npm run test:e2e", "publish": "npm run publish:mf && npm run publish:nf", "publish:mf": "nx run-many -t publish --projects=tag:scope:mf*", "publish:nf": "nx run-many -t publish --projects=tag:scope:nf*", @@ -75,8 +76,11 @@ "@nx/js": "22.0.1", "@nx/node": "22.0.1", "@nx/plugin": "22.0.1", + "@nx/vite": "22.0.1", + "@nx/web": "22.0.1", "@nx/webpack": "22.0.1", "@nx/workspace": "22.0.1", + "@playwright/test": "^1.56.1", "@rollup/plugin-commonjs": "^28.0.3", "@rollup/plugin-json": "^6.0.0", "@rollup/plugin-node-resolve": "^16.0.1", @@ -93,6 +97,8 @@ "@typescript-eslint/eslint-plugin": "^8.46.0", "@typescript-eslint/parser": "^8.46.0", "@typescript-eslint/utils": "^8.46.0", + "@vitest/browser": "^3.2.4", + "@vitest/ui": "^3.0.0", "acorn": "^8.8.1", "autoprefixer": "^10.4.0", "browser-sync": "^3.0.2", @@ -113,6 +119,7 @@ "json5": "^2.2.0", "jsonc-eslint-parser": "^2.1.0", "mrmime": "^2.0.1", + "msw": "^2.12.3", "ng-packagr": "^20.3.0", "node-fetch": "^3.3.2", "node-watch": "^0.7.3", @@ -132,6 +139,8 @@ "tslib": "^2.3.0", "typescript": "5.9.3", "verdaccio": "^6.2.1", + "vite": "^6.0.0", + "vitest": "^3.0.0", "word-wrap": "^1.2.5" }, "overrides": {