Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ run
.DS_Store
.tmp
.vscode
codex-logs/

package-lock.json
yarn.lock
Expand Down
64 changes: 64 additions & 0 deletions core/vitest/README.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions core/vitest/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './src';
60 changes: 60 additions & 0 deletions core/vitest/package.json
Original file line number Diff line number Diff line change
@@ -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 <killa123@126.com>",
"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"
Comment on lines +50 to +51
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# Find the vitest-adapter implementation
find . -path "*/core/vitest-adapter*" -type f -name "*.ts" -o -name "*.js" | head -20

Repository: eggjs/tegg

Length of output: 1192


🏁 Script executed:

# Check the runner.ts implementation
find . -path "*/vitest-adapter*" -name "runner.ts" -o -path "*/vitest-adapter*" -name "runner.js"

Repository: eggjs/tegg

Length of output: 121


🏁 Script executed:

# Read the vitest-adapter package.json to confirm the peer dependencies and devDependencies
find . -path "*/core/vitest-adapter/package.json" -exec cat {} \;

Repository: eggjs/tegg

Length of output: 1354


🌐 Web query:

vitest VitestTestRunner API breaking changes v1 v2 v3 vitest/runners

💡 Result:

Vitest VitestTestRunner / vitest/runners + VitestRunner API breaking changes (v1 → v2 → v3)

v1 → v2 (Runner API)

  1. onTaskUpdate payload shape

    • v1: onTaskUpdate?: (task: [string, TaskResult | undefined][]) => Promise<void> (2-tuple items) [1]
    • v2: same 2-tuple shape (still no task meta in the callback payload) [2]
  2. File task gained local?: boolean in v2

    • v2 File: includes local?: boolean (“initiated without running any tests… populate state on the server side”) [2]
    • (This field is not present in v1’s doc page; it appears as part of the v2 Runner Tasks model.) [2]

v2 → v3 (Runner API)

  1. onTaskUpdate payload became a 3-tuple (meta added)

    • v2: onTaskUpdate?: (task: [string, TaskResult | undefined][]) => Promise<void> [2]
    • v3: onTaskUpdate?: (task: [string, TaskResult | undefined, TaskMeta | undefined][]) => Promise<void> (breaking) [3]
  2. extendTaskContext signature changed (generic TaskContext → TestContext)

    • v2: extendTaskContext?: <T extends Test | Custom>(context: TaskContext<T>) => TaskContext<T> [2]
    • v3: extendTaskContext?: (context: TestContext) => TestContext (breaking) [3]
  3. New runner hooks / fields in v3

    • injectValue?: (key: string) => unknown (used with test.extend(..., { injected: true })) [3]
    • pool?: string exposed on the runner instance [3]
  4. Runner “File task” shape changed

    • v2 File: has local?: boolean and describes projectName as “workspace project” [2]
    • v3 File: removes local?: boolean and describes projectName as “test project” [4]

Notes about VitestTestRunner / vitest/runners

  • Across v1/v2/v3 docs, Vitest recommends extending VitestTestRunner from vitest/runners to retain snapshot support and related runner features [1] [2] [4].

🏁 Script executed:

# Read the runner.ts implementation to see what vitest APIs are actually used
cat -n ./core/vitest-adapter/src/runner.ts

Repository: eggjs/tegg

Length of output: 9228


🏁 Script executed:

# Also check the main index.ts for any vitest API usage
cat -n ./core/vitest-adapter/src/index.ts

Repository: eggjs/tegg

Length of output: 841


🏁 Script executed:

# Check what's in shared.ts
cat -n ./core/vitest-adapter/src/shared.ts

Repository: eggjs/tegg

Length of output: 2589


Peer dependency claims v1–v3 compatibility, but only v1 is tested.

peerDependencies declares ^1.6.0 || ^2.0.0 || ^3.0.0, yet devDependencies only pins ^1.6.0. Vitest's vitest/runners API has documented breaking changes between v2 and v3 (e.g., onTaskUpdate payload shape, extendTaskContext signature). The TeggVitestRunner class extends VitestTestRunner and calls multiple super.*() lifecycle methods, which may be affected by these API changes. Add CI matrix entries for vitest v2 and v3 to verify compatibility, or narrow the peer dependency range until tested.

🤖 Prompt for AI Agents
In `@core/vitest-adapter/package.json` around lines 50 - 51, The peerDependencies
claim compatibility with Vitest v1–v3 but only v1 is tested; update either the
CI to test vitest@^2 and vitest@^3 (add matrix entries in your workflow) to
verify TeggVitestRunner (which extends VitestTestRunner and invokes lifecycle
methods like super.onTaskUpdate and super.extendTaskContext) works with the
changed payload/signature (onTaskUpdate payload shape, extendTaskContext
signature), or tighten peerDependencies in package.json to only the tested range
(e.g., ^1.6.0) until you add those CI jobs; ensure devDependencies include the
same tested vitest versions so local dev/tests match CI.

},
"devDependencies": {
"@types/node": "^20.2.4",
"typescript": "^5.0.4",
"vitest": "^1.6.0",
"egg": "^3.9.1",
"egg-tracer": "^2.0.0"
}
}
1 change: 1 addition & 0 deletions core/vitest/runner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './src/runner';
17 changes: 17 additions & 0 deletions core/vitest/src/index.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
236 changes: 236 additions & 0 deletions core/vitest/src/runner.ts
Original file line number Diff line number Diff line change
@@ -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> | EggMockApp | undefined;
}

