diff --git a/.eslintrc.js b/.eslintrc.js index ccc278f2..8ccb3ed8 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -56,6 +56,9 @@ module.exports = { './blueprints/*/index.js', './config/**/*.js', './tests/dummy/config/**/*.js', + './bin/**/*.js', + './lib/**/*.js', + './node-tests/**/*.js', ], parserOptions: { sourceType: 'script', diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ffca1f17..7f89af7a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,6 +50,12 @@ jobs: - name: Coverage gate self-tests run: node --test scripts/check-coverage.test.mjs + # The engine release builder, compatibility check and their CLIs. The + # end-to-end mount test in node-tests/ skips itself: it needs two console + # builds (see docs/runtime-engines.md). + - name: Node tests (runtime engine tooling) + run: pnpm run test:node + - name: Test with coverage (full suite) run: pnpm run coverage diff --git a/.npmignore b/.npmignore index 69beb28f..6edbc572 100644 --- a/.npmignore +++ b/.npmignore @@ -23,6 +23,7 @@ /ember-cli-build.js /testem.js /tests/ +/node-tests/ /yarn-error.log /yarn.lock .gitkeep diff --git a/addon/services/universe/extension-manager.js b/addon/services/universe/extension-manager.js index a4f0147a..59314540 100644 --- a/addon/services/universe/extension-manager.js +++ b/addon/services/universe/extension-manager.js @@ -6,6 +6,7 @@ import { assert, debug, warn } from '@ember/debug'; import { next } from '@ember/runloop'; import loadInstalledExtensions from '@fleetbase/ember-core/utils/load-installed-extensions'; import mapEngines from '@fleetbase/ember-core/utils/map-engines'; +import { getRuntimeEngines, getRuntimeExtensionLoader } from '@fleetbase/ember-core/utils/runtime-engines'; import config from 'ember-get-config'; import { getExtensionLoader } from '@fleetbase/console/extensions'; import { isArray } from '@ember/array'; @@ -736,8 +737,8 @@ export default class ExtensionManagerService extends Service.extend(Evented) { // Get admin-configured extensions from config const additionalCoreExtensions = config.APP?.extensions ?? []; - // Load installed extensions (includes core, admin-configured, and user-installed) - const extensions = await loadInstalledExtensions(additionalCoreExtensions); + // Load installed extensions (includes core, admin-configured, user-installed and runtime engines) + const extensions = await loadInstalledExtensions(additionalCoreExtensions, getRuntimeEngines()); application.extensions = extensions; application.engines = mapEngines(extensions); @@ -790,8 +791,8 @@ export default class ExtensionManagerService extends Service.extend(Evented) { const extensionName = extension.name || extension; const extStartTime = performance.now(); - // Lookup the loader function from the build-time generated map - const loader = getExtensionLoader(extensionName); + // Lookup the loader function from the build-time generated map, then the runtime engines + const loader = getExtensionLoader(extensionName) ?? getRuntimeExtensionLoader(extensionName); if (!loader) { warn(`[ExtensionManager] No loader registered for ${extensionName}. Ensure addon/extension.js exists and prebuild generated the mapping.`, false, { diff --git a/addon/utils/load-extensions.js b/addon/utils/load-extensions.js index 8a213b07..788b6b99 100644 --- a/addon/utils/load-extensions.js +++ b/addon/utils/load-extensions.js @@ -6,14 +6,16 @@ import config from 'ember-get-config'; */ const CACHE_KEY = 'fleetbase_extensions_list'; const CACHE_VERSION_KEY = 'fleetbase_extensions_version'; +const CACHE_RUNTIME_SIGNATURE_KEY = 'fleetbase_extensions_runtime_signature'; const CACHE_TTL = 1000 * 60 * 60; // 1 hour /** * Get cached extensions from localStorage * + * @param {String} runtimeSignature Signature of the runtime engine list the cache must match * @returns {Array|null} Cached extensions or null */ -function getCachedExtensions() { +function getCachedExtensions(runtimeSignature) { try { const cached = localStorage.getItem(CACHE_KEY); const cachedVersion = localStorage.getItem(CACHE_VERSION_KEY); @@ -28,6 +30,13 @@ function getCachedExtensions() { return null; } + // A runtime engine was installed, removed or updated since the list was cached + const cachedSignature = localStorage.getItem(CACHE_RUNTIME_SIGNATURE_KEY) ?? ''; + if (cachedSignature !== runtimeSignature) { + debug(`[loadExtensions] Runtime engines changed (cached: "${cachedSignature}", current: "${runtimeSignature}")`); + return null; + } + const cacheData = JSON.parse(cached); const cacheAge = Date.now() - cacheData.timestamp; @@ -49,8 +58,9 @@ function getCachedExtensions() { * Save extensions to localStorage cache * * @param {Array} extensions Extensions array + * @param {String} runtimeSignature Signature of the runtime engine list at the time */ -function setCachedExtensions(extensions) { +function setCachedExtensions(extensions, runtimeSignature) { try { const cacheData = { extensions, @@ -58,6 +68,7 @@ function setCachedExtensions(extensions) { }; localStorage.setItem(CACHE_KEY, JSON.stringify(cacheData)); localStorage.setItem(CACHE_VERSION_KEY, config.APP.version); + localStorage.setItem(CACHE_RUNTIME_SIGNATURE_KEY, runtimeSignature); debug('[loadExtensions] Extensions list cached to localStorage'); } catch (e) { debug(`[loadExtensions] Failed to cache extensions: ${e.message}`); @@ -73,6 +84,7 @@ export function clearExtensionsCache() { try { localStorage.removeItem(CACHE_KEY); localStorage.removeItem(CACHE_VERSION_KEY); + localStorage.removeItem(CACHE_RUNTIME_SIGNATURE_KEY); debug('[loadExtensions] Cache cleared'); } catch (e) { debug(`[loadExtensions] Failed to clear cache: ${e.message}`); @@ -86,16 +98,19 @@ export function clearExtensionsCache() { * 1. Check localStorage cache first (instant, no HTTP request) * 2. If cache hit and valid, use it immediately * 3. If cache miss, fetch from server and cache the result - * 4. Cache is valid for 1 hour + * 4. Cache is valid for 1 hour, for one app version and one runtime engine list * * @export + * @param {Object} [options] + * @param {String} [options.runtimeSignature] Signature of the runtime engine list (see utils/runtime-engines); + * a cache written under another signature is discarded * @returns {Promise} Extensions array */ -export default async function loadExtensions() { +export default async function loadExtensions({ runtimeSignature = '' } = {}) { const isProduction = config?.environment === 'production'; if (isProduction) { // Try cache first only in production - const cachedExtensions = getCachedExtensions(); + const cachedExtensions = getCachedExtensions(runtimeSignature); if (cachedExtensions) { return Promise.resolve(cachedExtensions); } @@ -114,7 +129,7 @@ export default async function loadExtensions() { debug(`[loadExtensions] Fetched from server in ${(endTime - startTime).toFixed(2)}ms`); // Cache the result - setCachedExtensions(extensions); + setCachedExtensions(extensions, runtimeSignature); resolve(extensions); }) .catch(reject); diff --git a/addon/utils/load-installed-extensions.js b/addon/utils/load-installed-extensions.js index 350b1b63..01bfc396 100644 --- a/addon/utils/load-installed-extensions.js +++ b/addon/utils/load-installed-extensions.js @@ -1,8 +1,22 @@ import loadExtensions from './load-extensions'; import fleetbaseApiFetch from './fleetbase-api-fetch'; import isAuthenticated from './is-authenticated'; +import { runtimeSignature, toExtensionEntry } from './runtime-engines'; -export default async function loadInstalledExtensions(additionalCoreEngines = []) { +/** + * The extensions this console may set up: build-time `extensions.json` + * entries that are core, admin-configured or installed, followed by the + * runtime engines (prebuilt releases mounted at boot, see utils/runtime-engines). + * + * A runtime engine counts as installed: the runtime manifest only lists + * releases installed on this instance. When the build already has an engine, + * its build-time entry wins and the runtime one is dropped. + * + * @param {Array} additionalCoreEngines Engines to treat as core (admin-configured) + * @param {Array} runtimeEngines Prepared runtime engines (getRuntimeEngines()) + * @return {Promise>} + */ +export default async function loadInstalledExtensions(additionalCoreEngines = [], runtimeEngines = []) { const CORE_ENGINES = [ '@fleetbase/fleetops-engine', '@fleetbase/storefront-engine', @@ -17,7 +31,7 @@ export default async function loadInstalledExtensions(additionalCoreEngines = [] '@fleetbase/valhalla-engine', ...additionalCoreEngines, ]; - const INDEXED_ENGINES = await loadExtensions(); + const INDEXED_ENGINES = await loadExtensions({ runtimeSignature: runtimeSignature(runtimeEngines) }); // const INSTALLED_ENGINES = await fleetbaseApiFetch('get', 'engines', {}, { namespace: '~registry/v1', fallbackResponse: [] }); let INSTALLED_ENGINES = []; @@ -29,7 +43,10 @@ export default async function loadInstalledExtensions(additionalCoreEngines = [] return CORE_ENGINES.includes(engineName) || INSTALLED_ENGINES.find((pkg) => pkg.name === engineName); }; - return INDEXED_ENGINES.filter((pkg) => { + const indexed = INDEXED_ENGINES.filter((pkg) => { return isInstalledEngine(pkg.name); }); + const runtime = runtimeEngines.filter((engine) => !INDEXED_ENGINES.some((pkg) => pkg.name === engine.name)).map(toExtensionEntry); + + return [...indexed, ...runtime]; } diff --git a/addon/utils/runtime-engines.js b/addon/utils/runtime-engines.js new file mode 100644 index 00000000..8a9841cd --- /dev/null +++ b/addon/utils/runtime-engines.js @@ -0,0 +1,560 @@ +import { debug, warn } from '@ember/debug'; +import fleetbaseApiFetch from './fleetbase-api-fetch'; +import { getExtensionMountPath } from './map-engines'; + +/** + * Runtime engines: prebuilt engine releases mounted into a console that was + * built without them. + * + * A console build does seven things for every engine it compiles in (see + * docs/runtime-engines.md). A prebuilt release ships the output of those steps + * as files, and this module replays them at boot: + * + * before the app boots prepareRuntimeEngines(application) + * 1. fetch the runtime manifest (GET ~registry/v1/engines/runtime) + * 2. drop engines the host already has, and engines whose release needs a + * module the host does not define + * 3. append each engine's `/config/environment` meta tag + * 4. evaluate routes.js (`/routes`) and host-app.js (the engine's + * `app/` re-exports into `@fleetbase/console/*`) + * 5. fetch translations.json + * 6. wrap the generated router map so the mounts land inside it + * + * instance boot registerRuntimeEngines(appInstance) + * 7. push each bundle into the asset loader's manifest + * 8. add the translations to ember-intl + * 9. register an extension loader per engine, which the extension manager + * falls back to when the build-time map has none + * + * Runtime manifest, as served by the API (paths are relative to `baseUrl` + * unless absolute): + * + * { + * "engines": [{ + * "name": "@fleetbase/pallet-engine", + * "version": "0.0.2", + * "baseUrl": "/engines-dist/@fleetbase/pallet-engine/", + * "mount": { "as": "pallet", "path": "pallet", "root": false }, + * "bundle": { "assets": [{ "uri": "assets/engine.js", "type": "js" }], "dependencies": [] }, + * "config": { "modulePrefix": "@fleetbase/pallet-engine", ... }, + * "routes": "routes.js", + * "hostApp": "host-app.js", + * "translations": "translations.json", + * "extension": "extension.js", + * "manifest": "manifest.json", + * "requires": ["@fleetbase/ember-ui/components/layout/resource/action-buttons", ...] + * }] + * } + * + * `extension` is the URL of the standalone `/extension` module, `true` + * to load the whole bundle and require it from there, or `false` when the + * engine has none. `requires` is optional; without it the module list is read + * from the release's `manifest.json` (`requires.modules`), and with neither + * the compatibility check is skipped. + */ + +export const RUNTIME_ENGINES_NAMESPACE = '~registry/v1'; +export const RUNTIME_ENGINES_ENDPOINT = 'engines/runtime'; + +const state = { + engines: [], + rejected: [], + loaders: new Map(), +}; + +const wrappedRouters = new WeakSet(); + +/** + * The runtime engines accepted by the last `prepareRuntimeEngines` call. + * + * @return {Array} + */ +export function getRuntimeEngines() { + return state.engines; +} + +/** + * The engines the last `prepareRuntimeEngines` call refused, with the reason + * (`missing-modules`, `manifest-unavailable` or `load-failed`). + * + * @return {Array} + */ +export function getRejectedRuntimeEngines() { + return state.rejected; +} + +/** + * The extension loader registered for a runtime engine, if any. + * + * @param {String} name + * @return {Function|undefined} + */ +export function getRuntimeExtensionLoader(name) { + return state.loaders.get(name); +} + +/** + * Forget every runtime engine. For tests. + */ +export function resetRuntimeEngines() { + state.engines = []; + state.rejected = []; + state.loaders = new Map(); +} + +/** + * Resolve a release path against the engine's base URL. Absolute paths and + * URLs are kept as they are. + * + * @param {String} baseUrl + * @param {String} path + * @return {String} + */ +export function resolveRuntimeUrl(baseUrl, path) { + if (/^([a-z][a-z0-9+.-]*:|\/)/i.test(path)) { + return path; + } + + return `${baseUrl}${path}`; +} + +function optionalUrl(baseUrl, path) { + return typeof path === 'string' && path.length > 0 ? resolveRuntimeUrl(baseUrl, path) : null; +} + +function extensionSource(baseUrl, extension) { + if (extension === false) { + return false; + } + + return optionalUrl(baseUrl, extension) ?? true; +} + +/** + * Validate one runtime manifest entry and resolve its URLs. Returns null for an + * entry that cannot be mounted (no name, no bundle assets or no route map). + * + * @param {Object} raw + * @return {Object|null} + */ +export function normalizeRuntimeEngine(raw) { + const name = raw?.name; + if (typeof name !== 'string' || name.length === 0) { + warn('[runtime-engines] Ignoring a runtime engine without a name.', false, { id: 'ember-core.runtime-engines.invalid-entry' }); + return null; + } + + const assets = raw.bundle?.assets; + if (!Array.isArray(assets) || assets.length === 0 || typeof raw.routes !== 'string') { + warn(`[runtime-engines] Ignoring ${name}: a runtime engine needs bundle assets and a routes module.`, false, { id: 'ember-core.runtime-engines.invalid-entry' }); + return null; + } + + const baseUrl = (raw.baseUrl ?? `/engines-dist/${name}/`).replace(/\/?$/, '/'); + const as = raw.mount?.as ?? getExtensionMountPath(name); + + return { + name, + version: raw.version ?? null, + baseUrl, + mount: { as, path: raw.mount?.path ?? as, root: raw.mount?.root === true }, + bundle: { + assets: assets.map((asset) => ({ ...asset, uri: resolveRuntimeUrl(baseUrl, asset.uri) })), + dependencies: raw.bundle.dependencies ?? [], + }, + config: raw.config ?? null, + routes: resolveRuntimeUrl(baseUrl, raw.routes), + hostApp: optionalUrl(baseUrl, raw.hostApp), + translations: optionalUrl(baseUrl, raw.translations), + extension: extensionSource(baseUrl, raw.extension), + manifest: optionalUrl(baseUrl, raw.manifest), + requires: Array.isArray(raw.requires) ? raw.requires : null, + }; +} + +/** + * Normalize a runtime manifest payload (`{ engines: [...] }` or a bare array) + * into engine entries, dropping invalid entries and duplicate names. + * + * @param {Object|Array} payload + * @return {Array} + */ +export function parseRuntimeManifest(payload) { + const entries = Array.isArray(payload) ? payload : (payload?.engines ?? []); + const seen = new Set(); + const engines = []; + + for (const entry of entries) { + const engine = normalizeRuntimeEngine(entry); + if (engine && !seen.has(engine.name)) { + seen.add(engine.name); + engines.push(engine); + } + } + + return engines; +} + +/** + * A stable signature of a runtime engine list: changes whenever an engine is + * added, removed or moved to another version. + * + * @param {Array} engines + * @return {String} + */ +export function runtimeSignature(engines = []) { + return engines + .map((engine) => `${engine.name}@${engine.version ?? ''}`) + .sort() + .join(','); +} + +/** + * The entry a runtime engine contributes to the installed extensions list, in + * the shape of a build-time `extensions.json` entry. + * + * @param {Object} engine + * @return {Object} + */ +export function toExtensionEntry(engine) { + const fleetbase = { route: engine.mount.as }; + if (engine.mount.root) { + fleetbase.mount = 'root'; + } + + return { name: engine.name, version: engine.version, route: engine.mount.as, fleetbase, runtime: true }; +} + +/** + * Whether loader.js has a module, following its `/index` fallback. + * + * @param {String} name + * @return {Boolean} + */ +export function hasModule(name) { + const entries = window.requirejs.entries; + return name in entries || `${name}/index` in entries; +} + +/** + * The modules in `required` that the running host does not define. + * + * @param {Array} required + * @param {Function} has + * @return {Array} + */ +export function findMissingModules(required, has = hasModule) { + return required.filter((name) => !has(name)); +} + +/** + * Whether the host was built with this engine, in which case its own build + * wins over the runtime release. + * + * @param {String} name + * @param {Document} doc + * @return {Boolean} + */ +export function isBuiltInEngine(name, doc = document) { + return doc.querySelector(`meta[name="${name}/config/environment"]`) !== null || hasModule(`${name}/routes`); +} + +/** + * Append the engine's config meta tag, which its `config/environment` module + * reads (with `unescape`) the first time the bundle is required. + * + * @param {Object} engine + * @param {Document} doc + */ +export function appendConfigMeta(engine, doc = document) { + const metaName = `${engine.name}/config/environment`; + if (!engine.config || doc.querySelector(`meta[name="${metaName}"]`)) { + return; + } + + const meta = doc.createElement('meta'); + meta.setAttribute('name', metaName); + meta.setAttribute('content', escape(JSON.stringify(engine.config))); + doc.head.appendChild(meta); +} + +/** + * Load a classic script. Script elements rather than eval so a strict + * `script-src` CSP keeps working. + * + * @param {String} url + * @param {Document} doc + * @return {Promise} + */ +export function loadScript(url, doc = document) { + return new Promise((resolve, reject) => { + const script = doc.createElement('script'); + script.src = url; + script.async = false; + script.onload = () => resolve(); + script.onerror = () => reject(new Error(`Could not load ${url}`)); + doc.head.appendChild(script); + }); +} + +async function fetchJson(url) { + const response = await fetch(url, { cache: 'default' }); + if (!response.ok) { + throw new Error(`Could not load ${url} (${response.status})`); + } + + return response.json(); +} + +/** + * Fetch the runtime manifest from the registry. Resolves to an empty list when + * the endpoint is missing or fails, so a console without the registry boots as + * before. + * + * @return {Promise} + */ +export function fetchRuntimeEngines() { + return fleetbaseApiFetch('GET', RUNTIME_ENGINES_ENDPOINT, {}, { namespace: RUNTIME_ENGINES_NAMESPACE, fallbackResponse: { engines: [] } }); +} + +async function requiredModulesFor(engine) { + if (engine.requires) { + return engine.requires; + } + + if (!engine.manifest) { + return []; + } + + const manifest = await fetchJson(engine.manifest); + return manifest?.requires?.modules ?? []; +} + +async function checkCompatibility(engine, has) { + let required; + try { + required = await requiredModulesFor(engine); + } catch (error) { + return { name: engine.name, version: engine.version, reason: 'manifest-unavailable', error: error.message }; + } + + const missing = findMissingModules(required, has); + if (missing.length > 0) { + return { name: engine.name, version: engine.version, reason: 'missing-modules', missing }; + } + + return null; +} + +async function loadEngineFiles(engine, doc, load) { + appendConfigMeta(engine, doc); + + const [, , messages] = await Promise.all([load(engine.routes, doc), engine.hostApp ? load(engine.hostApp, doc) : null, engine.translations ? fetchJson(engine.translations) : []]); + + if (!hasModule(`${engine.name}/routes`)) { + throw new Error(`${engine.routes} did not define ${engine.name}/routes`); + } + + engine.messages = messages; +} + +/** + * Append the runtime engines' mounts to the generated router map. + * + * The generated map is wrapped rather than extended with a second + * `Router.map()`: a second callback that declares `console` again replaces + * the first one's children. Console mounts are added at the end of the + * existing `console` route's callback; root mounts (`mount.root`) go in front + * of the `catch` route, or at the end of the map when there is none. + * + * @param {Class} Router The application's router class + * @param {Array} engines + * @return {Boolean} whether the map was wrapped + */ +export function wrapRouterMap(Router, engines) { + if (engines.length === 0 || wrappedRouters.has(Router)) { + return false; + } + + const callbacks = Router?.dslCallbacks; + if (!Array.isArray(callbacks) || callbacks.length === 0) { + warn('[runtime-engines] The router has no map to mount runtime engines into.', false, { id: 'ember-core.runtime-engines.no-router-map' }); + return false; + } + + const consoleMounts = engines.filter((engine) => !engine.mount.root); + const rootMounts = engines.filter((engine) => engine.mount.root); + const mountedConsole = new WeakSet(); + const mountedRoot = new WeakSet(); + const mountAll = (dsl, list) => list.forEach((engine) => dsl.mount(engine.name, { as: engine.mount.as, path: engine.mount.path })); + const mountRoot = (dsl) => { + if (!mountedRoot.has(dsl)) { + mountedRoot.add(dsl); + mountAll(dsl, rootMounts); + } + }; + + const wrap = (original) => + function runtimeEnginesRouterMap() { + const dsl = this; + const route = dsl.route; + + dsl.route = function (name, ...rest) { + if (name === 'catch') { + mountRoot(dsl); + } + + const callbackIndex = rest.length - 1; + if (name === 'console' && typeof rest[callbackIndex] === 'function' && !mountedConsole.has(dsl)) { + mountedConsole.add(dsl); + const inner = rest[callbackIndex]; + rest[callbackIndex] = function () { + inner.call(this); + mountAll(this, consoleMounts); + }; + } + + return route.call(dsl, name, ...rest); + }; + + try { + original.call(dsl); + } finally { + dsl.route = route; + } + }; + + for (let i = 0; i < callbacks.length; i++) { + callbacks[i] = wrap(callbacks[i]); + } + + callbacks.push(function () { + mountRoot(this); + if (consoleMounts.length > 0 && !mountedConsole.has(this)) { + warn(`[runtime-engines] The router map has no console route; not mounting ${consoleMounts.map((engine) => engine.name).join(', ')}.`, false, { + id: 'ember-core.runtime-engines.no-console-route', + }); + } + }); + + wrappedRouters.add(Router); + return true; +} + +/** + * Before boot: fetch the runtime manifest, check and load each engine's files, + * and mount the accepted engines into the router map. Call from an + * initializer that defers readiness until this settles, after the runtime + * config (API host) is applied. + * + * @param {Application} application + * @param {Object} [options] + * @param {Object|Array} [options.payload] use this manifest instead of fetching it + * @param {Document} [options.doc] + * @param {Function} [options.has] module lookup used by the compatibility check + * @param {Function} [options.load] script loader + * @return {Promise>} the accepted engines + */ +export async function prepareRuntimeEngines(application, { payload, doc = document, has = hasModule, load = loadScript } = {}) { + const candidates = parseRuntimeManifest(payload ?? (await fetchRuntimeEngines())).filter((engine) => { + if (isBuiltInEngine(engine.name, doc)) { + debug(`[runtime-engines] ${engine.name} is built into this console; ignoring its runtime release.`); + return false; + } + + return true; + }); + + const rejected = []; + const verdicts = await Promise.all(candidates.map((engine) => checkCompatibility(engine, has))); + const compatible = candidates.filter((engine, index) => { + if (verdicts[index]) { + rejected.push(verdicts[index]); + return false; + } + + return true; + }); + + const loaded = await Promise.all( + compatible.map((engine) => + loadEngineFiles(engine, doc, load).then( + () => engine, + (error) => { + rejected.push({ name: engine.name, version: engine.version, reason: 'load-failed', error: error.message }); + return null; + } + ) + ) + ); + const accepted = loaded.filter(Boolean); + + for (const refusal of rejected) { + console.error(`[runtime-engines] Not mounting ${refusal.name}@${refusal.version} (${refusal.reason}):`, refusal.missing ?? refusal.error); + } + + wrapRouterMap(application.resolveRegistration('router:main'), accepted); + + state.engines = accepted; + state.rejected = rejected; + state.loaders = new Map(); + + debug(`[runtime-engines] Prepared ${accepted.length} runtime engine(s), refused ${rejected.length}.`); + return accepted; +} + +/** + * The loader the extension manager runs for a runtime engine: load the + * standalone `/extension` module when the release ships one (keeps the + * engine bundle lazy), otherwise load the whole bundle, then require it. + * + * @param {Object} engine + * @param {Service} assetLoader + * @param {Function} load script loader + * @return {Function} + */ +export function createExtensionLoader(engine, assetLoader, load = loadScript) { + return () => { + const ready = typeof engine.extension === 'string' ? load(engine.extension) : assetLoader.loadBundle(engine.name); + return ready.then(() => window.require(`${engine.name}/extension`)); + }; +} + +/** + * At instance boot, after the asset manifest is loaded and before extensions + * are set up: register the prepared engines' bundles, translations and + * extension loaders. + * + * @param {ApplicationInstance} appInstance + * @return {Array} the registered engines + */ +export function registerRuntimeEngines(appInstance) { + const engines = state.engines; + if (engines.length === 0) { + return engines; + } + + const assetLoader = appInstance.lookup('service:asset-loader'); + const intl = appInstance.lookup('service:intl'); + const bundles = {}; + + for (const engine of engines) { + bundles[engine.name] = engine.bundle; + } + + assetLoader.pushManifest({ bundles }); + + for (const engine of engines) { + for (const [locale, messages] of engine.messages ?? []) { + intl?.addTranslations(locale, messages); + } + + if (engine.extension) { + state.loaders.set(engine.name, createExtensionLoader(engine, assetLoader)); + } + } + + return engines; +} + +export default prepareRuntimeEngines; diff --git a/bin/build-engine-release.js b/bin/build-engine-release.js new file mode 100755 index 00000000..61d5b577 --- /dev/null +++ b/bin/build-engine-release.js @@ -0,0 +1,71 @@ +#!/usr/bin/env node +'use strict'; + +/** + * Build a prebuilt engine release from a console checkout that has the engine + * installed. + * + * build-engine-release --console --engine --out + * [--dist ] [--environment production] [--keep-maps] + * + * Without --dist it runs `ember build` in the console first (this takes a + * while: 30 to 80 minutes for the full console). The release is written to + * // with a manifest.json; see docs/runtime-engines.md. + */ + +const { spawnSync } = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { parseCliArgs } = require('../lib/cli-args'); +const { extractRelease } = require('../lib/engine-release'); + +const USAGE = 'Usage: build-engine-release --console --engine --out [--dist ] [--environment production] [--keep-maps]'; + +function main(argv) { + const values = parseCliArgs(argv, { + strings: ['console', 'engine', 'out', 'dist', 'environment'], + booleans: ['keep-maps', 'help'], + defaults: { environment: 'production', 'keep-maps': false, help: false }, + }); + + if (values.error || values.help || !values.console || !values.engine || !values.out) { + console.error(USAGE); + return values.help ? 0 : 2; + } + + const consoleDir = path.resolve(values.console); + let distDir = values.dist ? path.resolve(values.dist) : null; + + if (!distDir) { + distDir = fs.mkdtempSync(path.join(os.tmpdir(), 'engine-release-')); + const ember = path.join(consoleDir, 'node_modules', '.bin', 'ember'); + console.log(`Building the console (${values.environment}) into ${distDir} …`); + const build = spawnSync(ember, ['build', `--environment=${values.environment}`, `--output-path=${distDir}`], { cwd: consoleDir, stdio: 'inherit' }); + if (build.status !== 0) { + console.error(`ember build failed (exit ${build.status})`); + return 1; + } + } + + const manifest = extractRelease({ + consoleDir, + distDir, + engineName: values.engine, + outDir: path.resolve(values.out), + keepMaps: values['keep-maps'], + log: (message) => console.warn(message), + }); + + const pinned = Object.entries(manifest.builtAgainst) + .filter(([name]) => name.startsWith('@fleetbase/')) + .map(([name, version]) => `${name}@${version}`) + .join(', '); + console.log(`${manifest.name}@${manifest.version} → ${path.join(values.out, manifest.name)}`); + console.log(` built against ${pinned}`); + console.log(` ${Object.keys(manifest.files).length} files, ${manifest.hostApp.modules} host-app modules, ${manifest.requires.modules.length} required host modules`); + console.log(` extension: ${manifest.runtime.extension === 'extension.js' ? 'standalone' : manifest.runtime.extension ? 'from the bundle' : 'none'}`); + return 0; +} + +process.exitCode = main(process.argv.slice(2)); diff --git a/bin/check-engine-compat.js b/bin/check-engine-compat.js new file mode 100755 index 00000000..816da8d3 --- /dev/null +++ b/bin/check-engine-compat.js @@ -0,0 +1,44 @@ +#!/usr/bin/env node +'use strict'; + +/** + * Check a prebuilt engine release against a console build before enabling it. + * + * check-engine-compat --manifest /manifest.json --host + * [--policy minor|exact|modules] [--json] + * + * Exits 0 when compatible, 1 when not (missing modules or a pinned version + * mismatch), 2 on usage errors. + */ + +const fs = require('fs'); +const { parseCliArgs } = require('../lib/cli-args'); +const { checkEngineCompat, loadHost } = require('../lib/engine-compat'); + +const USAGE = 'Usage: check-engine-compat --manifest --host [--policy minor|exact|modules] [--json]'; +const POLICIES = ['minor', 'exact', 'modules']; + +function main(argv) { + const values = parseCliArgs(argv, { strings: ['manifest', 'host', 'policy'], booleans: ['json'], defaults: { policy: 'minor', json: false } }); + + if (values.error || !values.manifest || !values.host || !POLICIES.includes(values.policy)) { + console.error(USAGE); + return 2; + } + + const manifest = JSON.parse(fs.readFileSync(values.manifest, 'utf8')); + const result = checkEngineCompat(manifest, loadHost(values.host), { policy: values.policy }); + + if (values.json) { + console.log(JSON.stringify({ name: manifest.name, version: manifest.version, ...result }, null, 2)); + } else { + console.log(`${manifest.name}@${manifest.version}: ${result.compatible ? 'compatible' : 'NOT compatible'}`); + result.reasons.forEach((reason) => console.log(` - ${reason}`)); + result.missing.forEach((name) => console.log(` missing: ${name}`)); + result.versions.filter((version) => version.note).forEach((version) => console.log(` ${version.package}: ${version.built} vs ${version.host} (${version.note})`)); + } + + return result.compatible ? 0 : 1; +} + +process.exitCode = main(process.argv.slice(2)); diff --git a/bin/host-modules.js b/bin/host-modules.js new file mode 100755 index 00000000..86891716 --- /dev/null +++ b/bin/host-modules.js @@ -0,0 +1,35 @@ +#!/usr/bin/env node +'use strict'; + +/** + * Write host-modules.json for a console build: the modules it defines at boot + * and the versions of the packages engine releases are pinned to. The + * installer checks releases against it (see bin/check-engine-compat.js). + * + * host-modules --dist [--console ] [--out ] + * + * --out defaults to /host-modules.json; --console (the checkout the dist + * was built from) adds the versions. + */ + +const fs = require('fs'); +const path = require('path'); +const { parseCliArgs } = require('../lib/cli-args'); +const { buildHostModules } = require('../lib/engine-compat'); + +function main(argv) { + const values = parseCliArgs(argv, { strings: ['dist', 'console', 'out'] }); + + if (values.error || !values.dist) { + console.error('Usage: host-modules --dist [--console ] [--out ]'); + return 2; + } + + const document = buildHostModules(path.resolve(values.dist), { consoleDir: values.console ? path.resolve(values.console) : undefined }); + const out = values.out ?? path.join(values.dist, 'host-modules.json'); + fs.writeFileSync(out, `${JSON.stringify(document, null, 2)}\n`); + console.log(`${document.modules.length} modules → ${out}`); + return 0; +} + +process.exitCode = main(process.argv.slice(2)); diff --git a/docs/runtime-engines.md b/docs/runtime-engines.md new file mode 100644 index 00000000..13cf2571 --- /dev/null +++ b/docs/runtime-engines.md @@ -0,0 +1,117 @@ +# Runtime engines + +A runtime engine is a prebuilt engine release that the console mounts at boot without being rebuilt. This is Outcome A of decision D3 in the Extensions Registry plan (Workstream H). The spike that settled it ran Pallet 0.0.2 on console 0.7.69 with ember-ui 0.4.6. + +## What a console build does for an engine + +Copying `engines-dist//` into a console that was built without the engine does not mount it. The build also does seven things in the host, and a release has to carry the output of each: + +| Build-time step | Where the build puts it | Release artifact | Runtime replacement (`utils/runtime-engines.js`) | +| --- | --- | --- | --- | +| Router mount | generated `app/router.js` | `runtime.mount` in `manifest.json` | wrap the generated router map's DSL callback and add `mount()` inside the existing `console` route (or before `catch` for root mounts). A second `Router.map()` that declares `console` again replaces that route's children, so it can't be used. | +| Engine route map (`/routes`) | host `vendor.js` | `routes.js` | evaluated before boot | +| Engine `app/` tree re-exported as `@fleetbase/console/*` (models, adapters, components…) | host app bundle | `host-app.js` | evaluated before boot; a module is only defined when the host doesn't already define that name | +| Engine config | `` | `runtime.config` | meta tag appended before boot | +| Asset-manifest bundle | `` | `runtime.bundle` (paths relative to the release) | `assetLoader.pushManifest()` after `load-asset-manifest` | +| `addon/extension.js` loader | `app/extensions/.js` + `EXTENSION_LOADERS` | `extension.js` (standalone `/extension`) | the extension manager falls back to a runtime loader | +| Translations | merged into host `ember-intl/translations` | `translations.json` | `intl.addTranslations()` at instance boot | + +The engine's own copy of `ember-intl/translations` inside `engine-vendor.js` does nothing. loader.js ignores a `define()` of a module that has already been instantiated. + +## Boot sequence + +1. `load-runtime-config` (console initializer) loads `fleetbase.config.json` and exposes the promise as `application.runtimeConfigLoaded`. +2. `load-runtime-engines` (console initializer) defers readiness and calls `prepareRuntimeEngines(application)`, which: + 1. fetches `GET ~registry/v1/engines/runtime`. A missing endpoint counts as an empty list. + 2. skips engines that are built into this console (their config meta or `/routes` already exists). + 3. checks each engine's required modules against loader.js. It refuses an engine with missing modules and logs the refusal (see the compatibility section below). + 4. appends the config meta, loads `routes.js` and `host-app.js` with script elements (no eval, so a strict CSP keeps working), and fetches `translations.json`. + 5. wraps the router map. +3. `register-runtime-engines` (console instance-initializer, after `load-asset-manifest` and before `load-extensions`) pushes the bundles, adds the translations and registers the extension loaders. +4. `loadInstalledExtensions(core, getRuntimeEngines())` lists runtime engines after the build-time ones. If the build already has an engine, its build-time entry wins. The localStorage extensions cache is keyed on the runtime list's signature (`name@version,…`), so an install, update or removal invalidates it. + +## Runtime manifest (`GET ~registry/v1/engines/runtime`) + +```json +{ + "engines": [ + { + "name": "@fleetbase/pallet-engine", + "version": "0.0.2", + "baseUrl": "/engines-dist/@fleetbase/pallet-engine/", + "mount": { "as": "pallet", "path": "pallet", "root": false }, + "bundle": { "assets": [{ "uri": "assets/engine.js", "type": "js" }], "dependencies": [] }, + "config": { "modulePrefix": "@fleetbase/pallet-engine" }, + "routes": "routes.js", + "hostApp": "host-app.js", + "translations": "translations.json", + "extension": "extension.js", + "manifest": "manifest.json" + } + ] +} +``` + +Each entry is the release's `manifest.json` `runtime` block plus `name`, `version`, `baseUrl` and `manifest`. Relative paths resolve against `baseUrl`. `extension` is either the standalone module's path, `true` (load the bundle and require `/extension` from it), or `false` (the engine has no extension). An optional `requires` array inlines the module list, which saves the browser fetching `manifest.json`. The endpoint has to answer signed-out requests. The route map is built once at boot, before anyone signs in. + +## Building a release + +```sh +fleetbase-build-engine-release --console ../console --engine @fleetbase/pallet-engine --out ./releases +# or extract from a build you already have: +fleetbase-build-engine-release --console ../console --engine @fleetbase/pallet-engine --dist ../console/dist --out ./releases +``` + +The console checkout must have the engine installed. The tool writes `//` with the `engines-dist` folder (source maps only with `--keep-maps`), the four artifacts above, and `manifest.json`: + +- `name`, `version`, `builtWith` (console version and environment) +- `builtAgainst`: the versions of `@fleetbase/ember-ui` and `@fleetbase/ember-core`, plus ember-source, ember-engines, ember-data, ember-intl and ember-auto-import +- `runtime`: the entry the API serves (see the runtime manifest above) +- `requires.modules`: every host module the bundle imports or aliases. `requires.aliases` is the subset that comes from `define.alias()`. `requires.unresolved` lists names the build host didn't define either; Pallet has five such dangling references. Those can never be resolved and are never needed, so they are not checked. +- `files`: the sha256 of every file + +The build refuses an engine that adds initializers to the host, because a runtime engine can't run them. It also leaves out an `app/` file that compiled to another addon's module of the same name. Pallet 0.0.2 has seven of these (copies of storefront and starter files in its `app/`); shipping them would replace storefront's modules in a host that has storefront. + +## Compatibility and the version pin + +ember-engines dedupes shared addons by their package.json cache key. As a result, an engine bundle contains no ember-ui or ember-core code. It holds `define.alias()` entries for every module of the ember-ui and ember-core versions it was built against (457 + 142 for Pallet), and component lookups inside the engine do not fall back to the host's registry. In the spike: + +- An engine built against a newer ember-ui than the host's failed with `Could not find module '/components/…'` the first time a page used one of the 16 modules the host lacked. +- An engine whose templates use components missing from the ember-ui it was built against failed with `Attempted to resolve '…', expected to be a component, but nothing was found`, even though the host had those components. +- With the same versions on both sides, everything resolved. + +The check is therefore a set difference: `requires.modules` minus the modules the host defines. Run against the spike's builds, it reports exactly the modules that broke at runtime. It runs in three places: + +- **Installer:** `fleetbase-check-engine-compat --manifest /manifest.json --host [--policy minor|exact|modules]`. Exit code 1 means refuse. Production console builds write `dist/host-modules.json` (the module list plus the pinned versions) from `lib/fleetbase-extensions-generator`, and `fleetbase-host-modules --dist … --console …` writes it for any build. The PHP installer can do the same diff over those two JSON files without Node. +- **Policy:** the default `minor` refuses a different major.minor of ember-ui or ember-core even when every module exists, because modules with the same name can change their arguments and behaviour within a minor, and the module check can't see that. Other recorded packages are refused only on a different major. `modules` checks module names only. +- **Browser:** `prepareRuntimeEngines` repeats the module check against loader.js on every boot, so a console upgraded after the install refuses an engine it can no longer run instead of breaking on the first page that uses it. It does not repeat the version check, because the browser does not know the host's versions. + +Releases therefore need a build per supported ember-ui/ember-core minor. `compat.console` in the registry should state that minor. + +## Lazy loading + +A built-in lazy engine's `extension.js` is compiled into the host, so the engine bundle (13.7 MB for Pallet in development) is only fetched when a route or widget needs the engine. A runtime engine has no host copy. Requiring `/extension` from the bundle would mean fetching the whole bundle at boot for every runtime engine, even on the login page. That cost is the lost lazy-engine deferral. + +The build keeps the deferral when it can by shipping `/extension` on its own as `extension.js`. That is only possible when the module imports nothing from the engine itself. Every current `extension.js` meets this, because the generator already copies it into the host where engine-relative imports would not resolve. When it does import from the engine, `runtime.extension` is `true` and the bundle loads at boot. + +At boot a runtime engine therefore costs `routes.js` (9 KB for Pallet), `host-app.js` (250 KB unminified, 490 modules), `extension.js` (14 KB) and `translations.json` (25 KB). The end-to-end test checks that the bundle is not fetched until the engine is used. + +## Other constraints + +- `baseUrl` defaults to `/engines-dist//`. Absolute URLs inside the engine's CSS still point there, so serve releases from that path. A CDN base works only for engines whose CSS uses relative URLs. +- Releases carry their own fingerprinted asset names; the host's fingerprint pass never sees them. +- `mount.as` should match the route the engine was built for. `config.mountedEngineRoutePrefix` comes from the build. +- Every release duplicates about 7.2 MB of `ember-intl/translations` in `engine-vendor.js`. This was already the case for every engine before runtime engines; removing it from engine builds is a separate fix. + +## Tests + +- `tests/unit/utils/runtime-engines-test.js` and the cache, installed-list and extension-manager tests run in the QUnit suite. +- `node-tests/*.test.js` (`pnpm run test:node`, run in CI) cover the scanner, the release extractor, the compat check and the CLIs, using a fixture console build. +- `node-tests/runtime-engines-mount.test.js` is the end-to-end proof. It boots a real console dist built without the engine in headless Chrome, serves the release next to it with a stub API, and checks the mount, routes, host modules, asset manifest, config meta, translations, extension setup, that the bundle stays lazy and that the engine boots. A second run checks that a release requiring a missing module is refused and not mounted. It needs two builds, so CI skips it: + + ```sh + RUNTIME_ENGINES_HOST_DIST= \ + RUNTIME_ENGINES_RELEASE=/@fleetbase/pallet-engine \ + RUNTIME_ENGINES_CONSOLE= \ + node --test node-tests/runtime-engines-mount.test.js + ``` diff --git a/lib/amd-modules.js b/lib/amd-modules.js new file mode 100644 index 00000000..30646711 --- /dev/null +++ b/lib/amd-modules.js @@ -0,0 +1,307 @@ +'use strict'; + +/** + * Reading loader.js AMD bundles (vendor.js, the app bundle, engine.js) as text: + * which modules a bundle defines, which it aliases, what each module imports, + * and the exact source of one `define(...)` call. + * + * Works on both development and minified production output. Extraction scans + * the call with a small tokenizer that skips strings, template literals, + * comments and regular expression literals, so it does not depend on the + * bundle's formatting. + */ + +const DEFINE_PATTERN = /(?} + */ +function collectDefinedModules(source, { autoImport = false } = {}) { + const names = new Set(); + + for (const match of source.matchAll(DEFINE_PATTERN)) { + names.add(match[2]); + } + + for (const alias of collectAliases(source)) { + names.add(alias.alias); + } + + if (autoImport) { + for (const match of source.matchAll(AUTO_IMPORT_PATTERN)) { + names.add(match[2]); + } + } + + return names; +} + +/** + * Every `define.alias(target, alias)` in a bundle. The target must exist when + * the alias is required; the alias is the name the bundle provides. + * + * @param {string} source + * @returns {Array<{target: string, alias: string}>} + */ +function collectAliases(source) { + return Array.from(source.matchAll(ALIAS_PATTERN), (match) => ({ target: match[2], alias: match[4] })); +} + +/** + * Resolve a dependency the way loader.js does: relative to the importing + * module's directory. + * + * @param {string} dependency + * @param {string} moduleName + * @returns {string} + */ +function resolveDependency(dependency, moduleName) { + if (!dependency.startsWith('./') && !dependency.startsWith('../')) { + return dependency; + } + + const parts = moduleName.split('/').slice(0, -1); + for (const segment of dependency.split('/')) { + if (segment === '..') { + parts.pop(); + } else if (segment !== '.') { + parts.push(segment); + } + } + + return parts.join('/'); +} + +/** + * Every `define()` call in a bundle with the dependencies it declares. + * + * @param {string} source + * @returns {Array<{name: string, start: number, dependencies: string[]}>} + */ +function collectDefinitions(source) { + const definitions = []; + + for (const match of source.matchAll(DEFINE_PATTERN)) { + const name = match[2]; + const after = source.slice(match.index + match[0].length, match.index + match[0].length + 20000); + const list = /^\s*,\s*\[([^\]]*)\]/.exec(after); + const dependencies = list ? Array.from(list[1].matchAll(/(["'])([^"']+)\1/g), (dep) => resolveDependency(dep[2], name)).filter((dep) => !PSEUDO_DEPENDENCIES.has(dep)) : []; + + definitions.push({ name, start: match.index, dependencies }); + } + + return definitions; +} + +function skipString(source, index) { + const quote = source[index]; + let i = index + 1; + while (i < source.length) { + const char = source[i]; + if (char === '\\') { + i += 2; + continue; + } + if (char === quote || char === '\n') { + return i + 1; + } + i++; + } + return i; +} + +function skipTemplate(source, index) { + let i = index + 1; + while (i < source.length) { + const char = source[i]; + if (char === '\\') { + i += 2; + continue; + } + if (char === '`') { + return i + 1; + } + if (char === '$' && source[i + 1] === '{') { + i = scanTo(source, i + 2, '}'); + continue; + } + i++; + } + return i; +} + +function skipRegex(source, index) { + let i = index + 1; + let inClass = false; + while (i < source.length) { + const char = source[i]; + if (char === '\\') { + i += 2; + continue; + } + if (char === '\n') { + return i; + } + if (inClass) { + inClass = char !== ']'; + } else if (char === '[') { + inClass = true; + } else if (char === '/') { + i++; + while (i < source.length && /[a-z]/i.test(source[i])) { + i++; + } + return i; + } + i++; + } + return i; +} + +function regexAllowedAfter(previous) { + if (previous === '') { + return true; + } + if (/^[\w$]+$/.test(previous)) { + return REGEX_KEYWORDS.has(previous); + } + return !/[)\]}]/.test(previous); +} + +/** + * Scan code from `index` to the unmatched `closer` and return the index just + * past it (or the end of the source). + */ +function scanTo(source, index, closer) { + let depth = 0; + let previous = ''; + let i = index; + + while (i < source.length) { + const char = source[i]; + const next = source[i + 1]; + + if (char === '"' || char === "'") { + i = skipString(source, i); + previous = '"'; + } else if (char === '`') { + i = skipTemplate(source, i); + previous = '"'; + } else if (char === '/' && next === '/') { + const end = source.indexOf('\n', i); + i = end === -1 ? source.length : end; + } else if (char === '/' && next === '*') { + const end = source.indexOf('*/', i + 2); + i = end === -1 ? source.length : end + 2; + } else if (char === '/' && regexAllowedAfter(previous)) { + i = skipRegex(source, i); + previous = '"'; + } else if (char === '(' || char === '[' || char === '{') { + depth++; + previous = char; + i++; + } else if (char === ')' || char === ']' || char === '}') { + if (depth === 0 && char === closer) { + return i + 1; + } + depth--; + previous = char; + i++; + } else if (/[\w$]/.test(char)) { + let end = i + 1; + while (end < source.length && /[\w$]/.test(source[end])) { + end++; + } + previous = source.slice(i, end); + i = end; + } else { + if (!/\s/.test(char)) { + previous = char; + } + i++; + } + } + + return i; +} + +/** + * The source of one module's `define(...)` call, with its trailing semicolon, + * or null when the bundle does not define it. + * + * @param {string} source + * @param {string} moduleName + * @returns {string|null} + */ +function extractModule(source, moduleName) { + for (const quote of ['"', "'"]) { + const marker = `define(${quote}${moduleName}${quote}`; + let start = source.indexOf(marker); + + while (start > 0 && /[\w$.]/.test(source[start - 1])) { + start = source.indexOf(marker, start + 1); + } + + if (start !== -1) { + let end = scanTo(source, start + 'define('.length, ')'); + if (source[end] === ';') { + end++; + } + return source.slice(start, end); + } + } + + return null; +} + +/** + * The modules a set of bundles needs from outside itself: every alias target + * and every declared dependency that none of the bundles defines. + * + * @param {string[]} sources + * @returns {{modules: string[], aliases: string[], defined: Set}} + */ +function externalRequirements(sources) { + const defined = new Set(); + const aliasTargets = new Set(); + const dependencies = new Set(); + + for (const source of sources) { + for (const name of collectDefinedModules(source)) { + defined.add(name); + } + for (const { target } of collectAliases(source)) { + aliasTargets.add(target); + } + for (const definition of collectDefinitions(source)) { + definition.dependencies.forEach((dependency) => dependencies.add(dependency)); + } + } + + const isExternal = (name) => !defined.has(name) && !defined.has(`${name}/index`); + const aliases = [...aliasTargets].filter(isExternal).sort(); + const modules = [...new Set([...aliasTargets, ...dependencies])].filter(isExternal).sort(); + + return { modules, aliases, defined }; +} + +module.exports = { + collectDefinedModules, + collectAliases, + collectDefinitions, + resolveDependency, + extractModule, + externalRequirements, +}; diff --git a/lib/cli-args.js b/lib/cli-args.js new file mode 100644 index 00000000..a44a4a98 --- /dev/null +++ b/lib/cli-args.js @@ -0,0 +1,29 @@ +'use strict'; + +/** + * Parse `--name value` and `--flag` arguments. Kept here rather than using + * util.parseArgs, which needs Node 18.3. + * + * @param {string[]} argv + * @param {{strings?: string[], booleans?: string[], defaults?: Object}} spec + * @returns {Object} the values, or `{ error }` for an unknown or valueless option + */ +function parseCliArgs(argv, { strings = [], booleans = [], defaults = {} } = {}) { + const values = { ...defaults }; + + for (let i = 0; i < argv.length; i++) { + const name = argv[i].replace(/^--/, ''); + + if (booleans.includes(name)) { + values[name] = true; + } else if (strings.includes(name) && i + 1 < argv.length) { + values[name] = argv[++i]; + } else { + return { error: `unexpected argument ${argv[i]}` }; + } + } + + return values; +} + +module.exports = { parseCliArgs }; diff --git a/lib/engine-compat.js b/lib/engine-compat.js new file mode 100644 index 00000000..5edf82a4 --- /dev/null +++ b/lib/engine-compat.js @@ -0,0 +1,211 @@ +'use strict'; + +/** + * Compatibility of a prebuilt engine release with a console build. + * + * A lazy engine bundle does not carry the shared addons it was built with + * (ember-engines dedupes them against the host); instead it aliases and + * imports the host's modules by name, as they were in the versions it was + * built against, and component lookups do not fall back to the host. A release + * whose `requires.modules` lists a module the host does not define breaks the + * first time that module is needed, so the installer refuses it. + * + * Host side: `host-modules.json`, written next to a console build (by the + * console's build, or with bin/host-modules.js): + * + * { "schema": 1, "console": "0.7.69", + * "versions": { "@fleetbase/ember-ui": "0.4.6", "@fleetbase/ember-core": "0.3.26", … }, + * "modules": ["@ember/component/index", …] } + * + * Release side: the `manifest.json` written by bin/build-engine-release.js + * (`builtAgainst` and `requires.modules`). + */ + +const fs = require('fs'); +const path = require('path'); +const { collectDefinedModules } = require('./amd-modules'); + +const HOST_MODULES_SCHEMA = 1; + +/** + * Packages whose versions an engine release is pinned to. The engine bundle + * aliases every module of these at the version it was built against. + */ +const PINNED_PACKAGES = ['@fleetbase/ember-ui', '@fleetbase/ember-core']; + +/** + * Packages recorded for diagnosis; a different major is refused. + */ +const RECORDED_PACKAGES = ['ember-source', 'ember-engines', 'ember-data', 'ember-intl', 'ember-auto-import']; + +/** + * The classic scripts a console's index.html loads from its own assets, in + * order. + * + * @param {string} distDir + * @returns {string[]} paths relative to distDir + */ +function hostScripts(distDir) { + const html = fs.readFileSync(path.join(distDir, 'index.html'), 'utf8'); + return Array.from(html.matchAll(/]*\bsrc="([^"]+)"/g), (match) => match[1]) + .filter((src) => !/^([a-z]+:)?\/\//i.test(src)) + .map((src) => src.replace(/^\//, '').split('?')[0]) + .filter((src) => src.startsWith('assets/') && fs.existsSync(path.join(distDir, src))); +} + +/** + * Every module a console build defines at boot. + * + * @param {string} distDir + * @returns {string[]} sorted module names + */ +function collectHostModules(distDir) { + const modules = new Set(); + + for (const script of hostScripts(distDir)) { + const source = fs.readFileSync(path.join(distDir, script), 'utf8'); + for (const name of collectDefinedModules(source, { autoImport: path.basename(script).startsWith('chunk.') })) { + modules.add(name); + } + } + + return [...modules].sort(); +} + +function readPackageVersion(fromDir, packageName) { + try { + const file = require.resolve(`${packageName}/package.json`, { paths: [fromDir] }); + return JSON.parse(fs.readFileSync(file, 'utf8')).version; + } catch { + return null; + } +} + +/** + * The installed versions of the pinned and recorded packages in a console + * checkout. + * + * @param {string} consoleDir + * @returns {Object} + */ +function readHostVersions(consoleDir) { + const versions = {}; + for (const packageName of [...PINNED_PACKAGES, ...RECORDED_PACKAGES]) { + versions[packageName] = readPackageVersion(consoleDir, packageName); + } + return versions; +} + +/** + * Build the host-modules.json document for a console build. + * + * @param {string} distDir + * @param {{consoleDir?: string}} [options] checkout the dist was built from, for versions + * @returns {Object} + */ +function buildHostModules(distDir, { consoleDir } = {}) { + let consoleVersion = null; + let versions = {}; + + if (consoleDir) { + consoleVersion = JSON.parse(fs.readFileSync(path.join(consoleDir, 'package.json'), 'utf8')).version; + versions = readHostVersions(consoleDir); + } + + return { schema: HOST_MODULES_SCHEMA, console: consoleVersion, versions, modules: collectHostModules(distDir) }; +} + +/** + * Load the host side of a check: a host-modules.json file, or a dist + * directory (with or without one inside it). + * + * @param {string} hostPath + * @returns {Object} + */ +function loadHost(hostPath) { + if (fs.statSync(hostPath).isDirectory()) { + const file = path.join(hostPath, 'host-modules.json'); + return fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) : buildHostModules(hostPath); + } + + return JSON.parse(fs.readFileSync(hostPath, 'utf8')); +} + +function parseVersion(version) { + const match = /^(\d+)\.(\d+)\.(\d+)/.exec(String(version ?? '')); + return match ? match.slice(1, 4).map(Number) : null; +} + +/** + * Compare one package's built-against version with the host's. + * + * Policies: `exact` (same version), `minor` (same major.minor, the default for + * pinned packages), `modules` (versions are informational; only the module + * check decides). Recorded packages are only refused on a major mismatch. + */ +function compareVersion(packageName, built, host, policy) { + const result = { package: packageName, built: built ?? null, host: host ?? null, ok: true, note: null }; + const b = parseVersion(built); + const h = parseVersion(host); + + if (!b || !h) { + result.note = 'unknown version, not compared'; + return result; + } + + const pinned = PINNED_PACKAGES.includes(packageName); + if (pinned && policy === 'exact') { + result.ok = built === host; + } else if (pinned && policy === 'minor') { + result.ok = b[0] === h[0] && b[1] === h[1]; + } else { + result.ok = b[0] === h[0]; + } + + if (result.ok && built !== host) { + result.note = 'differs, allowed by policy'; + } + + return result; +} + +/** + * Check a release manifest against a host. + * + * @param {Object} manifest release manifest.json + * @param {Object} host host-modules.json document + * @param {{policy?: 'exact'|'minor'|'modules'}} [options] + * @returns {{compatible: boolean, missing: string[], versions: Object[], reasons: string[]}} + */ +function checkEngineCompat(manifest, host, { policy = 'minor' } = {}) { + const modules = new Set(host.modules ?? []); + const required = manifest.requires?.modules ?? []; + const missing = required.filter((name) => !modules.has(name) && !modules.has(`${name}/index`)); + + const builtAgainst = manifest.builtAgainst ?? {}; + const hostVersions = host.versions ?? {}; + const versions = Object.keys(builtAgainst).map((packageName) => compareVersion(packageName, builtAgainst[packageName], hostVersions[packageName], policy)); + + const reasons = []; + if (missing.length > 0) { + reasons.push(`the console does not define ${missing.length} module(s) the engine needs`); + } + for (const version of versions.filter((entry) => !entry.ok)) { + reasons.push(`${version.package} ${version.built} (built against) is not compatible with ${version.host} (console)`); + } + + return { compatible: reasons.length === 0, missing, versions, reasons }; +} + +module.exports = { + HOST_MODULES_SCHEMA, + PINNED_PACKAGES, + RECORDED_PACKAGES, + hostScripts, + collectHostModules, + readHostVersions, + buildHostModules, + loadHost, + compareVersion, + checkEngineCompat, +}; diff --git a/lib/engine-release.js b/lib/engine-release.js new file mode 100644 index 00000000..db323bc9 --- /dev/null +++ b/lib/engine-release.js @@ -0,0 +1,328 @@ +'use strict'; + +/** + * Extract a prebuilt engine release from a console build that includes the + * engine. + * + * A console build does more for an engine than write engines-dist// + * (see docs/runtime-engines.md). This collects everything a console built + * WITHOUT the engine needs to mount it at runtime: + * + * // engines-dist// as built (source maps optional) + * //routes.js the `/routes` module from the host bundle + * //host-app.js the engine's `app/` tree re-exported into the host namespace + * //extension.js the `/extension` module, standalone (when self-contained) + * //translations.json [[locale, messages], …] from the engine's translations/ + * //manifest.json versions, runtime entry, required host modules, file hashes + */ + +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); +const { collectDefinitions, extractModule, externalRequirements } = require('./amd-modules'); +const { collectHostModules, hostScripts, readHostVersions, PINNED_PACKAGES, RECORDED_PACKAGES } = require('./engine-compat'); + +const RELEASE_SCHEMA = 1; +const APP_MODULE_PREFIX = '@fleetbase/console'; + +function readJson(file) { + return JSON.parse(fs.readFileSync(file, 'utf8')); +} + +function walk(dir, base = dir) { + const files = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...walk(full, base)); + } else if (entry.isFile()) { + files.push(path.relative(base, full).split(path.sep).join('/')); + } + } + return files.sort(); +} + +function copyTree(from, to, { keepMaps = false } = {}) { + for (const file of walk(from)) { + if (!keepMaps && file.endsWith('.map')) { + continue; + } + const target = path.join(to, file); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.copyFileSync(path.join(from, file), target); + } +} + +/** + * The content of a `` tag in index.html, unescaped the way + * ember-cli escaped it. + */ +function readMeta(html, name) { + const escaped = name.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&'); + const match = new RegExp(` dependency === engineName || dependency.startsWith(`${engineName}/`); + const modules = []; + const seen = new Set(); + + for (const source of sources) { + for (const definition of collectDefinitions(source)) { + if (definition.name.startsWith(prefix) && !seen.has(definition.name) && definition.dependencies.some(fromEngine)) { + seen.add(definition.name); + modules.push({ name: definition.name, code: extractModule(source, definition.name) }); + } + } + } + + modules.sort((a, b) => (a.name < b.name ? -1 : 1)); + const skipped = appModuleNames.filter((name) => !seen.has(name)); + + return { modules, skipped }; +} + +/** + * host-app.js: the extracted modules, each defined only when the host does + * not define that name already, so a release never replaces a host module. + */ +function wrapHostApp(modules) { + return [ + '(function (define) {', + ...modules.map((module) => module.code), + '})(function (name) {', + ' if (name in window.requirejs.entries) {', + " console.warn('[runtime-engines] The console already defines ' + name + '; keeping its own.');", + ' return;', + ' }', + ' window.define.apply(null, arguments);', + '});', + '', + ].join('\n'); +} + +/** + * Translations as ember-intl's `ember-intl/translations` module holds them: + * `[[locale, messages], …]`, one entry per file in the engine's translations/. + */ +function readTranslations(engineDir, parseYaml) { + const dir = path.join(engineDir, 'translations'); + if (!fs.existsSync(dir)) { + return []; + } + + const translations = []; + for (const file of walk(dir)) { + const match = /^(?:.*\/)?([^/]+)\.(ya?ml|json)$/.exec(file); + if (!match) { + continue; + } + const content = fs.readFileSync(path.join(dir, file), 'utf8'); + const messages = match[2] === 'json' ? JSON.parse(content) : parseYaml(content); + translations.push([match[1].toLowerCase(), messages ?? {}]); + } + return translations; +} + +/** + * The engine's asset-manifest bundle with URIs made relative to its folder. + */ +function relativeBundle(bundle, engineName) { + const folder = `engines-dist/${engineName}/`; + return { + assets: bundle.assets.map((asset) => { + const index = asset.uri.indexOf(folder); + return { ...asset, uri: index === -1 ? asset.uri : asset.uri.slice(index + folder.length) }; + }), + dependencies: bundle.dependencies ?? [], + }; +} + +function sha256(file) { + return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); +} + +/** + * Extract a release from a console build. + * + * @param {Object} options + * @param {string} options.consoleDir console checkout the dist was built from (with the engine installed) + * @param {string} options.distDir that build's output + * @param {string} options.engineName package name, e.g. @fleetbase/pallet-engine + * @param {string} options.outDir release root; the release goes to // + * @param {boolean} [options.keepMaps] + * @param {Function} [options.parseYaml] YAML parser (defaults to the console's js-yaml) + * @param {Function} [options.log] + * @returns {Object} the release manifest + */ +function extractRelease({ consoleDir, distDir, engineName, outDir, keepMaps = false, parseYaml, log = () => {} }) { + const engineDir = path.dirname(require.resolve(`${engineName}/package.json`, { paths: [consoleDir] })); + const enginePackage = readJson(path.join(engineDir, 'package.json')); + const consolePackage = readJson(path.join(consoleDir, 'package.json')); + const html = fs.readFileSync(path.join(distDir, 'index.html'), 'utf8'); + const builtFolder = path.join(distDir, 'engines-dist', engineName); + + if (!fs.existsSync(builtFolder)) { + throw new Error(`${path.relative(process.cwd(), builtFolder)} does not exist: is ${engineName} installed in the console and a lazy engine?`); + } + + const assetManifest = readMeta(html, `${APP_MODULE_PREFIX}/config/asset-manifest`) ?? readJson(path.join(distDir, 'asset-manifest.json')); + const bundle = assetManifest?.bundles?.[engineName]; + if (!bundle) { + throw new Error(`The build's asset manifest has no bundle for ${engineName}.`); + } + + const config = readMeta(html, `${engineName}/config/environment`); + if (!config) { + throw new Error(`index.html has no ${engineName}/config/environment meta tag.`); + } + + const releaseDir = path.join(outDir, engineName); + fs.rmSync(releaseDir, { recursive: true, force: true }); + copyTree(builtFolder, releaseDir, { keepMaps }); + + const hostSources = hostScripts(distDir).map((script) => fs.readFileSync(path.join(distDir, script), 'utf8')); + const engineBundle = relativeBundle(bundle, engineName); + const bundleSources = engineBundle.assets.filter((asset) => asset.type === 'js').map((asset) => fs.readFileSync(path.join(builtFolder, asset.uri), 'utf8')); + + // 1. The route map ember-engines compiles into the host bundle. + const routes = hostSources.map((source) => extractModule(source, `${engineName}/routes`)).find(Boolean); + if (!routes) { + throw new Error(`The host bundle does not define ${engineName}/routes.`); + } + fs.writeFileSync(path.join(releaseDir, 'routes.js'), `${routes}\n`); + + // 2. The engine's app/ tree, merged into the host namespace. + const hostApp = extractHostApp(hostSources, engineName, appTreeModuleNames(engineDir)); + const initializers = hostApp.modules.filter((module) => /\/(instance-)?initializers\//.test(module.name)); + if (initializers.length > 0) { + throw new Error(`${engineName} adds initializers to the host (${initializers.map((module) => module.name).join(', ')}); runtime engines cannot run them.`); + } + hostApp.skipped.forEach((name) => log(`warning: ${name} (from the engine's app/ tree) is not a re-export of the engine in this build; left out`)); + fs.writeFileSync(path.join(releaseDir, 'host-app.js'), wrapHostApp(hostApp.modules)); + + // 3. The extension module, standalone when it only imports host modules. + const extensionName = `${engineName}/extension`; + const extensionCode = bundleSources.map((source) => extractModule(source, extensionName)).find(Boolean); + let extension = false; + if (extensionCode) { + const { modules: needs } = externalRequirements([extensionCode]); + const internal = needs.filter((name) => name === engineName || name.startsWith(`${engineName}/`)); + if (internal.length === 0) { + fs.writeFileSync(path.join(releaseDir, 'extension.js'), `${extensionCode}\n`); + extension = 'extension.js'; + } else { + log(`warning: ${extensionName} imports ${internal.join(', ')}; it will load the whole bundle at boot`); + extension = true; + } + } + + // 4. Translations, which ember-intl only merges for engines the host is built with. + const translations = readTranslations(engineDir, parseYaml ?? defaultYamlParser(consoleDir)); + fs.writeFileSync(path.join(releaseDir, 'translations.json'), JSON.stringify(translations)); + + // 5. What the release needs from the host: modules the bundle aliases or + // imports without defining them. Only modules the build host defined + // count; the rest are dangling in every build and never required. + const buildHostModules = new Set(collectHostModules(distDir)); + const requirements = externalRequirements([...bundleSources, routes, ...hostApp.modules.map((module) => module.code)]); + const provided = (name) => buildHostModules.has(name) || buildHostModules.has(`${name}/index`); + + const versions = readHostVersions(consoleDir); + const builtAgainst = {}; + for (const packageName of [...PINNED_PACKAGES, ...RECORDED_PACKAGES]) { + builtAgainst[packageName] = versions[packageName]; + } + + const route = enginePackage.fleetbase?.route ?? engineName.split('/').pop().replace('-engine', ''); + const runtime = { + mount: { as: route, path: route, root: enginePackage.fleetbase?.mount === 'root' }, + bundle: engineBundle, + config, + routes: 'routes.js', + hostApp: 'host-app.js', + translations: 'translations.json', + extension, + }; + + const files = {}; + for (const file of walk(releaseDir)) { + files[file] = `sha256-${sha256(path.join(releaseDir, file))}`; + } + + const manifest = { + schema: RELEASE_SCHEMA, + name: engineName, + version: enginePackage.version, + builtAt: new Date().toISOString(), + builtWith: { console: consolePackage.version, environment: config.environment ?? null }, + builtAgainst, + runtime, + requires: { + modules: requirements.modules.filter(provided), + aliases: requirements.aliases.filter(provided), + unresolved: requirements.modules.filter((name) => !provided(name)), + }, + hostApp: { modules: hostApp.modules.length, skipped: hostApp.skipped }, + files, + }; + + fs.writeFileSync(path.join(releaseDir, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`); + return manifest; +} + +/** + * js-yaml from the console checkout (ember-intl's own dependency), so the + * builder adds no dependency to ember-core. + */ +function defaultYamlParser(consoleDir) { + return (content) => { + const intlDir = path.dirname(require.resolve('ember-intl/package.json', { paths: [consoleDir] })); + const yaml = require(require.resolve('js-yaml', { paths: [intlDir] })); + return yaml.load(content); + }; +} + +module.exports = { + RELEASE_SCHEMA, + readMeta, + appTreeModuleNames, + extractHostApp, + wrapHostApp, + readTranslations, + relativeBundle, + extractRelease, + walk, +}; diff --git a/node-tests/amd-modules.test.js b/node-tests/amd-modules.test.js new file mode 100644 index 00000000..960162bd --- /dev/null +++ b/node-tests/amd-modules.test.js @@ -0,0 +1,103 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { collectAliases, collectDefinedModules, collectDefinitions, extractModule, externalRequirements, resolveDependency } = require('../lib/amd-modules'); + +const DEV_BUNDLE = ` +define("@acme/widgets-engine/routes", ["exports", "ember-engines/routes"], function (_exports, _routes) { + "use strict"; + // a comment with a ) and a "quote + var re = /[)}"']+/g; + var tpl = \`) \${ "}" } \`; + _exports.default = (0, _routes.default)(function () { + this.route('home', { path: '/' }); + }); +}); +define("@acme/widgets-engine/components/thing", ["exports", "@fleetbase/ember-ui/components/button", "./helper"], function () {}); +define.alias("@fleetbase/ember-ui/components/modal", "@acme/widgets-engine/components/modal"); +define.alias("@acme/widgets-engine/components/thing", "@acme/widgets-engine/components/thing-alias"); +`; + +const MINIFIED_BUNDLE = + 'define("a/one",["exports","b/two"],(function(e,t){"use strict";var n=t.x/2/1;e.default=n>1?")":"("}));define(\'a/two\',[],(function(){return{}}));define.alias("b/three","a/three")'; + +test('it lists the modules a bundle defines, including aliases', () => { + assert.deepEqual( + [...collectDefinedModules(DEV_BUNDLE)].sort(), + ['@acme/widgets-engine/components/modal', '@acme/widgets-engine/components/thing', '@acme/widgets-engine/components/thing-alias', '@acme/widgets-engine/routes'] + ); + assert.deepEqual([...collectDefinedModules(MINIFIED_BUNDLE)].sort(), ['a/one', 'a/three', 'a/two']); +}); + +test('ember-auto-import registrations count only when asked for', () => { + const chunk = "eval(\"var d = _eai_d;\\n d('lodash', [], function() {}); d('@floating-ui/dom', [], function() {});\")"; + + assert.deepEqual([...collectDefinedModules(chunk)], []); + assert.deepEqual([...collectDefinedModules(chunk, { autoImport: true })].sort(), ['@floating-ui/dom', 'lodash']); +}); + +test('a call that only ends in "define" is not a definition', () => { + assert.deepEqual([...collectDefinedModules('window.define("x", []); _define("y", []); define("z", [])')], ['z']); +}); + +test('it lists aliases as target and alias', () => { + assert.deepEqual(collectAliases(DEV_BUNDLE), [ + { target: '@fleetbase/ember-ui/components/modal', alias: '@acme/widgets-engine/components/modal' }, + { target: '@acme/widgets-engine/components/thing', alias: '@acme/widgets-engine/components/thing-alias' }, + ]); +}); + +test('dependencies are read and resolved like loader.js does', () => { + const definitions = collectDefinitions(DEV_BUNDLE); + + assert.deepEqual(definitions[0].dependencies, ['ember-engines/routes'], 'exports is not a dependency'); + assert.deepEqual(definitions[1].dependencies, ['@fleetbase/ember-ui/components/button', '@acme/widgets-engine/components/helper']); + assert.deepEqual(collectDefinitions('define("x", function () {})')[0].dependencies, [], 'a define without a dependency list'); + + assert.equal(resolveDependency('../utils/a', '@acme/e/components/x/y'), '@acme/e/components/utils/a'); + assert.equal(resolveDependency('./b', '@acme/e/c'), '@acme/e/b'); + assert.equal(resolveDependency('lodash', '@acme/e/c'), 'lodash'); +}); + +test('a module is extracted whole, past strings, comments, regexes and templates', () => { + const routes = extractModule(DEV_BUNDLE, '@acme/widgets-engine/routes'); + + assert.ok(routes.startsWith('define("@acme/widgets-engine/routes"')); + assert.ok(routes.endsWith('});'), 'with its semicolon'); + assert.ok(routes.includes("this.route('home'")); + assert.ok(!routes.includes('components/thing'), 'and nothing of the next module'); +}); + +test('minified modules are extracted, with either quote style', () => { + assert.equal(extractModule(MINIFIED_BUNDLE, 'a/one'), 'define("a/one",["exports","b/two"],(function(e,t){"use strict";var n=t.x/2/1;e.default=n>1?")":"("}));'); + assert.equal(extractModule(MINIFIED_BUNDLE, 'a/two'), "define('a/two',[],(function(){return{}}));"); + assert.equal(extractModule(MINIFIED_BUNDLE, 'a/missing'), null); + assert.equal(extractModule('x.define("a/one", [])', 'a/one'), null, 'a property call is not the module'); +}); + +test('regex literals after keywords and division after values are told apart', () => { + const source = 'define("k", [], function () { if (a) return /\\)/.test(b); var c = (d) / 2 / (e); return typeof /[/)]/; });define("next", [])'; + assert.equal(extractModule(source, 'k'), 'define("k", [], function () { if (a) return /\\)/.test(b); var c = (d) / 2 / (e); return typeof /[/)]/; });'); +}); + +test('unterminated input does not loop forever', () => { + assert.equal(extractModule('define("u", [], function () { var s = "open', 'u'), 'define("u", [], function () { var s = "open'); + assert.equal(extractModule('define("u", [], function () { /* open', 'u'), 'define("u", [], function () { /* open'); + assert.equal(extractModule('define("u", [], function () { // open', 'u'), 'define("u", [], function () { // open'); + assert.equal(extractModule('define("u", [], function () { `x ${ "y" ', 'u'), 'define("u", [], function () { `x ${ "y" '); +}); + +test('external requirements are what the bundles alias or import without defining', () => { + const engineVendor = 'define("@acme/widgets-engine/components/helper", ["exports", "@ember/component"], function () {});'; + const { modules, aliases, defined } = externalRequirements([DEV_BUNDLE, engineVendor]); + + assert.deepEqual(aliases, ['@fleetbase/ember-ui/components/modal']); + assert.deepEqual(modules, ['@ember/component', '@fleetbase/ember-ui/components/button', '@fleetbase/ember-ui/components/modal', 'ember-engines/routes']); + assert.ok(defined.has('@acme/widgets-engine/components/helper')); +}); + +test('a requirement met by an index module is not external', () => { + const { modules } = externalRequirements(['define("a", ["b/c"], function () {}); define("b/c/index", [], function () {});']); + assert.deepEqual(modules, []); +}); diff --git a/node-tests/engine-release.test.js b/node-tests/engine-release.test.js new file mode 100644 index 00000000..1760757c --- /dev/null +++ b/node-tests/engine-release.test.js @@ -0,0 +1,292 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); +const { extractRelease, readMeta, relativeBundle, wrapHostApp } = require('../lib/engine-release'); +const { buildHostModules, checkEngineCompat, compareVersion, loadHost } = require('../lib/engine-compat'); + +const ENGINE = '@acme/widgets-engine'; +const BIN = path.join(__dirname, '..', 'bin'); +const fixtures = []; + +test.after(() => fixtures.forEach((root) => fs.rmSync(root, { recursive: true, force: true }))); + +function write(root, file, content) { + const target = path.join(root, file); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, typeof content === 'string' ? content : JSON.stringify(content)); +} + +/** + * A console checkout with the engine installed and a development build of it: + * the same layout `ember build` produces, reduced to what the extractor reads. + */ +function makeFixture({ uiVersion = '0.4.6', hostModules = ['button', 'modal'] } = {}) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'engine-release-test-')); + fixtures.push(root); + const consoleDir = path.join(root, 'console'); + const distDir = path.join(root, 'dist'); + + write(consoleDir, 'package.json', { name: '@fleetbase/console', version: '0.7.69' }); + write(consoleDir, 'node_modules/@fleetbase/ember-ui/package.json', { name: '@fleetbase/ember-ui', version: uiVersion }); + write(consoleDir, 'node_modules/@fleetbase/ember-core/package.json', { name: '@fleetbase/ember-core', version: '0.3.26' }); + write(consoleDir, `node_modules/${ENGINE}/package.json`, { name: ENGINE, version: '1.2.0', fleetbase: { route: 'widgets' } }); + write(consoleDir, `node_modules/${ENGINE}/app/models/widget.js`, `export { default } from '${ENGINE}/models/widget';`); + write(consoleDir, `node_modules/${ENGINE}/app/routes/home.js`, `export { default } from '${ENGINE}/routes/home';`); + write(consoleDir, `node_modules/${ENGINE}/translations/en-us.yaml`, 'widgets: Widgets'); + write(consoleDir, `node_modules/${ENGINE}/translations/README.md`, 'not a translation'); + + const config = { modulePrefix: ENGINE, environment: 'development' }; + const bundle = { + assets: [ + { uri: `/engines-dist/${ENGINE}/assets/engine.js`, type: 'js' }, + { uri: `/engines-dist/${ENGINE}/assets/engine.css`, type: 'css' }, + ], + }; + const manifestMeta = escape(JSON.stringify({ bundles: { [ENGINE]: bundle } })); + write( + distDir, + 'index.html', + ` + + + + + + + +` + ); + write( + distDir, + 'assets/vendor.js', + [ + ...hostModules.map((name) => `define("@fleetbase/ember-ui/components/${name}", ["exports"], function () {});`), + 'define("ember-engines/routes", ["exports"], function () {});', + 'define("@ember/component/index", ["exports"], function () {});', + `define("${ENGINE}/routes", ["exports", "ember-engines/routes"], function (e, r) {\n e.default = (0, r.default)(function () { this.route('home', { path: '/' }); });\n});`, + ].join('\n') + ); + write(distDir, 'assets/chunk.app.js', "d('lodash', [], function () {});"); + write( + distDir, + 'assets/@fleetbase/console.js', + [ + `define("@fleetbase/console/models/widget", ["exports", "${ENGINE}/models/widget"], function () {});`, + `define("@fleetbase/console/serializers/widget", ["exports", "${ENGINE}/serializers/widget"], function () {});`, + 'define("@fleetbase/console/routes/home", ["exports", "@acme/other-engine/routes/home"], function () {});', + 'define("@fleetbase/console/app", ["exports"], function () {});', + ].join('\n') + ); + write( + distDir, + `engines-dist/${ENGINE}/assets/engine.js`, + [ + `define("${ENGINE}/engine", ["exports", "@ember/component", "lodash"], function () {});`, + `define("${ENGINE}/models/widget", ["exports"], function () {});`, + `define("${ENGINE}/serializers/widget", ["exports"], function () {});`, + `define("${ENGINE}/extension", ["exports", "@fleetbase/ember-core/contracts"], function (e) { e.default = {}; });`, + `define.alias("@fleetbase/ember-ui/components/button", "${ENGINE}/components/button");`, + `define.alias("@fleetbase/ember-ui/components/modal", "${ENGINE}/components/modal");`, + `define.alias("@fleetbase/ember-ui/utils/dangling", "${ENGINE}/utils/dangling");`, + ].join('\n') + ); + write(distDir, `engines-dist/${ENGINE}/assets/engine.css`, '.widget {}'); + write(distDir, `engines-dist/${ENGINE}/assets/engine.map`, '{}'); + + return { root, consoleDir, distDir, outDir: path.join(root, 'out') }; +} + +const parseYaml = (content) => Object.fromEntries(content.split('\n').map((line) => line.split(': '))); + +test('a release is extracted with every artifact a runtime mount needs', () => { + const fixture = makeFixture(); + const logs = []; + + const manifest = extractRelease({ ...fixture, engineName: ENGINE, parseYaml, log: (message) => logs.push(message) }); + const release = path.join(fixture.outDir, ENGINE); + + assert.deepEqual(fs.readdirSync(release).sort(), ['assets', 'extension.js', 'host-app.js', 'manifest.json', 'routes.js', 'translations.json']); + assert.ok(!fs.existsSync(path.join(release, 'assets/engine.map')), 'source maps are left out by default'); + assert.match(fs.readFileSync(path.join(release, 'routes.js'), 'utf8'), /^define\("@acme\/widgets-engine\/routes"/); + assert.deepEqual(JSON.parse(fs.readFileSync(path.join(release, 'translations.json'), 'utf8')), [['en-us', { widgets: 'Widgets' }]]); + assert.match(fs.readFileSync(path.join(release, 'extension.js'), 'utf8'), /define\("@acme\/widgets-engine\/extension"/); + + const hostApp = fs.readFileSync(path.join(release, 'host-app.js'), 'utf8'); + assert.match(hostApp, /@fleetbase\/console\/models\/widget/, 'a re-export from the app tree'); + assert.match(hostApp, /@fleetbase\/console\/serializers\/widget/, 'a re-export of the engine not in the app tree'); + assert.doesNotMatch(hostApp, /@fleetbase\/console\/routes\/home/, "another engine's module of the same name is left out"); + assert.deepEqual(manifest.hostApp, { modules: 2, skipped: ['@fleetbase/console/routes/home'] }); + assert.match(logs[0], /routes\/home .* left out/); + + assert.equal(manifest.name, ENGINE); + assert.equal(manifest.version, '1.2.0'); + assert.deepEqual(manifest.builtWith, { console: '0.7.69', environment: 'development' }); + assert.equal(manifest.builtAgainst['@fleetbase/ember-ui'], '0.4.6'); + assert.equal(manifest.builtAgainst['@fleetbase/ember-core'], '0.3.26'); + assert.deepEqual(manifest.runtime.mount, { as: 'widgets', path: 'widgets', root: false }); + assert.deepEqual(manifest.runtime.bundle.assets, [ + { uri: 'assets/engine.js', type: 'js' }, + { uri: 'assets/engine.css', type: 'css' }, + ]); + assert.deepEqual(manifest.runtime.config, { modulePrefix: ENGINE, environment: 'development' }); + assert.equal(manifest.runtime.extension, 'extension.js'); + assert.deepEqual(manifest.requires.modules, [ + '@ember/component', + '@fleetbase/ember-ui/components/button', + '@fleetbase/ember-ui/components/modal', + 'ember-engines/routes', + 'lodash', + ]); + assert.deepEqual(manifest.requires.aliases, ['@fleetbase/ember-ui/components/button', '@fleetbase/ember-ui/components/modal']); + assert.deepEqual( + manifest.requires.unresolved, + ['@fleetbase/ember-core/contracts', '@fleetbase/ember-ui/utils/dangling'], + 'modules the build host did not define either' + ); + assert.match(manifest.files['assets/engine.js'], /^sha256-[0-9a-f]{64}$/); + assert.deepEqual(Object.keys(manifest.files).sort(), ['assets/engine.css', 'assets/engine.js', 'extension.js', 'host-app.js', 'routes.js', 'translations.json']); +}); + +test('an extension that imports engine internals is not shipped standalone', () => { + const fixture = makeFixture(); + const engineJs = path.join(fixture.distDir, `engines-dist/${ENGINE}/assets/engine.js`); + fs.writeFileSync(engineJs, fs.readFileSync(engineJs, 'utf8').replace('"@fleetbase/ember-core/contracts"', `"${ENGINE}/utils/menu"`)); + const logs = []; + + const manifest = extractRelease({ ...fixture, engineName: ENGINE, parseYaml, keepMaps: true, log: (message) => logs.push(message) }); + + assert.equal(manifest.runtime.extension, true); + assert.ok(!fs.existsSync(path.join(fixture.outDir, ENGINE, 'extension.js'))); + assert.ok(logs.some((message) => message.includes('load the whole bundle'))); + assert.ok(fs.existsSync(path.join(fixture.outDir, ENGINE, 'assets/engine.map')), 'maps kept on request'); +}); + +test('engines that need what a runtime mount cannot give are refused', () => { + const fixture = makeFixture(); + const consoleJs = path.join(fixture.distDir, 'assets/@fleetbase/console.js'); + fs.appendFileSync(consoleJs, `\ndefine("@fleetbase/console/initializers/widgets", ["exports", "${ENGINE}/initializers/widgets"], function () {});`); + assert.throws(() => extractRelease({ ...fixture, engineName: ENGINE, parseYaml }), /adds initializers to the host/); + + const vendor = path.join(fixture.distDir, 'assets/vendor.js'); + fs.writeFileSync(vendor, fs.readFileSync(vendor, 'utf8').replace(`define("${ENGINE}/routes"`, 'define("x/routes"')); + assert.throws(() => extractRelease({ ...fixture, engineName: ENGINE, parseYaml }), /does not define @acme\/widgets-engine\/routes/); +}); + +test('a build without the engine, its bundle or its config is an error', () => { + const fixture = makeFixture(); + const html = path.join(fixture.distDir, 'index.html'); + + fs.writeFileSync(html, fs.readFileSync(html, 'utf8').replace(`${ENGINE}/config/environment`, 'other/config/environment')); + assert.throws(() => extractRelease({ ...fixture, engineName: ENGINE, parseYaml }), /no @acme\/widgets-engine\/config\/environment meta/); + + fs.writeFileSync(html, fs.readFileSync(html, 'utf8').replace(/]*>/, '')); + write(fixture.distDir, 'asset-manifest.json', { bundles: {} }); + assert.throws(() => extractRelease({ ...fixture, engineName: ENGINE, parseYaml }), /no bundle for @acme\/widgets-engine/); + + fs.rmSync(path.join(fixture.distDir, 'engines-dist'), { recursive: true }); + assert.throws(() => extractRelease({ ...fixture, engineName: ENGINE, parseYaml }), /is @acme\/widgets-engine installed/); +}); + +test('helpers: meta tags, bundle paths and the host-app guard', () => { + assert.equal(readMeta('', 'a/b').x, 1); + assert.equal(readMeta('', 'a/b'), null); + assert.deepEqual(relativeBundle({ assets: [{ uri: 'https://cdn.test/engines-dist/e/assets/a.js' }, { uri: '/shared/b.js' }] }, 'e'), { + assets: [{ uri: 'assets/a.js' }, { uri: '/shared/b.js' }], + dependencies: [], + }); + + const defined = []; + const window = { requirejs: { entries: { 'x/existing': {} } }, define: (name) => defined.push(name) }; + const console = { warn: () => {} }; + new Function('window', 'console', wrapHostApp([{ code: 'define("x/existing", [], function () {});' }, { code: 'define("x/new", [], function () {});' }]))(window, console); + assert.deepEqual(defined, ['x/new'], 'a host module is never replaced'); +}); + +test('the compat check refuses missing modules and pinned version drift', () => { + const fixture = makeFixture(); + const manifest = extractRelease({ ...fixture, engineName: ENGINE, parseYaml }); + + const same = buildHostModules(fixture.distDir, { consoleDir: fixture.consoleDir }); + assert.equal(same.console, '0.7.69'); + assert.ok(same.modules.includes('lodash'), 'auto-import registrations count for the host'); + assert.ok(!same.modules.includes('external'), 'external scripts are not read'); + assert.deepEqual(checkEngineCompat(manifest, same).compatible, true); + + const older = makeFixture({ uiVersion: '0.4.4', hostModules: ['button'] }); + const olderHost = buildHostModules(older.distDir, { consoleDir: older.consoleDir }); + const result = checkEngineCompat(manifest, olderHost); + assert.equal(result.compatible, false); + assert.deepEqual(result.missing, ['@fleetbase/ember-ui/components/modal']); + assert.equal(result.reasons.length, 1, 'same minor: only the module is a reason'); + + const nextMinor = { ...olderHost, modules: same.modules, versions: { ...olderHost.versions, '@fleetbase/ember-ui': '0.5.0' } }; + assert.match(checkEngineCompat(manifest, nextMinor).reasons[0], /@fleetbase\/ember-ui 0.4.6 .* not compatible with 0.5.0/); + assert.equal(checkEngineCompat(manifest, nextMinor, { policy: 'modules' }).compatible, true, 'the modules policy only checks modules'); + assert.equal(checkEngineCompat({}, { modules: [] }).compatible, true, 'an empty manifest needs nothing'); +}); + +test('version comparison by policy', () => { + assert.equal(compareVersion('@fleetbase/ember-ui', '0.4.6', '0.4.6', 'exact').ok, true); + assert.equal(compareVersion('@fleetbase/ember-ui', '0.4.6', '0.4.7', 'exact').ok, false); + assert.deepEqual(compareVersion('@fleetbase/ember-ui', '0.4.6', '0.4.7', 'minor'), { + package: '@fleetbase/ember-ui', + built: '0.4.6', + host: '0.4.7', + ok: true, + note: 'differs, allowed by policy', + }); + assert.equal(compareVersion('ember-source', '5.4.1', '5.12.0', 'minor').ok, true, 'recorded packages only refuse a new major'); + assert.equal(compareVersion('ember-source', '5.4.1', '6.0.0', 'minor').ok, false); + assert.equal(compareVersion('ember-source', null, '6.0.0', 'minor').note, 'unknown version, not compared'); +}); + +test('the CLIs write the host list and gate on compatibility', () => { + const fixture = makeFixture(); + extractRelease({ ...fixture, engineName: ENGINE, parseYaml }); + const manifestPath = path.join(fixture.outDir, ENGINE, 'manifest.json'); + const run = (script, args) => spawnSync(process.execPath, [path.join(BIN, script), ...args], { encoding: 'utf8' }); + + const written = run('host-modules.js', ['--dist', fixture.distDir, '--console', fixture.consoleDir]); + assert.equal(written.status, 0, written.stderr); + const hostFile = path.join(fixture.distDir, 'host-modules.json'); + assert.equal(loadHost(hostFile).console, '0.7.69'); + assert.equal(loadHost(fixture.distDir).console, '0.7.69', 'a dist with host-modules.json uses it'); + + const ok = run('check-engine-compat.js', ['--manifest', manifestPath, '--host', hostFile]); + assert.equal(ok.status, 0, ok.stdout); + assert.match(ok.stdout, /compatible/); + + fs.rmSync(hostFile); + const older = makeFixture({ hostModules: ['button'] }); + const refused = run('check-engine-compat.js', ['--manifest', manifestPath, '--host', older.distDir, '--json']); + assert.equal(refused.status, 1); + assert.deepEqual(JSON.parse(refused.stdout).missing, ['@fleetbase/ember-ui/components/modal']); + + assert.equal(run('check-engine-compat.js', ['--manifest', manifestPath]).status, 2); + assert.equal(run('check-engine-compat.js', ['--manifest', manifestPath, '--host', hostFile, '--policy', 'loose']).status, 2); + assert.equal(run('host-modules.js', []).status, 2); + assert.equal(run('build-engine-release.js', []).status, 2); + assert.equal(run('build-engine-release.js', ['--help']).status, 0); +}); + +test('build-engine-release extracts from an existing dist', () => { + const fixture = makeFixture(); + // The CLI uses the console's js-yaml; this fixture has none, so give the engine JSON translations. + const translations = path.join(fixture.consoleDir, `node_modules/${ENGINE}/translations`); + fs.rmSync(translations, { recursive: true }); + write(translations, 'en-us.json', { widgets: 'Widgets' }); + + const args = ['--console', fixture.consoleDir, '--engine', ENGINE, '--dist', fixture.distDir, '--out', fixture.outDir]; + const result = spawnSync(process.execPath, [path.join(BIN, 'build-engine-release.js'), ...args], { encoding: 'utf8' }); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /@acme\/widgets-engine@1\.2\.0/); + assert.match(result.stdout, /built against @fleetbase\/ember-ui@0\.4\.6, @fleetbase\/ember-core@0\.3\.26/); + assert.match(result.stdout, /extension: standalone/); + assert.ok(fs.existsSync(path.join(fixture.outDir, ENGINE, 'manifest.json'))); +}); diff --git a/node-tests/helpers/runtime-console.js b/node-tests/helpers/runtime-console.js new file mode 100644 index 00000000..a17a9910 --- /dev/null +++ b/node-tests/helpers/runtime-console.js @@ -0,0 +1,268 @@ +'use strict'; + +/* global WebSocket */ + +/** + * Serve a console dist with a runtime engine release beside it, and drive it + * in headless Chrome over the DevTools protocol. No dependencies: Node's http + * server and its global WebSocket (Node 22+). + */ + +const fs = require('fs'); +const http = require('http'); +const os = require('os'); +const path = require('path'); +const { spawn } = require('child_process'); + +const CONTENT_TYPES = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.ico': 'image/x-icon', + '.mp3': 'audio/mpeg', + '.woff': 'font/woff', + '.woff2': 'font/woff2', + '.ttf': 'font/ttf', +}; + +const CHROME_CANDIDATES = [ + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/usr/bin/google-chrome', + '/usr/bin/google-chrome-stable', + '/usr/bin/chromium', + '/usr/bin/chromium-browser', +]; + +function findChrome() { + return process.env.CHROME_PATH ?? CHROME_CANDIDATES.find((candidate) => fs.existsSync(candidate)) ?? null; +} + +/** + * Serve `hostDist` as the console, the release folder at + * /engines-dist//, the runtime manifest at + * /~registry/v1/engines/runtime, and a 404 for every other API call. + * + * @param {Object} options + * @param {string} options.hostDist console dist built without the engine + * @param {string} options.releaseDir / from build-engine-release + * @param {Function} options.runtimeManifest (origin) => runtime manifest payload + * @param {string} [options.inject] script injected before the app bundle + */ +function serveConsole({ hostDist, releaseDir, runtimeManifest, inject }) { + const manifest = JSON.parse(fs.readFileSync(path.join(releaseDir, 'manifest.json'), 'utf8')); + const enginePrefix = `/engines-dist/${manifest.name}/`; + const requests = []; + + const server = http.createServer((request, response) => { + const url = new URL(request.url, 'http://localhost'); + const origin = `http://${request.headers.host}`; + requests.push(`${request.method} ${url.pathname}`); + + const send = (status, body, type = 'application/json; charset=utf-8') => { + response.writeHead(status, { 'Content-Type': type, 'Cache-Control': 'no-store' }); + response.end(body); + }; + + if (url.pathname === '/fleetbase.config.json') { + return send(200, JSON.stringify({ API_HOST: origin })); + } + if (url.pathname === '/~registry/v1/engines/runtime') { + return send(200, JSON.stringify(runtimeManifest(origin))); + } + + const file = url.pathname.startsWith(enginePrefix) ? path.join(releaseDir, url.pathname.slice(enginePrefix.length)) : path.join(hostDist, url.pathname); + const root = url.pathname.startsWith(enginePrefix) ? releaseDir : hostDist; + const resolved = path.resolve(file); + + if (resolved.startsWith(path.resolve(root)) && fs.existsSync(resolved) && fs.statSync(resolved).isFile()) { + if (path.basename(resolved) === 'index.html') { + return send(200, injectScript(fs.readFileSync(resolved, 'utf8'), inject), CONTENT_TYPES['.html']); + } + response.writeHead(200, { 'Content-Type': CONTENT_TYPES[path.extname(resolved)] ?? 'application/octet-stream' }); + return fs.createReadStream(resolved).pipe(response); + } + + if (request.method === 'GET' && !path.extname(url.pathname) && (request.headers.accept ?? '').includes('text/html')) { + return send(200, injectScript(fs.readFileSync(path.join(hostDist, 'index.html'), 'utf8'), inject), CONTENT_TYPES['.html']); + } + + return send(404, JSON.stringify({ errors: ['Not found'] })); + }); + + return new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + const { port } = server.address(); + resolve({ origin: `http://127.0.0.1:${port}`, requests, close: () => new Promise((done) => server.close(done)) }); + }); + }); +} + +function injectScript(html, inject) { + if (!inject) { + return html; + } + + const appScript = /]+src="[^"]*\/assets\/@fleetbase\/console(-[0-9a-f]+)?\.js"[^>]*><\/script>/; + if (!appScript.test(html)) { + throw new Error('index.html has no @fleetbase/console app script to inject before'); + } + + return html.replace(appScript, (tag) => `\n${tag}`); +} + +/** + * Launch headless Chrome and open a DevTools session on its first page. + */ +async function launchChrome(chromePath) { + const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'runtime-engines-chrome-')); + const flags = ['--headless=new', '--remote-debugging-port=0', `--user-data-dir=${userDataDir}`, '--no-first-run', '--no-default-browser-check', '--disable-gpu', '--disable-extensions']; + const chrome = spawn(chromePath, [...flags, 'about:blank'], { stdio: 'ignore' }); + + const portFile = path.join(userDataDir, 'DevToolsActivePort'); + const deadline = Date.now() + 30000; + while (!fs.existsSync(portFile) || fs.readFileSync(portFile, 'utf8').split('\n').length < 2) { + if (Date.now() > deadline) { + chrome.kill(); + throw new Error('Chrome did not start'); + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + const port = fs.readFileSync(portFile, 'utf8').split('\n')[0].trim(); + const targets = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json(); + const page = targets.find((target) => target.type === 'page'); + const session = await connect(page.webSocketDebuggerUrl); + + session.close = async () => { + session.socket.close(); + chrome.kill(); + await new Promise((resolve) => chrome.once('exit', resolve)); + fs.rmSync(userDataDir, { recursive: true, force: true }); + }; + + return session; +} + +function connect(url) { + const socket = new WebSocket(url); + const pending = new Map(); + const events = []; + let nextId = 1; + + socket.addEventListener('message', (message) => { + const data = JSON.parse(message.data); + if (data.id && pending.has(data.id)) { + const { resolve, reject } = pending.get(data.id); + pending.delete(data.id); + data.error ? reject(new Error(data.error.message)) : resolve(data.result); + } else if (data.method) { + events.push(data); + } + }); + + const send = (method, params = {}) => + new Promise((resolve, reject) => { + const id = nextId++; + pending.set(id, { resolve, reject }); + socket.send(JSON.stringify({ id, method, params })); + }); + + const evaluate = async (expression) => { + const result = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true }); + if (result.exceptionDetails) { + throw new Error(result.exceptionDetails.exception?.description ?? result.exceptionDetails.text); + } + return result.result.value; + }; + + const waitFor = async (expression, { timeout = 180000, interval = 500 } = {}) => { + const deadline = Date.now() + timeout; + let last; + while (Date.now() < deadline) { + last = await evaluate(expression).catch((error) => ({ error: error.message })); + if (last?.ready) { + return last; + } + await new Promise((resolve) => setTimeout(resolve, interval)); + } + throw new Error(`Timed out waiting: ${JSON.stringify(last)}`); + }; + + /** + * Console messages and uncaught exceptions seen so far, as text. + */ + const logs = () => + events + .map((event) => { + if (event.method === 'Runtime.consoleAPICalled') { + return `${event.params.type}: ${event.params.args.map((arg) => arg.value ?? arg.description ?? '').join(' ')}`; + } + if (event.method === 'Runtime.exceptionThrown') { + return `exception: ${event.params.exceptionDetails.exception?.description ?? event.params.exceptionDetails.text}`; + } + return null; + }) + .filter(Boolean); + + return new Promise((resolve, reject) => { + socket.addEventListener('open', async () => { + await send('Runtime.enable'); + await send('Page.enable'); + resolve({ socket, send, evaluate, waitFor, logs, events }); + }); + socket.addEventListener('error', () => reject(new Error(`Could not connect to ${url}`))); + }); +} + +/** + * Transpile this checkout's runtime-engine sources to loader.js modules, for a + * host dist built with an older ember-core. The script defines them before + * the app bundle and then ignores the app bundle's own copies of the same + * names, so the boot runs this checkout's code. Babel comes from the + * console checkout's ember-cli-babel. + * + * @param {string} consoleDir console checkout with node_modules + * @param {Object} modules module name → source file + * @returns {string} + */ +function buildSourceOverlay(consoleDir, modules) { + const babelDir = fs.realpathSync(path.join(consoleDir, 'node_modules', 'ember-cli-babel')); + const resolve = (name) => require.resolve(name, { paths: [babelDir] }); + const babel = require(resolve('@babel/core')); + + const compiled = Object.entries(modules).map(([moduleId, file]) => { + const { code } = babel.transformSync(fs.readFileSync(file, 'utf8'), { + filename: file, + babelrc: false, + configFile: false, + moduleIds: true, + moduleId, + plugins: [ + [resolve('@babel/plugin-proposal-decorators'), { legacy: true }], + [resolve('@babel/plugin-transform-class-static-block')], + [resolve('@babel/plugin-transform-class-properties'), { loose: true }], + [resolve('@babel/plugin-transform-private-methods'), { loose: true }], + [resolve('@babel/plugin-transform-modules-amd'), { noInterop: true }], + ], + }); + return code; + }); + + return `(function () { + var originalDefine = window.define; + ${compiled.join('\n')} + var pinned = ${JSON.stringify(Object.keys(modules))}; + // A distinct name: a local \`var define\` would be hoisted over the modules above. + var guardedDefine = function (name) { + if (pinned.indexOf(name) !== -1) { return; } + return originalDefine.apply(this, arguments); + }; + Object.keys(originalDefine).forEach(function (key) { guardedDefine[key] = originalDefine[key]; }); + window.define = guardedDefine; +})();`; +} + +module.exports = { findChrome, serveConsole, launchChrome, buildSourceOverlay }; diff --git a/node-tests/runtime-engines-mount.test.js b/node-tests/runtime-engines-mount.test.js new file mode 100644 index 00000000..6d04ea43 --- /dev/null +++ b/node-tests/runtime-engines-mount.test.js @@ -0,0 +1,207 @@ +'use strict'; + +/** + * End to end: a prebuilt engine release mounts into a console that was built + * without it. + * + * Needs two build outputs, so it only runs when pointed at them: + * + * RUNTIME_ENGINES_HOST_DIST a console dist built WITHOUT the engine + * RUNTIME_ENGINES_RELEASE / written by bin/build-engine-release.js + * from a console build WITH it (same ember-ui/ember-core) + * RUNTIME_ENGINES_CONSOLE optional: a console checkout with node_modules. When set, + * this checkout's runtime-engine sources are transpiled and + * injected, for a host dist built with an older ember-core. + * CHROME_PATH optional: Chrome or Chromium binary + * + * node --test node-tests/runtime-engines-mount.test.js + * + * The page boots signed out against a stub API (only the runtime manifest and + * fleetbase.config.json are served; every other API call is a 404), which is + * enough to prove the mount: route map, host modules, asset manifest, config + * meta, translations, extension setup and loading the engine itself. + */ + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const { findChrome, serveConsole, launchChrome, buildSourceOverlay } = require('./helpers/runtime-console'); + +const HOST_DIST = process.env.RUNTIME_ENGINES_HOST_DIST; +const RELEASE = process.env.RUNTIME_ENGINES_RELEASE; +const CONSOLE = process.env.RUNTIME_ENGINES_CONSOLE; +const CHROME = findChrome(); +const EMBER_CORE = path.join(__dirname, '..'); + +const skip = + (!HOST_DIST || !RELEASE ? 'set RUNTIME_ENGINES_HOST_DIST and RUNTIME_ENGINES_RELEASE to run' : null) ?? + (typeof WebSocket === 'undefined' ? 'needs Node 22+ (global WebSocket)' : null) ?? + (!CHROME ? 'no Chrome found (set CHROME_PATH)' : null); + +function overlay() { + if (!CONSOLE) { + return undefined; + } + + const consoleApp = path.join(EMBER_CORE, '..', '..', 'console', 'app'); + return buildSourceOverlay(CONSOLE, { + '@fleetbase/ember-core/utils/runtime-engines': path.join(EMBER_CORE, 'addon/utils/runtime-engines.js'), + '@fleetbase/ember-core/utils/load-extensions': path.join(EMBER_CORE, 'addon/utils/load-extensions.js'), + '@fleetbase/ember-core/utils/load-installed-extensions': path.join(EMBER_CORE, 'addon/utils/load-installed-extensions.js'), + '@fleetbase/ember-core/services/universe/extension-manager': path.join(EMBER_CORE, 'addon/services/universe/extension-manager.js'), + '@fleetbase/console/initializers/load-runtime-config': path.join(consoleApp, 'initializers/load-runtime-config.js'), + '@fleetbase/console/initializers/load-runtime-engines': path.join(consoleApp, 'initializers/load-runtime-engines.js'), + '@fleetbase/console/instance-initializers/register-runtime-engines': path.join(consoleApp, 'instance-initializers/register-runtime-engines.js'), + }); +} + +/** + * Read the booted app from the page. Resolves `ready` once the extension + * manager has set up every extension, including the runtime one. + */ +const probe = (name) => `(() => { + const Namespace = require('@ember/application/namespace').default; + const app = Namespace.NAMESPACES.find((ns) => ns._applicationInstances && ns._applicationInstances.size); + if (!app) return { ready: false, stage: 'app' }; + const instance = [...app._applicationInstances][0]; + const router = instance.lookup('router:main'); + const recognizer = router._routerMicrolib && router._routerMicrolib.recognizer; + if (!recognizer) return { ready: false, stage: 'router' }; + const runtime = require('@fleetbase/ember-core/utils/runtime-engines'); + const extensions = instance.lookup('service:universe/extension-manager'); + const extensionModule = window.requirejs.entries[${JSON.stringify(`${name}/extension`)}]; + const settled = runtime.getRejectedRuntimeEngines().some((r) => r.name === ${JSON.stringify(name)}) || (extensionModule && extensionModule.state !== 'new'); + if (!settled) return { ready: false, stage: 'extensions' }; + return { ready: true }; +})()`; + +const inspect = (name, mount, translationKey) => `(async () => { + const Namespace = require('@ember/application/namespace').default; + const app = Namespace.NAMESPACES.find((ns) => ns._applicationInstances && ns._applicationInstances.size); + const instance = [...app._applicationInstances][0]; + const router = instance.lookup('router:main'); + const routeNames = Object.keys(router._routerMicrolib.recognizer.names); + const runtime = require('@fleetbase/ember-core/utils/runtime-engines'); + const extensions = instance.lookup('service:universe/extension-manager'); + const assetLoader = instance.lookup('service:asset-loader'); + const intl = instance.lookup('service:intl'); + const result = { + accepted: runtime.getRuntimeEngines().map((e) => e.name), + rejected: runtime.getRejectedRuntimeEngines(), + mountRoutes: routeNames.filter((n) => n === ${JSON.stringify(mount)} || n.startsWith(${JSON.stringify(`${mount}.`)})).length, + otherConsoleRoutes: routeNames.filter((n) => n.startsWith('console.') && !n.startsWith(${JSON.stringify(mount)})).length, + recognized: null, + configMeta: !!document.querySelector('meta[name="${name}/config/environment"]'), + inAssetManifest: !!assetLoader.__manifest.bundles[${JSON.stringify(name)}], + extensionInstalled: extensions.isInstalled(${JSON.stringify(name)}), + extensionModule: !!window.requirejs.entries[${JSON.stringify(`${name}/extension`)}], + translated: ${translationKey ? `intl.exists(${JSON.stringify(translationKey)}, 'en-us')` : 'null'}, + engineLoaded: null, + }; + try { + result.recognized = instance.lookup('service:router').recognize('/' + ${JSON.stringify(mount.replace(/^console\./, ''))}).name; + } catch (error) { + result.recognized = 'error: ' + error.message; + } + if (result.accepted.includes(${JSON.stringify(name)})) { + try { + const engine = await extensions.loadEngine(${JSON.stringify(name)}); + result.engineLoaded = engine.resolveRegistration('config:environment').modulePrefix; + } catch (error) { + result.engineLoaded = 'error: ' + error.message; + } + } + return result; +})()`; + +function firstTranslationKey(releaseDir) { + const translations = JSON.parse(fs.readFileSync(path.join(releaseDir, 'translations.json'), 'utf8')); + const messages = translations.find(([locale]) => locale === 'en-us')?.[1]; + const walk = (object, prefix) => { + for (const [key, value] of Object.entries(object ?? {})) { + const full = prefix ? `${prefix}.${key}` : key; + if (typeof value === 'string') return full; + const found = walk(value, full); + if (found) return found; + } + return null; + }; + return walk(messages, ''); +} + +function runtimeEntry(manifest, origin, extra = {}) { + return { ...manifest.runtime, name: manifest.name, version: manifest.version, baseUrl: `${origin}/engines-dist/${manifest.name}/`, manifest: 'manifest.json', ...extra }; +} + +test('a prebuilt engine mounts into a console built without it', { skip: skip ?? false, timeout: 600000 }, async (t) => { + const manifest = JSON.parse(fs.readFileSync(path.join(RELEASE, 'manifest.json'), 'utf8')); + const mount = manifest.runtime.mount.root ? manifest.runtime.mount.as : `console.${manifest.runtime.mount.as}`; + let mode = 'compatible'; + + const server = await serveConsole({ + hostDist: HOST_DIST, + releaseDir: RELEASE, + inject: overlay(), + runtimeManifest: (origin) => ({ + engines: [runtimeEntry(manifest, origin, mode === 'incompatible' ? { requires: ['@fleetbase/ember-ui/components/not-in-any-console'] } : {})], + }), + }); + const browser = await launchChrome(CHROME); + t.after(async () => { + await browser.close(); + await server.close(); + }); + + await t.test('the engine is mounted, set up and loadable', async (st) => { + await browser.send('Page.navigate', { url: `${server.origin}/` }); + await browser.waitFor(probe(manifest.name)); + const bootRequests = [...server.requests]; + const result = await browser.evaluate(inspect(manifest.name, mount, firstTranslationKey(RELEASE))); + const logs = browser.logs(); + st.diagnostic(JSON.stringify(result)); + + assert.deepEqual(result.rejected, [], 'nothing refused'); + assert.deepEqual(result.accepted, [manifest.name]); + assert.ok(result.mountRoutes > 1, `the engine's route map is mounted (${result.mountRoutes} routes under ${mount})`); + assert.ok(result.otherConsoleRoutes > 0, "the console's own routes and mounts are intact"); + assert.ok(result.recognized.startsWith(mount), `/${manifest.runtime.mount.path} resolves into the engine (${result.recognized}), not console.virtual`); + assert.ok(result.configMeta, 'config meta appended'); + assert.ok(result.inAssetManifest, 'bundle pushed into the asset manifest'); + assert.ok(result.extensionInstalled, 'listed as an installed extension'); + assert.ok(result.extensionModule, 'extension module loaded and run'); + assert.notEqual(result.translated, false, 'its translations are in ember-intl'); + assert.equal(result.engineLoaded, manifest.name, 'the engine bundle loads and boots'); + assert.deepEqual( + logs.filter((line) => line.includes(manifest.name) && /error|No loader registered|exception/i.test(line)), + [], + 'no errors about the engine' + ); + assert.ok(server.requests.some((request) => request.includes(`/engines-dist/${manifest.name}/routes.js`))); + + const engineJs = manifest.runtime.bundle.assets.find((asset) => asset.type === 'js' && /engine(-[0-9a-f]+)?\.js$/.test(asset.uri)).uri; + if (manifest.runtime.extension === 'extension.js') { + assert.ok( + bootRequests.some((request) => request.endsWith(`/engines-dist/${manifest.name}/extension.js`)), + 'the standalone extension module is loaded at boot' + ); + assert.ok(!bootRequests.some((request) => request.endsWith(`/engines-dist/${manifest.name}/${engineJs}`)), 'and the engine bundle is not: it stays lazy'); + } + assert.ok(server.requests.some((request) => request.endsWith(`/engines-dist/${manifest.name}/${engineJs}`)), 'the bundle loads when the engine is needed'); + }); + + await t.test('a release that needs a module the console lacks is refused and not mounted', async (st) => { + mode = 'incompatible'; + await browser.send('Page.navigate', { url: `${server.origin}/?incompatible` }); + await browser.waitFor(probe(manifest.name)); + const result = await browser.evaluate(inspect(manifest.name, mount, null)); + st.diagnostic(JSON.stringify(result)); + + assert.deepEqual(result.accepted, []); + assert.equal(result.rejected[0].reason, 'missing-modules'); + assert.deepEqual(result.rejected[0].missing, ['@fleetbase/ember-ui/components/not-in-any-console']); + assert.equal(result.mountRoutes, 0, 'no routes are mounted'); + assert.ok(!result.recognized.startsWith(mount), `the path falls through to the console (${result.recognized})`); + assert.equal(result.extensionModule, false, 'and nothing of it is loaded'); + }); +}); diff --git a/package.json b/package.json index 8484d190..71c444e4 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,11 @@ "repository": "https://github.com/fleetbase/ember-core", "license": "AGPL-3.0-or-later", "author": "Fleetbase Pte Ltd ", + "bin": { + "fleetbase-build-engine-release": "bin/build-engine-release.js", + "fleetbase-check-engine-compat": "bin/check-engine-compat.js", + "fleetbase-host-modules": "bin/host-modules.js" + }, "directories": { "app": "app", "addon": "addon", @@ -31,6 +36,7 @@ "test:ember": "ember test", "coverage": "COVERAGE=true ember test", "coverage:check": "node --test scripts/check-coverage.test.mjs && node scripts/check-coverage.mjs", + "test:node": "node --test node-tests/*.test.js", "test:ember-compatibility": "ember try:each", "publish:npm": "npm config set registry https://registry.npmjs.org/ && npm publish", "publish:github": "npm config set '@fleetbase:registry' https://npm.pkg.github.com/ && npm publish" diff --git a/tests/unit/services/universe/extension-manager-setup-test.js b/tests/unit/services/universe/extension-manager-setup-test.js index 65485827..6b1af340 100644 --- a/tests/unit/services/universe/extension-manager-setup-test.js +++ b/tests/unit/services/universe/extension-manager-setup-test.js @@ -2,6 +2,7 @@ import { module, test } from 'qunit'; import { setupTest } from 'dummy/tests/helpers'; import Service from '@ember/service'; import config from 'dummy/config/environment'; +import { prepareRuntimeEngines, registerRuntimeEngines, resetRuntimeEngines } from '@fleetbase/ember-core/utils/runtime-engines'; /** * `loadExtensions` and `setupExtensions` — the boot-time half of the extension @@ -18,6 +19,45 @@ import config from 'dummy/config/environment'; * same trick the fetch tests use on ember-fetch. */ const EXTENSIONS_MODULE = '@fleetbase/console/extensions'; +const RUNTIME_ENGINE = '@acme/runtime-engine'; + +/** + * Prepare and register one runtime engine the way the console's initializers + * do, with its route map and extension module defined in loader.js directly. + */ +async function installRuntimeEngine(calls) { + const engine = { + name: RUNTIME_ENGINE, + version: '1.0.0', + bundle: { assets: [{ uri: 'assets/engine.js', type: 'js' }] }, + routes: 'routes.js', + }; + const load = () => { + window.define(`${RUNTIME_ENGINE}/routes`, [], () => ({})); + return Promise.resolve(); + }; + await prepareRuntimeEngines( + { resolveRegistration: () => undefined }, + { payload: [engine], doc: document.implementation.createHTMLDocument(''), load, has: () => true } + ); + + const assetLoader = { + pushManifest() {}, + loadBundle() { + window.define(`${RUNTIME_ENGINE}/extension`, ['exports'], (exports) => { + exports.default = () => calls.push('runtime extension ran'); + }); + return Promise.resolve(); + }, + }; + registerRuntimeEngines({ lookup: (name) => (name === 'service:asset-loader' ? assetLoader : undefined) }); +} + +function uninstallRuntimeEngine() { + resetRuntimeEngines(); + delete window.requirejs.entries[`${RUNTIME_ENGINE}/routes`]; + delete window.requirejs.entries[`${RUNTIME_ENGINE}/extension`]; +} module('Unit | Service | universe/extension-manager (loading and setup)', function (hooks) { setupTest(hooks); @@ -133,6 +173,22 @@ module('Unit | Service | universe/extension-manager (loading and setup)', functi ); }); + test('runtime engines are added to the installed extensions', async function (assert) { + await installRuntimeEngine([]); + + try { + const extensions = await this.service.loadExtensions(this.application); + + assert.deepEqual( + extensions.map((e) => e.name), + ['@fleetbase/fleetops-engine', RUNTIME_ENGINE] + ); + assert.ok(this.application.engines[RUNTIME_ENGINE], 'with an engine dependency map'); + } finally { + uninstallRuntimeEngine(); + } + }); + test('it marks extensions as loaded', async function (assert) { let released = false; const waiting = this.service.waitForExtensionsLoaded().then(() => (released = true)); @@ -214,6 +270,20 @@ module('Unit | Service | universe/extension-manager (loading and setup)', functi assert.deepEqual(calls, [], 'it waits for the engine'); }); + test('a runtime engine falls back to its runtime extension loader', async function (assert) { + const calls = []; + await installRuntimeEngine(calls); + this.application.extensions = [{ name: RUNTIME_ENGINE }]; + + try { + await this.service.setupExtensions(this.appInstance, this.universeStub); + + assert.deepEqual(calls, ['runtime extension ran']); + } finally { + uninstallRuntimeEngine(); + } + }); + test('an extension with no registered loader is skipped', async function (assert) { await this.service.setupExtensions(this.appInstance, this.universeStub); diff --git a/tests/unit/utils/load-extensions-test.js b/tests/unit/utils/load-extensions-test.js index 61e6d1e7..3c814136 100644 --- a/tests/unit/utils/load-extensions-test.js +++ b/tests/unit/utils/load-extensions-test.js @@ -4,6 +4,7 @@ import { setupTest } from 'dummy/tests/helpers'; const CACHE_KEY = 'fleetbase_extensions_list'; const CACHE_VERSION_KEY = 'fleetbase_extensions_version'; +const CACHE_RUNTIME_SIGNATURE_KEY = 'fleetbase_extensions_runtime_signature'; /** * loadExtensions fetches /extensions.json, caching the result in localStorage @@ -65,6 +66,7 @@ module('Unit | Utility | load-extensions', function (hooks) { assert.deepEqual(cached.extensions, this.extensions); assert.ok(cached.timestamp, 'stamped with a time'); assert.strictEqual(window.localStorage.getItem(CACHE_VERSION_KEY), 'v1.2.3'); + assert.strictEqual(window.localStorage.getItem(CACHE_RUNTIME_SIGNATURE_KEY), '', 'with the (empty) runtime engine signature'); }); }); @@ -127,6 +129,42 @@ module('Unit | Utility | load-extensions', function (hooks) { assert.deepEqual(extensions, this.extensions); }); + test('in production an entry written for the same runtime engines is served', async function (assert) { + this.config.environment = 'production'; + this.config.APP.version = 'v1.2.3'; + window.localStorage.setItem(CACHE_KEY, JSON.stringify({ extensions: [{ name: 'cached' }], timestamp: Date.now() })); + window.localStorage.setItem(CACHE_VERSION_KEY, 'v1.2.3'); + window.localStorage.setItem(CACHE_RUNTIME_SIGNATURE_KEY, '@acme/widgets-engine@1.0.0'); + + const extensions = await loadExtensions({ runtimeSignature: '@acme/widgets-engine@1.0.0' }); + + assert.deepEqual(extensions, [{ name: 'cached' }]); + }); + + test('in production an entry from before a runtime engine changed is discarded', async function (assert) { + this.config.environment = 'production'; + this.config.APP.version = 'v1.2.3'; + window.localStorage.setItem(CACHE_KEY, JSON.stringify({ extensions: [{ name: 'cached' }], timestamp: Date.now() })); + window.localStorage.setItem(CACHE_VERSION_KEY, 'v1.2.3'); + window.localStorage.setItem(CACHE_RUNTIME_SIGNATURE_KEY, '@acme/widgets-engine@1.0.0'); + + const extensions = await loadExtensions({ runtimeSignature: '@acme/widgets-engine@1.1.0' }); + + assert.deepEqual(extensions, this.extensions, 'an install or update invalidates the cache'); + assert.strictEqual(window.localStorage.getItem(CACHE_RUNTIME_SIGNATURE_KEY), '@acme/widgets-engine@1.1.0', 'and the new list is recorded'); + }); + + test('in production an entry from before any runtime engine was installed is discarded', async function (assert) { + this.config.environment = 'production'; + this.config.APP.version = 'v1.2.3'; + window.localStorage.setItem(CACHE_KEY, JSON.stringify({ extensions: [{ name: 'cached' }], timestamp: Date.now() })); + window.localStorage.setItem(CACHE_VERSION_KEY, 'v1.2.3'); + + const extensions = await loadExtensions({ runtimeSignature: '@acme/widgets-engine@1.0.0' }); + + assert.deepEqual(extensions, this.extensions); + }); + test('in production a half-written cache is ignored', async function (assert) { this.config.environment = 'production'; window.localStorage.setItem(CACHE_KEY, JSON.stringify({ extensions: [{ name: 'cached' }], timestamp: Date.now() })); @@ -139,14 +177,16 @@ module('Unit | Utility | load-extensions', function (hooks) { }); module('clearExtensionsCache', function () { - test('it removes both keys', function (assert) { + test('it removes every key', function (assert) { window.localStorage.setItem(CACHE_KEY, 'anything'); window.localStorage.setItem(CACHE_VERSION_KEY, 'v1'); + window.localStorage.setItem(CACHE_RUNTIME_SIGNATURE_KEY, 'a@1'); clearExtensionsCache(); assert.strictEqual(window.localStorage.getItem(CACHE_KEY), null); assert.strictEqual(window.localStorage.getItem(CACHE_VERSION_KEY), null); + assert.strictEqual(window.localStorage.getItem(CACHE_RUNTIME_SIGNATURE_KEY), null); }); test('clearing an empty cache is harmless', function (assert) { diff --git a/tests/unit/utils/load-installed-extensions-test.js b/tests/unit/utils/load-installed-extensions-test.js index 6132e269..10bbdff7 100644 --- a/tests/unit/utils/load-installed-extensions-test.js +++ b/tests/unit/utils/load-installed-extensions-test.js @@ -153,6 +153,29 @@ module('Unit | Utility | load-installed-extensions', function (hooks) { }); }); + module('runtime engines', function () { + const runtimeEngine = (name, version = '1.0.0', mount = { as: 'widgets', path: 'widgets', root: false }) => ({ name, version, mount }); + + test('a runtime engine is installed without the registry vouching for it', async function (assert) { + this.manifest = [{ name: '@fleetbase/fleetops-engine' }]; + + const installed = await loadInstalledExtensions([], [runtimeEngine('@acme/widgets-engine')]); + + assert.deepEqual(installed, [ + { name: '@fleetbase/fleetops-engine' }, + { name: '@acme/widgets-engine', version: '1.0.0', route: 'widgets', fleetbase: { route: 'widgets' }, runtime: true }, + ]); + }); + + test('an engine the console was built with keeps its build-time entry', async function (assert) { + this.manifest = [{ name: '@fleetbase/pallet-engine', version: '0.0.2' }]; + + const installed = await loadInstalledExtensions([], [runtimeEngine('@fleetbase/pallet-engine', '0.0.3')]); + + assert.deepEqual(installed, [{ name: '@fleetbase/pallet-engine', version: '0.0.2' }]); + }); + }); + module('the core engine list', function () { test('every shipped engine is treated as core', async function (assert) { const core = [ diff --git a/tests/unit/utils/runtime-engines-test.js b/tests/unit/utils/runtime-engines-test.js new file mode 100644 index 00000000..ce4f41a4 --- /dev/null +++ b/tests/unit/utils/runtime-engines-test.js @@ -0,0 +1,660 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'dummy/tests/helpers'; +import { + RUNTIME_ENGINES_ENDPOINT, + RUNTIME_ENGINES_NAMESPACE, + appendConfigMeta, + createExtensionLoader, + fetchRuntimeEngines, + findMissingModules, + getRejectedRuntimeEngines, + getRuntimeEngines, + getRuntimeExtensionLoader, + hasModule, + isBuiltInEngine, + loadScript, + normalizeRuntimeEngine, + parseRuntimeManifest, + prepareRuntimeEngines, + registerRuntimeEngines, + resetRuntimeEngines, + resolveRuntimeUrl, + runtimeSignature, + toExtensionEntry, + wrapRouterMap, +} from '@fleetbase/ember-core/utils/runtime-engines'; +import prepareDefault from '@fleetbase/ember-core/utils/runtime-engines'; + +/** + * Runtime engines are prebuilt engine releases mounted into a console that was + * built without them. Everything they touch is a global — loader.js's module + * registry, document.head, fetch — or an object the caller passes (the + * application, the router class, the asset loader), so the tests use real + * loader.js modules and real script elements (blob URLs), and fakes for the + * rest. Each test removes the modules and meta tags it created. + */ +const ENGINE = '@acme/widgets-engine'; + +function blobScript(code) { + return URL.createObjectURL(new Blob([code], { type: 'text/javascript' })); +} + +function routesScript(name = ENGINE) { + return blobScript(`define(${JSON.stringify(`${name}/routes`)}, ["exports"], function (e) { e.default = function () {}; });`); +} + +function rawEngine(overrides = {}) { + const name = overrides.name ?? ENGINE; + return { + name, + version: '1.2.0', + baseUrl: `/engines-dist/${name}/`, + mount: { as: 'widgets', path: 'widgets' }, + bundle: { assets: [{ uri: 'assets/engine.js', type: 'js' }] }, + config: { modulePrefix: ENGINE }, + routes: 'routes.js', + ...overrides, + }; +} + +/** + * A stand-in for Ember's router DSL that records what is declared. `route` + * lives on the prototype, as on Ember's DSL, so the wrapper's own-property + * patch can be observed and its restore checked. + */ +class FakeDsl { + constructor(prefix = '', log = []) { + this.prefix = prefix; + this.log = log; + } + + route(name, ...rest) { + const fullName = this.prefix ? `${this.prefix}.${name}` : name; + this.log.push(`route ${fullName} (${rest.length + 1} args)`); + const callback = rest.find((arg) => typeof arg === 'function'); + if (callback) { + callback.call(new FakeDsl(fullName, this.log)); + } + } + + mount(name, options) { + const fullName = this.prefix ? `${this.prefix}.${options.as}` : options.as; + this.log.push(`mount ${name} at ${fullName} (/${options.path})`); + } +} + +function buildMap(Router) { + const dsl = new FakeDsl(); + Router.dslCallbacks.forEach((callback) => callback.call(dsl)); + return { dsl, log: dsl.log }; +} + +function consoleRouter() { + return { + dslCallbacks: [ + function () { + this.route('auth', function () { + this.route('login'); + }); + this.route('console', { path: '/' }, function () { + this.route('home'); + }); + this.route('catch', { path: '/*' }); + }, + ], + }; +} + +module('Unit | Utility | runtime-engines', function (hooks) { + setupTest(hooks); + + hooks.beforeEach(function () { + resetRuntimeEngines(); + this.definedModules = []; + this.define = (name, value = {}) => { + window.define(name, [], () => value); + this.definedModules.push(name); + }; + this.originalFetch = window.fetch; + this.fetched = []; + this.responses = {}; + window.fetch = (url) => { + this.fetched.push(String(url)); + const match = Object.keys(this.responses).find((key) => String(url).includes(key)); + if (!match) { + return Promise.resolve({ ok: false, status: 404, json: () => Promise.resolve({}) }); + } + return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(this.responses[match]) }); + }; + }); + + hooks.afterEach(function () { + window.fetch = this.originalFetch; + for (const name of this.definedModules) { + delete window.requirejs.entries[name]; + } + for (const name of [ENGINE, '@acme/other-engine', '@acme/root-engine']) { + delete window.requirejs.entries[`${name}/routes`]; + delete window.requirejs.entries[`${name}/extension`]; + document.querySelectorAll(`meta[name="${name}/config/environment"]`).forEach((meta) => meta.remove()); + } + resetRuntimeEngines(); + }); + + module('the manifest', function () { + test('relative paths resolve against the base URL; absolute ones are kept', function (assert) { + assert.strictEqual(resolveRuntimeUrl('/engines-dist/x/', 'assets/engine.js'), '/engines-dist/x/assets/engine.js'); + assert.strictEqual(resolveRuntimeUrl('/engines-dist/x/', '/elsewhere/engine.js'), '/elsewhere/engine.js'); + assert.strictEqual(resolveRuntimeUrl('/engines-dist/x/', 'https://cdn.example.test/engine.js'), 'https://cdn.example.test/engine.js'); + assert.strictEqual(resolveRuntimeUrl('/engines-dist/x/', 'blob:abc'), 'blob:abc'); + }); + + test('a complete entry is normalized with its URLs resolved', function (assert) { + const engine = normalizeRuntimeEngine( + rawEngine({ + baseUrl: 'https://cdn.example.test/widgets', + mount: { as: 'widgets', path: 'w', root: true }, + bundle: { assets: [{ uri: 'assets/engine.js', type: 'js' }], dependencies: ['leaflet'] }, + hostApp: 'host-app.js', + translations: 'translations.json', + extension: 'extension.js', + manifest: 'manifest.json', + requires: ['@fleetbase/ember-ui/components/button'], + }) + ); + + assert.deepEqual(engine, { + name: ENGINE, + version: '1.2.0', + baseUrl: 'https://cdn.example.test/widgets/', + mount: { as: 'widgets', path: 'w', root: true }, + bundle: { assets: [{ uri: 'https://cdn.example.test/widgets/assets/engine.js', type: 'js' }], dependencies: ['leaflet'] }, + config: { modulePrefix: ENGINE }, + routes: 'https://cdn.example.test/widgets/routes.js', + hostApp: 'https://cdn.example.test/widgets/host-app.js', + translations: 'https://cdn.example.test/widgets/translations.json', + extension: 'https://cdn.example.test/widgets/extension.js', + manifest: 'https://cdn.example.test/widgets/manifest.json', + requires: ['@fleetbase/ember-ui/components/button'], + }); + }); + + test('a minimal entry gets the build-time defaults', function (assert) { + const engine = normalizeRuntimeEngine({ name: ENGINE, bundle: { assets: [{ uri: 'assets/engine.js', type: 'js' }] }, routes: 'routes.js' }); + + assert.strictEqual(engine.baseUrl, '/engines-dist/@acme/widgets-engine/', 'served from engines-dist like a built engine'); + assert.deepEqual(engine.mount, { as: 'widgets', path: 'widgets', root: false }, 'mounted where the generator would mount it'); + assert.strictEqual(engine.version, null); + assert.strictEqual(engine.config, null); + assert.deepEqual(engine.bundle.dependencies, []); + assert.strictEqual(engine.hostApp, null); + assert.strictEqual(engine.translations, null); + assert.strictEqual(engine.manifest, null); + assert.strictEqual(engine.requires, null); + assert.true(engine.extension, 'without a standalone module, the extension comes from the bundle'); + }); + + test('an empty path counts as none, and extension:false means no extension', function (assert) { + const engine = normalizeRuntimeEngine(rawEngine({ hostApp: '', extension: '' })); + assert.strictEqual(engine.hostApp, null); + assert.true(engine.extension); + + assert.false(normalizeRuntimeEngine(rawEngine({ extension: false })).extension); + }); + + test('a mount with only a name uses it as the path', function (assert) { + assert.deepEqual(normalizeRuntimeEngine(rawEngine({ mount: { as: 'gadgets' } })).mount, { as: 'gadgets', path: 'gadgets', root: false }); + }); + + test('entries that cannot be mounted are ignored', function (assert) { + assert.strictEqual(normalizeRuntimeEngine(null), null, 'nothing'); + assert.strictEqual(normalizeRuntimeEngine({ name: '' }), null, 'no name'); + assert.strictEqual(normalizeRuntimeEngine({ name: 42 }), null, 'a name that is not a string'); + assert.strictEqual(normalizeRuntimeEngine(rawEngine({ bundle: undefined })), null, 'no bundle'); + assert.strictEqual(normalizeRuntimeEngine(rawEngine({ bundle: { assets: [] } })), null, 'an empty bundle'); + assert.strictEqual(normalizeRuntimeEngine(rawEngine({ routes: undefined })), null, 'no route map'); + }); + + test('the payload may be an object or a bare list; invalid entries and repeats are dropped', function (assert) { + const entries = [rawEngine(), rawEngine({ version: '9.9.9' }), { name: '' }, rawEngine({ name: '@acme/other-engine' })]; + + assert.deepEqual( + parseRuntimeManifest({ engines: entries }).map((engine) => `${engine.name}@${engine.version}`), + ['@acme/widgets-engine@1.2.0', '@acme/other-engine@1.2.0'], + 'the first entry of a name wins' + ); + assert.strictEqual(parseRuntimeManifest(entries).length, 2); + assert.deepEqual(parseRuntimeManifest(null), []); + assert.deepEqual(parseRuntimeManifest({}), []); + }); + + test('the signature changes with the set of engines and their versions, not their order', function (assert) { + const a = { name: 'a', version: '1.0.0' }; + const b = { name: 'b', version: null }; + + assert.strictEqual(runtimeSignature([b, a]), 'a@1.0.0,b@'); + assert.strictEqual(runtimeSignature([a, b]), runtimeSignature([b, a])); + assert.notStrictEqual(runtimeSignature([a]), runtimeSignature([{ ...a, version: '1.0.1' }])); + assert.strictEqual(runtimeSignature(), ''); + }); + + test('an engine becomes an extensions.json-shaped entry', function (assert) { + const engine = normalizeRuntimeEngine(rawEngine()); + assert.deepEqual(toExtensionEntry(engine), { name: ENGINE, version: '1.2.0', route: 'widgets', fleetbase: { route: 'widgets' }, runtime: true }); + + const root = normalizeRuntimeEngine(rawEngine({ mount: { as: 'portal', root: true } })); + assert.deepEqual(toExtensionEntry(root).fleetbase, { route: 'portal', mount: 'root' }); + }); + + test('it is fetched from the registry, falling back to an empty list', async function (assert) { + this.responses[RUNTIME_ENGINES_ENDPOINT] = { engines: [rawEngine()] }; + + const payload = await fetchRuntimeEngines(); + + assert.deepEqual(payload, { engines: [rawEngine()] }); + assert.true(this.fetched[0].includes(`${RUNTIME_ENGINES_NAMESPACE}/${RUNTIME_ENGINES_ENDPOINT}`)); + + delete this.responses[RUNTIME_ENGINES_ENDPOINT]; + assert.deepEqual(await fetchRuntimeEngines(), { engines: [] }, 'a console without the endpoint boots as before'); + }); + }); + + module('the host', function () { + test('a module is found under its name or its index', function (assert) { + this.define('@acme/lib/thing'); + this.define('@acme/lib/folder/index'); + + assert.true(hasModule('@acme/lib/thing')); + assert.true(hasModule('@acme/lib/folder')); + assert.false(hasModule('@acme/lib/nothing')); + }); + + test('missing modules are listed', function (assert) { + this.define('@acme/lib/thing'); + + assert.deepEqual(findMissingModules(['@acme/lib/thing', '@acme/lib/nothing']), ['@acme/lib/nothing']); + assert.deepEqual( + findMissingModules(['a', 'b'], (name) => name === 'a'), + ['b'], + 'the lookup can be supplied' + ); + }); + + test('an engine the console was built with is recognised by its config meta or its route map', function (assert) { + const doc = document.implementation.createHTMLDocument(''); + assert.false(isBuiltInEngine(ENGINE, doc)); + + this.define(`@acme/other-engine/routes`); + assert.true(isBuiltInEngine('@acme/other-engine', doc), 'by its route map in the host bundle'); + + const meta = doc.createElement('meta'); + meta.setAttribute('name', `${ENGINE}/config/environment`); + doc.head.appendChild(meta); + assert.true(isBuiltInEngine(ENGINE, doc), 'by its config meta'); + + assert.false(isBuiltInEngine('@acme/absent-engine'), 'the live document by default'); + }); + + test('the config meta is escaped the way the engine reads it', function (assert) { + const engine = normalizeRuntimeEngine(rawEngine({ config: { modulePrefix: ENGINE, greeting: 'héllo wörld' } })); + + appendConfigMeta(engine); + + const meta = document.querySelector(`meta[name="${ENGINE}/config/environment"]`); + assert.deepEqual(JSON.parse(unescape(meta.getAttribute('content'))), engine.config); + }); + + test('an existing meta tag is left alone, and no config means no tag', function (assert) { + const doc = document.implementation.createHTMLDocument(''); + appendConfigMeta(normalizeRuntimeEngine(rawEngine()), doc); + appendConfigMeta(normalizeRuntimeEngine(rawEngine({ config: { other: true } })), doc); + + const metas = doc.querySelectorAll(`meta[name="${ENGINE}/config/environment"]`); + assert.strictEqual(metas.length, 1); + assert.deepEqual(JSON.parse(unescape(metas[0].getAttribute('content'))), { modulePrefix: ENGINE }, 'the first one stays'); + + appendConfigMeta(normalizeRuntimeEngine(rawEngine({ name: '@acme/other-engine', config: undefined })), doc); + assert.strictEqual(doc.querySelectorAll('meta[name="@acme/other-engine/config/environment"]').length, 0); + }); + + test('scripts load through script elements', async function (assert) { + await loadScript(blobScript('window.__runtimeEnginesTestLoaded = 1;')); + assert.strictEqual(window.__runtimeEnginesTestLoaded, 1); + delete window.__runtimeEnginesTestLoaded; + + const url = blobScript(''); + URL.revokeObjectURL(url); + await assert.rejects(loadScript(url), /Could not load/, 'a failed load rejects'); + }); + }); + + module('the router map', function () { + test('console mounts go inside the generated console route; the other routes are untouched', function (assert) { + const Router = consoleRouter(); + const engines = [normalizeRuntimeEngine(rawEngine())]; + + assert.true(wrapRouterMap(Router, engines)); + const { dsl, log } = buildMap(Router); + + assert.deepEqual(log, [ + 'route auth (2 args)', + 'route auth.login (1 args)', + 'route console (3 args)', + 'route console.home (1 args)', + 'mount @acme/widgets-engine at console.widgets (/widgets)', + 'route catch (2 args)', + ]); + assert.strictEqual(dsl.route, FakeDsl.prototype.route, 'the DSL is restored'); + }); + + test('root mounts go before the catch-all route', function (assert) { + const Router = consoleRouter(); + wrapRouterMap(Router, [normalizeRuntimeEngine(rawEngine({ name: '@acme/root-engine', mount: { as: 'portal', root: true } }))]); + + const { log } = buildMap(Router); + + assert.deepEqual(log.slice(-2), ['mount @acme/root-engine at portal (/portal)', 'route catch (2 args)']); + assert.strictEqual(log.filter((line) => line.startsWith('mount')).length, 1, 'mounted once'); + }); + + test('without a catch-all, root mounts go at the end', function (assert) { + const Router = { + dslCallbacks: [ + function () { + this.route('console', function () {}); + }, + ], + }; + wrapRouterMap(Router, [normalizeRuntimeEngine(rawEngine({ name: '@acme/root-engine', mount: { as: 'portal', root: true } }))]); + + const { log } = buildMap(Router); + + assert.deepEqual(log, ['route console (2 args)', 'mount @acme/root-engine at portal (/portal)']); + }); + + test('a map without a console route mounts no console engines', function (assert) { + const Router = { + dslCallbacks: [ + function () { + this.route('console'); + this.route('about', function () {}); + }, + ], + }; + wrapRouterMap(Router, [normalizeRuntimeEngine(rawEngine())]); + + const { log } = buildMap(Router); + + assert.deepEqual(log, ['route console (1 args)', 'route about (2 args)'], 'a console route without children is not a mount point'); + }); + + test('a second console route in the same map is not mounted into again', function (assert) { + const Router = { + dslCallbacks: [ + function () { + this.route('console', function () {}); + this.route('console', function () {}); + }, + ], + }; + wrapRouterMap(Router, [normalizeRuntimeEngine(rawEngine())]); + + const { log } = buildMap(Router); + + assert.strictEqual(log.filter((line) => line.startsWith('mount')).length, 1); + }); + + test('each build of the map gets the mounts once', function (assert) { + const Router = consoleRouter(); + wrapRouterMap(Router, [normalizeRuntimeEngine(rawEngine())]); + + assert.strictEqual(buildMap(Router).log.filter((line) => line.startsWith('mount')).length, 1); + assert.strictEqual(buildMap(Router).log.filter((line) => line.startsWith('mount')).length, 1, 'a second router instance'); + }); + + test('nothing is wrapped twice, without engines, or without a map', function (assert) { + const Router = consoleRouter(); + const engines = [normalizeRuntimeEngine(rawEngine())]; + + assert.false(wrapRouterMap(Router, []), 'no engines'); + assert.true(wrapRouterMap(Router, engines)); + assert.false(wrapRouterMap(Router, engines), 'already wrapped'); + assert.false(wrapRouterMap(undefined, engines), 'no router'); + assert.false(wrapRouterMap({}, engines), 'no map'); + assert.false(wrapRouterMap({ dslCallbacks: [] }, engines), 'an empty map'); + }); + + test('the DSL is restored even when the map throws', function (assert) { + const Router = { + dslCallbacks: [ + function () { + throw new Error('bad map'); + }, + ], + }; + wrapRouterMap(Router, [normalizeRuntimeEngine(rawEngine())]); + const dsl = new FakeDsl(); + + assert.throws(() => Router.dslCallbacks[0].call(dsl), /bad map/); + assert.strictEqual(dsl.route, FakeDsl.prototype.route); + }); + }); + + module('before boot', function (hooks) { + hooks.beforeEach(function () { + this.Router = consoleRouter(); + this.application = { resolveRegistration: (name) => (name === 'router:main' ? this.Router : undefined) }; + this.doc = document.implementation.createHTMLDocument(''); + this.loaded = []; + this.load = (url) => { + this.loaded.push(url); + if (url.includes('broken')) { + return Promise.reject(new Error('404')); + } + if (url.endsWith('/routes.js')) { + const name = url.split('/engines-dist/')[1].replace('/routes.js', ''); + window.define(`${name}/routes`, [], () => ({})); + } + return Promise.resolve(); + }; + this.errors = []; + this.originalError = console.error; + console.error = (...args) => this.errors.push(args); + }); + + hooks.afterEach(function () { + console.error = this.originalError; + }); + + test('an accepted engine gets its config meta, scripts, translations and mount', async function (assert) { + this.responses['translations.json'] = [['en-us', { widgets: { title: 'Widgets' } }]]; + const payload = { engines: [rawEngine({ hostApp: 'host-app.js', translations: 'translations.json', requires: [] })] }; + + const accepted = await prepareRuntimeEngines(this.application, { payload, doc: this.doc, load: this.load }); + + assert.deepEqual( + accepted.map((engine) => engine.name), + [ENGINE] + ); + assert.strictEqual(getRuntimeEngines(), accepted); + assert.deepEqual(getRejectedRuntimeEngines(), []); + assert.deepEqual(this.loaded, ['/engines-dist/@acme/widgets-engine/routes.js', '/engines-dist/@acme/widgets-engine/host-app.js']); + assert.ok(this.doc.querySelector(`meta[name="${ENGINE}/config/environment"]`), 'config meta appended'); + assert.deepEqual(accepted[0].messages, [['en-us', { widgets: { title: 'Widgets' } }]]); + assert.true( + buildMap(this.Router).log.includes('mount @acme/widgets-engine at console.widgets (/widgets)'), + 'mounted' + ); + }); + + test('an engine without host modules or translations still loads', async function (assert) { + const accepted = await prepareRuntimeEngines(this.application, { payload: [rawEngine()], doc: this.doc, load: this.load }); + + assert.deepEqual(this.loaded, ['/engines-dist/@acme/widgets-engine/routes.js']); + assert.deepEqual(accepted[0].messages, []); + }); + + test('an engine the console was built with keeps its build', async function (assert) { + this.define(`${ENGINE}/routes`); + + const accepted = await prepareRuntimeEngines(this.application, { payload: [rawEngine()], doc: this.doc, load: this.load }); + + assert.deepEqual(accepted, []); + assert.deepEqual(this.loaded, [], 'nothing of the release is loaded'); + assert.deepEqual(getRejectedRuntimeEngines(), [], 'and it is not a refusal'); + }); + + test('an engine that needs modules the console lacks is refused', async function (assert) { + this.define('@acme/lib/present'); + const payload = [rawEngine({ requires: ['@acme/lib/present', '@acme/lib/absent'] })]; + + const accepted = await prepareRuntimeEngines(this.application, { payload, doc: this.doc, load: this.load }); + + assert.deepEqual(accepted, []); + assert.deepEqual(getRejectedRuntimeEngines(), [{ name: ENGINE, version: '1.2.0', reason: 'missing-modules', missing: ['@acme/lib/absent'] }]); + assert.deepEqual(this.loaded, [], 'nothing of it is loaded'); + assert.false(Boolean(this.doc.querySelector('meta')), 'not even its config'); + assert.deepEqual(this.errors[0][1], ['@acme/lib/absent'], 'the missing modules are logged'); + assert.strictEqual(buildMap(this.Router).log.filter((line) => line.startsWith('mount')).length, 0, 'and it is not mounted'); + }); + + test("the required modules come from the release's manifest.json when not inlined", async function (assert) { + this.responses['manifest.json'] = { requires: { modules: ['@acme/lib/absent'] } }; + const payload = [rawEngine({ manifest: 'manifest.json' }), rawEngine({ name: '@acme/other-engine', manifest: 'other.json' })]; + this.responses['other.json'] = {}; + + const accepted = await prepareRuntimeEngines(this.application, { payload, doc: this.doc, load: this.load, has: () => false }); + + assert.deepEqual( + accepted.map((engine) => engine.name), + ['@acme/other-engine'], + 'a manifest without requirements passes' + ); + assert.strictEqual(getRejectedRuntimeEngines()[0].reason, 'missing-modules'); + }); + + test('an engine whose manifest cannot be read is refused', async function (assert) { + const accepted = await prepareRuntimeEngines(this.application, { payload: [rawEngine({ manifest: 'gone.json' })], doc: this.doc, load: this.load }); + + assert.deepEqual(accepted, []); + assert.strictEqual(getRejectedRuntimeEngines()[0].reason, 'manifest-unavailable'); + assert.true(/gone\.json/.test(this.errors[0][1]), 'the error is logged'); + }); + + test('an engine whose files fail to load is refused', async function (assert) { + const payload = [rawEngine({ baseUrl: '/engines-dist/broken/' })]; + + const accepted = await prepareRuntimeEngines(this.application, { payload, doc: this.doc, load: this.load }); + + assert.deepEqual(accepted, []); + assert.deepEqual(getRejectedRuntimeEngines()[0].reason, 'load-failed'); + }); + + test('a routes file that does not define the route map is a load failure', async function (assert) { + const payload = [rawEngine()]; + + await prepareRuntimeEngines(this.application, { payload, doc: this.doc, load: () => Promise.resolve() }); + + assert.true(/did not define @acme\/widgets-engine\/routes/.test(getRejectedRuntimeEngines()[0].error)); + }); + + test('with no options it fetches the manifest and loads real scripts', async function (assert) { + this.responses[RUNTIME_ENGINES_ENDPOINT] = { engines: [rawEngine({ routes: routesScript() })] }; + + const accepted = await prepareDefault(this.application); + + assert.deepEqual( + accepted.map((engine) => engine.name), + [ENGINE] + ); + assert.ok(document.querySelector(`meta[name="${ENGINE}/config/environment"]`), 'on the live document'); + }); + }); + + module('at instance boot', function (hooks) { + hooks.beforeEach(function () { + this.pushed = []; + this.bundlesLoaded = []; + this.translations = []; + this.assetLoader = { + pushManifest: (manifest) => this.pushed.push(manifest), + loadBundle: (name) => { + this.bundlesLoaded.push(name); + window.define(`${name}/extension`, ['exports'], (exports) => { + exports.default = { from: 'bundle' }; + }); + return Promise.resolve(); + }, + }; + this.intl = { addTranslations: (locale, messages) => this.translations.push([locale, messages]) }; + this.appInstance = { + lookup: (name) => ({ 'service:asset-loader': this.assetLoader, 'service:intl': this.intl })[name], + }; + const load = (url) => { + if (url.endsWith('/routes.js')) { + window.define(`${ENGINE}/routes`, [], () => ({})); + } + return Promise.resolve(); + }; + const application = { resolveRegistration: () => consoleRouter() }; + this.prepare = (payload) => prepareRuntimeEngines(application, { payload, doc: document.implementation.createHTMLDocument(''), load, has: () => true }); + }); + + test('bundles, translations and extension loaders are registered', async function (assert) { + const [engine] = await this.prepare([rawEngine()]); + engine.messages = [['en-us', { a: 'A' }]]; + + const registered = registerRuntimeEngines(this.appInstance); + + assert.deepEqual(registered, [engine]); + assert.deepEqual(this.pushed, [{ bundles: { [ENGINE]: engine.bundle } }]); + assert.deepEqual(this.translations, [['en-us', { a: 'A' }]]); + assert.strictEqual(typeof getRuntimeExtensionLoader(ENGINE), 'function'); + }); + + test('nothing happens without runtime engines', function (assert) { + assert.deepEqual(registerRuntimeEngines(this.appInstance), []); + assert.deepEqual(this.pushed, []); + }); + + test('an engine without an extension or translations gets no loader', async function (assert) { + const [engine] = await this.prepare([rawEngine({ extension: false })]); + engine.messages = undefined; + + registerRuntimeEngines(this.appInstance); + + assert.strictEqual(getRuntimeExtensionLoader(ENGINE), undefined); + assert.deepEqual(this.pushed.length, 1, 'its bundle is still registered'); + }); + + test('a console without ember-intl skips the translations', async function (assert) { + const [engine] = await this.prepare([rawEngine()]); + engine.messages = [['en-us', { a: 'A' }]]; + this.intl = undefined; + + registerRuntimeEngines(this.appInstance); + + assert.deepEqual(this.translations, []); + assert.strictEqual(typeof getRuntimeExtensionLoader(ENGINE), 'function'); + }); + + test('the extension loader without a standalone module loads the bundle first', async function (assert) { + const [engine] = await this.prepare([rawEngine()]); + + const extension = await createExtensionLoader(engine, this.assetLoader)(); + + assert.deepEqual(this.bundlesLoaded, [ENGINE]); + assert.deepEqual(extension.default, { from: 'bundle' }); + }); + + test('the extension loader with a standalone module leaves the bundle alone', async function (assert) { + const url = blobScript(`define("${ENGINE}/extension", ["exports"], function (e) { e.default = { from: 'standalone' }; });`); + const [engine] = await this.prepare([rawEngine({ extension: url })]); + + const extension = await createExtensionLoader(engine, this.assetLoader)(); + + assert.deepEqual(this.bundlesLoaded, [], 'the engine stays lazy'); + assert.deepEqual(extension.default, { from: 'standalone' }); + }); + }); +});