diff --git a/AGENTS.md b/AGENTS.md index 250483c42..dbd7ca50e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,6 +34,7 @@ When refactoring replaces an old path, delete the old one. No back-compat shims, - Bug fixes start with a failing test that reproduces the bug. Do not start by patching. - `tests/unit/` shared unit tests and helpers · co-located `src/**/*.test.ts` for module logic · `tests/fixtures/` fixture repos · `tests/integration/` reactor/permission harness. Planned: `tests/e2e/` (fixture-repo runs). - A test must not depend on another file having run, or on the default file order. It must pass under `bun test ./src ./tests ./evals --randomize`. If a test mutates module-level state or calls `mock.module`, it must restore that state itself (`afterEach`/`afterAll`), not rely on the process happening to reset it. When capturing a module's real exports to restore later, shallow-copy them (`{ ...moduleNamespace }`) at capture time, whether the namespace came from `await import(path)` or a static `import * as ns from "path"` — Bun mutates the live namespace object in place when the module is mocked, so holding a bare reference to it (either form) silently turns into the mocked exports. +- Never call `mock.module` directly. Bun runs every test file in one process, so a `mock.module` call without its own teardown stays installed for the rest of the run and silently replaces the real module for other files — producing failures in files the change never touched, with no obvious link to the cause and no signal from `tsc` or a per-file run (CL-6967). Use `withMockedModule`/`withMockedModuleDuring` from `tests/helpers/mock-module.ts`, which capture the real module and register their own restore. An eslint rule (`no-restricted-syntax` in `eslint.config.js`) rejects bare `mock.module` calls in `*.test.ts` files. ## Build & Validation diff --git a/eslint.config.js b/eslint.config.js index ea80b8400..42ed60d7c 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -56,4 +56,22 @@ export default tseslint.config( "no-control-regex": "off", }, }, + { + // A bare `mock.module` call has no teardown of its own, so a mock left + // installed by one test file silently replaces a real module for every + // other file in the same `bun test` process (see CL-6967). Route through + // withMockedModule/withMockedModuleDuring (tests/helpers/mock-module.ts) + // instead, which register their own restore. + files: ["**/*.test.ts"], + rules: { + "no-restricted-syntax": [ + "error", + { + selector: "CallExpression[callee.object.name='mock'][callee.property.name='module']", + message: + "Use withMockedModule/withMockedModuleDuring from tests/helpers/mock-module.ts instead of bare mock.module — an un-restored mock.module leaks into every test file that runs after this one.", + }, + ], + }, + }, ); diff --git a/src/auth/codex/instructions.test.ts b/src/auth/codex/instructions.test.ts index 377ca829d..2bce5201f 100644 --- a/src/auth/codex/instructions.test.ts +++ b/src/auth/codex/instructions.test.ts @@ -1,20 +1,15 @@ -import { afterAll, afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { withMockedModule } from "../../../tests/helpers/mock-module.js"; // Reused by both the TUI and exec boot paths (src/tui/runner.ts, // src/exec/runner.ts) to refresh the pinned Codex instructions before first // Codex inference. This exercises the shared refresh/fallback logic directly, // with disk I/O faked so tests never touch the real ~/.corbits cache. -// -// Shallow-copy + afterAll restore is required: Bun mutates the live module -// namespace when mock.module runs, and CI loads ./src before ./tests — a leaked -// node:fs mock turns later suites into ENOENT / missing-settings failures. - -const realFs = { ...(await import("node:fs")) }; let fakeDisk = new Map(); -mock.module("node:fs", () => ({ - ...realFs, +await withMockedModule(import.meta.resolve("node:fs"), (real: typeof import("node:fs")) => ({ + ...real, readFileSync: (path: string) => { const contents = fakeDisk.get(path); if (contents === undefined) { @@ -30,10 +25,6 @@ mock.module("node:fs", () => ({ mkdirSync: () => undefined, })); -afterAll(() => { - mock.module("node:fs", () => realFs); -}); - const { refreshCodexInstructions, codexInstructions, codexInstructionsHash } = await import("./instructions.js"); const { GPT_5_CODEX_PROMPT } = await import("./prompts/gpt-5-codex.js"); diff --git a/src/auth/oauth-scope-check.test.ts b/src/auth/oauth-scope-check.test.ts index 0dc63525e..1a98538b2 100644 --- a/src/auth/oauth-scope-check.test.ts +++ b/src/auth/oauth-scope-check.test.ts @@ -1,4 +1,5 @@ -import { afterAll, afterEach, describe, expect, mock, test } from "bun:test"; +import { afterEach, describe, expect, test } from "bun:test"; +import { withMockedModule } from "../../tests/helpers/mock-module.js"; // getValidCodexToken/getValidXaiToken hit the real home-level auth store and // refresh endpoints; stub the session layer so this test only exercises the @@ -6,22 +7,20 @@ import { afterAll, afterEach, describe, expect, mock, test } from "bun:test"; // (tests/unit/codex-session.test.ts) import the real modules directly, so the // mocks must be torn down after this file's tests run rather than leaking // into the rest of the bun test process. -const realCodexSession = { ...(await import("./codex/session.js")) }; -const realXaiSession = { ...(await import("./xai/session.js")) }; - -mock.module("./codex/session.js", () => ({ - ...realCodexSession, - getValidCodexToken: async () => ({ access: "codex-token", accountId: "acct-1" }), -})); -mock.module("./xai/session.js", () => ({ - ...realXaiSession, - getValidXaiToken: async () => ({ access: "xai-token" }), -})); - -afterAll(() => { - mock.module("./codex/session.js", () => realCodexSession); - mock.module("./xai/session.js", () => realXaiSession); -}); +await withMockedModule( + import.meta.resolve("./codex/session.js"), + (real: typeof import("./codex/session.js")) => ({ + ...real, + getValidCodexToken: async () => ({ access: "codex-token", accountId: "acct-1" }), + }), +); +await withMockedModule( + import.meta.resolve("./xai/session.js"), + (real: typeof import("./xai/session.js")) => ({ + ...real, + getValidXaiToken: async () => ({ access: "xai-token" }), + }), +); const { checkOAuthProviderScope } = await import("./oauth-scope-check.js"); diff --git a/src/session/state.test.ts b/src/session/state.test.ts index c0c5797e2..cee7a89b2 100644 --- a/src/session/state.test.ts +++ b/src/session/state.test.ts @@ -1,33 +1,26 @@ -import { afterAll, afterEach, beforeEach, expect, mock, test } from "bun:test"; +import { afterEach, beforeEach, expect, test } from "bun:test"; import { mkdir, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; - -// Bun mutates the imported namespace object in place when a module is -// mocked, so the capture is shallow-copied immediately -- holding onto the -// live namespace would turn into the mocked exports as soon as mock.module -// below runs, making a later restore a no-op. -const realFs = { ...(await import("node:fs/promises")) }; +import { withMockedModule } from "../../tests/helpers/mock-module.js"; // Simulates the straggler write's real await point (e.g. cycleRecorder.dispose // during the terminal path) landing its writeFile after a later-issued // terminal write's writeFile, so rename-order alone would let it win. -const realWriteFile = realFs.writeFile; let delayNextWrite = false; -mock.module("node:fs/promises", () => ({ - ...realFs, - writeFile: async (path: string, data: string) => { - if (delayNextWrite) { - delayNextWrite = false; - await new Promise((resolve) => setTimeout(resolve, 30)); - } - return realWriteFile(path, data); - }, -})); - -afterAll(() => { - mock.module("node:fs/promises", () => realFs); -}); +await withMockedModule( + import.meta.resolve("node:fs/promises"), + (real: typeof import("node:fs/promises")) => ({ + ...real, + writeFile: async (path: string, data: string) => { + if (delayNextWrite) { + delayNextWrite = false; + await new Promise((resolve) => setTimeout(resolve, 30)); + } + return real.writeFile(path, data); + }, + }), +); const { finalizeRunState, loadState, saveState } = await import("./state.js"); const { getActiveRun, setActiveRun } = await import("./active-run.js"); diff --git a/src/tools/web-search.test.ts b/src/tools/web-search.test.ts index ea1fc1356..762aa0296 100644 --- a/src/tools/web-search.test.ts +++ b/src/tools/web-search.test.ts @@ -1,39 +1,33 @@ -import { afterAll, afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { withMockedModule } from "../../tests/helpers/mock-module.js"; const calls: { toolName: string; args: Record }[] = []; let connectConfigs: { name: string; url?: string }[] = []; -// Bun mutates the imported namespace object in place when a module is -// mocked, so the capture is shallow-copied immediately -- holding onto the -// live namespace would turn into the mocked exports as soon as mock.module -// below runs, making the afterAll restore a no-op. The mock also needs to -// spread the real module rather than replace it outright, or any other -// export (unwrapToolContent, connectMCPServers) disappears for the rest of -// the process for every file that runs after this one. -const realClient = { ...(await import("../mcp/client.js")) }; - -mock.module("../mcp/client.js", () => ({ - ...realClient, - connectMCPServer: async (config: { name: string; url?: string }) => { - connectConfigs.push(config); - return { - ok: true, - client: { - serverName: config.name, - tools: [], - call: async (toolName: string, args: Record) => { - calls.push({ toolName, args }); - return "mock result"; +// The mock needs to spread the real module rather than replace it outright, +// or any other export (unwrapToolContent, connectMCPServers) disappears for +// the rest of the process for every file that runs after this one. +await withMockedModule( + import.meta.resolve("../mcp/client.js"), + (real: typeof import("../mcp/client.js")) => ({ + ...real, + connectMCPServer: async (config: { name: string; url?: string }) => { + connectConfigs.push(config); + return { + ok: true, + client: { + serverName: config.name, + tools: [], + call: async (toolName: string, args: Record) => { + calls.push({ toolName, args }); + return "mock result"; + }, + close: async () => undefined, }, - close: async () => undefined, - }, - }; - }, -})); - -afterAll(() => { - mock.module("../mcp/client.js", () => realClient); -}); + }; + }, + }), +); const { createWebSearchTool, diff --git a/src/tui/mouse-reporting-disabled.test.ts b/src/tui/mouse-reporting-disabled.test.ts index a687339ba..4a5568093 100644 --- a/src/tui/mouse-reporting-disabled.test.ts +++ b/src/tui/mouse-reporting-disabled.test.ts @@ -7,8 +7,9 @@ * `createCliRenderer` branch runs, and assert on the options it was * actually called with. */ -import { afterAll, afterEach, describe, expect, mock, test } from "bun:test"; +import { afterEach, describe, expect, test } from "bun:test"; import type { Harness } from "./harness.js"; +import { withMockedModule } from "../../tests/helpers/mock-module.js"; interface CapturedRendererOptions { readonly useMouse?: boolean; @@ -21,33 +22,20 @@ const mountedHarnesses: Harness[] = []; // The mock must be registered before anything (including this file's own // helpers) does a real `@opentui/core` import, or that import wins the module // cache and the mock never takes effect. Every dependency below is loaded -// with a dynamic `import()` after `mock.module` for that reason. -// -// Bun mutates the imported namespace object in place when a module is -// mocked, so the capture is shallow-copied immediately -- holding onto the -// live namespace would turn into the mocked exports the moment mock.module -// below runs, making the afterAll restore below a no-op. -const realCore = { ...(await import("@opentui/core")) }; - -mock.module("@opentui/core", () => ({ - ...realCore, - createCliRenderer: async (options: CapturedRendererOptions) => { - capturedOptions.push(options); - const { createHarness } = await import("./harness.js"); - const harness = await createHarness({ width: 80, height: 24 }); - mountedHarnesses.push(harness); - return harness.renderer; - }, -})); - -// `mock.module` replaces the shared module cache for the whole test process, -// not just this file — every other test that imports `@opentui/core` runs in -// the same process. Put the real module back once this file is done so a -// later un-injected `createCliRenderer` caller does not silently get this -// fake harness renderer instead. -afterAll(() => { - mock.module("@opentui/core", () => realCore); -}); +// with a dynamic `import()` after the mock is installed for that reason. +await withMockedModule( + import.meta.resolve("@opentui/core"), + (real: typeof import("@opentui/core")) => ({ + ...real, + createCliRenderer: async (options: CapturedRendererOptions) => { + capturedOptions.push(options); + const { createHarness } = await import("./harness.js"); + const harness = await createHarness({ width: 80, height: 24 }); + mountedHarnesses.push(harness); + return harness.renderer; + }, + }), +); afterEach(() => { let harness: Harness | undefined; diff --git a/src/tui/provider-setup-submit.test.ts b/src/tui/provider-setup-submit.test.ts index d64174105..db51e9bc7 100644 --- a/src/tui/provider-setup-submit.test.ts +++ b/src/tui/provider-setup-submit.test.ts @@ -1,25 +1,22 @@ -import { describe, test, expect, afterEach, afterAll, mock } from "bun:test"; +import { describe, test, expect, afterEach } from "bun:test"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { OAuthScopeCheckResult } from "../auth/oauth-scope-check.js"; +import { withMockedModule } from "../../tests/helpers/mock-module.js"; // The oauth branch probes real provider scope over the network; stub the // check so these tests exercise buildProviderSubmitHandler's own branching -// (ok / insufficient-scope / unavailable) without a live call. Restored after -// this file's tests run so a mock never leaks into another suite that -// imports provider-setup-submit.js and expects the real probe. -const realOAuthScopeCheck = { ...(await import("../auth/oauth-scope-check.js")) }; +// (ok / insufficient-scope / unavailable) without a live call. let scopeCheckResult: OAuthScopeCheckResult = { status: "ok" }; -mock.module("../auth/oauth-scope-check.js", () => ({ - ...realOAuthScopeCheck, - checkOAuthProviderScope: async () => scopeCheckResult, -})); - -afterAll(() => { - mock.module("../auth/oauth-scope-check.js", () => realOAuthScopeCheck); -}); +await withMockedModule( + import.meta.resolve("../auth/oauth-scope-check.js"), + (real: typeof import("../auth/oauth-scope-check.js")) => ({ + ...real, + checkOAuthProviderScope: async () => scopeCheckResult, + }), +); const { buildProviderSubmitHandler } = await import("./provider-setup-submit.js"); const { loadLocalSettings, loadSettings, localSettingsPath } = diff --git a/tests/helpers/mock-module.ts b/tests/helpers/mock-module.ts new file mode 100644 index 000000000..e2401fa25 --- /dev/null +++ b/tests/helpers/mock-module.ts @@ -0,0 +1,72 @@ +import { afterAll, mock } from "bun:test"; +import { fileURLToPath } from "node:url"; + +// `mock.module` keys its registry by filesystem path, not by `file://` URL -- +// registering under a URL string silently fails to intercept a module +// resolved elsewhere by relative specifier, once anything else in the +// process has already loaded the real module. `import.meta.resolve` returns +// a `file://` URL for relative specifiers, so normalize it back to a path. +function toModulePath(path: string): string { + return path.startsWith("file://") ? fileURLToPath(path) : path; +} + +async function captureModule(path: string): Promise { + // Bun mutates the imported namespace object in place when a module is + // mocked, so the capture must be a shallow copy taken before any mock + // installs -- holding onto the live namespace would silently turn into + // the mocked exports as soon as mock.module runs. + return { ...(await import(path)) } as T; +} + +/** + * Mocks a module for the rest of this test file and registers its own + * `afterAll` restore, so correctness never depends on remembering to add + * one. Bun runs every test file in a single process, so an un-restored + * `mock.module` silently replaces the real module for every file that runs + * after this one -- this is the only sanctioned way to call `mock.module` + * at file scope. + * + * `impl` receives the captured real module so mocks can spread it + * (`...real`) without a separate capture line. + * + * Pass `path` as `import.meta.resolve("./relative/path.js")` from the + * calling file, not a bare relative specifier -- both `import()` and + * `mock.module` inside this helper resolve relative specifiers against + * this file's own location, not the caller's. + */ +export async function withMockedModule( + path: string, + impl: (real: T) => object, +): Promise { + const modulePath = toModulePath(path); + const real = await captureModule(modulePath); + mock.module(modulePath, () => impl(real)); + afterAll(() => { + mock.module(modulePath, () => real); + }); + return real; +} + +/** + * Mocks a module only for the duration of `run`, restoring it immediately + * afterward -- even if `run` throws -- rather than leaving it mocked for + * the rest of the file. Use this when a mock only needs to apply around a + * single call. + * + * Pass `path` as `import.meta.resolve("./relative/path.js")` -- see + * `withMockedModule` above. + */ +export async function withMockedModuleDuring( + path: string, + impl: (real: T) => object, + run: () => Promise, +): Promise { + const modulePath = toModulePath(path); + const real = await captureModule(modulePath); + mock.module(modulePath, () => impl(real)); + try { + return await run(); + } finally { + mock.module(modulePath, () => real); + } +} diff --git a/tests/unit/config.test.ts b/tests/unit/config.test.ts index e198a03d6..2a54d1963 100644 --- a/tests/unit/config.test.ts +++ b/tests/unit/config.test.ts @@ -1,16 +1,10 @@ -import { test, expect, mock } from "bun:test"; +import { test, expect } from "bun:test"; import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises"; -import * as nodeOs from "node:os"; - -// Bun mutates the imported namespace object in place when a module is -// mocked, so `nodeOs` itself is not safe to hold onto across a mock.module -// call -- capture a shallow copy now, before anything mocks node:os, so the -// snapshot below still reads "real" after the mock/restore round-trip. -const realNodeOs = { ...nodeOs }; import { tmpdir } from "node:os"; import { join } from "node:path"; import { loadConfig } from "../../src/config/index.js"; import { resetPricingMetadataRefreshForTests } from "../../src/cost/pricing-metadata.js"; +import { withMockedModuleDuring } from "../helpers/mock-module.js"; // Rejects immediately instead of touching the network. loadConfig's pricing // refresh is fire-and-forget, so a resolved run proves only that the injected @@ -237,22 +231,23 @@ test("loadConfig resolves an OAuth-profile provider absent from any settings fil // parameter — so the only way to point it at a synthetic auth store // without touching the real one is to stub node:os for the duration of // this call. - mock.module("node:os", () => ({ ...realNodeOs, homedir: () => fakeHome })); - try { - const { impl } = offlineFetch(); - const config = await loadConfig( - ["exec", "--cwd", cwd, "--provider", "xai/synthetic", "do something"], - { pricing: { fetchImpl: impl } }, - ); + await withMockedModuleDuring( + import.meta.resolve("node:os"), + (real: typeof import("node:os")) => ({ ...real, homedir: () => fakeHome }), + async () => { + const { impl } = offlineFetch(); + const config = await loadConfig( + ["exec", "--cwd", cwd, "--provider", "xai/synthetic", "do something"], + { pricing: { fetchImpl: impl } }, + ); - expect(config.configured).toBe(true); - if (config.configured) { - expect(config.providerName).toBe("xai/synthetic"); - expect(config.providers.some((p) => p.name === "xai/synthetic")).toBe(true); - } - } finally { - mock.module("node:os", () => realNodeOs); - } + expect(config.configured).toBe(true); + if (config.configured) { + expect(config.providerName).toBe("xai/synthetic"); + expect(config.providers.some((p) => p.name === "xai/synthetic")).toBe(true); + } + }, + ); } finally { await rm(fakeHome, { recursive: true, force: true }); await rm(cwd, { recursive: true, force: true }); diff --git a/tests/unit/tui/agent-tools.test.ts b/tests/unit/tui/agent-tools.test.ts index 798f3fec9..cf30fba89 100644 --- a/tests/unit/tui/agent-tools.test.ts +++ b/tests/unit/tui/agent-tools.test.ts @@ -1,7 +1,8 @@ -import { afterAll, test, expect, mock } from "bun:test"; +import { test, expect, mock } from "bun:test"; import type { ToolDefinition, ToolCall } from "@intx/types/runtime"; import { TOOL_NAMES } from "@intx/tools-posix"; import type { PermissionGate } from "../../../src/permission/gate.js"; +import { withMockedModule } from "../../helpers/mock-module.js"; const mockDispose = mock(async () => {}); @@ -36,36 +37,16 @@ const mockPosixTools = { dispose: mockDispose, }; -// mock.module replaces the shared module cache for the whole test process, so -// every other file that imports these modules runs against the mock until it -// is put back. Capture the real modules up front and restore them in -// afterAll so this file's mocking is invisible outside its own tests. Bun -// mutates the imported namespace object in place when a module is mocked, so -// each capture is shallow-copied immediately -- holding onto the live -// namespace instead would silently turn into the mocked exports as soon as -// mock.module below runs, making the "restore" a no-op. -const realToolsPosix = { ...(await import("@intx/tools-posix")) }; -const realPosixToolPlugins = { ...(await import("../../../src/agent/posix-tool-plugins.js")) }; -const realMcpClient = { ...(await import("../../../src/mcp/client.js")) }; -const realMcpPlugin = { ...(await import("../../../src/mcp/plugin.js")) }; -const realPathEscapePlugin = { ...(await import("../../../src/plugins/path-escape-plugin.js")) }; -const realAuthzPlugin = { ...(await import("../../../src/plugins/authz-plugin.js")) }; -const realVerifyPlugin = { ...(await import("../../../src/plugins/verify-plugin.js")) }; -const realPermissionPlugin = { ...(await import("../../../src/plugins/permission-plugin.js")) }; -const realSecretGuardPlugin = { ...(await import("../../../src/plugins/secret-guard-plugin.js")) }; -const realShellGuardPlugin = { ...(await import("../../../src/plugins/shell-guard-plugin.js")) }; -const realReadFileGuardPlugin = { - ...(await import("../../../src/plugins/read-file-guard-plugin.js")), -}; -const realEditFileLineRange = { ...(await import("../../../src/plugins/edit-file-line-range.js")) }; -const realDirector = { ...(await import("../../../src/agent/director.js")) }; - -mock.module("@intx/tools-posix", () => ({ +// withMockedModule captures each real module and registers its own afterAll +// restore, so none of these mocks can outlive this file (Bun runs every test +// file in one process, and an un-restored mock.module silently replaces the +// real module for every file that runs after this one). +await withMockedModule(import.meta.resolve("@intx/tools-posix"), () => ({ createPosixTools: () => mockPosixTools, TOOL_NAMES, })); -mock.module("../../../src/agent/posix-tool-plugins.js", () => ({ +await withMockedModule(import.meta.resolve("../../../src/agent/posix-tool-plugins.js"), () => ({ buildCorePosixToolPlugins: () => [], })); @@ -80,28 +61,31 @@ const mockConnectMCPServer = mock( }), ); -mock.module("../../../src/mcp/client.js", () => ({ - ...realMcpClient, - connectMCPServer: mockConnectMCPServer, -})); +await withMockedModule( + import.meta.resolve("../../../src/mcp/client.js"), + (real: typeof import("../../../src/mcp/client.js")) => ({ + ...real, + connectMCPServer: mockConnectMCPServer, + }), +); -mock.module("../../../src/mcp/plugin.js", () => ({ +await withMockedModule(import.meta.resolve("../../../src/mcp/plugin.js"), () => ({ mcpClientToAgentTools: () => [], })); -mock.module("../../../src/plugins/path-escape-plugin.js", () => ({ +await withMockedModule(import.meta.resolve("../../../src/plugins/path-escape-plugin.js"), () => ({ pathEscapePlugin: () => ({}), })); -mock.module("../../../src/plugins/authz-plugin.js", () => ({ +await withMockedModule(import.meta.resolve("../../../src/plugins/authz-plugin.js"), () => ({ authzPlugin: () => ({}), })); -mock.module("../../../src/plugins/verify-plugin.js", () => ({ +await withMockedModule(import.meta.resolve("../../../src/plugins/verify-plugin.js"), () => ({ verifyPlugin: () => ({}), })); -mock.module("../../../src/plugins/permission-plugin.js", () => ({ +await withMockedModule(import.meta.resolve("../../../src/plugins/permission-plugin.js"), () => ({ permissionPlugin: () => ({}), gateToolCall: async ( _gate: unknown, @@ -111,24 +95,27 @@ mock.module("../../../src/plugins/permission-plugin.js", () => ({ ) => next(call, signal), })); -mock.module("../../../src/plugins/secret-guard-plugin.js", () => ({ +await withMockedModule(import.meta.resolve("../../../src/plugins/secret-guard-plugin.js"), () => ({ secretGuardPlugin: () => ({}), })); -mock.module("../../../src/plugins/shell-guard-plugin.js", () => ({ +await withMockedModule(import.meta.resolve("../../../src/plugins/shell-guard-plugin.js"), () => ({ shellGuardPlugin: () => ({}), advertiseShellGuardTimeout: (defs: ToolDefinition[]) => defs, })); -mock.module("../../../src/plugins/read-file-guard-plugin.js", () => ({ - readFileGuardPlugin: () => ({}), -})); +await withMockedModule( + import.meta.resolve("../../../src/plugins/read-file-guard-plugin.js"), + () => ({ + readFileGuardPlugin: () => ({}), + }), +); -mock.module("../../../src/plugins/edit-file-line-range.js", () => ({ +await withMockedModule(import.meta.resolve("../../../src/plugins/edit-file-line-range.js"), () => ({ advertiseEditFileLineRange: (defs: ToolDefinition[]) => defs, })); -mock.module("../../../src/agent/director.js", () => ({ +await withMockedModule(import.meta.resolve("../../../src/agent/director.js"), () => ({ askOperatorDefinition: { name: "ask_operator", description: "Ask operator", @@ -147,22 +134,6 @@ mock.module("../../../src/agent/director.js", () => ({ createChatDirector: mock(() => ({})), })); -afterAll(() => { - mock.module("@intx/tools-posix", () => realToolsPosix); - mock.module("../../../src/agent/posix-tool-plugins.js", () => realPosixToolPlugins); - mock.module("../../../src/mcp/client.js", () => realMcpClient); - mock.module("../../../src/mcp/plugin.js", () => realMcpPlugin); - mock.module("../../../src/plugins/path-escape-plugin.js", () => realPathEscapePlugin); - mock.module("../../../src/plugins/authz-plugin.js", () => realAuthzPlugin); - mock.module("../../../src/plugins/verify-plugin.js", () => realVerifyPlugin); - mock.module("../../../src/plugins/permission-plugin.js", () => realPermissionPlugin); - mock.module("../../../src/plugins/secret-guard-plugin.js", () => realSecretGuardPlugin); - mock.module("../../../src/plugins/shell-guard-plugin.js", () => realShellGuardPlugin); - mock.module("../../../src/plugins/read-file-guard-plugin.js", () => realReadFileGuardPlugin); - mock.module("../../../src/plugins/edit-file-line-range.js", () => realEditFileLineRange); - mock.module("../../../src/agent/director.js", () => realDirector); -}); - const { createAgentToolset } = await import("../../../src/agent/tools.js"); const preApproveMock = mock((_tool: string, _pattern: string) => {});