From 39b261c2adbe9cbdef38566cd69abc3e5dc1e68e Mon Sep 17 00:00:00 2001 From: killagu Date: Fri, 19 Jun 2026 21:08:16 +0800 Subject: [PATCH] feat(tegg-loader): route module discovery through shared loader fs Wire @eggjs/tegg-loader's module discovery through the shared @eggjs/loader-fs abstraction instead of importing globby directly, so a bundled app's manifest-backed VFS (ManifestLoaderFS) serves tegg discovery too. Normal startup keeps using RealLoaderFS, so behavior is unchanged. - ModuleLoader: discovery uses an injectable LoaderFS.glob (default RealLoaderFS); module loading (LoaderUtil.loadFile + bundle hook) is left untouched. - LoaderFactory: thread an optional loaderFS through createLoader/loadApp. - EggModuleLoader: reuse app.loader.loaderFS in buildAppGraph and loadModule. - drop the direct globby dependency from @eggjs/tegg-loader. Tests: - ModuleLoaderLoaderFS + LoaderFactoryManifest: discovery routes through the injected LoaderFS, precompute skips glob, default RealLoaderFS. - BundledDI (runtime): cross-module DI resolves via manifest decoratedFiles with a glob-throwing LoaderFS, proving zero disk scan. - BundledAppBoot (plugin): full app boots in manifest-consume mode and serves the controller -> service -> cross-module repo DI chain over HTTP; with an injected ManifestLoaderFS, core discovery does zero disk globs. Co-Authored-By: Claude Opus 4.8 (1M context) --- tegg/core/loader/package.json | 2 +- tegg/core/loader/src/LoaderFactory.ts | 15 +- tegg/core/loader/src/impl/ModuleLoader.ts | 22 ++- .../loader/test/LoaderFactoryManifest.test.ts | 19 +++ .../loader/test/ModuleLoaderLoaderFS.test.ts | 67 ++++++++ .../loader/test/ModuleLoaderManifest.test.ts | 8 +- tegg/core/runtime/package.json | 1 + tegg/core/runtime/test/BundledDI.test.ts | 126 +++++++++++++++ tegg/plugin/tegg/package.json | 1 + tegg/plugin/tegg/src/lib/EggModuleLoader.ts | 8 +- tegg/plugin/tegg/test/BundledAppBoot.test.ts | 153 ++++++++++++++++++ 11 files changed, 403 insertions(+), 19 deletions(-) create mode 100644 tegg/core/loader/test/ModuleLoaderLoaderFS.test.ts create mode 100644 tegg/core/runtime/test/BundledDI.test.ts create mode 100644 tegg/plugin/tegg/test/BundledAppBoot.test.ts diff --git a/tegg/core/loader/package.json b/tegg/core/loader/package.json index 8aa8cfe817..b395c67860 100644 --- a/tegg/core/loader/package.json +++ b/tegg/core/loader/package.json @@ -42,10 +42,10 @@ }, "dependencies": { "@eggjs/core-decorator": "workspace:*", + "@eggjs/loader-fs": "workspace:*", "@eggjs/metadata": "workspace:*", "@eggjs/tegg-types": "workspace:*", "@eggjs/typings": "workspace:*", - "globby": "catalog:", "is-type-of": "catalog:" }, "devDependencies": { diff --git a/tegg/core/loader/src/LoaderFactory.ts b/tegg/core/loader/src/LoaderFactory.ts index 35e12ff72b..2ac1e231eb 100644 --- a/tegg/core/loader/src/LoaderFactory.ts +++ b/tegg/core/loader/src/LoaderFactory.ts @@ -1,4 +1,5 @@ import { PrototypeUtil } from '@eggjs/core-decorator'; +import type { LoaderFS } from '@eggjs/loader-fs'; import type { ModuleDescriptor } from '@eggjs/metadata'; import { EggLoadUnitType, @@ -8,7 +9,7 @@ import { type ModuleReference, } from '@eggjs/tegg-types'; -export type LoaderCreator = (unitPath: string) => Loader; +export type LoaderCreator = (unitPath: string, loaderFS?: LoaderFS) => Loader; export interface ManifestModuleReference { name: string; @@ -40,12 +41,12 @@ export interface LoadAppManifest { export class LoaderFactory { private static loaderCreatorMap: Map = new Map(); - static createLoader(unitPath: string, type: EggLoadUnitTypeLike): Loader { + static createLoader(unitPath: string, type: EggLoadUnitTypeLike, loaderFS?: LoaderFS): Loader { const creator = this.loaderCreatorMap.get(type); if (!creator) { throw new Error(`not find creator for loader type ${type}`); } - return creator(unitPath); + return creator(unitPath, loaderFS); } static registerLoader(type: EggLoadUnitTypeLike, creator: LoaderCreator): void { @@ -55,6 +56,7 @@ export class LoaderFactory { static async loadApp( moduleReferences: readonly ModuleReference[], manifest?: LoadAppManifest, + loaderFS?: LoaderFS, ): Promise { const result: ModuleDescriptor[] = []; const multiInstanceClazzList: EggProtoImplClass[] = []; @@ -79,9 +81,12 @@ export class LoaderFactory { let loader: Loader; if (manifestDesc && ModuleLoaderClass && loaderType === EggLoadUnitType.MODULE) { - loader = new ModuleLoaderClass(moduleReference.path, manifestDesc.decoratedFiles); + loader = new ModuleLoaderClass(moduleReference.path, { + precomputedFiles: manifestDesc.decoratedFiles, + loaderFS, + }); } else { - loader = LoaderFactory.createLoader(moduleReference.path, loaderType); + loader = LoaderFactory.createLoader(moduleReference.path, loaderType, loaderFS); } const res: ModuleDescriptor = { diff --git a/tegg/core/loader/src/impl/ModuleLoader.ts b/tegg/core/loader/src/impl/ModuleLoader.ts index 4d20d5b542..c32e64d0b6 100644 --- a/tegg/core/loader/src/impl/ModuleLoader.ts +++ b/tegg/core/loader/src/impl/ModuleLoader.ts @@ -1,23 +1,31 @@ import path from 'node:path'; import { debuglog } from 'node:util'; +import { RealLoaderFS, type LoaderFS } from '@eggjs/loader-fs'; import type { EggProtoImplClass, Loader } from '@eggjs/tegg-types'; -import globby from 'globby'; import { LoaderFactory } from '../LoaderFactory.ts'; import { LoaderUtil } from '../LoaderUtil.ts'; const debug = debuglog('egg/tegg/loader/impl/ModuleLoader'); +export interface ModuleLoaderOptions { + /** Pre-computed file list from manifest (only decorated files) */ + precomputedFiles?: string[]; + /** File system abstraction used for discovery; manifest-backed in bundle mode */ + loaderFS?: LoaderFS; +} + export class ModuleLoader implements Loader { private readonly moduleDir: string; private protoClazzList: EggProtoImplClass[]; - /** Pre-computed file list from manifest (only decorated files) */ private readonly precomputedFiles?: string[]; + private readonly loaderFS: LoaderFS; - constructor(moduleDir: string, precomputedFiles?: string[]) { + constructor(moduleDir: string, options: ModuleLoaderOptions = {}) { this.moduleDir = moduleDir; - this.precomputedFiles = precomputedFiles; + this.precomputedFiles = options.precomputedFiles; + this.loaderFS = options.loaderFS ?? new RealLoaderFS(); } async load(): Promise { @@ -33,7 +41,7 @@ export class ModuleLoader implements Loader { debug('load from manifest, files: %o, moduleDir: %o', files, this.moduleDir); } else { const filePattern = LoaderUtil.filePattern(); - files = await globby(filePattern, { cwd: this.moduleDir }); + files = this.loaderFS.glob(filePattern, { cwd: this.moduleDir }); debug('load files: %o, filePattern: %o, moduleDir: %o', files, filePattern, this.moduleDir); } for (const file of files) { @@ -47,8 +55,8 @@ export class ModuleLoader implements Loader { return this.protoClazzList; } - static createModuleLoader(path: string): ModuleLoader { - return new ModuleLoader(path); + static createModuleLoader(path: string, loaderFS?: LoaderFS): ModuleLoader { + return new ModuleLoader(path, { loaderFS }); } } diff --git a/tegg/core/loader/test/LoaderFactoryManifest.test.ts b/tegg/core/loader/test/LoaderFactoryManifest.test.ts index ecb2f4ed38..5c1ec9f6b3 100644 --- a/tegg/core/loader/test/LoaderFactoryManifest.test.ts +++ b/tegg/core/loader/test/LoaderFactoryManifest.test.ts @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import path from 'node:path'; +import { RealLoaderFS, type LoaderFSGlobOptions } from '@eggjs/loader-fs'; import { ModuleDescriptorDumper } from '@eggjs/metadata'; import { describe, it } from 'vitest'; @@ -61,6 +62,24 @@ describe('core/loader/test/LoaderFactoryManifest.test.ts', () => { assert(descriptors[0].clazzList.length > 0); }); + it('should route discovery through an injected LoaderFS (non-manifest branch)', async () => { + class StubLoaderFS extends RealLoaderFS { + globCalls = 0; + glob(_patterns: string | string[], _options?: LoaderFSGlobOptions): string[] { + this.globCalls++; + return ['UserRepo.ts']; + } + } + const loaderFS = new StubLoaderFS(); + const descriptors = await LoaderFactory.loadApp([moduleRef], undefined, loaderFS); + + assert.equal(descriptors.length, 1); + // Only the file the stub returned is loaded. + const names = descriptors[0].clazzList.map((c) => c.name).sort(); + assert.deepStrictEqual(names, ['UserRepo']); + assert.equal(loaderFS.globCalls, 1); + }); + it('should roundtrip: loadApp → getDecoratedFiles → loadApp(manifest)', async () => { // Step 1: normal load const firstDescs = await LoaderFactory.loadApp([moduleRef]); diff --git a/tegg/core/loader/test/ModuleLoaderLoaderFS.test.ts b/tegg/core/loader/test/ModuleLoaderLoaderFS.test.ts new file mode 100644 index 0000000000..7c15dc0438 --- /dev/null +++ b/tegg/core/loader/test/ModuleLoaderLoaderFS.test.ts @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict'; +import path from 'node:path'; + +import { RealLoaderFS, type LoaderFS, type LoaderFSGlobOptions } from '@eggjs/loader-fs'; +import { describe, it } from 'vitest'; + +import { ModuleLoader } from '../src/impl/ModuleLoader.ts'; + +describe('core/loader/test/ModuleLoaderLoaderFS.test.ts', () => { + const repoModulePath = path.join(__dirname, './fixtures/modules/module-for-loader'); + + /** A LoaderFS whose glob returns a fixed list; everything else delegates to the real fs. */ + class StubLoaderFS extends RealLoaderFS implements LoaderFS { + globCalls: Array<{ patterns: string | string[]; options?: LoaderFSGlobOptions }> = []; + private readonly files: string[]; + constructor(files: string[]) { + super(); + this.files = files; + } + glob(patterns: string | string[], options?: LoaderFSGlobOptions): string[] { + this.globCalls.push({ patterns, options }); + return this.files; + } + } + + it('should discover files through the injected LoaderFS.glob', async () => { + const loaderFS = new StubLoaderFS(['UserRepo.ts']); + const loader = new ModuleLoader(repoModulePath, { loaderFS }); + const prototypes = await loader.load(); + + // Only the single file the stub returned is loaded (UserRepo). + assert.equal(prototypes.length, 1); + assert(prototypes.find((t) => t.name === 'UserRepo')); + // Discovery went through the injected fs with the module dir as cwd. + assert.equal(loaderFS.globCalls.length, 1); + assert.equal(loaderFS.globCalls[0].options?.cwd, repoModulePath); + }); + + it('should not call glob when precomputed files are provided', async () => { + const loaderFS = new StubLoaderFS(['UserRepo.ts']); + const loader = new ModuleLoader(repoModulePath, { precomputedFiles: ['AppRepo.ts'], loaderFS }); + const prototypes = await loader.load(); + + // AppRepo.ts has 2 decorated classes; glob is never consulted. + assert.equal(prototypes.length, 2); + assert.equal(loaderFS.globCalls.length, 0); + }); + + it('should default to RealLoaderFS discovery when no LoaderFS is injected', async () => { + const loader = new ModuleLoader(repoModulePath); + const prototypes = await loader.load(); + + // Real globby-equivalent discovery finds every decorated class in the fixture. + const names = prototypes.map((p) => p.name).sort(); + assert.deepStrictEqual(names, ['AppRepo', 'AppRepo2', 'SprintRepo', 'UserRepo']); + }); + + it('createModuleLoader should forward the injected LoaderFS', async () => { + const loaderFS = new StubLoaderFS(['SprintRepo.ts']); + const loader = ModuleLoader.createModuleLoader(repoModulePath, loaderFS); + const prototypes = await loader.load(); + + assert.equal(prototypes.length, 1); + assert(prototypes.find((t) => t.name === 'SprintRepo')); + assert.equal(loaderFS.globCalls.length, 1); + }); +}); diff --git a/tegg/core/loader/test/ModuleLoaderManifest.test.ts b/tegg/core/loader/test/ModuleLoaderManifest.test.ts index b797d96144..42ca958507 100644 --- a/tegg/core/loader/test/ModuleLoaderManifest.test.ts +++ b/tegg/core/loader/test/ModuleLoaderManifest.test.ts @@ -11,7 +11,7 @@ describe('core/loader/test/ModuleLoaderManifest.test.ts', () => { const repoModulePath = path.join(__dirname, './fixtures/modules/module-for-loader'); it('should load only precomputed files when provided', async () => { - const loader = new ModuleLoader(repoModulePath, ['AppRepo.ts']); + const loader = new ModuleLoader(repoModulePath, { precomputedFiles: ['AppRepo.ts'] }); const prototypes = await loader.load(); // AppRepo.ts has 2 decorated classes: AppRepo and AppRepo2 assert.equal(prototypes.length, 2); @@ -27,7 +27,7 @@ describe('core/loader/test/ModuleLoaderManifest.test.ts', () => { // Load via precomputed files (manifest path) // Get the file list from normal loading to ensure consistency const fileNames = ['AppRepo.ts', 'SprintRepo.ts', 'UserRepo.ts']; - const manifestLoader = new ModuleLoader(repoModulePath, fileNames); + const manifestLoader = new ModuleLoader(repoModulePath, { precomputedFiles: fileNames }); const manifestProtos = await manifestLoader.load(); // Same number and same class names @@ -38,13 +38,13 @@ describe('core/loader/test/ModuleLoaderManifest.test.ts', () => { }); it('should return empty list for empty precomputedFiles', async () => { - const loader = new ModuleLoader(repoModulePath, []); + const loader = new ModuleLoader(repoModulePath, { precomputedFiles: [] }); const prototypes = await loader.load(); assert.equal(prototypes.length, 0); }); it('should cache result on subsequent calls', async () => { - const loader = new ModuleLoader(repoModulePath, ['AppRepo.ts']); + const loader = new ModuleLoader(repoModulePath, { precomputedFiles: ['AppRepo.ts'] }); const first = await loader.load(); const second = await loader.load(); assert.strictEqual(first, second); diff --git a/tegg/core/runtime/package.json b/tegg/core/runtime/package.json index a7e20a918b..70fc9760d9 100644 --- a/tegg/core/runtime/package.json +++ b/tegg/core/runtime/package.json @@ -48,6 +48,7 @@ "@eggjs/tegg-types": "workspace:*" }, "devDependencies": { + "@eggjs/loader-fs": "workspace:*", "@eggjs/module-test-util": "workspace:*", "@eggjs/tegg-loader": "workspace:*", "@types/node": "catalog:", diff --git a/tegg/core/runtime/test/BundledDI.test.ts b/tegg/core/runtime/test/BundledDI.test.ts new file mode 100644 index 0000000000..23815dd8ce --- /dev/null +++ b/tegg/core/runtime/test/BundledDI.test.ts @@ -0,0 +1,126 @@ +import assert from 'node:assert/strict'; +import path from 'node:path'; + +import { type EggProtoImplClass, PrototypeUtil } from '@eggjs/core-decorator'; +import { RealLoaderFS, type LoaderFS, type LoaderFSGlobOptions } from '@eggjs/loader-fs'; +import { + EggLoadUnitType, + EggPrototypeFactory, + GlobalGraph, + GlobalModuleNodeBuilder, + LoadUnitFactory, +} from '@eggjs/metadata'; +import { ModuleConfigUtil } from '@eggjs/tegg-common-util'; +import { ModuleLoader } from '@eggjs/tegg-loader'; +import { type LoadUnitInstance } from '@eggjs/tegg-types'; +import { describe, beforeAll, afterAll, it } from 'vitest'; + +import { EggContainerFactory, LoadUnitInstanceFactory } from '../src/index.ts'; +import { ContextHandler } from '../src/model/ContextHandler.ts'; +import { EggContextStorage } from './fixtures/EggContextStorage.ts'; +import { EggTestContext } from './fixtures/EggTestContext.ts'; +import AppService from './fixtures/modules/multi-module/multi-module-service/AppService.ts'; + +/** + * Theme G (attempt) — bundled cross-module DI regression. + * + * Drives the same multi-module fixtures as LoadUnitInstance.test.ts, but loads + * every module through the manifest path (precomputed `decoratedFiles`) instead + * of globby discovery. A LoaderFS whose `glob` throws guarantees the whole chain + * never falls back to a real filesystem scan, mirroring how a bundled app loads + * from a manifest-backed VFS. Verifies that cross-module dependency injection + * (service -> public repo -> private persistence) still resolves end to end. + */ +describe('test/BundledDI.test.ts', () => { + const fixturesRoot = path.join(__dirname, 'fixtures/modules/multi-module'); + + // The "manifest": files that contain decorated classes, relative to each module. + const manifest: Array<{ dir: string; decoratedFiles: string[] }> = [ + { dir: 'multi-module-common', decoratedFiles: [] }, + { dir: 'multi-module-repo', decoratedFiles: ['AppRepo.ts', 'PersistenceService.ts'] }, + { dir: 'multi-module-service', decoratedFiles: ['AppService.ts'] }, + ]; + + // A LoaderFS that serves everything from the real fs EXCEPT discovery: any + // glob call means we fell off the manifest path, which must not happen. + class NoGlobLoaderFS extends RealLoaderFS implements LoaderFS { + glob(_patterns: string | string[], _options?: LoaderFSGlobOptions): string[] { + throw new Error('discovery must come from the manifest, glob should never be called'); + } + } + const loaderFS = new NoGlobLoaderFS(); + + const loadUnitInstances: LoadUnitInstance[] = []; + + async function loadModuleProtos(absPath: string, decoratedFiles: string[]): Promise { + return new ModuleLoader(absPath, { precomputedFiles: decoratedFiles, loaderFS }).load(); + } + + beforeAll(async () => { + EggContextStorage.register(); + + const absModules = manifest.map((m) => ({ ...m, absPath: path.join(fixturesRoot, m.dir) })); + + // Build the global module graph from manifest-discovered protos. + GlobalGraph.instance = new GlobalGraph(); + for (const { absPath, decoratedFiles } of absModules) { + const clazzList = await loadModuleProtos(absPath, decoratedFiles); + const eggProtoClass = clazzList.filter((clazz) => PrototypeUtil.isEggPrototype(clazz)); + const builder = GlobalModuleNodeBuilder.create(absPath, false); + for (const clazz of eggProtoClass) { + builder.addClazz(clazz); + } + GlobalGraph.instance.addModuleNode(builder.build()); + } + // build()/sort() validate cross-module dependency resolution; they throw if + // AppService cannot reach AppRepo across the module boundary. + GlobalGraph.instance.build(); + GlobalGraph.instance.sort(); + + // Create runtime load unit instances, again via the manifest loaders. + for (const { absPath, decoratedFiles } of absModules) { + const loader = new ModuleLoader(absPath, { precomputedFiles: decoratedFiles, loaderFS }); + const loadUnit = await LoadUnitFactory.createLoadUnit(absPath, EggLoadUnitType.MODULE, loader); + loadUnitInstances.push(await LoadUnitInstanceFactory.createLoadUnitInstance(loadUnit)); + } + }); + + afterAll(async () => { + for (const instance of loadUnitInstances) { + await LoadUnitInstanceFactory.destroyLoadUnitInstance(instance); + await LoadUnitFactory.destroyLoadUnit(instance.loadUnit); + } + }); + + it('should resolve module names from the real package.json without globbing', () => { + // Sanity: non-discovery fs operations still work through the real fs. + const repoName = ModuleConfigUtil.readModuleNameSync(path.join(fixturesRoot, 'multi-module-repo')); + assert.equal(repoName, 'multi-module-repo'); + }); + + it('should inject a public proto across module boundaries (manifest path)', async () => { + const serviceInstance = loadUnitInstances.find((i) => i.loadUnit.unitPath.endsWith('multi-module-service')); + assert.ok(serviceInstance, 'service load unit instance should exist'); + + const appServiceProto = EggPrototypeFactory.instance.getPrototype('appService', serviceInstance.loadUnit); + assert.ok(appServiceProto, 'appService prototype should be discovered from manifest decoratedFiles'); + + const saveCtx = new EggTestContext(); + const findCtx = new EggTestContext(); + + await ContextHandler.run(saveCtx, async () => { + const obj = await EggContainerFactory.getOrCreateEggObject(appServiceProto, appServiceProto.name); + const appService = obj.obj as AppService; + // appService -> appRepo (other module, PUBLIC) -> persistenceService injected and usable + await appService.save({ name: 'bundled-app', desc: 'loaded-from-manifest' }); + }); + + const found = await ContextHandler.run(findCtx, async () => { + const obj = await EggContainerFactory.getOrCreateEggObject(appServiceProto, appServiceProto.name); + const appService = obj.obj as AppService; + return appService.findApp('bundled-app'); + }); + + assert.deepStrictEqual(found, { name: 'bundled-app', desc: 'loaded-from-manifest' }); + }); +}); diff --git a/tegg/plugin/tegg/package.json b/tegg/plugin/tegg/package.json index 64ba3468ef..89ed922529 100644 --- a/tegg/plugin/tegg/package.json +++ b/tegg/plugin/tegg/package.json @@ -101,6 +101,7 @@ "sdk-base": "catalog:" }, "devDependencies": { + "@eggjs/core": "workspace:*", "@eggjs/mock": "workspace:*", "@eggjs/tegg": "workspace:*", "@eggjs/tegg-config": "workspace:*", diff --git a/tegg/plugin/tegg/src/lib/EggModuleLoader.ts b/tegg/plugin/tegg/src/lib/EggModuleLoader.ts index fc97c39ede..f7dae5843d 100644 --- a/tegg/plugin/tegg/src/lib/EggModuleLoader.ts +++ b/tegg/plugin/tegg/src/lib/EggModuleLoader.ts @@ -40,7 +40,10 @@ export class EggModuleLoader { const manifestTegg = manifest.getExtension(TEGG_MANIFEST_KEY) as TeggManifestExtension | undefined; const loadAppManifest = manifestTegg?.moduleDescriptors?.length ? manifestTegg : undefined; - const moduleDescriptors = await LoaderFactory.loadApp(this.app.moduleReferences, loadAppManifest); + // Reuse egg-core's loader fs so discovery goes through the shared VFS: + // RealLoaderFS in normal mode (zero behavior change), ManifestLoaderFS in bundle mode. + const loaderFS = this.app.loader.loaderFS; + const moduleDescriptors = await LoaderFactory.loadApp(this.app.moduleReferences, loadAppManifest, loaderFS); // Collect manifest data when not loaded from manifest if (!loadAppManifest) { @@ -94,9 +97,10 @@ export class EggModuleLoader { this.globalGraph.build(); this.globalGraph.sort(); const moduleConfigList = this.globalGraph.moduleConfigList; + const loaderFS = this.app.loader.loaderFS; for (const moduleConfig of moduleConfigList) { const modulePath = moduleConfig.path; - const loader = LoaderFactory.createLoader(modulePath, EggLoadUnitType.MODULE); + const loader = LoaderFactory.createLoader(modulePath, EggLoadUnitType.MODULE, loaderFS); const loadUnit = await LoadUnitFactory.createLoadUnit(modulePath, EggLoadUnitType.MODULE, loader); this.app.moduleHandler.loadUnits.push(loadUnit); } diff --git a/tegg/plugin/tegg/test/BundledAppBoot.test.ts b/tegg/plugin/tegg/test/BundledAppBoot.test.ts new file mode 100644 index 0000000000..2575593199 --- /dev/null +++ b/tegg/plugin/tegg/test/BundledAppBoot.test.ts @@ -0,0 +1,153 @@ +import assert from 'node:assert/strict'; +import path from 'node:path'; + +import { ManifestStore, ManifestLoaderFS, RealLoaderFS } from '@eggjs/core'; +import type { LoaderFSGlobOptions } from '@eggjs/core'; +import { mm, type MockApplication } from '@eggjs/mock'; +import { TEGG_MANIFEST_KEY } from '@eggjs/tegg-loader'; +import type { TeggManifestExtension } from '@eggjs/tegg-loader'; +import { describe, it, beforeAll, afterEach, afterAll } from 'vitest'; + +import { getAppBaseDir } from './utils.ts'; + +/** + * Bundle-mode app-boot integration test. + * + * Reproduces the production bundled startup path (see egg-bundler EntryGenerator): + * ManifestStore.fromBundle(data) -> setBundleStore -> start app + * so the tegg loader runs through EggModuleLoader.buildAppGraph's `loadAppManifest` + * (manifest-consume) branch instead of globby discovery. + * + * Step 1: a tegg app boots (`app.ready()`) in manifest-consume mode. + * Step 2: core framework features work end to end in that mode — an HTTP request + * drives the full controller -> service -> cross-module repo DI chain. + */ +describe('plugin/tegg/test/BundledAppBoot.test.ts', () => { + const baseDir = getAppBaseDir('egg-app'); + let manifestData: ReturnType; + let app: MockApplication; + + // Records the cwd of every fallback (real-fs) glob the manifest VFS could not + // serve from the manifest, so the test can prove WHERE disk discovery still happens. + const fallbackGlobTargets: string[] = []; + let bootFallbackGlobTargets: string[] = []; + class CountingFallbackLoaderFS extends RealLoaderFS { + glob(patterns: string | string[], options?: LoaderFSGlobOptions): string[] { + fallbackGlobTargets.push(String(options?.cwd)); + return super.glob(patterns, options); + } + } + + beforeAll(async () => { + // Phase 1: a normal boot to collect manifest data (fileDiscovery, resolveCache, + // and the tegg extension with decoratedFiles). Run as `local` so the auto + // dumpManifest() is skipped and we don't write .egg/manifest.json into the fixture. + mm.env('local'); + const collectApp = mm.app({ baseDir }); + await collectApp.ready(); + const resolvedBaseDir = collectApp.baseDir; + manifestData = collectApp.loader.generateManifest(); + await collectApp.close(); + mm.restore(); + + // Phase 2: boot in manifest-consume mode, exactly like the bundled worker entry + // (egg-bundler EntryGenerator): fromBundle -> ManifestLoaderFS -> setBundleStore + // -> start with the manifest-backed loaderFS injected. + const store = ManifestStore.fromBundle(manifestData, resolvedBaseDir); + const loaderFS = new ManifestLoaderFS(store, new CountingFallbackLoaderFS()); + ManifestStore.setBundleStore(store); + // NOTE: @eggjs/mock does not expose a typed `loaderFS` option, so we rely on + // its `{ ...options }` passthrough in single mode to forward it to the app + // loader (same channel egg-bundler uses via startEgg). The cast is the only + // way today. + // TODO(egg-mock): add first-class `loaderFS` support to MockOptions so bundle- + // mode app-boot tests don't need this passthrough cast. See [[bundle-startup-mock-loaderfs-todo]]. + app = mm.app({ baseDir, mode: 'single', loaderFS } as Parameters[0]); + await app.ready(); + bootFallbackGlobTargets = [...fallbackGlobTargets]; + }); + + afterEach(async () => { + return mm.restore(); + }); + + afterAll(async () => { + await app?.close(); + ManifestStore.setBundleStore(undefined); + }); + + it('phase-1 manifest should capture the tegg moduleDescriptors with decoratedFiles', () => { + const tegg = manifestData.extensions?.[TEGG_MANIFEST_KEY] as TeggManifestExtension | undefined; + assert.ok(tegg, 'tegg extension should be present in the generated manifest'); + assert.ok(tegg.moduleDescriptors?.length, 'should have moduleDescriptors'); + assert.ok( + tegg.moduleDescriptors.some((d) => d.decoratedFiles.length > 0), + 'at least one module should carry decoratedFiles', + ); + }); + + it('should boot a tegg app in manifest-consume mode (loadAppManifest branch)', () => { + // generatedAt is only set on a *loaded* manifest, proving we consumed a + // prebuilt manifest rather than collecting a fresh one. + assert.ok(app.loader.manifest.data.generatedAt, 'app should have booted from a prebuilt manifest'); + + const consumed = app.loader.manifest.getExtension(TEGG_MANIFEST_KEY) as TeggManifestExtension | undefined; + assert.ok(consumed?.moduleDescriptors?.length, 'tegg extension should be consumed from the bundle manifest'); + }); + + it('should wire the manifest-backed LoaderFS and route tegg discovery through it (Theme F)', () => { + // The injected manifest VFS is the loader's fs, exactly like a bundled app. + assert.ok( + app.loader.loaderFS instanceof ManifestLoaderFS, + `expected ManifestLoaderFS, got ${app.loader.loaderFS?.constructor?.name}`, + ); + + // Core/framework discovery is fully served from the manifest — nothing outside + // tegg module dirs ever falls back to a real-fs glob. Match the `modules` path + // segment exactly rather than a substring, to avoid similarly named dirs. + const isUnderModulesDir = (cwd: string): boolean => cwd.split(path.sep).includes('modules'); + const nonModuleGlobs = bootFallbackGlobTargets.filter((cwd) => !isUnderModulesDir(cwd)); + assert.deepEqual( + nonModuleGlobs, + [], + `core discovery should be fully manifest-served, but globbed: ${JSON.stringify(nonModuleGlobs)}`, + ); + + // The tegg module discovery that DOES run reaches the manifest VFS's fallback, + // which is only possible because EggModuleLoader now goes through + // app.loader.loaderFS (Theme F) — before, tegg used its own globby import. + assert.ok( + bootFallbackGlobTargets.some(isUnderModulesDir), + 'tegg module discovery should route through the injected loaderFS', + ); + + // KNOWN RESIDUAL: in consume mode EggModuleLoader.loadModule still re-globs each + // module dir (manifest stores tegg decoratedFiles, not module dirs in core + // fileDiscovery, and loadModule does not pass decoratedFiles). Pre-existing, + // out of Theme F scope. See [[bundle-startup-loadmodule-residual-glob]]. + }); + + it('should serve the full controller -> service -> cross-module repo DI chain over HTTP', async () => { + app.mockCsrf(); + // POST drives controller -> AppService -> AppRepo (other module, PUBLIC) -> PersistenceService + await app + .httpRequest() + .post('/apps') + .send({ name: 'bundled', desc: 'via-manifest' }) + .expect(200) + .expect((res) => { + assert.equal(res.body.success, true); + assert.ok(res.body.traceId, 'TraceService (context proto) should be injected'); + }); + + // GET reads it back through the same DI chain (singleton PersistenceService keeps state). + await app + .httpRequest() + .get('/apps?name=bundled') + .expect(200) + .expect((res) => { + assert.ok(res.body.traceId); + assert.deepStrictEqual(res.body.app, { name: 'bundled', desc: 'via-manifest' }); + }); + }); +});