diff --git a/extensions/browser/src/browser/chrome.default-browser.test.ts b/extensions/browser/src/browser/chrome.default-browser.test.ts index 33d92f7f124..e2f2465a133 100644 --- a/extensions/browser/src/browser/chrome.default-browser.test.ts +++ b/extensions/browser/src/browser/chrome.default-browser.test.ts @@ -1,33 +1,25 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("node:child_process", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, + const { mockNodeBuiltinModule } = await import("../../../../test/helpers/node-builtin-mocks.js"); + return mockNodeBuiltinModule(importOriginal, { execFileSync: vi.fn(), - }; + }); }); vi.mock("node:fs", async (importOriginal) => { - const actual = await importOriginal(); + const { mockNodeBuiltinModule } = await import("../../../../test/helpers/node-builtin-mocks.js"); const existsSync = vi.fn(); const readFileSync = vi.fn(); - const module = { existsSync, readFileSync }; - return { - ...actual, - ...module, - default: { - ...actual, - ...module, - }, - }; + return mockNodeBuiltinModule( + importOriginal, + { existsSync, readFileSync }, + { mirrorToDefault: true }, + ); }); -vi.mock("node:os", () => { +vi.mock("node:os", async (importOriginal) => { + const { mockNodeBuiltinModule } = await import("../../../../test/helpers/node-builtin-mocks.js"); const homedir = vi.fn(); - const module = { homedir }; - return { - ...module, - default: module, - }; + return mockNodeBuiltinModule(importOriginal, { homedir }, { mirrorToDefault: true }); }); import { execFileSync } from "node:child_process"; import * as fs from "node:fs"; diff --git a/scripts/test-projects.mjs b/scripts/test-projects.mjs index af569deabff..f74320e349b 100644 --- a/scripts/test-projects.mjs +++ b/scripts/test-projects.mjs @@ -3,6 +3,8 @@ import { acquireLocalHeavyCheckLockSync } from "./lib/local-heavy-check-runtime. import { spawnPnpmRunner } from "./pnpm-runner.mjs"; import { createVitestRunSpecs, writeVitestIncludeFile } from "./test-projects.test-support.mjs"; +// Keep this shim so `pnpm test -- src/foo.test.ts` still forwards filters +// cleanly instead of leaking pnpm's passthrough sentinel to Vitest. const releaseLock = acquireLocalHeavyCheckLockSync({ cwd: process.cwd(), env: process.env, diff --git a/src/infra/gateway-processes.test.ts b/src/infra/gateway-processes.test.ts index 67964d7661d..c3418f4516e 100644 --- a/src/infra/gateway-processes.test.ts +++ b/src/infra/gateway-processes.test.ts @@ -8,23 +8,21 @@ const isGatewayArgvMock = vi.hoisted(() => vi.fn()); const findGatewayPidsOnPortSyncMock = vi.hoisted(() => vi.fn()); vi.mock("node:child_process", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, + const { mockNodeBuiltinModule } = await import("../../test/helpers/node-builtin-mocks.js"); + return mockNodeBuiltinModule(importOriginal, { spawnSync: (...args: unknown[]) => spawnSyncMock(...args), - }; + }); }); vi.mock("node:fs", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - default: { - ...actual, + const { mockNodeBuiltinModule } = await import("../../test/helpers/node-builtin-mocks.js"); + return mockNodeBuiltinModule( + importOriginal, + { readFileSync: (...args: unknown[]) => readFileSyncMock(...args), }, - readFileSync: (...args: unknown[]) => readFileSyncMock(...args), - }; + { mirrorToDefault: true }, + ); }); vi.mock("../daemon/cmd-argv.js", () => ({ diff --git a/src/infra/ssh-config.test.ts b/src/infra/ssh-config.test.ts index 01d40fa85f6..db1c34255da 100644 --- a/src/infra/ssh-config.test.ts +++ b/src/infra/ssh-config.test.ts @@ -17,7 +17,7 @@ function createMockSpawnChild() { } vi.mock("node:child_process", async (importOriginal) => { - const actual = await importOriginal(); + const { mockNodeBuiltinModule } = await import("../../test/helpers/node-builtin-mocks.js"); const spawn = vi.fn(() => { const { child, stdout } = createMockSpawnChild(); process.nextTick(() => { @@ -36,10 +36,9 @@ vi.mock("node:child_process", async (importOriginal) => { }); return child; }); - return { - ...actual, + return mockNodeBuiltinModule(importOriginal, { spawn, - }; + }); }); const spawnMock = vi.mocked(spawn); diff --git a/src/infra/windows-task-restart.test.ts b/src/infra/windows-task-restart.test.ts index ce6f8cd8da9..1f4177350e8 100644 --- a/src/infra/windows-task-restart.test.ts +++ b/src/infra/windows-task-restart.test.ts @@ -8,11 +8,10 @@ const spawnMock = vi.hoisted(() => vi.fn()); const resolvePreferredOpenClawTmpDirMock = vi.hoisted(() => vi.fn(() => os.tmpdir())); vi.mock("node:child_process", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, + const { mockNodeBuiltinModule } = await import("../../test/helpers/node-builtin-mocks.js"); + return mockNodeBuiltinModule(importOriginal, { spawn: (...args: unknown[]) => spawnMock(...args), - }; + }); }); vi.mock("./tmp-openclaw-dir.js", () => ({ resolvePreferredOpenClawTmpDir: () => resolvePreferredOpenClawTmpDirMock(), diff --git a/src/plugin-sdk/browser-maintenance.test.ts b/src/plugin-sdk/browser-maintenance.test.ts index 630301bc429..48a5f2510b4 100644 --- a/src/plugin-sdk/browser-maintenance.test.ts +++ b/src/plugin-sdk/browser-maintenance.test.ts @@ -15,31 +15,21 @@ vi.mock("./facade-runtime.js", () => ({ })); vi.mock("node:fs/promises", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - default: { - ...actual, - mkdir, - access, - rename, - }, - mkdir, - access, - rename, - }; + const { mockNodeBuiltinModule } = await import("../../test/helpers/node-builtin-mocks.js"); + return mockNodeBuiltinModule( + importOriginal, + { mkdir, access, rename }, + { mirrorToDefault: true }, + ); }); vi.mock("node:os", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - default: { - ...actual, - homedir: () => "/home/test", - }, - homedir: () => "/home/test", - }; + const { mockNodeBuiltinModule } = await import("../../test/helpers/node-builtin-mocks.js"); + return mockNodeBuiltinModule( + importOriginal, + { homedir: () => "/home/test" }, + { mirrorToDefault: true }, + ); }); describe("browser maintenance", () => { diff --git a/src/process/exec.windows.test.ts b/src/process/exec.windows.test.ts index 537404e54d2..202e4372976 100644 --- a/src/process/exec.windows.test.ts +++ b/src/process/exec.windows.test.ts @@ -7,12 +7,11 @@ const spawnMock = vi.hoisted(() => vi.fn()); const execFileMock = vi.hoisted(() => vi.fn()); vi.mock("node:child_process", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, + const { mockNodeBuiltinModule } = await import("../../test/helpers/node-builtin-mocks.js"); + return mockNodeBuiltinModule(importOriginal, { spawn: spawnMock, execFile: execFileMock, - }; + }); }); let runCommandWithTimeout: typeof import("./exec.js").runCommandWithTimeout; diff --git a/src/security/windows-acl.test.ts b/src/security/windows-acl.test.ts index fc144c6c34d..369f97a8cb8 100644 --- a/src/security/windows-acl.test.ts +++ b/src/security/windows-acl.test.ts @@ -4,16 +4,12 @@ import type { WindowsAclEntry, WindowsAclSummary } from "./windows-acl.js"; const MOCK_USERNAME = "MockUser"; vi.mock("node:os", async (importOriginal) => { - const actual = await importOriginal(); - const base = ("default" in actual ? actual.default : actual) as Record; - return { - ...actual, - default: { - ...base, - userInfo: () => ({ username: MOCK_USERNAME }), - }, - userInfo: () => ({ username: MOCK_USERNAME }), - }; + const { mockNodeBuiltinModule } = await import("../../test/helpers/node-builtin-mocks.js"); + return mockNodeBuiltinModule( + importOriginal, + { userInfo: () => ({ username: MOCK_USERNAME }) }, + { mirrorToDefault: true }, + ); }); let createIcaclsResetCommand: typeof import("./windows-acl.js").createIcaclsResetCommand; diff --git a/test/extension-test-boundary.test.ts b/test/extension-test-boundary.test.ts index 834299ba686..5e2dc581fbe 100644 --- a/test/extension-test-boundary.test.ts +++ b/test/extension-test-boundary.test.ts @@ -34,6 +34,24 @@ function walk(dir: string, entries: string[] = []): string[] { return entries; } +function walkCode(dir: string, entries: string[] = []): string[] { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === "node_modules" || entry.name === "dist" || entry.name === ".git") { + continue; + } + walkCode(fullPath, entries); + continue; + } + if (!entry.name.endsWith(".ts") && !entry.name.endsWith(".tsx")) { + continue; + } + entries.push(path.relative(repoRoot, fullPath).replaceAll(path.sep, "/")); + } + return entries; +} + function findExtensionImports(source: string): string[] { return [ ...source.matchAll(/from\s+["']((?:\.\.\/)+extensions\/[^"']+)["']/g), @@ -124,4 +142,21 @@ describe("non-extension test boundaries", () => { expect(imports).toEqual([]); }); + + it("keeps bundled plugin sync test-api loaders out of core tests", () => { + const files = [ + ...walkCode(path.join(repoRoot, "src")), + ...walkCode(path.join(repoRoot, "test")), + ] + .filter((file) => !file.startsWith(BUNDLED_PLUGIN_PATH_PREFIX)) + .filter((file) => !file.startsWith("test/helpers/")) + .filter((file) => file !== "test/extension-test-boundary.test.ts"); + + const offenders = files.filter((file) => { + const source = fs.readFileSync(path.join(repoRoot, file), "utf8"); + return source.includes("loadBundledPluginTestApiSync("); + }); + + expect(offenders).toEqual([]); + }); }); diff --git a/test/helpers/node-builtin-mocks.test.ts b/test/helpers/node-builtin-mocks.test.ts new file mode 100644 index 00000000000..8d6b7d156c3 --- /dev/null +++ b/test/helpers/node-builtin-mocks.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { mockNodeBuiltinModule } from "./node-builtin-mocks.js"; + +describe("mockNodeBuiltinModule", () => { + it("merges partial overrides into the original module", async () => { + const actual = { readFileSync: () => "actual", watch: () => "watch" }; + const readFileSync = () => "mock"; + + const mocked = await mockNodeBuiltinModule(async () => actual, { + readFileSync, + }); + + expect(mocked.readFileSync).toBe(readFileSync); + expect(mocked.watch).toBe(actual.watch); + expect("default" in mocked).toBe(false); + }); + + it("mirrors overrides into the default export when requested", async () => { + const homedir = () => "/tmp/home"; + + const mocked = await mockNodeBuiltinModule( + async () => ({ tmpdir: () => "/tmp" }), + { homedir }, + { mirrorToDefault: true }, + ); + + expect(mocked.default).toMatchObject({ + homedir, + tmpdir: expect.any(Function), + }); + }); + + it("preserves existing default exports while overriding members", async () => { + const actual = { + readFileSync: () => "actual", + default: { + readFileSync: () => "actual", + statSync: () => "stat", + }, + }; + const readFileSync = () => "mock"; + + const mocked = await mockNodeBuiltinModule( + async () => actual, + { readFileSync }, + { mirrorToDefault: true }, + ); + + expect(mocked.default).toMatchObject({ + readFileSync, + statSync: expect.any(Function), + }); + }); +}); diff --git a/test/helpers/node-builtin-mocks.ts b/test/helpers/node-builtin-mocks.ts new file mode 100644 index 00000000000..a35d3118236 --- /dev/null +++ b/test/helpers/node-builtin-mocks.ts @@ -0,0 +1,43 @@ +type MockFactory = + | Partial + | ((actual: TModule) => Partial); + +function resolveMockOverrides( + actual: TModule, + factory: MockFactory, +): Partial { + return typeof factory === "function" ? factory(actual) : factory; +} + +function resolveDefaultBase(actual: TModule): Record { + const defaultExport = (actual as TModule & { default?: unknown }).default; + if (defaultExport && typeof defaultExport === "object") { + return defaultExport as Record; + } + return actual as Record; +} + +export async function mockNodeBuiltinModule( + importOriginal: () => Promise, + factory: MockFactory, + options?: { mirrorToDefault?: boolean }, +): Promise { + const actual = await importOriginal(); + const overrides = resolveMockOverrides(actual, factory); + const mocked = { + ...actual, + ...overrides, + } as TModule & { default?: Record }; + + if (!options?.mirrorToDefault) { + return mocked; + } + + return { + ...mocked, + default: { + ...resolveDefaultBase(actual), + ...overrides, + }, + } as TModule; +}