From 244ab5509bcebb3a4b26ece6a252a8a00975635a Mon Sep 17 00:00:00 2001 From: Chris Manson Date: Thu, 16 May 2024 16:13:42 +0100 Subject: [PATCH] virtualise test entrypoint --- packages/compat/src/compat-app-builder.ts | 169 ++----------------- packages/core/src/module-resolver.ts | 35 +++- packages/core/src/virtual-content.ts | 6 + packages/core/src/virtual-test-entrypoint.ts | 85 ++++++++++ 4 files changed, 133 insertions(+), 162 deletions(-) create mode 100644 packages/core/src/virtual-test-entrypoint.ts diff --git a/packages/compat/src/compat-app-builder.ts b/packages/compat/src/compat-app-builder.ts index ca3312f98..46011b72b 100644 --- a/packages/compat/src/compat-app-builder.ts +++ b/packages/compat/src/compat-app-builder.ts @@ -20,7 +20,7 @@ import { Resolver, locateEmbroiderWorkingDir, } from '@embroider/core'; -import { resolve as resolvePath, posix } from 'path'; +import { resolve as resolvePath } from 'path'; import type { JSDOM } from 'jsdom'; import type Options from './options'; import type { CompatResolverOptions } from './resolver-transform'; @@ -341,7 +341,7 @@ export class CompatAppBuilder { return portable; } - private insertEmberApp(asset: ParsedEmberAsset, appFiles: AppFiles[], prepared: Map) { + private insertEmberApp(asset: ParsedEmberAsset) { let html = asset.html; if (this.fastbootConfig) { @@ -377,9 +377,7 @@ export class CompatAppBuilder { } // Test-related assets happen below this point - - let testJS = this.testJSEntrypoint(appFiles, prepared); - html.insertScriptTag(html.testJavascript, testJS.relativePath, { type: 'module' }); + html.insertScriptTag(html.javascript, '@embroider/core/test-entrypoint', { type: 'module' }); } // recurse to find all active addons that don't cross an engine boundary. @@ -529,7 +527,7 @@ export class CompatAppBuilder { } } - private prepareAsset(asset: Asset, appFiles: AppFiles[], prepared: Map) { + private prepareAsset(asset: Asset, prepared: Map) { if (asset.kind === 'ember') { let prior = this.assets.get(asset.relativePath); let parsed: ParsedEmberAsset; @@ -540,17 +538,17 @@ export class CompatAppBuilder { } else { parsed = new ParsedEmberAsset(asset); } - this.insertEmberApp(parsed, appFiles, prepared); + this.insertEmberApp(parsed); prepared.set(asset.relativePath, new BuiltEmberAsset(parsed)); } else { prepared.set(asset.relativePath, asset); } } - private prepareAssets(requestedAssets: Asset[], appFiles: AppFiles[]): Map { + private prepareAssets(requestedAssets: Asset[]): Map { let prepared: Map = new Map(); for (let asset of requestedAssets) { - this.prepareAsset(asset, appFiles, prepared); + this.prepareAsset(asset, prepared); } return prepared; } @@ -587,8 +585,8 @@ export class CompatAppBuilder { writeFileSync(destination, asset.source, 'utf8'); } - private async updateAssets(requestedAssets: Asset[], appFiles: AppFiles[]): Promise { - let assets = this.prepareAssets(requestedAssets, appFiles); + private async updateAssets(requestedAssets: Asset[]): Promise { + let assets = this.prepareAssets(requestedAssets); for (let asset of assets.values()) { if (this.assetIsValid(asset, this.assets.get(asset.relativePath))) { continue; @@ -656,7 +654,7 @@ export class CompatAppBuilder { let appFiles = this.updateAppJS(inputPaths.appJS); let assets = this.gatherAssets(inputPaths); - await this.updateAssets(assets, appFiles); + await this.updateAssets(assets); let assetPaths = assets.map(asset => asset.relativePath); @@ -801,49 +799,6 @@ export class CompatAppBuilder { private addAppBoot(appBoot?: string) { writeFileSync(join(locateEmbroiderWorkingDir(this.compatApp.root), 'ember-app-boot.js'), appBoot ?? ''); } - - private importPaths({ engine }: AppFiles, engineRelativePath: string) { - let noHBS = engineRelativePath.replace(this.resolvableExtensionsPattern, '').replace(/\.hbs$/, ''); - return { - runtime: `${engine.modulePrefix}/${noHBS}`, - buildtime: posix.join(engine.package.name, engineRelativePath), - }; - } - - private testJSEntrypoint(appFiles: AppFiles[], prepared: Map): InternalAsset { - let asset = prepared.get(`assets/test.js`); - if (asset) { - return asset; - } - - // We're only building tests from the first engine (the app). This is the - // normal thing to do -- tests from engines don't automatically roll up into - // the app. - let engine = appFiles[0]; - - const myName = 'assets/test.js'; - - let amdModules: { runtime: string; buildtime: string }[] = []; - - for (let relativePath of engine.tests) { - amdModules.push(this.importPaths(engine, relativePath)); - } - - let source = entryTemplate({ - amdModules, - testSuffix: true, - // this is a backward-compatibility feature: addons can force inclusion of test support modules. - defineModulesFrom: './-embroider-implicit-test-modules.js', - }); - - asset = { - kind: 'in-memory', - source, - relativePath: myName, - }; - prepared.set(asset.relativePath, asset); - return asset; - } } function defaultAddonPackageRules(): PackageRules[] { @@ -858,110 +813,6 @@ function defaultAddonPackageRules(): PackageRules[] { .reduce((a, b) => a.concat(b), []); } -const entryTemplate = jsHandlebarsCompile(` -import { importSync as i, macroCondition, getGlobalConfig } from '@embroider/macros'; -let w = window; -let d = w.define; - -{{#if styles}} - if (macroCondition(!getGlobalConfig().fastboot?.isRunning)) { - {{#each styles as |stylePath| ~}} - i("{{js-string-escape stylePath.path}}"); - {{/each}} - } -{{/if}} - -{{#if defineModulesFrom ~}} - import implicitModules from "{{js-string-escape defineModulesFrom}}"; - - for(const [name, module] of Object.entries(implicitModules)) { - d(name, function() { return module }); - } -{{/if}} - - -import "ember-testing"; -import "@embroider/core/entrypoint"; - -{{#each amdModules as |amdModule| ~}} - d("{{js-string-escape amdModule.runtime}}", function(){ return i("{{js-string-escape amdModule.buildtime}}");}); -{{/each}} - -{{#if fastbootOnlyAmdModules}} - if (macroCondition(getGlobalConfig().fastboot?.isRunning)) { - let fastbootModules = {}; - - {{#each fastbootOnlyAmdModules as |amdModule| ~}} - fastbootModules["{{js-string-escape amdModule.runtime}}"] = import("{{js-string-escape amdModule.buildtime}}"); - {{/each}} - - const resolvedValues = await Promise.all(Object.values(fastbootModules)); - - Object.keys(fastbootModules).forEach((k, i) => { - d(k, function(){ return resolvedValues[i];}); - }) - } -{{/if}} - - -{{#if lazyRoutes}} -w._embroiderRouteBundles_ = [ - {{#each lazyRoutes as |route|}} - { - names: {{json-stringify route.names}}, - load: function() { - return import("{{js-string-escape route.path}}"); - } - }, - {{/each}} -] -{{/if}} - -{{#if lazyEngines}} -w._embroiderEngineBundles_ = [ - {{#each lazyEngines as |engine|}} - { - names: {{json-stringify engine.names}}, - load: function() { - return import("{{js-string-escape engine.path}}"); - } - }, - {{/each}} -] -{{/if}} - -{{#if autoRun ~}} -if (!runningTests) { - i("{{js-string-escape mainModule}}").default.create({{json-stringify appConfig}}); -} -{{else if appBoot ~}} - {{ appBoot }} -{{/if}} - -{{#if testSuffix ~}} - {{!- TODO: both of these suffixes should get dynamically generated so they incorporate - any content-for added by addons. -}} - - - {{!- this is the traditional tests-suffix.js -}} - i('../tests/test-helper'); - EmberENV.TESTS_FILE_LOADED = true; -{{/if}} -`) as (params: { - amdModules: { runtime: string; buildtime: string }[]; - fastbootOnlyAmdModules?: { runtime: string; buildtime: string }[]; - defineModulesFrom?: string; - eagerModules?: string[]; - autoRun?: boolean; - appBoot?: string; - mainModule?: string; - appConfig?: unknown; - testSuffix?: boolean; - lazyRoutes?: { names: string[]; path: string }[]; - lazyEngines?: { names: string[]; path: string }[]; - styles?: { path: string }[]; -}) => string; - function stringOrBufferEqual(a: string | Buffer, b: string | Buffer): boolean { if (typeof a === 'string' && typeof b === 'string') { return a === b; diff --git a/packages/core/src/module-resolver.ts b/packages/core/src/module-resolver.ts index ab34268c2..07df079d4 100644 --- a/packages/core/src/module-resolver.ts +++ b/packages/core/src/module-resolver.ts @@ -201,6 +201,7 @@ export class Resolver { request = this.handleVendorStyles(request); request = this.handleTestSupportStyles(request); request = this.handleEntrypoint(request); + request = this.handleTestEntrypoint(request); request = this.handleRouteEntrypoint(request); request = this.handleRenaming(request); request = this.handleVendor(request); @@ -435,9 +436,6 @@ export class Resolver { if (isTerminal(request)) { return request; } - // TODO: also handle targeting from the outside (for engines) like: - // request.specifier === 'my-package-name/-embroider-entrypoint.js' - // just like implicit-modules does. //TODO move the extra forwardslash handling out into the vite plugin const candidates = ['@embroider/core/entrypoint', '/@embroider/core/entrypoint', './@embroider/core/entrypoint']; @@ -472,6 +470,37 @@ export class Resolver { return logTransition('entrypoint', request, request.virtualize(resolve(pkg.root, '-embroider-entrypoint.js'))); } + private handleTestEntrypoint(request: R): R { + if (isTerminal(request)) { + return request; + } + + //TODO move the extra forwardslash handling out into the vite plugin + const candidates = [ + '@embroider/core/test-entrypoint', + '/@embroider/core/test-entrypoint', + './@embroider/core/test-entrypoint', + ]; + + if (!candidates.some(c => request.specifier === c)) { + return request; + } + + const pkg = this.packageCache.ownerOfFile(request.fromFile); + + if (!pkg?.isV2Ember() || !pkg.isV2App()) { + throw new Error( + `bug: found test entrypoint import from somewhere other than the top-level app engine: ${request.fromFile}` + ); + } + + return logTransition( + 'test-entrypoint', + request, + request.virtualize(resolve(pkg.root, '-embroider-test-entrypoint.js')) + ); + } + private handleRouteEntrypoint(request: R): R { if (isTerminal(request)) { return request; diff --git a/packages/core/src/virtual-content.ts b/packages/core/src/virtual-content.ts index b46f64ab3..7e405462e 100644 --- a/packages/core/src/virtual-content.ts +++ b/packages/core/src/virtual-content.ts @@ -8,6 +8,7 @@ import { decodeVirtualVendor, renderVendor } from './virtual-vendor'; import { decodeVirtualVendorStyles, renderVendorStyles } from './virtual-vendor-styles'; import { decodeEntrypoint, renderEntrypoint } from './virtual-entrypoint'; +import { decodeTestEntrypoint, renderTestEntrypoint } from './virtual-test-entrypoint'; import { decodeRouteEntrypoint, renderRouteEntrypoint } from './virtual-route-entrypoint'; const externalESPrefix = '/@embroider/ext-es/'; @@ -33,6 +34,11 @@ export function virtualContent(filename: string, resolver: Resolver): VirtualCon return renderEntrypoint(resolver, entrypoint); } + let testEntrypoint = decodeTestEntrypoint(filename); + if (testEntrypoint) { + return renderTestEntrypoint(resolver, testEntrypoint); + } + let routeEntrypoint = decodeRouteEntrypoint(filename); if (routeEntrypoint) { return renderRouteEntrypoint(resolver, routeEntrypoint); diff --git a/packages/core/src/virtual-test-entrypoint.ts b/packages/core/src/virtual-test-entrypoint.ts new file mode 100644 index 000000000..cb9168f81 --- /dev/null +++ b/packages/core/src/virtual-test-entrypoint.ts @@ -0,0 +1,85 @@ +import { AppFiles } from './app-files'; +import { compile } from './js-handlebars'; +import type { Resolver } from './module-resolver'; +import { extensionsPattern } from '@embroider/shared-internals'; +import type { V2AddonPackage } from '@embroider/shared-internals/src/package'; +import { getAppFiles, importPaths, staticAppPathsPattern } from './virtual-entrypoint'; + +const entrypointPattern = /(?.*)[\\/]-embroider-test-entrypoint.js/; + +export function decodeTestEntrypoint(filename: string): { fromFile: string } | undefined { + // Performance: avoid paying regex exec cost unless needed + if (!filename.includes('-embroider-test-entrypoint.js')) { + return; + } + let m = entrypointPattern.exec(filename); + if (m) { + return { + fromFile: m.groups!.filename, + }; + } +} + +export function renderTestEntrypoint( + resolver: Resolver, + { fromFile }: { fromFile: string } +): { src: string; watches: string[] } { + const owner = resolver.packageCache.ownerOfFile(fromFile); + + if (!owner) { + throw new Error(`Owner expected while loading test entrypoint from file: ${fromFile}`); + } + + let engine = resolver.owningEngine(owner); + + let appFiles = new AppFiles( + { + package: owner, + addons: new Map( + engine.activeAddons.map(addon => [ + resolver.packageCache.get(addon.root) as V2AddonPackage, + addon.canResolveFromFile, + ]) + ), + isApp: true, + modulePrefix: resolver.options.modulePrefix, + appRelativePath: 'NOT_USED_DELETE_ME', + }, + getAppFiles(owner.root), + new Set(), // no fastboot files + extensionsPattern(resolver.options.resolvableExtensions), + staticAppPathsPattern(resolver.options.staticAppPaths), + resolver.options.podModulePrefix + ); + + let amdModules: { runtime: string; buildtime: string }[] = []; + + for (let relativePath of appFiles.tests) { + amdModules.push(importPaths(resolver, appFiles, relativePath)); + } + + let src = entryTemplate({ + amdModules, + }); + + return { + src, + watches: [], + }; +} + +const entryTemplate = compile(` +import { importSync as i } from '@embroider/macros'; +let w = window; +let d = w.define; + +import "ember-testing"; +import "@embroider/core/entrypoint"; + +{{#each amdModules as |amdModule| ~}} + d("{{js-string-escape amdModule.runtime}}", function(){ return i("{{js-string-escape amdModule.buildtime}}");}); +{{/each}} + +import('./tests/test-helper'); +EmberENV.TESTS_FILE_LOADED = true; +`) as (params: { amdModules: { runtime: string; buildtime: string }[] }) => string;