interface HeldScope {
scopePromise: Promise<void>;
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<HeldScope> {
let endScope!: () => void;
const gate = new Promise<void>(resolve => {
endScope = resolve;
});

let scopeReady!: () => void;
const readyPromise = new Promise<void>(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 };
}
Comment on lines +42 to +68
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

createHeldScope can hang if beginModuleScope rejects before invoking the inner function.

If ctx.beginModuleScope throws (or rejects) during its initialization phase—before calling the inner callback—scopeReady() is never invoked, so await readyPromise (line 70) will block forever. Meanwhile, scopePromise rejects with no handler, causing an unhandled rejection.

Race scopePromise against readyPromise so that an init failure propagates immediately:

Proposed fix
-  // Wait for init() inside beginModuleScope to finish
-  await readyPromise;
+  // Wait for init() inside beginModuleScope to finish,
+  // but bail immediately if beginModuleScope itself rejects.
+  await Promise.race([readyPromise, scopePromise]);

If scopePromise rejects first, the Promise.race will propagate the error. If readyPromise resolves first, we continue as before (and scopePromise remains pending, held open by the gate).

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async function createHeldScope(ctx: any): Promise<HeldScope> {
let endScope!: () => void;
const gate = new Promise<void>(resolve => {
endScope = resolve;
});
let scopeReady!: () => void;
const readyPromise = new Promise<void>(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 { ctx, scopePromise, endScope };
}
async function createHeldScope(ctx: any): Promise<HeldScope> {
let endScope!: () => void;
const gate = new Promise<void>(resolve => {
endScope = resolve;
});
let scopeReady!: () => void;
const readyPromise = new Promise<void>(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,
// but bail immediately if beginModuleScope itself rejects.
await Promise.race([readyPromise, scopePromise]);
return { ctx, scopePromise, endScope };
}
🤖 Prompt for AI Agents
In `@core/vitest-adapter/src/runner.ts` around lines 52 - 73, createHeldScope can
hang if ctx.beginModuleScope rejects before it invokes the inner callback
because scopeReady() is never called; modify createHeldScope to race
scopePromise against readyPromise (e.g., await
Promise.race([scopePromise.catch(e => { throw e }), readyPromise])) so that if
beginModuleScope rejects the error propagates immediately instead of blocking,
and ensure scopePromise still refers to the original ctx.beginModuleScope result
(so gate/endScope behavior is preserved) and that any rejection is
awaited/handled to avoid unhandled rejections (reference createHeldScope,
ctx.beginModuleScope, scopePromise, readyPromise, scopeReady, gate, 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<string, FileScopeState>();
private taskScopeMap = new Map<string, TaskScopeState>();
private fileAppMap = new Map<string, FileAppState>();
private warned = false;
Comment on lines +84 to +88
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# First, let's look at the runner.ts file to understand the full context
cat -n core/vitest-adapter/src/runner.ts | head -150

Repository: eggjs/tegg

Length of output: 5326


🏁 Script executed:

# Search for how TeggVitestRunner is instantiated or used
rg -n "TeggVitestRunner\|new.*Runner" --type=ts -C 3 core/vitest-adapter/

Repository: eggjs/tegg

Length of output: 36


🏁 Script executed:

# Look for lifecycle cleanup methods like destroy, cleanup, or reset
rg -n "destroy\|cleanup\|reset\|teardown" --type=ts core/vitest-adapter/src/

Repository: eggjs/tegg

Length of output: 36


🌐 Web query:

Vitest runner instance lifecycle in watch mode

💡 Result:

In Vitest watch mode (i.e. vitest / vitest watch), there isn’t a single “runner instance” that’s torn down and recreated on every rerun. Instead, Vitest keeps the main process (and Vite dev server/module graph) alive and triggers new test runs on changes, with different lifetimes depending on which layer you’re talking about:

1) Main process (Vitest/Vite server)

  • Initialized once when you start watch mode; it stays alive between reruns. The “initialization” phase can run again only if the config (or its imports) changes. [1]
  • globalSetup runs once per Vitest process start, and its teardown runs only when the process exits, not between watch reruns. [1][2]

2) Workers / pool processes (where test files actually execute)

  • Vitest spawns workers based on your pool (forks, threads, vmForks, vmThreads, etc.). [2]
  • By default, workers are not reused (to preserve isolation). Workers are reused only when:
    • isolate is disabled, or
    • you use VM pools (vmThreads / vmForks) where the VM layer provides isolation. [2]

3) Per-rerun behavior (what repeats when you edit files / press r)

