From cab41b855953cd9f13c188eeb322641e98533283 Mon Sep 17 00:00:00 2001 From: zhizhizhina Date: Sun, 15 Feb 2026 01:19:45 +0800 Subject: [PATCH 1/7] feat: add tegg vitest adapter with custom runner - Custom VitestTestRunner using AsyncLocalStorage.enterWith() for context injection - Each file suite gets its own mock context with held beginModuleScope - Each test gets isolated context via onBeforeTryTask, restored after test - Supports nested and concurrent ctxStorage.run() without conflicts - Retry-safe: releases previous scope before creating new one - Graceful degradation when getApp() fails Co-Authored-By: Claude Opus 4.6 --- .gitignore | 1 + core/vitest-adapter/README.md | 64 +++++ core/vitest-adapter/index.ts | 1 + core/vitest-adapter/package.json | 60 +++++ core/vitest-adapter/runner.ts | 1 + core/vitest-adapter/src/index.ts | 17 ++ core/vitest-adapter/src/runner.ts | 225 ++++++++++++++++++ core/vitest-adapter/src/shared.ts | 55 +++++ core/vitest-adapter/test/fixture_app.test.ts | 47 ++++ .../apps/demo-app/app/service/hello.js | 11 + .../apps/demo-app/config/config.default.js | 7 + .../fixtures/apps/demo-app/config/module.json | 5 + .../modules/demo-module/HelloService.ts | 10 + .../demo-app/modules/demo-module/package.json | 6 + .../test/fixtures/apps/demo-app/package.json | 6 + .../vitest-adapter/test/get_app_throw.test.ts | 26 ++ .../test/get_store_restore.test.ts | 113 +++++++++ core/vitest-adapter/test/hooks.test.ts | 86 +++++++ core/vitest-adapter/test/setup.ts | 23 ++ core/vitest-adapter/tsconfig.json | 11 + core/vitest-adapter/tsconfig.pub.json | 13 + core/vitest-adapter/vitest.config.ts | 32 +++ 22 files changed, 820 insertions(+) create mode 100644 core/vitest-adapter/README.md create mode 100644 core/vitest-adapter/index.ts create mode 100644 core/vitest-adapter/package.json create mode 100644 core/vitest-adapter/runner.ts create mode 100644 core/vitest-adapter/src/index.ts create mode 100644 core/vitest-adapter/src/runner.ts create mode 100644 core/vitest-adapter/src/shared.ts create mode 100644 core/vitest-adapter/test/fixture_app.test.ts create mode 100644 core/vitest-adapter/test/fixtures/apps/demo-app/app/service/hello.js create mode 100644 core/vitest-adapter/test/fixtures/apps/demo-app/config/config.default.js create mode 100644 core/vitest-adapter/test/fixtures/apps/demo-app/config/module.json create mode 100644 core/vitest-adapter/test/fixtures/apps/demo-app/modules/demo-module/HelloService.ts create mode 100644 core/vitest-adapter/test/fixtures/apps/demo-app/modules/demo-module/package.json create mode 100644 core/vitest-adapter/test/fixtures/apps/demo-app/package.json create mode 100644 core/vitest-adapter/test/get_app_throw.test.ts create mode 100644 core/vitest-adapter/test/get_store_restore.test.ts create mode 100644 core/vitest-adapter/test/hooks.test.ts create mode 100644 core/vitest-adapter/test/setup.ts create mode 100644 core/vitest-adapter/tsconfig.json create mode 100644 core/vitest-adapter/tsconfig.pub.json create mode 100644 core/vitest-adapter/vitest.config.ts diff --git a/.gitignore b/.gitignore index ddcda1460..8269f7e89 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ run .DS_Store .tmp .vscode +codex-logs/ package-lock.json yarn.lock diff --git a/core/vitest-adapter/README.md b/core/vitest-adapter/README.md new file mode 100644 index 000000000..0a7f86de8 --- /dev/null +++ b/core/vitest-adapter/README.md @@ -0,0 +1,64 @@ +# @eggjs/tegg-vitest-adapter + +Vitest adapter that provides tegg context injection and lifecycle handling via a custom Vitest runner. + +## Install + +This package lives in the tegg monorepo workspace. + +## Usage + +1. Create a Vitest setup file that calls `configureTeggRunner`: + +```ts +// vitest.setup.ts +import path from 'path'; +import mm from 'egg-mock'; +import { configureTeggRunner } from '@eggjs/tegg-vitest-adapter'; + +const app = mm.app({ + baseDir: path.join(__dirname, 'fixtures/apps/my-app'), + framework: require.resolve('egg'), +}); + +configureTeggRunner({ + getApp: () => app, + restoreMocks: true, + parallel: process.env.VITEST_WORKER_ID != null, +}); +``` + +2. Wire it in `vitest.config.ts` with the custom runner: + +```ts +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + setupFiles: ['./vitest.setup.ts'], + runner: '@eggjs/tegg-vitest-adapter/runner', + }, +}); +``` + +## Options + +- `getApp`: Provide a custom app getter. Default: `require('egg-mock/bootstrap').app`. +- `parallel`: Skip auto `app.close()` when running in parallel mode. Default: auto-detected from `VITEST_WORKER_ID`. +- `restoreMocks`: Restore mocks after each test (defaults to true). + +## Lifecycle & Context Injection + +The custom runner extends Vitest's `VitestTestRunner` and manages tegg context at the runner level: + +- **`importFile` (collection phase)**: Captures per-file config from `configureTeggRunner()` and calls `app.ready()`. +- **`onBeforeRunSuite` (file suite)**: Creates a suite-scoped `ctx` via `app.mockContext()`, overrides `ctxStorage.getStore()`, and opens a held `beginModuleScope` that stays alive for the entire file. +- **`onBeforeRunTask` (per test)**: Creates a per-test `ctx` and opens a held `beginModuleScope` for the test. +- **`onAfterRunTask`**: Releases the test scope, restores mocks, and restores `ctxStorage.getStore()` back to the suite `ctx`. +- **`onAfterRunSuite`**: Releases the suite scope, restores original `getStore()`, and calls `app.close()` unless `parallel` is true. + +## Limitations + +- Context is managed at the **file suite** level. `egg-mock`'s Mocha runner patch can switch context at the **`describe` suite** level. If your tests rely on describe-scoped suite context, you must manage that manually. +- If `getApp` throws or returns `undefined`, the adapter will run tests without context injection. diff --git a/core/vitest-adapter/index.ts b/core/vitest-adapter/index.ts new file mode 100644 index 000000000..8420b1093 --- /dev/null +++ b/core/vitest-adapter/index.ts @@ -0,0 +1 @@ +export * from './src'; diff --git a/core/vitest-adapter/package.json b/core/vitest-adapter/package.json new file mode 100644 index 000000000..3d069fe33 --- /dev/null +++ b/core/vitest-adapter/package.json @@ -0,0 +1,60 @@ +{ + "name": "@eggjs/tegg-vitest-adapter", + "version": "3.72.0", + "description": "Vitest adapter for tegg context injection", + "keywords": [ + "egg", + "tegg", + "vitest", + "test", + "adapter" + ], + "main": "dist/index.js", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./runner": { + "types": "./dist/runner.d.ts", + "default": "./dist/runner.js" + } + }, + "files": [ + "dist/**/*.js", + "dist/**/*.d.ts" + ], + "typings": "dist/index.d.ts", + "scripts": { + "clean": "tsc -b --clean", + "tsc:pub": "ut run clean && tsc -p ./tsconfig.pub.json", + "test": "vitest run" + }, + "author": "killagu ", + "license": "MIT", + "homepage": "https://github.com/eggjs/tegg", + "bugs": { + "url": "https://github.com/eggjs/tegg/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/eggjs/tegg.git", + "directory": "core/vitest-adapter" + }, + "engines": { + "node": ">=18.0.0" + }, + "dependencies": { + "egg-mock": "^5.5.0" + }, + "peerDependencies": { + "vitest": "^1.6.0 || ^2.0.0 || ^3.0.0" + }, + "devDependencies": { + "@types/node": "^20.2.4", + "typescript": "^5.0.4", + "vitest": "^1.6.0", + "egg": "^3.9.1", + "egg-tracer": "^2.0.0" + } +} diff --git a/core/vitest-adapter/runner.ts b/core/vitest-adapter/runner.ts new file mode 100644 index 000000000..78bab1af9 --- /dev/null +++ b/core/vitest-adapter/runner.ts @@ -0,0 +1 @@ +export { default } from './src/runner'; diff --git a/core/vitest-adapter/src/index.ts b/core/vitest-adapter/src/index.ts new file mode 100644 index 000000000..3ff5862d9 --- /dev/null +++ b/core/vitest-adapter/src/index.ts @@ -0,0 +1,17 @@ +import { + defaultGetApp, +} from './shared'; +import type { TeggVitestAdapterOptions } from './shared'; + +export type { EggMockApp, TeggVitestAdapterOptions } from './shared'; + +/** + * Configure the custom Vitest runner (used via globalThis.__teggVitestConfig). + * Call this in a setupFile and set `runner` in vitest.config.ts to use the runner approach. + */ +export function configureTeggRunner(options: TeggVitestAdapterOptions = {}) { + (globalThis as any).__teggVitestConfig = { + restoreMocks: options.restoreMocks ?? true, + getApp: options.getApp ?? defaultGetApp, + }; +} diff --git a/core/vitest-adapter/src/runner.ts b/core/vitest-adapter/src/runner.ts new file mode 100644 index 000000000..ebd86f083 --- /dev/null +++ b/core/vitest-adapter/src/runner.ts @@ -0,0 +1,225 @@ +import { VitestTestRunner } from 'vitest/runners'; +import type { Suite, Task, File } from 'vitest'; +import { + debugLog, + defaultGetApp, + restoreEggMocksIfNeeded, +} from './shared'; +import type { EggMockApp } from './shared'; + +interface TeggRunnerConfig { + restoreMocks: boolean; + getApp: () => Promise | EggMockApp | undefined; +} + +interface HeldScope { + scopePromise: Promise; + endScope: () => void; +} + +interface FileAppState { + app: EggMockApp; + config: TeggRunnerConfig; +} + +interface FileScopeState { + app: EggMockApp; + config: TeggRunnerConfig; + suiteCtx: any; + suiteScope: HeldScope | null; +} + +interface TaskScopeState { + testScope: HeldScope | null; + filepath: string; +} + +/** + * Create a held beginModuleScope: starts the scope and waits until init() is + * complete (the inner fn starts executing), then returns the held scope. + * The scope stays alive until endScope() is called. + */ +async function createHeldScope(ctx: any): Promise { + let endScope!: () => void; + const gate = new Promise(resolve => { + endScope = resolve; + }); + + let scopeReady!: () => void; + const readyPromise = new Promise(resolve => { + scopeReady = resolve; + }); + + const scopePromise = ctx.beginModuleScope(async () => { + // init() has completed at this point, signal readiness + scopeReady(); + await gate; + }); + + // Wait for init() inside beginModuleScope to finish + await readyPromise; + + return { scopePromise, endScope }; +} + +async function releaseHeldScope(scope: HeldScope | null) { + if (!scope) return; + scope.endScope(); + await scope.scopePromise; +} + +function isFileSuite(suite: Suite): suite is File { + return !suite.suite && !!suite.filepath; +} + +function getTaskFilepath(task: Task): string | undefined { + return (task as any).file?.filepath; +} + +export default class TeggVitestRunner extends VitestTestRunner { + private fileScopeMap = new Map(); + private taskScopeMap = new Map(); + private fileAppMap = new Map(); + private warned = false; + + /** + * Override importFile to capture per-file config set by configureTeggRunner() + * and await app.ready() during collection phase. + */ + async importFile(filepath: string, source: string): Promise { + // Clear any stale config before importing + delete (globalThis as any).__teggVitestConfig; + + const result = await super.importFile(filepath, source as any); + + if (source === 'collect') { + const rawConfig = (globalThis as any).__teggVitestConfig; + if (rawConfig) { + delete (globalThis as any).__teggVitestConfig; + + const config: TeggRunnerConfig = { + restoreMocks: rawConfig.restoreMocks ?? true, + getApp: rawConfig.getApp ?? defaultGetApp, + }; + + debugLog(`captured config for ${filepath}`); + + // Resolve app and await ready during collection + try { + const app = await config.getApp(); + if (app) { + await app.ready(); + this.fileAppMap.set(filepath, { app, config }); + debugLog(`app ready for ${filepath}`); + } + } catch (err) { + if (!this.warned) { + this.warned = true; + // eslint-disable-next-line no-console + console.warn('[tegg-vitest-adapter] getApp failed, skip context injection.', err); + } + } + } + } + + return result; + } + + async onBeforeRunSuite(suite: Suite): Promise { + if (isFileSuite(suite)) { + const filepath = suite.filepath!; + debugLog(`onBeforeRunSuite (file): ${filepath}`); + + const fileApp = this.fileAppMap.get(filepath); + if (fileApp) { + const { app, config } = fileApp; + + if (typeof app.mockContext === 'function' && app.ctxStorage) { + const suiteCtx = app.mockContext(undefined, { + mockCtxStorage: false, + reuseCtxStorage: false, + }); + app.ctxStorage.enterWith(suiteCtx); + + let suiteScope: HeldScope | null = null; + if (typeof suiteCtx.beginModuleScope === 'function') { + suiteScope = await createHeldScope(suiteCtx); + debugLog('suite held scope created'); + } + + this.fileScopeMap.set(filepath, { app, config, suiteCtx, suiteScope }); + debugLog('file suite scope created'); + } + } + } + + await super.onBeforeRunSuite(suite); + } + + async onAfterRunSuite(suite: Suite): Promise { + if (isFileSuite(suite)) { + const filepath = suite.filepath!; + debugLog(`onAfterRunSuite (file): ${filepath}`); + + const fileState = this.fileScopeMap.get(filepath); + if (fileState) { + await releaseHeldScope(fileState.suiteScope); + this.fileScopeMap.delete(filepath); + } + } + + await super.onAfterRunSuite(suite); + } + + async onBeforeTryTask(test: Task, options: { retry: number; repeats: number }): Promise { + const filepath = getTaskFilepath(test); + if (filepath) { + const fileState = this.fileScopeMap.get(filepath); + if (fileState) { + // Release previous scope on retry to avoid leaks + const existing = this.taskScopeMap.get(test.id); + if (existing) { + await releaseHeldScope(existing.testScope); + } + + debugLog(`onBeforeTryTask: ${test.name} (retry=${options.retry})`); + + const testCtx = fileState.app.mockContext!(undefined, { + mockCtxStorage: false, + reuseCtxStorage: false, + }); + fileState.app.ctxStorage!.enterWith(testCtx); + + let testScope: HeldScope | null = null; + if (typeof testCtx.beginModuleScope === 'function') { + testScope = await createHeldScope(testCtx); + debugLog('test held scope created'); + } + + this.taskScopeMap.set(test.id, { testScope, filepath }); + } + } + + await super.onBeforeTryTask(test, options); + } + + async onAfterRunTask(test: Task): Promise { + const taskState = this.taskScopeMap.get(test.id); + if (taskState) { + debugLog(`onAfterRunTask: ${test.name}`); + + await releaseHeldScope(taskState.testScope); + this.taskScopeMap.delete(test.id); + + const fileState = this.fileScopeMap.get(taskState.filepath); + if (fileState) { + await restoreEggMocksIfNeeded(fileState.config.restoreMocks); + // Restore suite context + fileState.app.ctxStorage!.enterWith(fileState.suiteCtx); + debugLog('restored suite context'); + } + } + + await super.onAfterRunTask(test); + } +} diff --git a/core/vitest-adapter/src/shared.ts b/core/vitest-adapter/src/shared.ts new file mode 100644 index 000000000..b8232baca --- /dev/null +++ b/core/vitest-adapter/src/shared.ts @@ -0,0 +1,55 @@ +import type { AsyncLocalStorage } from 'node:async_hooks'; +import type { Application } from 'egg'; + +export type EggMockApp = Application & { + // egg-mock ctx API + ctxStorage?: { + getStore?: () => any; + enterWith?: (store: any) => void; + } & AsyncLocalStorage; + + mockContext?: (data?: any, options?: any) => any; +}; + +export interface TeggVitestAdapterOptions { + /** + * Resolve app instance. + * Default: require('egg-mock/bootstrap').app + */ + getApp?: () => Promise | EggMockApp | undefined; + + /** + * Restore mocks after each test. + * Default: true (calls egg-mock.restore()) + */ + restoreMocks?: boolean; +} + +export const DEBUG_ENABLED = process.env.DEBUG_TEGG_VITEST_ADAPTER === '1'; + +export function debugLog(message: string, extra?: unknown) { + if (!DEBUG_ENABLED) return; + if (extra === undefined) { + // eslint-disable-next-line no-console + console.log(`[tegg-vitest-adapter] ${message}`); + return; + } + // eslint-disable-next-line no-console + console.log(`[tegg-vitest-adapter] ${message}`, extra); +} + +export async function defaultGetApp(): Promise { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const bootstrap = require('egg-mock/bootstrap'); + return bootstrap?.app; +} + +export async function restoreEggMocksIfNeeded(restoreMocks: boolean) { + if (!restoreMocks) return; + // eslint-disable-next-line @typescript-eslint/no-var-requires + const eggMock = require('egg-mock'); + const mm = eggMock?.default || eggMock; + if (mm?.restore) { + await mm.restore(); + } +} diff --git a/core/vitest-adapter/test/fixture_app.test.ts b/core/vitest-adapter/test/fixture_app.test.ts new file mode 100644 index 000000000..51bdccb8f --- /dev/null +++ b/core/vitest-adapter/test/fixture_app.test.ts @@ -0,0 +1,47 @@ +import assert from 'assert'; +import path from 'path'; +import mm from 'egg-mock'; +import { createRequire } from 'module'; +import { describe, beforeAll, afterAll, it } from 'vitest'; +import { configureTeggRunner } from '../src'; + +const require = createRequire(import.meta.url); +const { HelloService } = require( + path.join(__dirname, 'fixtures/apps/demo-app/modules/demo-module/HelloService') +); + +const app = mm.app({ + baseDir: path.join(__dirname, 'fixtures/apps/demo-app'), + framework: require.resolve('egg'), +}); + +configureTeggRunner({ + getApp() { + return app as any; + }, + restoreMocks: false, +}); + +describe('fixture demo app', () => { + beforeAll(async () => { + await app.ready(); + }); + + afterAll(async () => { + await app.close(); + await mm.restore(); + }); + + it('injects ctx and service', () => { + const ctx = app.ctxStorage.getStore(); + assert(ctx); + assert.strictEqual(ctx.service.hello.sayHi('Ada'), 'hi Ada'); + }); + + it('supports ctx.getEggObject()', async () => { + const ctx = app.ctxStorage.getStore(); + assert(ctx); + const helloService = await ctx.getEggObject(HelloService); + assert.strictEqual(helloService.sayHi('Ada'), 'hi Ada'); + }); +}); diff --git a/core/vitest-adapter/test/fixtures/apps/demo-app/app/service/hello.js b/core/vitest-adapter/test/fixtures/apps/demo-app/app/service/hello.js new file mode 100644 index 000000000..699e8b208 --- /dev/null +++ b/core/vitest-adapter/test/fixtures/apps/demo-app/app/service/hello.js @@ -0,0 +1,11 @@ +'use strict'; + +const Service = require('egg').Service; + +class HelloService extends Service { + sayHi(name) { + return `hi ${name}`; + } +} + +module.exports = HelloService; diff --git a/core/vitest-adapter/test/fixtures/apps/demo-app/config/config.default.js b/core/vitest-adapter/test/fixtures/apps/demo-app/config/config.default.js new file mode 100644 index 000000000..67a683cb1 --- /dev/null +++ b/core/vitest-adapter/test/fixtures/apps/demo-app/config/config.default.js @@ -0,0 +1,7 @@ +'use strict'; + +module.exports = appInfo => { + const config = {}; + config.keys = appInfo.name + '_keys'; + return config; +}; diff --git a/core/vitest-adapter/test/fixtures/apps/demo-app/config/module.json b/core/vitest-adapter/test/fixtures/apps/demo-app/config/module.json new file mode 100644 index 000000000..d95223e08 --- /dev/null +++ b/core/vitest-adapter/test/fixtures/apps/demo-app/config/module.json @@ -0,0 +1,5 @@ +[ + { + "path": "../modules/demo-module" + } +] diff --git a/core/vitest-adapter/test/fixtures/apps/demo-app/modules/demo-module/HelloService.ts b/core/vitest-adapter/test/fixtures/apps/demo-app/modules/demo-module/HelloService.ts new file mode 100644 index 000000000..9a0d57f9b --- /dev/null +++ b/core/vitest-adapter/test/fixtures/apps/demo-app/modules/demo-module/HelloService.ts @@ -0,0 +1,10 @@ +import { AccessLevel, ContextProto } from '@eggjs/core-decorator'; + +@ContextProto({ + accessLevel: AccessLevel.PUBLIC, +}) +export class HelloService { + sayHi(name: string) { + return `hi ${name}`; + } +} diff --git a/core/vitest-adapter/test/fixtures/apps/demo-app/modules/demo-module/package.json b/core/vitest-adapter/test/fixtures/apps/demo-app/modules/demo-module/package.json new file mode 100644 index 000000000..32f2d880e --- /dev/null +++ b/core/vitest-adapter/test/fixtures/apps/demo-app/modules/demo-module/package.json @@ -0,0 +1,6 @@ +{ + "name": "demo-module", + "eggModule": { + "name": "demoModule" + } +} diff --git a/core/vitest-adapter/test/fixtures/apps/demo-app/package.json b/core/vitest-adapter/test/fixtures/apps/demo-app/package.json new file mode 100644 index 000000000..61de28ecd --- /dev/null +++ b/core/vitest-adapter/test/fixtures/apps/demo-app/package.json @@ -0,0 +1,6 @@ +{ + "name": "demo-app", + "version": "0.0.1", + "private": true, + "description": "vitest-adapter fixture app" +} diff --git a/core/vitest-adapter/test/get_app_throw.test.ts b/core/vitest-adapter/test/get_app_throw.test.ts new file mode 100644 index 000000000..317772a0d --- /dev/null +++ b/core/vitest-adapter/test/get_app_throw.test.ts @@ -0,0 +1,26 @@ +import assert from 'assert'; +import { describe, it, afterAll, vi } from 'vitest'; +import { configureTeggRunner } from '../src'; + +let getAppCalls = 0; +const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + +configureTeggRunner({ + getApp() { + getAppCalls += 1; + throw new Error('boom'); + }, + restoreMocks: false, +}); + +describe('getApp throw handling', () => { + it('should not crash suite when getApp throws', () => { + // The runner calls getApp in onBeforeRunSuite, so it should have been called + assert(getAppCalls > 0); + assert(warnSpy.mock.calls.length > 0); + }); + + afterAll(() => { + warnSpy.mockRestore(); + }); +}); diff --git a/core/vitest-adapter/test/get_store_restore.test.ts b/core/vitest-adapter/test/get_store_restore.test.ts new file mode 100644 index 000000000..d27b399c4 --- /dev/null +++ b/core/vitest-adapter/test/get_store_restore.test.ts @@ -0,0 +1,113 @@ +import assert from 'assert'; +import path from 'path'; +import mm from 'egg-mock'; +import { describe, beforeAll, beforeEach, afterEach, afterAll, it } from 'vitest'; +import { configureTeggRunner } from '../src'; + +const app = mm.app({ + baseDir: path.join(__dirname, '../../..', 'plugin/tegg/test/fixtures/apps/egg-app'), + framework: require.resolve('egg'), +}); + +configureTeggRunner({ + getApp() { + return app as any; + }, + restoreMocks: false, +}); + +describe('ctxStorage.getStore restore', () => { + const getCtx = () => app.ctxStorage.getStore(); + let suiteCtx: any; + const testCtxList: any[] = []; + const afterEachCtxList: any[] = []; + + beforeAll(() => { + suiteCtx = getCtx(); + assert(suiteCtx); + }); + + beforeEach(() => { + const current = getCtx(); + testCtxList.push(current); + assert(current); + assert.notStrictEqual(current, suiteCtx); + }); + + it('should have test context (1)', () => { + const ctx = getCtx(); + assert(ctx); + assert.notStrictEqual(ctx, suiteCtx); + }); + + it('should have test context (2)', () => { + const ctx = getCtx(); + assert(ctx); + assert.notStrictEqual(ctx, suiteCtx); + }); + + afterEach(() => { + const current = getCtx(); + afterEachCtxList.push(current); + assert.strictEqual(current, testCtxList[afterEachCtxList.length - 1]); + }); + + it('should not conflict with nested ctxStorage.run()', async () => { + const outerCtx = getCtx(); + assert(outerCtx); + assert.notStrictEqual(outerCtx, suiteCtx); + + // Nested ctxStorage.run() should see its own store + const nestedCtx = app.mockContext(undefined, { + mockCtxStorage: false, + reuseCtxStorage: false, + }); + await app.ctxStorage.run(nestedCtx, async () => { + assert.strictEqual(getCtx(), nestedCtx); + assert.notStrictEqual(getCtx(), outerCtx); + }); + + // After nested run() returns, outer context is restored + assert.strictEqual(getCtx(), outerCtx); + }); + + it('should not conflict with concurrent ctxStorage.run()', async () => { + const outerCtx = getCtx(); + assert(outerCtx); + + await Promise.all([ + app.ctxStorage.run( + app.mockContext(undefined, { mockCtxStorage: false, reuseCtxStorage: false }), + async () => { + const innerCtx = getCtx(); + assert(innerCtx); + assert.notStrictEqual(innerCtx, outerCtx); + }, + ), + app.ctxStorage.run( + app.mockContext(undefined, { mockCtxStorage: false, reuseCtxStorage: false }), + async () => { + const innerCtx = getCtx(); + assert(innerCtx); + assert.notStrictEqual(innerCtx, outerCtx); + }, + ), + ]); + + // After concurrent runs, outer context is restored + assert.strictEqual(getCtx(), outerCtx); + }); + + afterAll(async () => { + // After all tests, suite context is restored + assert.strictEqual(getCtx(), suiteCtx); + assert.strictEqual(testCtxList.length, afterEachCtxList.length); + testCtxList.forEach((ctx, index) => { + assert.notStrictEqual(ctx, suiteCtx); + assert.strictEqual(ctx, afterEachCtxList[index]); + }); + assert.notStrictEqual(testCtxList[0], testCtxList[1]); + await app.close(); + await mm.restore(); + }); +}); diff --git a/core/vitest-adapter/test/hooks.test.ts b/core/vitest-adapter/test/hooks.test.ts new file mode 100644 index 000000000..dbc878517 --- /dev/null +++ b/core/vitest-adapter/test/hooks.test.ts @@ -0,0 +1,86 @@ +import assert from 'assert'; +import path from 'path'; +import mm from 'egg-mock'; +import { describe, beforeAll, afterAll, beforeEach, afterEach, it } from 'vitest'; +import { configureTeggRunner } from '../src'; + +const app = mm.app({ + baseDir: path.join(__dirname, '../../..', 'plugin/tegg/test/fixtures/apps/egg-app'), + framework: require.resolve('egg'), +}); + +configureTeggRunner({ + getApp() { + return app as any; + }, + restoreMocks: false, +}); + +describe('vitest adapter ctx semantics', () => { + const getCtx = () => app.ctxStorage.getStore(); + let beforeCtx: any; + let afterCtx: any; + const beforeEachCtxList: Record = {}; + const afterEachCtxList: Record = {}; + const itCtxList: Record = {}; + + beforeAll(() => { + beforeCtx = getCtx(); + }); + + afterAll(async () => { + afterCtx = getCtx(); + assert(beforeCtx); + assert(beforeCtx !== itCtxList.foo); + assert(itCtxList.foo !== itCtxList.bar); + assert.strictEqual(afterCtx, beforeCtx); + assert.strictEqual(beforeEachCtxList.foo, afterEachCtxList.foo); + assert.strictEqual(beforeEachCtxList.foo, itCtxList.foo); + await app.close(); + await mm.restore(); + }); + + describe('foo', () => { + beforeEach(() => { + beforeEachCtxList.foo = getCtx(); + }); + + it('should work', () => { + itCtxList.foo = getCtx(); + }); + + afterEach(() => { + afterEachCtxList.foo = getCtx(); + }); + }); + + describe('bar', () => { + beforeEach(() => { + beforeEachCtxList.bar = getCtx(); + }); + + it('should work', () => { + itCtxList.bar = getCtx(); + }); + + afterEach(() => { + afterEachCtxList.bar = getCtx(); + }); + }); + + describe('multi it', () => { + const multiItCtxList: any[] = []; + + it('should work 1', () => { + multiItCtxList.push(getCtx()); + }); + + it('should work 2', () => { + multiItCtxList.push(getCtx()); + }); + + afterAll(() => { + assert(multiItCtxList[0] !== multiItCtxList[1]); + }); + }); +}); diff --git a/core/vitest-adapter/test/setup.ts b/core/vitest-adapter/test/setup.ts new file mode 100644 index 000000000..76e3fe224 --- /dev/null +++ b/core/vitest-adapter/test/setup.ts @@ -0,0 +1,23 @@ +import { createRequire } from 'module'; + +const require = createRequire(import.meta.url); +const tsNodeRegister = 'ts-node/register/transpile-only'; +const tsNodeRegisterPath = require.resolve(tsNodeRegister); + +if (!process.env.EGG_TYPESCRIPT) { + process.env.EGG_TYPESCRIPT = 'true'; +} + +const nodeOptions = process.env.NODE_OPTIONS ?? ''; +const hasNodeOptions = nodeOptions.includes('ts-node/register') || nodeOptions.includes(tsNodeRegisterPath); +if (!hasNodeOptions) { + process.env.NODE_OPTIONS = `${nodeOptions} --require ${tsNodeRegister}`.trim(); +} + +const execArgv = process.execArgv; +const hasExecArgv = execArgv.includes(tsNodeRegister) || execArgv.includes(tsNodeRegisterPath); +if (!hasExecArgv) { + execArgv.push('--require', tsNodeRegister); +} + +require(tsNodeRegisterPath); diff --git a/core/vitest-adapter/tsconfig.json b/core/vitest-adapter/tsconfig.json new file mode 100644 index 000000000..ed206ac64 --- /dev/null +++ b/core/vitest-adapter/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "baseUrl": "./" + }, + "exclude": [ + "dist", + "node_modules" + ] +} diff --git a/core/vitest-adapter/tsconfig.pub.json b/core/vitest-adapter/tsconfig.pub.json new file mode 100644 index 000000000..ad1c29d46 --- /dev/null +++ b/core/vitest-adapter/tsconfig.pub.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "baseUrl": "./" + }, + "exclude": [ + "dist", + "node_modules", + "test", + "vitest.config.ts" + ] +} diff --git a/core/vitest-adapter/vitest.config.ts b/core/vitest-adapter/vitest.config.ts new file mode 100644 index 000000000..2412b6860 --- /dev/null +++ b/core/vitest-adapter/vitest.config.ts @@ -0,0 +1,32 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vitest/config'; + +const workspacePath = (rel: string) => path.resolve( + fileURLToPath(new URL('.', import.meta.url)), + rel, +); + +export default defineConfig({ + resolve: { + // Use array form so more specific subpath aliases win over package root aliases. + alias: [ + // In the tegg monorepo, many workspace packages point "main" to dist/ which doesn't exist in-source. + // Alias to source entrypoints so Vitest/Vite can resolve them. + // Important: subpath imports like "@eggjs/tegg-types/common" must resolve too. + { find: /^@eggjs\/tegg-types\/(.*)$/, replacement: workspacePath('../types/$1') }, + { find: '@eggjs/tegg-types', replacement: workspacePath('../types/index.ts') }, + + { find: '@eggjs/core-decorator', replacement: workspacePath('../core-decorator/index.ts') }, + { find: '@eggjs/tegg-common-util', replacement: workspacePath('../common-util/index.ts') }, + ], + }, + test: { + environment: 'node', + include: [ 'test/**/*.test.ts' ], + // Register TS loader (ts-node) before tests so Egg can load .ts via Module._extensions. + setupFiles: [ 'test/setup.ts' ], + // Custom runner for tegg context injection via enterWith + held beginModuleScope. + runner: './src/runner.ts', + }, +}); From 87f8de7723d02d84c77192746f4efc3af6c09889 Mon Sep 17 00:00:00 2001 From: killagu-claw Date: Tue, 17 Feb 2026 20:42:39 +0800 Subject: [PATCH 2/7] refactor: rename vitest-adapter to vitest and fix runner issues - Rename core/vitest-adapter to core/vitest, package @eggjs/tegg-vitest-adapter to @eggjs/tegg-vitest - Fix createHeldScope hang when beginModuleScope rejects before callback by racing readyPromise against scopePromise - Fix watch-mode stale state by clearing fileAppMap per-file before re-collection and after suite completion - Fix onBeforeTryTask signature to match VitestTestRunner base class - Wrap afterAll assertions in try/finally to guarantee cleanup in tests Co-Authored-By: Claude Opus 4.6 --- .../apps/demo-app/app/service/hello.js | 11 --------- .../apps/demo-app/config/config.default.js | 7 ------ core/{vitest-adapter => vitest}/README.md | 6 ++--- core/{vitest-adapter => vitest}/index.ts | 0 core/{vitest-adapter => vitest}/package.json | 4 ++-- core/{vitest-adapter => vitest}/runner.ts | 0 core/{vitest-adapter => vitest}/src/index.ts | 0 core/{vitest-adapter => vitest}/src/runner.ts | 23 ++++++++++++++----- core/{vitest-adapter => vitest}/src/shared.ts | 6 ++--- .../test/fixture_app.test.ts | 0 .../fixtures/apps/demo-app/config/module.json | 0 .../modules/demo-module/HelloService.ts | 0 .../demo-app/modules/demo-module/package.json | 0 .../test/fixtures/apps/demo-app/package.json | 2 +- .../test/get_app_throw.test.ts | 0 .../test/get_store_restore.test.ts | 23 +++++++++++-------- .../test/hooks.test.ts | 21 +++++++++-------- core/{vitest-adapter => vitest}/test/setup.ts | 0 core/{vitest-adapter => vitest}/tsconfig.json | 0 .../tsconfig.pub.json | 0 .../vitest.config.ts | 0 21 files changed, 51 insertions(+), 52 deletions(-) delete mode 100644 core/vitest-adapter/test/fixtures/apps/demo-app/app/service/hello.js delete mode 100644 core/vitest-adapter/test/fixtures/apps/demo-app/config/config.default.js rename core/{vitest-adapter => vitest}/README.md (93%) rename core/{vitest-adapter => vitest}/index.ts (100%) rename core/{vitest-adapter => vitest}/package.json (93%) rename core/{vitest-adapter => vitest}/runner.ts (100%) rename core/{vitest-adapter => vitest}/src/index.ts (100%) rename core/{vitest-adapter => vitest}/src/runner.ts (87%) rename core/{vitest-adapter => vitest}/src/shared.ts (88%) rename core/{vitest-adapter => vitest}/test/fixture_app.test.ts (100%) rename core/{vitest-adapter => vitest}/test/fixtures/apps/demo-app/config/module.json (100%) rename core/{vitest-adapter => vitest}/test/fixtures/apps/demo-app/modules/demo-module/HelloService.ts (100%) rename core/{vitest-adapter => vitest}/test/fixtures/apps/demo-app/modules/demo-module/package.json (100%) rename core/{vitest-adapter => vitest}/test/fixtures/apps/demo-app/package.json (59%) rename core/{vitest-adapter => vitest}/test/get_app_throw.test.ts (100%) rename core/{vitest-adapter => vitest}/test/get_store_restore.test.ts (85%) rename core/{vitest-adapter => vitest}/test/hooks.test.ts (80%) rename core/{vitest-adapter => vitest}/test/setup.ts (100%) rename core/{vitest-adapter => vitest}/tsconfig.json (100%) rename core/{vitest-adapter => vitest}/tsconfig.pub.json (100%) rename core/{vitest-adapter => vitest}/vitest.config.ts (100%) diff --git a/core/vitest-adapter/test/fixtures/apps/demo-app/app/service/hello.js b/core/vitest-adapter/test/fixtures/apps/demo-app/app/service/hello.js deleted file mode 100644 index 699e8b208..000000000 --- a/core/vitest-adapter/test/fixtures/apps/demo-app/app/service/hello.js +++ /dev/null @@ -1,11 +0,0 @@ -'use strict'; - -const Service = require('egg').Service; - -class HelloService extends Service { - sayHi(name) { - return `hi ${name}`; - } -} - -module.exports = HelloService; diff --git a/core/vitest-adapter/test/fixtures/apps/demo-app/config/config.default.js b/core/vitest-adapter/test/fixtures/apps/demo-app/config/config.default.js deleted file mode 100644 index 67a683cb1..000000000 --- a/core/vitest-adapter/test/fixtures/apps/demo-app/config/config.default.js +++ /dev/null @@ -1,7 +0,0 @@ -'use strict'; - -module.exports = appInfo => { - const config = {}; - config.keys = appInfo.name + '_keys'; - return config; -}; diff --git a/core/vitest-adapter/README.md b/core/vitest/README.md similarity index 93% rename from core/vitest-adapter/README.md rename to core/vitest/README.md index 0a7f86de8..047f81dd8 100644 --- a/core/vitest-adapter/README.md +++ b/core/vitest/README.md @@ -1,4 +1,4 @@ -# @eggjs/tegg-vitest-adapter +# @eggjs/tegg-vitest Vitest adapter that provides tegg context injection and lifecycle handling via a custom Vitest runner. @@ -14,7 +14,7 @@ This package lives in the tegg monorepo workspace. // vitest.setup.ts import path from 'path'; import mm from 'egg-mock'; -import { configureTeggRunner } from '@eggjs/tegg-vitest-adapter'; +import { configureTeggRunner } from '@eggjs/tegg-vitest'; const app = mm.app({ baseDir: path.join(__dirname, 'fixtures/apps/my-app'), @@ -37,7 +37,7 @@ export default defineConfig({ test: { environment: 'node', setupFiles: ['./vitest.setup.ts'], - runner: '@eggjs/tegg-vitest-adapter/runner', + runner: '@eggjs/tegg-vitest/runner', }, }); ``` diff --git a/core/vitest-adapter/index.ts b/core/vitest/index.ts similarity index 100% rename from core/vitest-adapter/index.ts rename to core/vitest/index.ts diff --git a/core/vitest-adapter/package.json b/core/vitest/package.json similarity index 93% rename from core/vitest-adapter/package.json rename to core/vitest/package.json index 3d069fe33..770117e4c 100644 --- a/core/vitest-adapter/package.json +++ b/core/vitest/package.json @@ -1,5 +1,5 @@ { - "name": "@eggjs/tegg-vitest-adapter", + "name": "@eggjs/tegg-vitest", "version": "3.72.0", "description": "Vitest adapter for tegg context injection", "keywords": [ @@ -39,7 +39,7 @@ "repository": { "type": "git", "url": "https://github.com/eggjs/tegg.git", - "directory": "core/vitest-adapter" + "directory": "core/vitest" }, "engines": { "node": ">=18.0.0" diff --git a/core/vitest-adapter/runner.ts b/core/vitest/runner.ts similarity index 100% rename from core/vitest-adapter/runner.ts rename to core/vitest/runner.ts diff --git a/core/vitest-adapter/src/index.ts b/core/vitest/src/index.ts similarity index 100% rename from core/vitest-adapter/src/index.ts rename to core/vitest/src/index.ts diff --git a/core/vitest-adapter/src/runner.ts b/core/vitest/src/runner.ts similarity index 87% rename from core/vitest-adapter/src/runner.ts rename to core/vitest/src/runner.ts index ebd86f083..82cafd40d 100644 --- a/core/vitest-adapter/src/runner.ts +++ b/core/vitest/src/runner.ts @@ -56,8 +56,13 @@ async function createHeldScope(ctx: any): Promise { await gate; }); - // Wait for init() inside beginModuleScope to finish - await readyPromise; + // Race readyPromise against scopePromise: if beginModuleScope rejects + // before invoking the callback (so scopeReady is never called), the error + // propagates immediately instead of hanging forever on readyPromise. + // Promise.race attaches a rejection handler to scopePromise, so there are + // no unhandled rejections. scopePromise itself is preserved as-is for + // gate/endScope behavior in releaseHeldScope. + await Promise.race([ readyPromise, scopePromise ]); return { scopePromise, endScope }; } @@ -87,6 +92,11 @@ export default class TeggVitestRunner extends VitestTestRunner { * and await app.ready() during collection phase. */ async importFile(filepath: string, source: string): Promise { + // Clear stale state for this file before re-collection in watch mode + if (source === 'collect') { + this.fileAppMap.delete(filepath); + this.warned = false; + } // Clear any stale config before importing delete (globalThis as any).__teggVitestConfig; @@ -116,7 +126,7 @@ export default class TeggVitestRunner extends VitestTestRunner { if (!this.warned) { this.warned = true; // eslint-disable-next-line no-console - console.warn('[tegg-vitest-adapter] getApp failed, skip context injection.', err); + console.warn('[tegg-vitest] getApp failed, skip context injection.', err); } } } @@ -166,12 +176,13 @@ export default class TeggVitestRunner extends VitestTestRunner { await releaseHeldScope(fileState.suiteScope); this.fileScopeMap.delete(filepath); } + this.fileAppMap.delete(filepath); } await super.onAfterRunSuite(suite); } - async onBeforeTryTask(test: Task, options: { retry: number; repeats: number }): Promise { + async onBeforeTryTask(test: Task, options?: { retry: number; repeats: number }): Promise { const filepath = getTaskFilepath(test); if (filepath) { const fileState = this.fileScopeMap.get(filepath); @@ -182,7 +193,7 @@ export default class TeggVitestRunner extends VitestTestRunner { await releaseHeldScope(existing.testScope); } - debugLog(`onBeforeTryTask: ${test.name} (retry=${options.retry})`); + debugLog(`onBeforeTryTask: ${test.name} (retry=${options?.retry})`); const testCtx = fileState.app.mockContext!(undefined, { mockCtxStorage: false, @@ -200,7 +211,7 @@ export default class TeggVitestRunner extends VitestTestRunner { } } - await super.onBeforeTryTask(test, options); + await super.onBeforeTryTask(test); } async onAfterRunTask(test: Task): Promise { diff --git a/core/vitest-adapter/src/shared.ts b/core/vitest/src/shared.ts similarity index 88% rename from core/vitest-adapter/src/shared.ts rename to core/vitest/src/shared.ts index b8232baca..0641794e8 100644 --- a/core/vitest-adapter/src/shared.ts +++ b/core/vitest/src/shared.ts @@ -25,17 +25,17 @@ export interface TeggVitestAdapterOptions { restoreMocks?: boolean; } -export const DEBUG_ENABLED = process.env.DEBUG_TEGG_VITEST_ADAPTER === '1'; +export const DEBUG_ENABLED = process.env.DEBUG_TEGG_VITEST === '1'; export function debugLog(message: string, extra?: unknown) { if (!DEBUG_ENABLED) return; if (extra === undefined) { // eslint-disable-next-line no-console - console.log(`[tegg-vitest-adapter] ${message}`); + console.log(`[tegg-vitest] ${message}`); return; } // eslint-disable-next-line no-console - console.log(`[tegg-vitest-adapter] ${message}`, extra); + console.log(`[tegg-vitest] ${message}`, extra); } export async function defaultGetApp(): Promise { diff --git a/core/vitest-adapter/test/fixture_app.test.ts b/core/vitest/test/fixture_app.test.ts similarity index 100% rename from core/vitest-adapter/test/fixture_app.test.ts rename to core/vitest/test/fixture_app.test.ts diff --git a/core/vitest-adapter/test/fixtures/apps/demo-app/config/module.json b/core/vitest/test/fixtures/apps/demo-app/config/module.json similarity index 100% rename from core/vitest-adapter/test/fixtures/apps/demo-app/config/module.json rename to core/vitest/test/fixtures/apps/demo-app/config/module.json diff --git a/core/vitest-adapter/test/fixtures/apps/demo-app/modules/demo-module/HelloService.ts b/core/vitest/test/fixtures/apps/demo-app/modules/demo-module/HelloService.ts similarity index 100% rename from core/vitest-adapter/test/fixtures/apps/demo-app/modules/demo-module/HelloService.ts rename to core/vitest/test/fixtures/apps/demo-app/modules/demo-module/HelloService.ts diff --git a/core/vitest-adapter/test/fixtures/apps/demo-app/modules/demo-module/package.json b/core/vitest/test/fixtures/apps/demo-app/modules/demo-module/package.json similarity index 100% rename from core/vitest-adapter/test/fixtures/apps/demo-app/modules/demo-module/package.json rename to core/vitest/test/fixtures/apps/demo-app/modules/demo-module/package.json diff --git a/core/vitest-adapter/test/fixtures/apps/demo-app/package.json b/core/vitest/test/fixtures/apps/demo-app/package.json similarity index 59% rename from core/vitest-adapter/test/fixtures/apps/demo-app/package.json rename to core/vitest/test/fixtures/apps/demo-app/package.json index 61de28ecd..e6b10c22c 100644 --- a/core/vitest-adapter/test/fixtures/apps/demo-app/package.json +++ b/core/vitest/test/fixtures/apps/demo-app/package.json @@ -2,5 +2,5 @@ "name": "demo-app", "version": "0.0.1", "private": true, - "description": "vitest-adapter fixture app" + "description": "tegg-vitest fixture app" } diff --git a/core/vitest-adapter/test/get_app_throw.test.ts b/core/vitest/test/get_app_throw.test.ts similarity index 100% rename from core/vitest-adapter/test/get_app_throw.test.ts rename to core/vitest/test/get_app_throw.test.ts diff --git a/core/vitest-adapter/test/get_store_restore.test.ts b/core/vitest/test/get_store_restore.test.ts similarity index 85% rename from core/vitest-adapter/test/get_store_restore.test.ts rename to core/vitest/test/get_store_restore.test.ts index d27b399c4..a9c196bda 100644 --- a/core/vitest-adapter/test/get_store_restore.test.ts +++ b/core/vitest/test/get_store_restore.test.ts @@ -99,15 +99,18 @@ describe('ctxStorage.getStore restore', () => { }); afterAll(async () => { - // After all tests, suite context is restored - assert.strictEqual(getCtx(), suiteCtx); - assert.strictEqual(testCtxList.length, afterEachCtxList.length); - testCtxList.forEach((ctx, index) => { - assert.notStrictEqual(ctx, suiteCtx); - assert.strictEqual(ctx, afterEachCtxList[index]); - }); - assert.notStrictEqual(testCtxList[0], testCtxList[1]); - await app.close(); - await mm.restore(); + try { + // After all tests, suite context is restored + assert.strictEqual(getCtx(), suiteCtx); + assert.strictEqual(testCtxList.length, afterEachCtxList.length); + testCtxList.forEach((ctx, index) => { + assert.notStrictEqual(ctx, suiteCtx); + assert.strictEqual(ctx, afterEachCtxList[index]); + }); + assert.notStrictEqual(testCtxList[0], testCtxList[1]); + } finally { + await app.close(); + await mm.restore(); + } }); }); diff --git a/core/vitest-adapter/test/hooks.test.ts b/core/vitest/test/hooks.test.ts similarity index 80% rename from core/vitest-adapter/test/hooks.test.ts rename to core/vitest/test/hooks.test.ts index dbc878517..14c2fd3f9 100644 --- a/core/vitest-adapter/test/hooks.test.ts +++ b/core/vitest/test/hooks.test.ts @@ -29,15 +29,18 @@ describe('vitest adapter ctx semantics', () => { }); afterAll(async () => { - afterCtx = getCtx(); - assert(beforeCtx); - assert(beforeCtx !== itCtxList.foo); - assert(itCtxList.foo !== itCtxList.bar); - assert.strictEqual(afterCtx, beforeCtx); - assert.strictEqual(beforeEachCtxList.foo, afterEachCtxList.foo); - assert.strictEqual(beforeEachCtxList.foo, itCtxList.foo); - await app.close(); - await mm.restore(); + try { + afterCtx = getCtx(); + assert(beforeCtx); + assert(beforeCtx !== itCtxList.foo); + assert(itCtxList.foo !== itCtxList.bar); + assert.strictEqual(afterCtx, beforeCtx); + assert.strictEqual(beforeEachCtxList.foo, afterEachCtxList.foo); + assert.strictEqual(beforeEachCtxList.foo, itCtxList.foo); + } finally { + await app.close(); + await mm.restore(); + } }); describe('foo', () => { diff --git a/core/vitest-adapter/test/setup.ts b/core/vitest/test/setup.ts similarity index 100% rename from core/vitest-adapter/test/setup.ts rename to core/vitest/test/setup.ts diff --git a/core/vitest-adapter/tsconfig.json b/core/vitest/tsconfig.json similarity index 100% rename from core/vitest-adapter/tsconfig.json rename to core/vitest/tsconfig.json diff --git a/core/vitest-adapter/tsconfig.pub.json b/core/vitest/tsconfig.pub.json similarity index 100% rename from core/vitest-adapter/tsconfig.pub.json rename to core/vitest/tsconfig.pub.json diff --git a/core/vitest-adapter/vitest.config.ts b/core/vitest/vitest.config.ts similarity index 100% rename from core/vitest-adapter/vitest.config.ts rename to core/vitest/vitest.config.ts From 3c748cd65251177a8256d016d04e76b1891b14e2 Mon Sep 17 00:00:00 2001 From: killagu-claw Date: Tue, 17 Feb 2026 20:45:47 +0800 Subject: [PATCH 3/7] fix: lint errors in vitest test files - Add trailing comma in fixture_app.test.ts - Add comment to empty arrow function in get_app_throw.test.ts Co-Authored-By: Claude Opus 4.6 --- core/vitest/test/fixture_app.test.ts | 2 +- core/vitest/test/get_app_throw.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/core/vitest/test/fixture_app.test.ts b/core/vitest/test/fixture_app.test.ts index 51bdccb8f..78e7b414d 100644 --- a/core/vitest/test/fixture_app.test.ts +++ b/core/vitest/test/fixture_app.test.ts @@ -7,7 +7,7 @@ import { configureTeggRunner } from '../src'; const require = createRequire(import.meta.url); const { HelloService } = require( - path.join(__dirname, 'fixtures/apps/demo-app/modules/demo-module/HelloService') + path.join(__dirname, 'fixtures/apps/demo-app/modules/demo-module/HelloService'), ); const app = mm.app({ diff --git a/core/vitest/test/get_app_throw.test.ts b/core/vitest/test/get_app_throw.test.ts index 317772a0d..7e0b17a13 100644 --- a/core/vitest/test/get_app_throw.test.ts +++ b/core/vitest/test/get_app_throw.test.ts @@ -3,7 +3,7 @@ import { describe, it, afterAll, vi } from 'vitest'; import { configureTeggRunner } from '../src'; let getAppCalls = 0; -const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); +const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { /* noop */ }); configureTeggRunner({ getApp() { From 970cc15947b321f7b19fd306970e1550660a3a74 Mon Sep 17 00:00:00 2001 From: killagu-claw Date: Tue, 17 Feb 2026 20:54:13 +0800 Subject: [PATCH 4/7] fix: suppress no-var-requires lint in CJS fixture files Co-Authored-By: Claude Opus 4.6 --- .../fixtures/apps/demo-app/app/service/hello.js | 12 ++++++++++++ .../test/fixtures/apps/demo-app/config/plugin.js | 16 ++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 core/vitest/test/fixtures/apps/demo-app/app/service/hello.js create mode 100644 core/vitest/test/fixtures/apps/demo-app/config/plugin.js diff --git a/core/vitest/test/fixtures/apps/demo-app/app/service/hello.js b/core/vitest/test/fixtures/apps/demo-app/app/service/hello.js new file mode 100644 index 000000000..4db79d24a --- /dev/null +++ b/core/vitest/test/fixtures/apps/demo-app/app/service/hello.js @@ -0,0 +1,12 @@ +'use strict'; + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const Service = require('egg').Service; + +class HelloService extends Service { + sayHi(name) { + return `hi ${name}`; + } +} + +module.exports = HelloService; diff --git a/core/vitest/test/fixtures/apps/demo-app/config/plugin.js b/core/vitest/test/fixtures/apps/demo-app/config/plugin.js new file mode 100644 index 000000000..e9fa61be2 --- /dev/null +++ b/core/vitest/test/fixtures/apps/demo-app/config/plugin.js @@ -0,0 +1,16 @@ +// eslint-disable-next-line @typescript-eslint/no-var-requires +const path = require('path'); + +// Enable tegg in this fixture app so we can test `ctx.getEggObject()`. +// NOTE: use local plugin paths (monorepo) instead of installing deps. +const teggRoot = path.join(__dirname, '../../../../../../..'); + +exports.teggConfig = { + enable: true, + path: path.join(teggRoot, 'plugin/config'), +}; + +exports.tegg = { + enable: true, + path: path.join(teggRoot, 'plugin/tegg'), +}; From 4455837babf290c249c814fc13892f42cae44dd3 Mon Sep 17 00:00:00 2001 From: killagu-claw Date: Tue, 17 Feb 2026 21:05:22 +0800 Subject: [PATCH 5/7] fix: skip vitest tests on Node < 18 Co-Authored-By: Claude Opus 4.6 --- core/vitest/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/vitest/package.json b/core/vitest/package.json index 770117e4c..1474986ae 100644 --- a/core/vitest/package.json +++ b/core/vitest/package.json @@ -28,7 +28,7 @@ "scripts": { "clean": "tsc -b --clean", "tsc:pub": "ut run clean && tsc -p ./tsconfig.pub.json", - "test": "vitest run" + "test": "node --eval \"process.exit(parseInt(process.versions.node) < 18 ? 0 : 1)\" || vitest run" }, "author": "killagu ", "license": "MIT", From a1855ec4a10678dbf91b8fea0b78b97ed64b198e Mon Sep 17 00:00:00 2001 From: killagu-claw Date: Tue, 17 Feb 2026 21:08:18 +0800 Subject: [PATCH 6/7] fix: use VitestRunnerImportSource type for importFile signature Replace `source: string` with proper `VitestRunnerImportSource` type and remove unnecessary `as any` cast on super call. Co-Authored-By: Claude Opus 4.6 --- core/vitest/src/runner.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/core/vitest/src/runner.ts b/core/vitest/src/runner.ts index 82cafd40d..04135396f 100644 --- a/core/vitest/src/runner.ts +++ b/core/vitest/src/runner.ts @@ -1,4 +1,5 @@ import { VitestTestRunner } from 'vitest/runners'; +import type { VitestRunnerImportSource } from 'vitest/runners'; import type { Suite, Task, File } from 'vitest'; import { debugLog, @@ -91,7 +92,7 @@ export default class TeggVitestRunner extends VitestTestRunner { * Override importFile to capture per-file config set by configureTeggRunner() * and await app.ready() during collection phase. */ - async importFile(filepath: string, source: string): Promise { + async importFile(filepath: string, source: VitestRunnerImportSource): Promise { // Clear stale state for this file before re-collection in watch mode if (source === 'collect') { this.fileAppMap.delete(filepath); @@ -100,7 +101,7 @@ export default class TeggVitestRunner extends VitestTestRunner { // Clear any stale config before importing delete (globalThis as any).__teggVitestConfig; - const result = await super.importFile(filepath, source as any); + const result = await super.importFile(filepath, source); if (source === 'collect') { const rawConfig = (globalThis as any).__teggVitestConfig; From 480a8a54d0c72ce0b7e3ff91b3ae82be2b1dd20b Mon Sep 17 00:00:00 2001 From: killagu-claw Date: Tue, 17 Feb 2026 23:27:59 +0800 Subject: [PATCH 7/7] fix: derive importFile source type from base class VitestRunnerImportSource is not re-exported from vitest/runners, use Parameters[1] instead. Co-Authored-By: Claude Opus 4.6 --- core/vitest/src/runner.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/core/vitest/src/runner.ts b/core/vitest/src/runner.ts index 04135396f..f03042be5 100644 --- a/core/vitest/src/runner.ts +++ b/core/vitest/src/runner.ts @@ -1,5 +1,4 @@ import { VitestTestRunner } from 'vitest/runners'; -import type { VitestRunnerImportSource } from 'vitest/runners'; import type { Suite, Task, File } from 'vitest'; import { debugLog, @@ -92,7 +91,7 @@ export default class TeggVitestRunner extends VitestTestRunner { * Override importFile to capture per-file config set by configureTeggRunner() * and await app.ready() during collection phase. */ - async importFile(filepath: string, source: VitestRunnerImportSource): Promise { + async importFile(filepath: string, source: Parameters[1]): Promise { // Clear stale state for this file before re-collection in watch mode if (source === 'collect') { this.fileAppMap.delete(filepath);