diff --git a/.gitignore b/.gitignore index ddcda146..8269f7e8 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/README.md b/core/vitest/README.md new file mode 100644 index 00000000..047f81dd --- /dev/null +++ b/core/vitest/README.md @@ -0,0 +1,64 @@ +# @eggjs/tegg-vitest + +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'; + +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/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/index.ts b/core/vitest/index.ts new file mode 100644 index 00000000..8420b109 --- /dev/null +++ b/core/vitest/index.ts @@ -0,0 +1 @@ +export * from './src'; diff --git a/core/vitest/package.json b/core/vitest/package.json new file mode 100644 index 00000000..1474986a --- /dev/null +++ b/core/vitest/package.json @@ -0,0 +1,60 @@ +{ + "name": "@eggjs/tegg-vitest", + "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": "node --eval \"process.exit(parseInt(process.versions.node) < 18 ? 0 : 1)\" || 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" + }, + "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/runner.ts b/core/vitest/runner.ts new file mode 100644 index 00000000..78bab1af --- /dev/null +++ b/core/vitest/runner.ts @@ -0,0 +1 @@ +export { default } from './src/runner'; diff --git a/core/vitest/src/index.ts b/core/vitest/src/index.ts new file mode 100644 index 00000000..3ff5862d --- /dev/null +++ b/core/vitest/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/src/runner.ts b/core/vitest/src/runner.ts new file mode 100644 index 00000000..f03042be --- /dev/null +++ b/core/vitest/src/runner.ts @@ -0,0 +1,236 @@ +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; + }); + + // 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 }; +} + +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: Parameters[1]): 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; + + const result = await super.importFile(filepath, source); + + 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] 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); + } + this.fileAppMap.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); + } + + 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/src/shared.ts b/core/vitest/src/shared.ts new file mode 100644 index 00000000..0641794e --- /dev/null +++ b/core/vitest/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 === '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] ${message}`); + return; + } + // eslint-disable-next-line no-console + console.log(`[tegg-vitest] ${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/test/fixture_app.test.ts b/core/vitest/test/fixture_app.test.ts new file mode 100644 index 00000000..78e7b414 --- /dev/null +++ b/core/vitest/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/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 00000000..4db79d24 --- /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/module.json b/core/vitest/test/fixtures/apps/demo-app/config/module.json new file mode 100644 index 00000000..d95223e0 --- /dev/null +++ b/core/vitest/test/fixtures/apps/demo-app/config/module.json @@ -0,0 +1,5 @@ +[ + { + "path": "../modules/demo-module" + } +] 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 00000000..e9fa61be --- /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'), +}; diff --git a/core/vitest/test/fixtures/apps/demo-app/modules/demo-module/HelloService.ts b/core/vitest/test/fixtures/apps/demo-app/modules/demo-module/HelloService.ts new file mode 100644 index 00000000..9a0d57f9 --- /dev/null +++ b/core/vitest/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/test/fixtures/apps/demo-app/modules/demo-module/package.json b/core/vitest/test/fixtures/apps/demo-app/modules/demo-module/package.json new file mode 100644 index 00000000..32f2d880 --- /dev/null +++ b/core/vitest/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/test/fixtures/apps/demo-app/package.json b/core/vitest/test/fixtures/apps/demo-app/package.json new file mode 100644 index 00000000..e6b10c22 --- /dev/null +++ b/core/vitest/test/fixtures/apps/demo-app/package.json @@ -0,0 +1,6 @@ +{ + "name": "demo-app", + "version": "0.0.1", + "private": true, + "description": "tegg-vitest fixture app" +} diff --git a/core/vitest/test/get_app_throw.test.ts b/core/vitest/test/get_app_throw.test.ts new file mode 100644 index 00000000..7e0b17a1 --- /dev/null +++ b/core/vitest/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(() => { /* noop */ }); + +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/test/get_store_restore.test.ts b/core/vitest/test/get_store_restore.test.ts new file mode 100644 index 00000000..a9c196bd --- /dev/null +++ b/core/vitest/test/get_store_restore.test.ts @@ -0,0 +1,116 @@ +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 () => { + 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/test/hooks.test.ts b/core/vitest/test/hooks.test.ts new file mode 100644 index 00000000..14c2fd3f --- /dev/null +++ b/core/vitest/test/hooks.test.ts @@ -0,0 +1,89 @@ +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 () => { + 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', () => { + 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/test/setup.ts b/core/vitest/test/setup.ts new file mode 100644 index 00000000..76e3fe22 --- /dev/null +++ b/core/vitest/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/tsconfig.json b/core/vitest/tsconfig.json new file mode 100644 index 00000000..ed206ac6 --- /dev/null +++ b/core/vitest/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "baseUrl": "./" + }, + "exclude": [ + "dist", + "node_modules" + ] +} diff --git a/core/vitest/tsconfig.pub.json b/core/vitest/tsconfig.pub.json new file mode 100644 index 00000000..ad1c29d4 --- /dev/null +++ b/core/vitest/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/vitest.config.ts b/core/vitest/vitest.config.ts new file mode 100644 index 00000000..2412b686 --- /dev/null +++ b/core/vitest/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', + }, +});