  • A rerun starts a new test run, and Vitest will only re-run affected test files (based on the module graph, HMR-style). [1][3]
  • For each test file that runs in that rerun:
    • setupFiles execute before the test file (again for those files). [1][2]
    • file-level code re-imports according to isolation/module caching rules (notably, if isolate is disabled, imported modules can remain cached even though setupFiles still re-run). [2]

4) If you need “global setup/teardown per rerun” in watch mode

Vitest doesn’t currently re-run globalSetup/teardown on every watch rerun. The supported workaround is a custom reporter using onWatcherRerun (and onInit/onFinished). [4][5]

Sources:
[1] Vitest “Test Run Lifecycle” (watch mode + phases) (vitest.dev)
[2] Vitest lifecycle details on worker reuse + teardown in watch mode (cn.vitest.dev)
[3] Vitest “Features” guide (watch mode reruns related tests via module graph) (main.vitest.dev)
[4] Vitest GitHub Discussion #6719 (maintainer: use reporter onWatcherRerun) (github.com)
[5] Vitest Reporters API (includes onWatcherRerun, onInit, etc.) (main.vitest.dev)

Citations:


🏁 Script executed:

# Get the complete runner.ts file to check for lifecycle cleanup methods
wc -l core/vitest-adapter/src/runner.ts

Repository: eggjs/tegg

Length of output: 91


🏁 Script executed:

# Read the rest of the file to look for cleanup/destroy/reset methods
tail -200 core/vitest-adapter/src/runner.ts | cat -n

Repository: eggjs/tegg

Length of output: 7523


🏁 Script executed:

# Search for any methods that might clear state maps
rg -n "fileScopeMap\|taskScopeMap\|fileAppMap\|disabledFiles" --type=ts core/vitest-adapter/src/runner.ts -A 2 -B 2

Repository: eggjs/tegg

Length of output: 36


🏁 Script executed:

# Check what methods VitestTestRunner base class might provide
rg -n "override|class.*VitestTestRunner" --type=ts core/vitest-adapter/src/ -A 5

Repository: eggjs/tegg

Length of output: 593


🏁 Script executed:

# Check if there are any lifecycle hooks like onRun, reset, or other cleanup mechanisms
rg -n "onRun|reset|clear|teardown" --type=ts core/vitest-adapter/src/runner.ts

Repository: eggjs/tegg

Length of output: 36


🏁 Script executed:

# Look for config files or setup that might instantiate or reset the runner
find core/vitest-adapter -name "*.ts" -o -name "*.js" | head -20

Repository: eggjs/tegg

Length of output: 692


🏁 Script executed:

# Check the package exports to see how runner is used
cat -n core/vitest-adapter/src/index.ts

Repository: eggjs/tegg

Length of output: 841


🏁 Script executed:

# Check the vitest config to understand how the runner is configured
cat -n core/vitest-adapter/vitest.config.ts

Repository: eggjs/tegg

Length of output: 1629


🏁 Script executed:

# Look at the test setup to understand collection behavior across runs
cat -n core/vitest-adapter/test/setup.ts

Repository: eggjs/tegg

Length of output: 997


State maps accumulate across runs — fileAppMap, disabledFiles, and warned never clear in watch mode.

The Vitest runner instance persists across watch-mode reruns. During each collection phase, importFile populates fileAppMap (line 85), adds to disabledFiles (line 89), and may set warned = true (line 91), but these are never cleared between runs. This causes:

  • fileAppMap to accumulate stale entries for files across reruns
  • disabledFiles entries to persist permanently (disabled files cannot recover)
  • warned flag to suppress warnings even if later test files fail with getApp

While fileScopeMap and taskScopeMap are properly cleaned in onAfterRunSuite and onAfterRunTask, the importFile-populated collections need reset or conditional re-population logic in watch mode.

🤖 Prompt for AI Agents
In `@core/vitest-adapter/src/runner.ts` around lines 109 - 114, TeggVitestRunner's
per-file state (fileAppMap, disabledFiles, and warned) is accumulating across
watch-mode reruns because importFile repopulates them but they are never
cleared; update the runner to reset or selectively clear these collections at
the start/end of a run: either clear fileAppMap.clear(), disabledFiles.clear(),
and warned = false at the beginning of the collection phase (e.g. in the method
that runs before importFile), or add logic in onAfterRunSuite/onAfterRunTask to
remove entries for files not present in the current run; locate the importFile
implementation and the TeggVitestRunner lifecycle hooks (onAfterRunSuite,
onAfterRunTask) and implement the clearing or conditional re-population there so
state does not persist incorrectly between watch-mode reruns.


/**
* Override importFile to capture per-file config set by configureTeggRunner()
* and await app.ready() during collection phase.
*/
async importFile(filepath: string, source: Parameters<VitestTestRunner['importFile']>[1]): Promise<unknown> {
// 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<void> {
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<void> {
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<void> {
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<void> {
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);
}
}
Loading
Loading