From 1ceaad18a69007ccc6b4e297a6fa3d143472a335 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Mar 2026 10:47:52 -0700 Subject: [PATCH] test: harden vitest no-isolate coverage --- docs/help/testing.md | 12 +- extensions/github-copilot/models.test.ts | 14 +- scripts/test-parallel.mjs | 53 +++++- src/acp/control-plane/manager.test.ts | 9 +- src/agents/anthropic-vertex-stream.test.ts | 4 +- ...auth.openai-codex-refresh-fallback.test.ts | 20 ++- src/agents/btw.test.ts | 10 +- .../compact.hooks.harness.ts | 14 +- .../extra-params.google.test.ts | 16 +- .../extra-params.xai-tool-payload.test.ts | 16 +- .../extra-params.zai-tool-stream.test.ts | 16 +- .../pi-tools.workspace-only-false.test.ts | 14 +- src/agents/subagent-spawn.workspace.test.ts | 14 +- src/auto-reply/reply/auto-topic-label.test.ts | 10 +- src/canvas-host/server.state-dir.test.ts | 9 +- src/canvas-host/server.test.ts | 95 ++++++---- src/canvas-host/server.ts | 29 +++- src/cli/nodes-camera.test.ts | 48 ++++-- src/cli/qr-dashboard.integration.test.ts | 10 +- src/commands/openai-codex-oauth.test.ts | 12 +- src/config/io.owner-display-secret.test.ts | 9 +- src/cron/store.test.ts | 10 +- src/infra/fs-safe.test.ts | 5 + src/media-understanding/runner.proxy.test.ts | 35 +++- src/media/fetch.test.ts | 92 +++++++--- src/media/read-response-with-limit.test.ts | 37 ++-- src/memory/batch-gemini.test.ts | 68 +++++--- src/memory/batch-voyage.test.ts | 163 ++++++++++-------- src/node-host/invoke-browser.test.ts | 39 ++++- src/node-host/invoke-system-run.test.ts | 25 ++- src/plugin-sdk/root-alias.cjs | 37 +++- src/plugin-sdk/subpaths.test.ts | 7 +- src/tts/tts.test.ts | 14 +- src/tui/components/filterable-select-list.ts | 5 +- src/tui/components/searchable-select-list.ts | 5 +- test/helpers/fast-short-timeouts.ts | 6 +- test/non-isolated-runner.ts | 68 ++++++++ test/setup.ts | 71 +++++++- vitest.unit.config.ts | 1 + 39 files changed, 827 insertions(+), 295 deletions(-) create mode 100644 test/non-isolated-runner.ts diff --git a/docs/help/testing.md b/docs/help/testing.md index 06cc31b185f..dccada2ea69 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -68,9 +68,11 @@ Think of the suites as “increasing realism” (and increasing flakiness/cost): through the real `run.ts` / `compact.ts` paths; helper-only tests are not a sufficient substitute for those integration paths. - Pool note: - - OpenClaw uses Vitest `vmForks` on Node 22, 23, and 24 for faster unit shards. - - On Node 25+, OpenClaw automatically falls back to regular `forks` until the repo is re-validated there. - - Override manually with `OPENCLAW_TEST_VM_FORKS=0` (force `forks`) or `OPENCLAW_TEST_VM_FORKS=1` (force `vmForks`). + - OpenClaw uses Vitest `forks` by default for unit shards. + - `pnpm test` also defaults to `--isolate=false` at the wrapper level for faster file startup. + - Opt back into Vitest file isolation with `OPENCLAW_TEST_ISOLATE=1 pnpm test`. + - `OPENCLAW_TEST_NO_ISOLATE=0` or `OPENCLAW_TEST_NO_ISOLATE=false` also force isolated runs. + - `OPENCLAW_TEST_VM_FORKS=1` remains an opt-in experiment on Node 22, 23, and 24 only. ### E2E (gateway smoke) @@ -78,8 +80,8 @@ Think of the suites as “increasing realism” (and increasing flakiness/cost): - Config: `vitest.e2e.config.ts` - Files: `src/**/*.e2e.test.ts`, `test/**/*.e2e.test.ts` - Runtime defaults: - - Uses Vitest `vmForks` for faster file startup. - - Uses adaptive workers (CI: 2-4, local: 4-8). + - Uses Vitest `forks` for deterministic cross-file isolation. + - Uses adaptive workers (CI: up to 2, local: 1 by default). - Runs in silent mode by default to reduce console I/O overhead. - Useful overrides: - `OPENCLAW_E2E_WORKERS=` to force worker count (capped at 16). diff --git a/extensions/github-copilot/models.test.ts b/extensions/github-copilot/models.test.ts index 75bb67eac7a..98a00a8dc6d 100644 --- a/extensions/github-copilot/models.test.ts +++ b/extensions/github-copilot/models.test.ts @@ -1,9 +1,15 @@ import { describe, expect, it, vi } from "vitest"; -vi.mock("@mariozechner/pi-ai/oauth", () => ({ - getOAuthApiKey: vi.fn(), - getOAuthProviders: vi.fn(() => []), -})); +vi.mock("@mariozechner/pi-ai/oauth", async () => { + const actual = await vi.importActual( + "@mariozechner/pi-ai/oauth", + ); + return { + ...actual, + getOAuthApiKey: vi.fn(), + getOAuthProviders: vi.fn(() => []), + }; +}); vi.mock("openclaw/plugin-sdk/provider-models", () => ({ normalizeModelCompat: (model: Record) => model, diff --git a/scripts/test-parallel.mjs b/scripts/test-parallel.mjs index 954e22ed087..bc590aeb52c 100644 --- a/scripts/test-parallel.mjs +++ b/scripts/test-parallel.mjs @@ -94,7 +94,12 @@ const isMacMiniProfile = testProfile === "macmini"; // Preserve OPENCLAW_TEST_VM_FORKS=1 as the explicit override/debug escape hatch. const supportsVmForks = Number.isFinite(nodeMajor) ? nodeMajor <= 24 : true; const useVmForks = process.env.OPENCLAW_TEST_VM_FORKS === "1" && supportsVmForks; -const disableIsolation = process.env.OPENCLAW_TEST_NO_ISOLATE === "1"; +const forceIsolation = + process.env.OPENCLAW_TEST_ISOLATE === "1" || process.env.OPENCLAW_TEST_ISOLATE === "true"; +const disableIsolation = + !forceIsolation && + process.env.OPENCLAW_TEST_NO_ISOLATE !== "0" && + process.env.OPENCLAW_TEST_NO_ISOLATE !== "false"; const includeGatewaySuite = process.env.OPENCLAW_TEST_INCLUDE_GATEWAY === "1"; const includeExtensionsSuite = process.env.OPENCLAW_TEST_INCLUDE_EXTENSIONS === "1"; // Even on low-memory hosts, keep the isolated lane split so files like @@ -699,10 +704,40 @@ const createTargetedEntry = (owner, isolated, filters) => { ], }; }; +const formatPerFileEntryName = (owner, file) => { + const baseName = path + .basename(file) + .replace(/\.live\.test\.ts$/u, "") + .replace(/\.e2e\.test\.ts$/u, "") + .replace(/\.test\.ts$/u, ""); + return `${owner}-${baseName}`; +}; +const createPerFileTargetedEntry = (file) => { + const target = inferTarget(file); + const owner = isThreadSingletonUnitFile(file) + ? "unit-threads" + : isVmForkSingletonUnitFile(file) + ? "unit-vmforks" + : target.owner; + return { + ...createTargetedEntry(owner, target.isolated, [file]), + name: formatPerFileEntryName(owner, file), + }; +}; const targetedEntries = (() => { if (passthroughFileFilters.length === 0) { return []; } + if (disableIsolation) { + const matchedFiles = passthroughFileFilters.flatMap((fileFilter) => { + const resolved = resolveFilterMatches(fileFilter); + if (resolved.length > 0) { + return resolved; + } + return [normalizeRepoPath(fileFilter)]; + }); + return [...new Set(matchedFiles)].map((file) => createPerFileTargetedEntry(file)); + } const groups = passthroughFileFilters.reduce((acc, fileFilter) => { const matchedFiles = resolveFilterMatches(fileFilter); if (matchedFiles.length === 0) { @@ -738,6 +773,9 @@ const targetedEntries = (() => { return createTargetedEntry(owner, mode === "isolated", [...new Set(filters)]); }); })(); +if (disableIsolation && passthroughFileFilters.length === 0) { + runs = allKnownUnitFiles.map((file) => createPerFileTargetedEntry(file)); +} // Node 25 local runs still show cross-process worker shutdown contention even // after moving the known heavy files into singleton lanes. const topLevelParallelEnabled = @@ -745,8 +783,17 @@ const topLevelParallelEnabled = testProfile !== "serial" && !(!isCI && nodeMajor >= 25) && !isMacMiniProfile; -const defaultTopLevelParallelLimit = - testProfile === "serial" +const defaultTopLevelParallelLimit = disableIsolation + ? isCI + ? isWindows + ? 2 + : 4 + : highMemLocalHost + ? Math.min(16, hostCpuCount) + : lowMemLocalHost + ? Math.min(8, hostCpuCount) + : Math.min(12, hostCpuCount) + : testProfile === "serial" ? 1 : testProfile === "low" ? lowMemLocalHost diff --git a/src/acp/control-plane/manager.test.ts b/src/acp/control-plane/manager.test.ts index 8e900ce4800..c87858a18cd 100644 --- a/src/acp/control-plane/manager.test.ts +++ b/src/acp/control-plane/manager.test.ts @@ -1,3 +1,5 @@ +import { setTimeout as scheduleNativeTimeout } from "node:timers"; +import { setTimeout as sleep } from "node:timers/promises"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; import type { AcpSessionRuntimeOptions, SessionAcpMeta } from "../../config/sessions/types.js"; @@ -148,6 +150,7 @@ function extractRuntimeOptionsFromUpserts(): Array { beforeEach(() => { + vi.useRealTimers(); hoisted.listAcpSessionEntriesMock.mockReset().mockResolvedValue([]); hoisted.readAcpSessionEntryMock.mockReset(); hoisted.upsertAcpSessionMetaMock.mockReset().mockResolvedValue(null); @@ -240,7 +243,7 @@ describe("AcpSessionManager", () => { inFlight += 1; maxInFlight = Math.max(maxInFlight, inFlight); try { - await new Promise((resolve) => setTimeout(resolve, 10)); + await sleep(10); yield { type: "done" }; } finally { inFlight -= 1; @@ -321,7 +324,7 @@ describe("AcpSessionManager", () => { (error) => ({ status: "rejected" as const, error }), ), new Promise<{ status: "pending" }>((resolve) => { - setTimeout(() => resolve({ status: "pending" }), 100); + scheduleNativeTimeout(() => resolve({ status: "pending" }), 100); }), ]); @@ -538,7 +541,7 @@ describe("AcpSessionManager", () => { inFlight += 1; maxInFlight = Math.max(maxInFlight, inFlight); try { - await new Promise((resolve) => setTimeout(resolve, 15)); + await sleep(15); yield { type: "done" as const }; } finally { inFlight -= 1; diff --git a/src/agents/anthropic-vertex-stream.test.ts b/src/agents/anthropic-vertex-stream.test.ts index 3209bc0fb02..906c3f92423 100644 --- a/src/agents/anthropic-vertex-stream.test.ts +++ b/src/agents/anthropic-vertex-stream.test.ts @@ -13,8 +13,10 @@ const hoisted = vi.hoisted(() => { }; }); -vi.mock("@mariozechner/pi-ai", () => { +vi.mock("@mariozechner/pi-ai", async (importOriginal) => { + const original = await importOriginal(); return { + ...original, streamAnthropic: (model: unknown, context: unknown, options: unknown) => hoisted.streamAnthropicMock(model, context, options), }; diff --git a/src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts b/src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts index eff06553752..92a18c7f20d 100644 --- a/src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts +++ b/src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts @@ -29,13 +29,19 @@ const { buildProviderAuthDoctorHintWithPluginMock: vi.fn(async () => undefined), })); -vi.mock("@mariozechner/pi-ai/oauth", () => ({ - getOAuthApiKey: getOAuthApiKeyMock, - getOAuthProviders: () => [ - { id: "openai-codex", envApiKey: "OPENAI_API_KEY", oauthTokenEnv: "OPENAI_OAUTH_TOKEN" }, // pragma: allowlist secret - { id: "anthropic", envApiKey: "ANTHROPIC_API_KEY", oauthTokenEnv: "ANTHROPIC_OAUTH_TOKEN" }, // pragma: allowlist secret - ], -})); +vi.mock("@mariozechner/pi-ai/oauth", async () => { + const actual = await vi.importActual( + "@mariozechner/pi-ai/oauth", + ); + return { + ...actual, + getOAuthApiKey: getOAuthApiKeyMock, + getOAuthProviders: () => [ + { id: "openai-codex", envApiKey: "OPENAI_API_KEY", oauthTokenEnv: "OPENAI_OAUTH_TOKEN" }, // pragma: allowlist secret + { id: "anthropic", envApiKey: "ANTHROPIC_API_KEY", oauthTokenEnv: "ANTHROPIC_OAUTH_TOKEN" }, // pragma: allowlist secret + ], + }; +}); vi.mock("../../plugins/provider-runtime.runtime.js", () => ({ refreshProviderOAuthCredentialWithPlugin: refreshProviderOAuthCredentialWithPluginMock, diff --git a/src/agents/btw.test.ts b/src/agents/btw.test.ts index b9f3a9c19f1..56ca4493563 100644 --- a/src/agents/btw.test.ts +++ b/src/agents/btw.test.ts @@ -16,9 +16,13 @@ const resolveSessionAuthProfileOverrideMock = vi.fn(); const getActiveEmbeddedRunSnapshotMock = vi.fn(); const diagDebugMock = vi.fn(); -vi.mock("@mariozechner/pi-ai", () => ({ - streamSimple: (...args: unknown[]) => streamSimpleMock(...args), -})); +vi.mock("@mariozechner/pi-ai", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + streamSimple: (...args: unknown[]) => streamSimpleMock(...args), + }; +}); vi.mock("@mariozechner/pi-coding-agent", () => ({ SessionManager: { diff --git a/src/agents/pi-embedded-runner/compact.hooks.harness.ts b/src/agents/pi-embedded-runner/compact.hooks.harness.ts index c4a21c734ba..a21a8a2bd90 100644 --- a/src/agents/pi-embedded-runner/compact.hooks.harness.ts +++ b/src/agents/pi-embedded-runner/compact.hooks.harness.ts @@ -192,10 +192,16 @@ export async function loadCompactHooksHarness(): Promise<{ }; }); - vi.doMock("@mariozechner/pi-ai/oauth", () => ({ - getOAuthApiKey: vi.fn(), - getOAuthProviders: vi.fn(() => []), - })); + vi.doMock("@mariozechner/pi-ai/oauth", async () => { + const actual = await vi.importActual( + "@mariozechner/pi-ai/oauth", + ); + return { + ...actual, + getOAuthApiKey: vi.fn(), + getOAuthProviders: vi.fn(() => []), + }; + }); vi.doMock("@mariozechner/pi-coding-agent", () => ({ AuthStorage: class AuthStorage {}, diff --git a/src/agents/pi-embedded-runner/extra-params.google.test.ts b/src/agents/pi-embedded-runner/extra-params.google.test.ts index 622e85b475c..b76eb6f21cf 100644 --- a/src/agents/pi-embedded-runner/extra-params.google.test.ts +++ b/src/agents/pi-embedded-runner/extra-params.google.test.ts @@ -2,12 +2,16 @@ import type { Model } from "@mariozechner/pi-ai"; import { describe, expect, it, vi } from "vitest"; import { runExtraParamsCase } from "./extra-params.test-support.js"; -vi.mock("@mariozechner/pi-ai", () => ({ - streamSimple: vi.fn(() => ({ - push: vi.fn(), - result: vi.fn(), - })), -})); +vi.mock("@mariozechner/pi-ai", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + streamSimple: vi.fn(() => ({ + push: vi.fn(), + result: vi.fn(), + })), + }; +}); describe("extra-params: Google thinking payload compatibility", () => { it("strips negative thinking budgets and fills Gemini 3.1 thinkingLevel", () => { diff --git a/src/agents/pi-embedded-runner/extra-params.xai-tool-payload.test.ts b/src/agents/pi-embedded-runner/extra-params.xai-tool-payload.test.ts index 0e1fb50ca80..314db2620a6 100644 --- a/src/agents/pi-embedded-runner/extra-params.xai-tool-payload.test.ts +++ b/src/agents/pi-embedded-runner/extra-params.xai-tool-payload.test.ts @@ -2,12 +2,16 @@ import type { Model } from "@mariozechner/pi-ai"; import { describe, expect, it, vi } from "vitest"; import { runExtraParamsCase } from "./extra-params.test-support.js"; -vi.mock("@mariozechner/pi-ai", () => ({ - streamSimple: vi.fn(() => ({ - push: vi.fn(), - result: vi.fn(), - })), -})); +vi.mock("@mariozechner/pi-ai", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + streamSimple: vi.fn(() => ({ + push: vi.fn(), + result: vi.fn(), + })), + }; +}); describe("extra-params: xAI tool payload compatibility", () => { it("strips function.strict for xai providers", () => { diff --git a/src/agents/pi-embedded-runner/extra-params.zai-tool-stream.test.ts b/src/agents/pi-embedded-runner/extra-params.zai-tool-stream.test.ts index e1817218512..b4bce5c2869 100644 --- a/src/agents/pi-embedded-runner/extra-params.zai-tool-stream.test.ts +++ b/src/agents/pi-embedded-runner/extra-params.zai-tool-stream.test.ts @@ -4,12 +4,16 @@ import type { OpenClawConfig } from "../../config/config.js"; import { runExtraParamsCase } from "./extra-params.test-support.js"; // Mock streamSimple for testing -vi.mock("@mariozechner/pi-ai", () => ({ - streamSimple: vi.fn(() => ({ - push: vi.fn(), - result: vi.fn(), - })), -})); +vi.mock("@mariozechner/pi-ai", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + streamSimple: vi.fn(() => ({ + push: vi.fn(), + result: vi.fn(), + })), + }; +}); type ToolStreamCase = { applyProvider: string; diff --git a/src/agents/pi-tools.workspace-only-false.test.ts b/src/agents/pi-tools.workspace-only-false.test.ts index 99d3a9e4b39..602cf6945c8 100644 --- a/src/agents/pi-tools.workspace-only-false.test.ts +++ b/src/agents/pi-tools.workspace-only-false.test.ts @@ -10,10 +10,16 @@ vi.mock("@mariozechner/pi-ai", async (importOriginal) => { }; }); -vi.mock("@mariozechner/pi-ai/oauth", () => ({ - getOAuthApiKey: () => undefined, - getOAuthProviders: () => [], -})); +vi.mock("@mariozechner/pi-ai/oauth", async () => { + const actual = await vi.importActual( + "@mariozechner/pi-ai/oauth", + ); + return { + ...actual, + getOAuthApiKey: () => undefined, + getOAuthProviders: () => [], + }; +}); import { createOpenClawCodingTools } from "./pi-tools.js"; diff --git a/src/agents/subagent-spawn.workspace.test.ts b/src/agents/subagent-spawn.workspace.test.ts index 9955e587c89..b8c848da863 100644 --- a/src/agents/subagent-spawn.workspace.test.ts +++ b/src/agents/subagent-spawn.workspace.test.ts @@ -34,10 +34,16 @@ vi.mock("../config/config.js", async (importOriginal) => { }; }); -vi.mock("@mariozechner/pi-ai/oauth", () => ({ - getOAuthApiKey: () => "", - getOAuthProviders: () => [], -})); +vi.mock("@mariozechner/pi-ai/oauth", async () => { + const actual = await vi.importActual( + "@mariozechner/pi-ai/oauth", + ); + return { + ...actual, + getOAuthApiKey: () => "", + getOAuthProviders: () => [], + }; +}); vi.mock("./subagent-registry.js", () => ({ countActiveRunsForSession: () => 0, diff --git a/src/auto-reply/reply/auto-topic-label.test.ts b/src/auto-reply/reply/auto-topic-label.test.ts index 0cf3b60af47..f6a612ba2bf 100644 --- a/src/auto-reply/reply/auto-topic-label.test.ts +++ b/src/auto-reply/reply/auto-topic-label.test.ts @@ -7,9 +7,13 @@ const resolveDefaultModelForAgent = vi.hoisted(() => vi.fn()); const resolveModelAsync = vi.hoisted(() => vi.fn()); const prepareModelForSimpleCompletion = vi.hoisted(() => vi.fn()); -vi.mock("@mariozechner/pi-ai", () => ({ - completeSimple, -})); +vi.mock("@mariozechner/pi-ai", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + completeSimple, + }; +}); vi.mock("../../agents/model-auth.js", () => ({ getApiKeyForModel, diff --git a/src/canvas-host/server.state-dir.test.ts b/src/canvas-host/server.state-dir.test.ts index 744daef57d8..d274b9826ed 100644 --- a/src/canvas-host/server.state-dir.test.ts +++ b/src/canvas-host/server.state-dir.test.ts @@ -1,11 +1,16 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { describe, expect, it } from "vitest"; +import { beforeAll, describe, expect, it } from "vitest"; import { defaultRuntime } from "../runtime.js"; import { withStateDirEnv } from "../test-helpers/state-dir-env.js"; -import { createCanvasHostHandler } from "./server.js"; describe("canvas host state dir defaults", () => { + let createCanvasHostHandler: typeof import("./server.js").createCanvasHostHandler; + + beforeAll(async () => { + ({ createCanvasHostHandler } = await import("./server.js")); + }); + it("uses OPENCLAW_STATE_DIR for the default canvas root", async () => { await withStateDirEnv("openclaw-canvas-state-", async ({ stateDir }) => { const handler = await createCanvasHostHandler({ diff --git a/src/canvas-host/server.test.ts b/src/canvas-host/server.test.ts index 05fdb47528e..dd839193964 100644 --- a/src/canvas-host/server.test.ts +++ b/src/canvas-host/server.test.ts @@ -3,20 +3,20 @@ import { createServer } from "node:http"; import type { AddressInfo } from "node:net"; import os from "node:os"; import path from "node:path"; -import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; -import { WebSocket } from "ws"; +import { + clearTimeout as clearNativeTimeout, + setTimeout as scheduleNativeTimeout, +} from "node:timers"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { rawDataToString } from "../infra/ws.js"; import { defaultRuntime } from "../runtime.js"; import { A2UI_PATH, CANVAS_HOST_PATH, CANVAS_WS_PATH, injectCanvasLiveReload } from "./a2ui.js"; -import { createCanvasHostHandler, startCanvasHost } from "./server.js"; -const chokidarMockState = vi.hoisted(() => ({ - watchers: [] as Array<{ - on: (event: string, cb: (...args: unknown[]) => void) => unknown; - close: () => Promise; - __emit: (event: string, ...args: unknown[]) => void; - }>, -})); +type MockWatcher = { + on: (event: string, cb: (...args: unknown[]) => void) => MockWatcher; + close: () => Promise; + __emit: (event: string, ...args: unknown[]) => void; +}; const CANVAS_WS_OPEN_TIMEOUT_MS = 2_000; const CANVAS_RELOAD_TIMEOUT_MS = 4_000; @@ -27,11 +27,11 @@ function isLoopbackBindDenied(error: unknown) { return code === "EPERM" || code === "EACCES"; } -// Tests: avoid chokidar polling/fsevents; trigger "all" events manually. -vi.mock("chokidar", () => { +function createMockWatcherState() { + const watchers: MockWatcher[] = []; const createWatcher = () => { const handlers = new Map void>>(); - const api = { + const api: MockWatcher = { on: (event: string, cb: (...args: unknown[]) => void) => { const list = handlers.get(event) ?? []; list.push(cb); @@ -45,22 +45,26 @@ vi.mock("chokidar", () => { } }, }; - chokidarMockState.watchers.push(api); + watchers.push(api); return api; }; - - const watch = () => createWatcher(); return { - default: { watch }, - watch, + watchers, + watchFactory: () => createWatcher(), }; -}); +} describe("canvas host", () => { const quietRuntime = { ...defaultRuntime, log: (..._args: Parameters) => {}, }; + let createCanvasHostHandler: typeof import("./server.js").createCanvasHostHandler; + let startCanvasHost: typeof import("./server.js").startCanvasHost; + let realFetch: typeof import("undici").fetch; + let WebSocketClient: typeof import("ws").WebSocket; + let WebSocketServerClass: typeof import("ws").WebSocketServer; + let watcherState: ReturnType; let fixtureRoot = ""; let fixtureCount = 0; @@ -80,19 +84,34 @@ describe("canvas host", () => { port: 0, listenHost: "127.0.0.1", allowInTests: true, + watchFactory: watcherState.watchFactory as unknown as Parameters< + typeof startCanvasHost + >[0]["watchFactory"], + webSocketServerClass: WebSocketServerClass, ...overrides, }); const fetchCanvasHtml = async (port: number) => { - const res = await fetch(`http://127.0.0.1:${port}${CANVAS_HOST_PATH}/`); + const res = await realFetch(`http://127.0.0.1:${port}${CANVAS_HOST_PATH}/`); const html = await res.text(); return { res, html }; }; beforeAll(async () => { + ({ createCanvasHostHandler, startCanvasHost } = await import("./server.js")); + const undiciModule = await vi.importActual("undici"); + realFetch = undiciModule.fetch; + const wsModule = await vi.importActual("ws"); + WebSocketClient = wsModule.WebSocket; + WebSocketServerClass = wsModule.WebSocketServer; fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-canvas-fixtures-")); }); + beforeEach(() => { + vi.useRealTimers(); + watcherState = createMockWatcherState(); + }); + afterAll(async () => { await fs.rm(fixtureRoot, { recursive: true, force: true }); }); @@ -147,7 +166,7 @@ describe("canvas host", () => { expect(html).toContain("no-reload"); expect(html).not.toContain(CANVAS_WS_PATH); - const wsRes = await fetch(`http://127.0.0.1:${server.port}${CANVAS_WS_PATH}`); + const wsRes = await realFetch(`http://127.0.0.1:${server.port}${CANVAS_WS_PATH}`); expect(wsRes.status).toBe(404); } finally { await server.close(); @@ -163,6 +182,10 @@ describe("canvas host", () => { rootDir: dir, basePath: CANVAS_HOST_PATH, allowInTests: true, + watchFactory: watcherState.watchFactory as unknown as Parameters< + typeof createCanvasHostHandler + >[0]["watchFactory"], + webSocketServerClass: WebSocketServerClass, }); const server = createServer((req, res) => { @@ -205,13 +228,13 @@ describe("canvas host", () => { const port = (server.address() as AddressInfo).port; try { - const res = await fetch(`http://127.0.0.1:${port}${CANVAS_HOST_PATH}/`); + const res = await realFetch(`http://127.0.0.1:${port}${CANVAS_HOST_PATH}/`); const html = await res.text(); expect(res.status).toBe(200); expect(html).toContain("v1"); expect(html).toContain(CANVAS_WS_PATH); - const miss = await fetch(`http://127.0.0.1:${port}/`); + const miss = await realFetch(`http://127.0.0.1:${port}/`); expect(miss.status).toBe(404); } finally { await new Promise((resolve, reject) => @@ -247,7 +270,7 @@ describe("canvas host", () => { const index = path.join(dir, "index.html"); await fs.writeFile(index, "v1", "utf8"); - const watcherStart = chokidarMockState.watchers.length; + const watcherStart = watcherState.watchers.length; let server: Awaited>; try { server = await startFixtureCanvasHost(dir); @@ -259,7 +282,7 @@ describe("canvas host", () => { } try { - const watcher = chokidarMockState.watchers[watcherStart]; + const watcher = watcherState.watchers[watcherStart]; expect(watcher).toBeTruthy(); const { res, html } = await fetchCanvasHtml(server.port); @@ -267,29 +290,29 @@ describe("canvas host", () => { expect(html).toContain("v1"); expect(html).toContain(CANVAS_WS_PATH); - const ws = new WebSocket(`ws://127.0.0.1:${server.port}${CANVAS_WS_PATH}`); + const ws = new WebSocketClient(`ws://127.0.0.1:${server.port}${CANVAS_WS_PATH}`); await new Promise((resolve, reject) => { - const timer = setTimeout( + const timer = scheduleNativeTimeout( () => reject(new Error("ws open timeout")), CANVAS_WS_OPEN_TIMEOUT_MS, ); ws.on("open", () => { - clearTimeout(timer); + clearNativeTimeout(timer); resolve(); }); ws.on("error", (err) => { - clearTimeout(timer); + clearNativeTimeout(timer); reject(err); }); }); const msg = new Promise((resolve, reject) => { - const timer = setTimeout( + const timer = scheduleNativeTimeout( () => reject(new Error("reload timeout")), CANVAS_RELOAD_TIMEOUT_MS, ); ws.on("message", (data) => { - clearTimeout(timer); + clearNativeTimeout(timer); resolve(rawDataToString(data)); }); }); @@ -297,7 +320,7 @@ describe("canvas host", () => { await fs.writeFile(index, "v2", "utf8"); watcher.__emit("all", "change", index); expect(await msg).toBe("reload"); - ws.close(); + ws.terminate(); } finally { await server.close(); } @@ -335,24 +358,24 @@ describe("canvas host", () => { throw error; } - const res = await fetch(`http://127.0.0.1:${server.port}/__openclaw__/a2ui/`); + const res = await realFetch(`http://127.0.0.1:${server.port}/__openclaw__/a2ui/`); const html = await res.text(); expect(res.status).toBe(200); expect(html).toContain("openclaw-a2ui-host"); expect(html).toContain("openclawCanvasA2UIAction"); - const bundleRes = await fetch( + const bundleRes = await realFetch( `http://127.0.0.1:${server.port}/__openclaw__/a2ui/a2ui.bundle.js`, ); const js = await bundleRes.text(); expect(bundleRes.status).toBe(200); expect(js).toContain("openclawA2UI"); - const traversalRes = await fetch( + const traversalRes = await realFetch( `http://127.0.0.1:${server.port}${A2UI_PATH}/%2e%2e%2fpackage.json`, ); expect(traversalRes.status).toBe(404); expect(await traversalRes.text()).toBe("not found"); - const symlinkRes = await fetch(`http://127.0.0.1:${server.port}${A2UI_PATH}/${linkName}`); + const symlinkRes = await realFetch(`http://127.0.0.1:${server.port}${A2UI_PATH}/${linkName}`); expect(symlinkRes.status).toBe(404); expect(await symlinkRes.text()).toBe("not found"); } finally { diff --git a/src/canvas-host/server.ts b/src/canvas-host/server.ts index a8a55f01c02..cb53b28a082 100644 --- a/src/canvas-host/server.ts +++ b/src/canvas-host/server.ts @@ -4,6 +4,10 @@ import http, { type IncomingMessage, type Server, type ServerResponse } from "no import type { Socket } from "node:net"; import path from "node:path"; import type { Duplex } from "node:stream"; +import { + clearTimeout as clearNativeTimeout, + setTimeout as scheduleNativeTimeout, +} from "node:timers"; import chokidar from "chokidar"; import { type WebSocket, WebSocketServer } from "ws"; import { resolveStateDir } from "../config/paths.js"; @@ -26,6 +30,8 @@ export type CanvasHostOpts = { listenHost?: string; allowInTests?: boolean; liveReload?: boolean; + watchFactory?: typeof chokidar.watch; + webSocketServerClass?: typeof WebSocketServer; }; export type CanvasHostServerOpts = CanvasHostOpts & { @@ -45,6 +51,8 @@ export type CanvasHostHandlerOpts = { basePath?: string; allowInTests?: boolean; liveReload?: boolean; + watchFactory?: typeof chokidar.watch; + webSocketServerClass?: typeof WebSocketServer; }; export type CanvasHostHandler = { @@ -224,7 +232,8 @@ export async function createCanvasHostHandler( const reloadDebounceMs = testMode ? 12 : 75; const writeStabilityThresholdMs = testMode ? 12 : 75; const writePollIntervalMs = testMode ? 5 : 10; - const wss = liveReload ? new WebSocketServer({ noServer: true }) : null; + const WebSocketServerClass = opts.webSocketServerClass ?? WebSocketServer; + const wss = liveReload ? new WebSocketServerClass({ noServer: true }) : null; const sockets = new Set(); if (wss) { wss.on("connection", (ws) => { @@ -248,9 +257,9 @@ export async function createCanvasHostHandler( }; const scheduleReload = () => { if (debounce) { - clearTimeout(debounce); + clearNativeTimeout(debounce); } - debounce = setTimeout(() => { + debounce = scheduleNativeTimeout(() => { debounce = null; broadcastReload(); }, reloadDebounceMs); @@ -258,8 +267,9 @@ export async function createCanvasHostHandler( }; let watcherClosed = false; + const watchFactory = opts.watchFactory ?? chokidar.watch.bind(chokidar); const watcher = liveReload - ? chokidar.watch(rootReal, { + ? watchFactory(rootReal, { ignoreInitial: true, awaitWriteFinish: { stabilityThreshold: writeStabilityThresholdMs, @@ -385,10 +395,17 @@ export async function createCanvasHostHandler( handleUpgrade, close: async () => { if (debounce) { - clearTimeout(debounce); + clearNativeTimeout(debounce); } watcherClosed = true; await watcher?.close().catch(() => {}); + for (const ws of sockets) { + try { + ws.terminate?.(); + } catch { + // ignore + } + } if (wss) { await new Promise((resolve) => wss.close(() => resolve())); } @@ -409,6 +426,8 @@ export async function startCanvasHost(opts: CanvasHostServerOpts): Promise ({ + fetchWithSsrFGuard: vi.fn(async (params: { url: string }) => { + return { + response: await globalThis.fetch(params.url), + finalUrl: params.url, + release: async () => {}, + }; + }), +})); + +vi.mock("../infra/net/fetch-guard.js", () => ({ + fetchWithSsrFGuard: fetchGuardMocks.fetchWithSsrFGuard, +})); + +let cameraTempPath: typeof import("./nodes-camera.js").cameraTempPath; +let parseCameraClipPayload: typeof import("./nodes-camera.js").parseCameraClipPayload; +let parseCameraSnapPayload: typeof import("./nodes-camera.js").parseCameraSnapPayload; +let writeCameraClipPayloadToFile: typeof import("./nodes-camera.js").writeCameraClipPayloadToFile; +let writeBase64ToFile: typeof import("./nodes-camera.js").writeBase64ToFile; +let writeUrlToFile: typeof import("./nodes-camera.js").writeUrlToFile; +let parseScreenRecordPayload: typeof import("./nodes-screen.js").parseScreenRecordPayload; +let screenRecordTempPath: typeof import("./nodes-screen.js").screenRecordTempPath; async function withCameraTempDir(run: (dir: string) => Promise): Promise { return await withTempDir("openclaw-test-", run); } describe("nodes camera helpers", () => { + beforeEach(async () => { + vi.resetModules(); + vi.clearAllMocks(); + ({ + cameraTempPath, + parseCameraClipPayload, + parseCameraSnapPayload, + writeCameraClipPayloadToFile, + writeBase64ToFile, + writeUrlToFile, + } = await import("./nodes-camera.js")); + ({ parseScreenRecordPayload, screenRecordTempPath } = await import("./nodes-screen.js")); + }); + it("parses camera.snap payload", () => { expect( parseCameraSnapPayload({ diff --git a/src/cli/qr-dashboard.integration.test.ts b/src/cli/qr-dashboard.integration.test.ts index 81550c5922a..cbc3852a33e 100644 --- a/src/cli/qr-dashboard.integration.test.ts +++ b/src/cli/qr-dashboard.integration.test.ts @@ -35,8 +35,8 @@ vi.mock("../runtime.js", () => ({ defaultRuntime: runtime, })); -const { registerQrCli } = await import("./qr-cli.js"); -const { registerMaintenanceCommands } = await import("./program/register.maintenance.js"); +let registerQrCli: typeof import("./qr-cli.js").registerQrCli; +let registerMaintenanceCommands: typeof import("./program/register.maintenance.js").registerMaintenanceCommands; function createGatewayTokenRefFixture() { return { @@ -100,6 +100,12 @@ describe("cli integration: qr + dashboard token SecretRef", () => { ]); }); + beforeAll(async () => { + vi.resetModules(); + ({ registerQrCli } = await import("./qr-cli.js")); + ({ registerMaintenanceCommands } = await import("./program/register.maintenance.js")); + }); + afterAll(() => { envSnapshot.restore(); }); diff --git a/src/commands/openai-codex-oauth.test.ts b/src/commands/openai-codex-oauth.test.ts index 07c7dc88a0a..aa2f82b58a4 100644 --- a/src/commands/openai-codex-oauth.test.ts +++ b/src/commands/openai-codex-oauth.test.ts @@ -8,9 +8,15 @@ const mocks = vi.hoisted(() => ({ formatOpenAIOAuthTlsPreflightFix: vi.fn(), })); -vi.mock("@mariozechner/pi-ai/oauth", () => ({ - loginOpenAICodex: mocks.loginOpenAICodex, -})); +vi.mock("@mariozechner/pi-ai/oauth", async () => { + const actual = await vi.importActual( + "@mariozechner/pi-ai/oauth", + ); + return { + ...actual, + loginOpenAICodex: mocks.loginOpenAICodex, + }; +}); vi.mock("../plugins/provider-openai-codex-oauth-tls.js", () => ({ runOpenAIOAuthTlsPreflight: mocks.runOpenAIOAuthTlsPreflight, diff --git a/src/config/io.owner-display-secret.test.ts b/src/config/io.owner-display-secret.test.ts index bbe7e048587..90e31b1baa8 100644 --- a/src/config/io.owner-display-secret.test.ts +++ b/src/config/io.owner-display-secret.test.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { describe, expect, it } from "vitest"; +import { setTimeout as sleep } from "node:timers/promises"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { withTempHome } from "./home-env.test-harness.js"; import { createConfigIO } from "./io.js"; @@ -14,12 +15,16 @@ async function waitForPersistedSecret(configPath: string, expectedSecret: string if (parsed.commands?.ownerDisplaySecret === expectedSecret) { return; } - await new Promise((resolve) => setTimeout(resolve, 5)); + await sleep(5); } throw new Error("timed out waiting for ownerDisplaySecret persistence"); } describe("config io owner display secret autofill", () => { + beforeEach(() => { + vi.useRealTimers(); + }); + it("auto-generates and persists commands.ownerDisplaySecret in hash mode", async () => { await withTempHome("openclaw-owner-display-secret-", async (home) => { const configPath = path.join(home, ".openclaw", "openclaw.json"); diff --git a/src/cron/store.test.ts b/src/cron/store.test.ts index 405d04cbe60..27d97fd8b97 100644 --- a/src/cron/store.test.ts +++ b/src/cron/store.test.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { setTimeout as scheduleNativeTimeout } from "node:timers"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createCronStoreHarness } from "./service.test-harness.js"; import { loadCronStore, resolveCronStorePath, saveCronStore } from "./store.js"; import type { CronStoreFile } from "./types.js"; @@ -149,6 +150,10 @@ describe("cron store", () => { describe("saveCronStore", () => { const dummyStore: CronStoreFile = { version: 1, jobs: [] }; + beforeEach(() => { + vi.useRealTimers(); + }); + it("persists and round-trips a store file", async () => { const { storePath } = await makeStorePath(); await saveCronStore(storePath, dummyStore); @@ -158,11 +163,10 @@ describe("saveCronStore", () => { it("retries rename on EBUSY then succeeds", async () => { const { storePath } = await makeStorePath(); - const realSetTimeout = globalThis.setTimeout; const setTimeoutSpy = vi .spyOn(globalThis, "setTimeout") .mockImplementation(((handler: TimerHandler, _timeout?: number, ...args: unknown[]) => - realSetTimeout(handler, 0, ...args)) as typeof setTimeout); + scheduleNativeTimeout(handler, 0, ...args)) as typeof setTimeout); const origRename = fs.rename.bind(fs); let ebusyCount = 0; const spy = vi.spyOn(fs, "rename").mockImplementation(async (src, dest) => { diff --git a/src/infra/fs-safe.test.ts b/src/infra/fs-safe.test.ts index 9d1f480579c..53afefce297 100644 --- a/src/infra/fs-safe.test.ts +++ b/src/infra/fs-safe.test.ts @@ -435,17 +435,21 @@ describe("tilde expansion in file tools", () => { it("keeps tilde expansion behavior aligned", async () => { const { expandHomePrefix } = await import("./home-dir.js"); const originalHome = process.env.HOME; + const originalOpenClawHome = process.env.OPENCLAW_HOME; const fakeHome = path.resolve(path.sep, "tmp", "fake-home-test"); process.env.HOME = fakeHome; + process.env.OPENCLAW_HOME = fakeHome; try { const result = expandHomePrefix("~/file.txt"); expect(path.normalize(result)).toBe(path.join(fakeHome, "file.txt")); } finally { process.env.HOME = originalHome; + process.env.OPENCLAW_HOME = originalOpenClawHome; } const root = await tempDirs.make("openclaw-tilde-test-"); process.env.HOME = root; + process.env.OPENCLAW_HOME = root; try { await fs.writeFile(path.join(root, "hello.txt"), "tilde-works"); const result = await openFileWithinRoot({ @@ -466,6 +470,7 @@ describe("tilde expansion in file tools", () => { expect(content).toBe("tilde-write-works"); } finally { process.env.HOME = originalHome; + process.env.OPENCLAW_HOME = originalOpenClawHome; } const outsideRoot = await tempDirs.make("openclaw-tilde-outside-"); diff --git a/src/media-understanding/runner.proxy.test.ts b/src/media-understanding/runner.proxy.test.ts index f05ff4a87a1..da45296223a 100644 --- a/src/media-understanding/runner.proxy.test.ts +++ b/src/media-understanding/runner.proxy.test.ts @@ -1,9 +1,29 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; -import { buildProviderRegistry, runCapability } from "./runner.js"; import { withAudioFixture, withVideoFixture } from "./runner.test-utils.js"; import type { AudioTranscriptionRequest, VideoDescriptionRequest } from "./types.js"; +const proxyFetchMocks = vi.hoisted(() => { + const proxyFetch = vi.fn() as unknown as typeof fetch; + const resolveProxyFetchFromEnv = vi.fn((env: NodeJS.ProcessEnv = process.env) => { + const hasProxy = Boolean( + env.https_proxy?.trim() || + env.HTTPS_PROXY?.trim() || + env.http_proxy?.trim() || + env.HTTP_PROXY?.trim(), + ); + return hasProxy ? proxyFetch : undefined; + }); + return { proxyFetch, resolveProxyFetchFromEnv }; +}); + +vi.mock("../infra/net/proxy-fetch.js", () => ({ + resolveProxyFetchFromEnv: proxyFetchMocks.resolveProxyFetchFromEnv, +})); + +let buildProviderRegistry: typeof import("./runner.js").buildProviderRegistry; +let runCapability: typeof import("./runner.js").runCapability; + async function runAudioCapabilityWithFetchCapture(params: { fixturePrefix: string; outputText: string; @@ -55,7 +75,12 @@ async function runAudioCapabilityWithFetchCapture(params: { } describe("runCapability proxy fetch passthrough", () => { - beforeEach(() => vi.clearAllMocks()); + beforeEach(async () => { + vi.useRealTimers(); + vi.resetModules(); + vi.clearAllMocks(); + ({ buildProviderRegistry, runCapability } = await import("./runner.js")); + }); afterEach(() => vi.unstubAllEnvs()); it("passes fetchFn to audio provider when HTTPS_PROXY is set", async () => { @@ -64,8 +89,7 @@ describe("runCapability proxy fetch passthrough", () => { fixturePrefix: "openclaw-audio-proxy", outputText: "transcribed", }); - expect(seenFetchFn).toBeDefined(); - expect(seenFetchFn).not.toBe(globalThis.fetch); + expect(seenFetchFn).toBe(proxyFetchMocks.proxyFetch); }); it("passes fetchFn to video provider when HTTPS_PROXY is set", async () => { @@ -113,8 +137,7 @@ describe("runCapability proxy fetch passthrough", () => { }); expect(result.outputs[0]?.text).toBe("video ok"); - expect(seenFetchFn).toBeDefined(); - expect(seenFetchFn).not.toBe(globalThis.fetch); + expect(seenFetchFn).toBe(proxyFetchMocks.proxyFetch); }); }); diff --git a/src/media/fetch.test.ts b/src/media/fetch.test.ts index ea7354135d4..46ecc8cbeaf 100644 --- a/src/media/fetch.test.ts +++ b/src/media/fetch.test.ts @@ -1,5 +1,15 @@ -import { describe, expect, it, vi } from "vitest"; -import { fetchRemoteMedia } from "./fetch.js"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn()); + +vi.mock("../infra/net/fetch-guard.js", () => ({ + fetchWithSsrFGuard: (...args: unknown[]) => fetchWithSsrFGuardMock(...args), + withStrictGuardedFetchMode: (params: T) => params, +})); + +type FetchRemoteMedia = typeof import("./fetch.js").fetchRemoteMedia; +type LookupFn = NonNullable[0]["lookupFn"]>; +let fetchRemoteMedia: FetchRemoteMedia; function makeStream(chunks: Uint8Array[]) { return new ReadableStream({ @@ -25,10 +35,8 @@ function makeStallingFetch(firstChunk: Uint8Array) { }); } -function makeLookupFn() { - return vi.fn(async () => [{ address: "149.154.167.220", family: 4 }]) as unknown as NonNullable< - Parameters[0]["lookupFn"] - >; +function makeLookupFn(): LookupFn { + return vi.fn(async () => ({ address: "149.154.167.220", family: 4 })) as unknown as LookupFn; } async function expectRedactedTelegramFetchError(params: { @@ -59,10 +67,38 @@ describe("fetchRemoteMedia", () => { const redactedTelegramToken = `${telegramToken.slice(0, 6)}…${telegramToken.slice(-4)}`; const telegramFileUrl = `https://api.telegram.org/file/bot${telegramToken}/photos/1.jpg`; + beforeAll(async () => { + ({ fetchRemoteMedia } = await import("./fetch.js")); + }); + + beforeEach(() => { + vi.useRealTimers(); + fetchWithSsrFGuardMock.mockReset().mockImplementation(async (paramsUnknown: unknown) => { + const params = paramsUnknown as { + url: string; + fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise; + init?: RequestInit; + }; + if (params.url.startsWith("http://127.0.0.1/")) { + throw new Error("Blocked hostname or private/internal/special-use IP address"); + } + const fetcher = params.fetchImpl ?? globalThis.fetch; + if (!fetcher) { + throw new Error("fetch is not available"); + } + return { + response: await fetcher(params.url, params.init), + finalUrl: params.url, + release: async () => {}, + }; + }); + }); + it("rejects when content-length exceeds maxBytes", async () => { - const lookupFn = vi.fn(async () => [ - { address: "93.184.216.34", family: 4 }, - ]) as unknown as NonNullable[0]["lookupFn"]>; + const lookupFn = vi.fn(async () => ({ + address: "93.184.216.34", + family: 4, + })) as unknown as LookupFn; const fetchImpl = async () => new Response(makeStream([new Uint8Array([1, 2, 3, 4, 5])]), { status: 200, @@ -80,9 +116,10 @@ describe("fetchRemoteMedia", () => { }); it("rejects when streamed payload exceeds maxBytes", async () => { - const lookupFn = vi.fn(async () => [ - { address: "93.184.216.34", family: 4 }, - ]) as unknown as NonNullable[0]["lookupFn"]>; + const lookupFn = vi.fn(async () => ({ + address: "93.184.216.34", + family: 4, + })) as unknown as LookupFn; const fetchImpl = async () => new Response(makeStream([new Uint8Array([1, 2, 3]), new Uint8Array([4, 5, 6])]), { status: 200, @@ -99,23 +136,30 @@ describe("fetchRemoteMedia", () => { }); it("aborts stalled body reads when idle timeout expires", async () => { - const lookupFn = vi.fn(async () => [ - { address: "93.184.216.34", family: 4 }, - ]) as unknown as NonNullable[0]["lookupFn"]>; - const fetchImpl = makeStallingFetch(new Uint8Array([1, 2])); - - await expect( - fetchRemoteMedia({ + vi.useFakeTimers(); + try { + const lookupFn = vi.fn(async () => ({ + address: "93.184.216.34", + family: 4, + })) as unknown as LookupFn; + const fetchImpl = makeStallingFetch(new Uint8Array([1, 2])); + const fetchPromise = fetchRemoteMedia({ url: "https://example.com/file.bin", fetchImpl, lookupFn, maxBytes: 1024, readIdleTimeoutMs: 20, - }), - ).rejects.toMatchObject({ - code: "fetch_failed", - name: "MediaFetchError", - }); + }); + const rejection = expect(fetchPromise).rejects.toMatchObject({ + code: "fetch_failed", + name: "MediaFetchError", + }); + + await vi.advanceTimersByTimeAsync(25); + await rejection; + } finally { + vi.useRealTimers(); + } }, 5_000); it("redacts Telegram bot tokens from fetch failure messages", async () => { diff --git a/src/media/read-response-with-limit.test.ts b/src/media/read-response-with-limit.test.ts index c4cdcfc4fb3..cba6f05fec4 100644 --- a/src/media/read-response-with-limit.test.ts +++ b/src/media/read-response-with-limit.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { readResponseWithLimit } from "./read-response-with-limit.js"; function makeStream(chunks: Uint8Array[], delayMs?: number) { @@ -26,6 +26,10 @@ function makeStallingStream(initialChunks: Uint8Array[]) { } describe("readResponseWithLimit", () => { + beforeEach(() => { + vi.useRealTimers(); + }); + it("reads all chunks within the limit", async () => { const body = makeStream([new Uint8Array([1, 2]), new Uint8Array([3, 4])]); const res = new Response(body); @@ -50,17 +54,30 @@ describe("readResponseWithLimit", () => { }); it("times out when no new chunk arrives before idle timeout", async () => { - const body = makeStallingStream([new Uint8Array([1, 2])]); - const res = new Response(body); - await expect(readResponseWithLimit(res, 1024, { chunkTimeoutMs: 50 })).rejects.toThrow( - /stalled/i, - ); + vi.useFakeTimers(); + try { + const body = makeStallingStream([new Uint8Array([1, 2])]); + const res = new Response(body); + const readPromise = readResponseWithLimit(res, 1024, { chunkTimeoutMs: 50 }); + const rejection = expect(readPromise).rejects.toThrow(/stalled/i); + await vi.advanceTimersByTimeAsync(60); + await rejection; + } finally { + vi.useRealTimers(); + } }, 5_000); it("does not time out while chunks keep arriving", async () => { - const body = makeStream([new Uint8Array([1]), new Uint8Array([2])], 10); - const res = new Response(body); - const buf = await readResponseWithLimit(res, 100, { chunkTimeoutMs: 500 }); - expect(buf).toEqual(Buffer.from([1, 2])); + vi.useFakeTimers(); + try { + const body = makeStream([new Uint8Array([1]), new Uint8Array([2])], 10); + const res = new Response(body); + const readPromise = readResponseWithLimit(res, 100, { chunkTimeoutMs: 500 }); + await vi.advanceTimersByTimeAsync(25); + const buf = await readPromise; + expect(buf).toEqual(Buffer.from([1, 2])); + } finally { + vi.useRealTimers(); + } }); }); diff --git a/src/memory/batch-gemini.test.ts b/src/memory/batch-gemini.test.ts index 0cbada7293b..cd8154b1057 100644 --- a/src/memory/batch-gemini.test.ts +++ b/src/memory/batch-gemini.test.ts @@ -1,15 +1,25 @@ -import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { GeminiEmbeddingClient } from "./embeddings-gemini.js"; +vi.mock("./remote-http.js", () => ({ + withRemoteHttpResponse: vi.fn(), +})); + function magnitude(values: number[]) { return Math.sqrt(values.reduce((sum, value) => sum + value * value, 0)); } describe("runGeminiEmbeddingBatches", () => { let runGeminiEmbeddingBatches: typeof import("./batch-gemini.js").runGeminiEmbeddingBatches; + let withRemoteHttpResponse: typeof import("./remote-http.js").withRemoteHttpResponse; + let remoteHttpMock: ReturnType>; - beforeAll(async () => { + beforeEach(async () => { + vi.resetModules(); + vi.clearAllMocks(); ({ runGeminiEmbeddingBatches } = await import("./batch-gemini.js")); + ({ withRemoteHttpResponse } = await import("./remote-http.js")); + remoteHttpMock = vi.mocked(withRemoteHttpResponse); }); afterEach(() => { @@ -27,24 +37,26 @@ describe("runGeminiEmbeddingBatches", () => { }; it("includes outputDimensionality in batch upload requests", async () => { - const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { - const url = - typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; - if (url.includes("/upload/v1beta/files?uploadType=multipart")) { - const body = init?.body; - if (!(body instanceof Blob)) { - throw new Error("expected multipart blob body"); - } - const text = await body.text(); - expect(text).toContain('"taskType":"RETRIEVAL_DOCUMENT"'); - expect(text).toContain('"outputDimensionality":1536'); - return new Response(JSON.stringify({ name: "files/file-123" }), { + remoteHttpMock.mockImplementationOnce(async (params) => { + expect(params.url).toContain("/upload/v1beta/files?uploadType=multipart"); + const body = params.init?.body; + if (!(body instanceof Blob)) { + throw new Error("expected multipart blob body"); + } + const text = await body.text(); + expect(text).toContain('"taskType":"RETRIEVAL_DOCUMENT"'); + expect(text).toContain('"outputDimensionality":1536'); + return await params.onResponse( + new Response(JSON.stringify({ name: "files/file-123" }), { status: 200, headers: { "Content-Type": "application/json" }, - }); - } - if (url.endsWith(":asyncBatchEmbedContent")) { - return new Response( + }), + ); + }); + remoteHttpMock.mockImplementationOnce(async (params) => { + expect(params.url).toMatch(/:asyncBatchEmbedContent$/u); + return await params.onResponse( + new Response( JSON.stringify({ name: "batches/batch-1", state: "COMPLETED", @@ -54,10 +66,13 @@ describe("runGeminiEmbeddingBatches", () => { status: 200, headers: { "Content-Type": "application/json" }, }, - ); - } - if (url.endsWith("/files/output-1:download")) { - return new Response( + ), + ); + }); + remoteHttpMock.mockImplementationOnce(async (params) => { + expect(params.url).toMatch(/\/files\/output-1:download$/u); + return await params.onResponse( + new Response( JSON.stringify({ key: "req-1", response: { embedding: { values: [3, 4] } }, @@ -66,13 +81,10 @@ describe("runGeminiEmbeddingBatches", () => { status: 200, headers: { "Content-Type": "application/jsonl" }, }, - ); - } - throw new Error(`unexpected fetch ${url}`); + ), + ); }); - vi.stubGlobal("fetch", fetchMock); - const results = await runGeminiEmbeddingBatches({ gemini: mockClient, agentId: "main", @@ -97,6 +109,6 @@ describe("runGeminiEmbeddingBatches", () => { expect(embedding?.[0]).toBeCloseTo(0.6, 5); expect(embedding?.[1]).toBeCloseTo(0.8, 5); expect(magnitude(embedding ?? [])).toBeCloseTo(1, 5); - expect(fetchMock).toHaveBeenCalledTimes(3); + expect(remoteHttpMock).toHaveBeenCalledTimes(3); }); }); diff --git a/src/memory/batch-voyage.test.ts b/src/memory/batch-voyage.test.ts index 1b0a6c05248..6a4b2e242c3 100644 --- a/src/memory/batch-voyage.test.ts +++ b/src/memory/batch-voyage.test.ts @@ -1,8 +1,7 @@ import { ReadableStream } from "node:stream/web"; -import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { VoyageBatchOutputLine, VoyageBatchRequest } from "./batch-voyage.js"; import type { VoyageEmbeddingClient } from "./embeddings-voyage.js"; -import { mockPublicPinnedHostname } from "./test-helpers/ssrf.js"; // Mock internal.js if needed, but runWithConcurrency is simple enough to keep real. // We DO need to mock retryAsync to avoid actual delays/retries logic complicating tests @@ -10,11 +9,21 @@ vi.mock("../infra/retry.js", () => ({ retryAsync: async (fn: () => Promise) => fn(), })); +vi.mock("./remote-http.js", () => ({ + withRemoteHttpResponse: vi.fn(), +})); + describe("runVoyageEmbeddingBatches", () => { let runVoyageEmbeddingBatches: typeof import("./batch-voyage.js").runVoyageEmbeddingBatches; + let withRemoteHttpResponse: typeof import("./remote-http.js").withRemoteHttpResponse; + let remoteHttpMock: ReturnType>; - beforeAll(async () => { + beforeEach(async () => { + vi.resetModules(); + vi.clearAllMocks(); ({ runVoyageEmbeddingBatches } = await import("./batch-voyage.js")); + ({ withRemoteHttpResponse } = await import("./remote-http.js")); + remoteHttpMock = vi.mocked(withRemoteHttpResponse); }); afterEach(() => { @@ -34,39 +43,6 @@ describe("runVoyageEmbeddingBatches", () => { ]; it("successfully submits batch, waits, and streams results", async () => { - const fetchMock = vi.fn(); - vi.stubGlobal("fetch", fetchMock); - mockPublicPinnedHostname(); - - // Sequence of fetch calls: - // 1. Upload file - fetchMock.mockResolvedValueOnce({ - ok: true, - json: async () => ({ id: "file-123" }), - }); - - // 2. Create batch - fetchMock.mockResolvedValueOnce({ - ok: true, - json: async () => ({ id: "batch-abc", status: "pending" }), - }); - - // 3. Poll status (pending) - Optional depending on wait loop, let's say it finishes immediately for this test - // Actually the code does: initial check (if completed) -> wait loop. - // If create returns "pending", it enters waitForVoyageBatch. - // waitForVoyageBatch fetches status. - - // 3. Poll status (completed) - fetchMock.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - id: "batch-abc", - status: "completed", - output_file_id: "file-out-999", - }), - }); - - // 4. Download content (Streaming) const outputLines: VoyageBatchOutputLine[] = [ { custom_id: "req-1", @@ -86,10 +62,64 @@ describe("runVoyageEmbeddingBatches", () => { controller.close(); }, }); - - fetchMock.mockResolvedValueOnce({ - ok: true, - body: stream, + remoteHttpMock.mockImplementationOnce(async (params) => { + expect(params.url).toContain("/files"); + const uploadBody = params.init?.body; + expect(uploadBody).toBeInstanceOf(FormData); + expect((uploadBody as FormData).get("purpose")).toBe("batch"); + return await params.onResponse( + new Response(JSON.stringify({ id: "file-123" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + }); + remoteHttpMock.mockImplementationOnce(async (params) => { + expect(params.url).toContain("/batches"); + const body = params.init?.body; + expect(typeof body).toBe("string"); + const createBody = JSON.parse(body as string) as { + input_file_id: string; + completion_window: string; + request_params: { model: string; input_type: string }; + }; + expect(createBody.input_file_id).toBe("file-123"); + expect(createBody.completion_window).toBe("12h"); + expect(createBody.request_params).toEqual({ + model: "voyage-4-large", + input_type: "document", + }); + return await params.onResponse( + new Response(JSON.stringify({ id: "batch-abc", status: "pending" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + }); + remoteHttpMock.mockImplementationOnce(async (params) => { + expect(params.url).toContain("/batches/batch-abc"); + return await params.onResponse( + new Response( + JSON.stringify({ + id: "batch-abc", + status: "completed", + output_file_id: "file-out-999", + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ), + ); + }); + remoteHttpMock.mockImplementationOnce(async (params) => { + expect(params.url).toContain("/files/file-out-999/content"); + return await params.onResponse( + new Response(stream as unknown as BodyInit, { + status: 200, + headers: { "Content-Type": "application/x-ndjson" }, + }), + ); }); const results = await runVoyageEmbeddingBatches({ @@ -105,43 +135,10 @@ describe("runVoyageEmbeddingBatches", () => { expect(results.size).toBe(2); expect(results.get("req-1")).toEqual([0.1, 0.1]); expect(results.get("req-2")).toEqual([0.2, 0.2]); - - // Verify calls - expect(fetchMock).toHaveBeenCalledTimes(4); - - // Verify File Upload - expect(fetchMock.mock.calls[0][0]).toContain("/files"); - const uploadBody = fetchMock.mock.calls[0][1].body as FormData; - expect(uploadBody).toBeInstanceOf(FormData); - expect(uploadBody.get("purpose")).toBe("batch"); - - // Verify Batch Create - expect(fetchMock.mock.calls[1][0]).toContain("/batches"); - const createBody = JSON.parse(fetchMock.mock.calls[1][1].body); - expect(createBody.input_file_id).toBe("file-123"); - expect(createBody.completion_window).toBe("12h"); - expect(createBody.request_params).toEqual({ - model: "voyage-4-large", - input_type: "document", - }); - - // Verify Content Fetch - expect(fetchMock.mock.calls[3][0]).toContain("/files/file-out-999/content"); + expect(remoteHttpMock).toHaveBeenCalledTimes(4); }); it("handles empty lines and stream chunks correctly", async () => { - const fetchMock = vi.fn(); - vi.stubGlobal("fetch", fetchMock); - mockPublicPinnedHostname(); - - // 1. Upload - fetchMock.mockResolvedValueOnce({ ok: true, json: async () => ({ id: "f1" }) }); - // 2. Create (completed immediately) - fetchMock.mockResolvedValueOnce({ - ok: true, - json: async () => ({ id: "b1", status: "completed", output_file_id: "out1" }), - }); - // 3. Download Content (Streaming with chunks and newlines) const stream = new ReadableStream({ start(controller) { const line1 = JSON.stringify({ @@ -160,8 +157,22 @@ describe("runVoyageEmbeddingBatches", () => { controller.close(); }, }); - - fetchMock.mockResolvedValueOnce({ ok: true, body: stream }); + remoteHttpMock.mockImplementationOnce(async (params) => { + expect(params.url).toContain("/files"); + return await params.onResponse(new Response(JSON.stringify({ id: "f1" }), { status: 200 })); + }); + remoteHttpMock.mockImplementationOnce(async (params) => { + expect(params.url).toContain("/batches"); + return await params.onResponse( + new Response(JSON.stringify({ id: "b1", status: "completed", output_file_id: "out1" }), { + status: 200, + }), + ); + }); + remoteHttpMock.mockImplementationOnce(async (params) => { + expect(params.url).toContain("/files/out1/content"); + return await params.onResponse(new Response(stream as unknown as BodyInit, { status: 200 })); + }); const results = await runVoyageEmbeddingBatches({ client: mockClient, diff --git a/src/node-host/invoke-browser.test.ts b/src/node-host/invoke-browser.test.ts index 7f8c51a2b39..a5341841f03 100644 --- a/src/node-host/invoke-browser.test.ts +++ b/src/node-host/invoke-browser.test.ts @@ -34,11 +34,29 @@ vi.mock("../media/mime.js", () => ({ detectMime: vi.fn(async () => "image/png"), })); -import { runBrowserProxyCommand } from "./invoke-browser.js"; +let runBrowserProxyCommand: typeof import("./invoke-browser.js").runBrowserProxyCommand; describe("runBrowserProxyCommand", () => { - beforeEach(() => { - vi.clearAllMocks(); + beforeEach(async () => { + // No-isolate runs can reuse a cached invoke-browser module that was loaded + // via node-host entrypoints before this file's mocks were declared. + vi.useRealTimers(); + vi.resetModules(); + dispatcherMocks.dispatch.mockReset(); + dispatcherMocks.createBrowserRouteDispatcher.mockReset().mockImplementation(() => ({ + dispatch: dispatcherMocks.dispatch, + })); + controlServiceMocks.createBrowserControlContext.mockReset().mockReturnValue({ control: true }); + controlServiceMocks.startBrowserControlServiceFromConfig.mockReset().mockResolvedValue(true); + configMocks.loadConfig.mockReset().mockReturnValue({ + browser: {}, + nodeHost: { browserProxy: { enabled: true } }, + }); + browserConfigMocks.resolveBrowserConfig.mockReset().mockReturnValue({ + enabled: true, + defaultProfile: "openclaw", + }); + ({ runBrowserProxyCommand } = await import("./invoke-browser.js")); configMocks.loadConfig.mockReturnValue({ browser: {}, nodeHost: { browserProxy: { enabled: true } }, @@ -51,6 +69,7 @@ describe("runBrowserProxyCommand", () => { }); it("adds profile and browser status details on ws-backed timeouts", async () => { + vi.useFakeTimers(); dispatcherMocks.dispatch .mockImplementationOnce(async () => { await new Promise(() => {}); @@ -65,7 +84,7 @@ describe("runBrowserProxyCommand", () => { }, }); - await expect( + const result = expect( runBrowserProxyCommand( JSON.stringify({ method: "GET", @@ -77,9 +96,12 @@ describe("runBrowserProxyCommand", () => { ).rejects.toThrow( /browser proxy timed out for GET \/snapshot after 5ms; ws-backed browser action; profile=openclaw; status\(running=true, cdpHttp=true, cdpReady=false, cdpUrl=http:\/\/127\.0\.0\.1:18792\)/, ); + await vi.advanceTimersByTimeAsync(10); + await result; }); it("includes chrome-mcp transport in timeout diagnostics when no CDP URL exists", async () => { + vi.useFakeTimers(); dispatcherMocks.dispatch .mockImplementationOnce(async () => { await new Promise(() => {}); @@ -95,7 +117,7 @@ describe("runBrowserProxyCommand", () => { }, }); - await expect( + const result = expect( runBrowserProxyCommand( JSON.stringify({ method: "GET", @@ -107,9 +129,12 @@ describe("runBrowserProxyCommand", () => { ).rejects.toThrow( /browser proxy timed out for GET \/snapshot after 5ms; ws-backed browser action; profile=user; status\(running=true, cdpHttp=true, cdpReady=false, transport=chrome-mcp\)/, ); + await vi.advanceTimersByTimeAsync(10); + await result; }); it("redacts sensitive cdpUrl details in timeout diagnostics", async () => { + vi.useFakeTimers(); dispatcherMocks.dispatch .mockImplementationOnce(async () => { await new Promise(() => {}); @@ -125,7 +150,7 @@ describe("runBrowserProxyCommand", () => { }, }); - await expect( + const result = expect( runBrowserProxyCommand( JSON.stringify({ method: "GET", @@ -137,6 +162,8 @@ describe("runBrowserProxyCommand", () => { ).rejects.toThrow( /status\(running=true, cdpHttp=true, cdpReady=false, cdpUrl=https:\/\/example\.com\/chrome\?token=supers…7890\)/, ); + await vi.advanceTimersByTimeAsync(10); + await result; }); it("keeps non-timeout browser errors intact", async () => { diff --git a/src/node-host/invoke-system-run.test.ts b/src/node-host/invoke-system-run.test.ts index 962389b3702..4c1be533edb 100644 --- a/src/node-host/invoke-system-run.test.ts +++ b/src/node-host/invoke-system-run.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it, type Mock, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from "vitest"; import { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } from "../config/config.js"; import type { SystemRunApprovalPlan } from "../infra/exec-approvals.js"; import { loadExecApprovals, saveExecApprovals } from "../infra/exec-approvals.js"; @@ -31,6 +31,29 @@ describe("formatSystemRunAllowlistMissMessage", () => { }); describe("handleSystemRunInvoke mac app exec host routing", () => { + let testOpenClawHome = ""; + let previousOpenClawHome: string | undefined; + + beforeEach(() => { + previousOpenClawHome = process.env.OPENCLAW_HOME; + testOpenClawHome = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-node-host-home-")); + process.env.OPENCLAW_HOME = testOpenClawHome; + clearRuntimeConfigSnapshot(); + }); + + afterEach(() => { + clearRuntimeConfigSnapshot(); + if (previousOpenClawHome === undefined) { + delete process.env.OPENCLAW_HOME; + } else { + process.env.OPENCLAW_HOME = previousOpenClawHome; + } + if (testOpenClawHome) { + fs.rmSync(testOpenClawHome, { recursive: true, force: true }); + testOpenClawHome = ""; + } + }); + function createLocalRunResult(stdout = "local-ok") { return { success: true, diff --git a/src/plugin-sdk/root-alias.cjs b/src/plugin-sdk/root-alias.cjs index 11ffc459ef2..d5fc9225fd1 100644 --- a/src/plugin-sdk/root-alias.cjs +++ b/src/plugin-sdk/root-alias.cjs @@ -4,6 +4,7 @@ const path = require("node:path"); const fs = require("node:fs"); let monolithicSdk = null; +let diagnosticEventsModule = null; const jitiLoaders = new Map(); const pluginSdkSubpathsCache = new Map(); @@ -64,10 +65,14 @@ function resolveControlCommandGate(params) { function onDiagnosticEvent(listener) { const monolithic = loadMonolithicSdk(); - if (!monolithic || typeof monolithic.onDiagnosticEvent !== "function") { + if (monolithic && typeof monolithic.onDiagnosticEvent === "function") { + return monolithic.onDiagnosticEvent(listener); + } + const diagnosticEvents = loadDiagnosticEventsModule(); + if (!diagnosticEvents || typeof diagnosticEvents.onDiagnosticEvent !== "function") { throw new Error("openclaw/plugin-sdk root alias could not resolve onDiagnosticEvent"); } - return monolithic.onDiagnosticEvent(listener); + return diagnosticEvents.onDiagnosticEvent(listener); } function getPackageRoot() { @@ -150,6 +155,34 @@ function loadMonolithicSdk() { return monolithicSdk; } +function loadDiagnosticEventsModule() { + if (diagnosticEventsModule) { + return diagnosticEventsModule; + } + + const distCandidate = path.resolve( + __dirname, + "..", + "..", + "dist", + "infra", + "diagnostic-events.js", + ); + if (fs.existsSync(distCandidate)) { + try { + diagnosticEventsModule = getJiti(true)(distCandidate); + return diagnosticEventsModule; + } catch { + // Fall through to source path if dist is unavailable or stale. + } + } + + diagnosticEventsModule = getJiti(false)( + path.join(__dirname, "..", "infra", "diagnostic-events.ts"), + ); + return diagnosticEventsModule; +} + function tryLoadMonolithicSdk() { try { return loadMonolithicSdk(); diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index cdca5c6e591..e310f184cc1 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -1,7 +1,6 @@ import { readFileSync } from "node:fs"; -import { createRequire } from "node:module"; import { dirname, resolve } from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; +import { fileURLToPath } from "node:url"; import type { BaseProbeResult as ContractBaseProbeResult, BaseTokenResolution as ContractBaseTokenResolution, @@ -47,12 +46,10 @@ import { pluginSdkSubpaths } from "./entrypoints.js"; const ROOT_DIR = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const PLUGIN_SDK_DIR = resolve(ROOT_DIR, "plugin-sdk"); -const requireFromHere = createRequire(import.meta.url); const sourceCache = new Map(); const representativeRuntimeSmokeSubpaths = ["channel-runtime", "conversation-runtime"] as const; -const importResolvedPluginSdkSubpath = async (specifier: string) => - import(pathToFileURL(requireFromHere.resolve(specifier)).href); +const importResolvedPluginSdkSubpath = async (specifier: string) => import(specifier); function readPluginSdkSource(subpath: string): string { const file = resolve(PLUGIN_SDK_DIR, `${subpath}.ts`); diff --git a/src/tts/tts.test.ts b/src/tts/tts.test.ts index c6e0fd62b00..0ac16566fe5 100644 --- a/src/tts/tts.test.ts +++ b/src/tts/tts.test.ts @@ -15,10 +15,16 @@ vi.mock("@mariozechner/pi-ai", async (importOriginal) => { }; }); -vi.mock("@mariozechner/pi-ai/oauth", () => ({ - getOAuthProviders: () => [], - getOAuthApiKey: vi.fn(async () => null), -})); +vi.mock("@mariozechner/pi-ai/oauth", async () => { + const actual = await vi.importActual( + "@mariozechner/pi-ai/oauth", + ); + return { + ...actual, + getOAuthProviders: () => [], + getOAuthApiKey: vi.fn(async () => null), + }; +}); function createResolvedModel(provider: string, modelId: string, api = "openai-completions") { return { diff --git a/src/tui/components/filterable-select-list.ts b/src/tui/components/filterable-select-list.ts index 6d5a2f469ad..7a2834872f1 100644 --- a/src/tui/components/filterable-select-list.ts +++ b/src/tui/components/filterable-select-list.ts @@ -1,11 +1,11 @@ import type { Component } from "@mariozechner/pi-tui"; import { Input, - Key, matchesKey, type SelectItem, SelectList, type SelectListTheme, + getEditorKeybindings, } from "@mariozechner/pi-tui"; import chalk from "chalk"; import { fuzzyFilterLower, prepareSearchItems } from "./fuzzy-filter.js"; @@ -110,7 +110,8 @@ export class FilterableSelectList implements Component { } // Escape: clear filter or cancel - if (matchesKey(keyData, Key.escape)) { + const kb = getEditorKeybindings(); + if (kb.matches(keyData, "selectCancel")) { if (this.filterText) { this.filterText = ""; this.input.setValue(""); diff --git a/src/tui/components/searchable-select-list.ts b/src/tui/components/searchable-select-list.ts index 0372b7f5b78..8a27858e367 100644 --- a/src/tui/components/searchable-select-list.ts +++ b/src/tui/components/searchable-select-list.ts @@ -1,8 +1,8 @@ import { type Component, + getEditorKeybindings, Input, isKeyRelease, - Key, matchesKey, type SelectItem, type SelectListTheme, @@ -362,7 +362,8 @@ export class SearchableSelectList implements Component { return; } - if (matchesKey(keyData, Key.escape)) { + const kb = getEditorKeybindings(); + if (kb.matches(keyData, "selectCancel")) { if (this.onCancel) { this.onCancel(); } diff --git a/test/helpers/fast-short-timeouts.ts b/test/helpers/fast-short-timeouts.ts index 66ff38061fa..32fa2bd0d90 100644 --- a/test/helpers/fast-short-timeouts.ts +++ b/test/helpers/fast-short-timeouts.ts @@ -1,7 +1,7 @@ +import { setTimeout as nativeSetTimeout } from "node:timers"; import { vi } from "vitest"; export function useFastShortTimeouts(maxDelayMs = 2000): () => void { - const realSetTimeout = setTimeout; const spy = vi.spyOn(global, "setTimeout").mockImplementation((( handler: TimerHandler, timeout?: number, @@ -9,9 +9,9 @@ export function useFastShortTimeouts(maxDelayMs = 2000): () => void { ) => { const delay = typeof timeout === "number" ? timeout : 0; if (delay > 0 && delay <= maxDelayMs) { - return realSetTimeout(handler, 0, ...args); + return nativeSetTimeout(handler, 0, ...args); } - return realSetTimeout(handler, delay, ...args); + return nativeSetTimeout(handler, delay, ...args); }) as typeof setTimeout); return () => spy.mockRestore(); } diff --git a/test/non-isolated-runner.ts b/test/non-isolated-runner.ts new file mode 100644 index 00000000000..208d6f21cc5 --- /dev/null +++ b/test/non-isolated-runner.ts @@ -0,0 +1,68 @@ +import fs from "node:fs"; +import { TestRunner, type RunnerTestSuite, vi } from "vitest"; + +type EvaluatedModuleNode = { + promise?: unknown; + exports?: unknown; + evaluated?: boolean; + importers: Set; +}; + +type EvaluatedModules = { + idToModuleMap: Map; +}; + +function resetEvaluatedModules(modules: EvaluatedModules, resetMocks: boolean) { + const skipPaths = [ + /\/vitest\/dist\//, + /vitest-virtual-\w+\/dist/u, + /@vitest\/dist/u, + ...(resetMocks ? [] : [/^mock:/u]), + ]; + + modules.idToModuleMap.forEach((node, modulePath) => { + if (skipPaths.some((pattern) => pattern.test(modulePath))) { + return; + } + node.promise = undefined; + node.exports = undefined; + node.evaluated = false; + node.importers.clear(); + }); +} + +export default class OpenClawNonIsolatedRunner extends TestRunner { + override onCollectStart(file: { filepath: string }) { + super.onCollectStart(file); + const orderLogPath = process.env.OPENCLAW_VITEST_FILE_ORDER_LOG?.trim(); + if (orderLogPath) { + fs.appendFileSync(orderLogPath, `START ${file.filepath}\n`); + } + } + + override async onAfterRunSuite(suite: RunnerTestSuite) { + await super.onAfterRunSuite(suite); + if (this.config.isolate || !("filepath" in suite) || typeof suite.filepath !== "string") { + return; + } + + const orderLogPath = process.env.OPENCLAW_VITEST_FILE_ORDER_LOG?.trim(); + if (orderLogPath) { + fs.appendFileSync(orderLogPath, `END ${suite.filepath}\n`); + } + + // Mirror the missing cleanup from Vitest isolate mode so shared workers do + // not carry file-scoped timers, stubs, spies, or stale module state + // forward into the next file. + if (vi.isFakeTimers()) { + vi.useRealTimers(); + } + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + vi.clearAllMocks(); + vi.resetModules(); + this.moduleRunner?.mocker?.reset?.(); + resetEvaluatedModules(this.workerState.evaluatedModules as EvaluatedModules, true); + } +} diff --git a/test/setup.ts b/test/setup.ts index 1d4429d48d7..c393fe59c9e 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -75,6 +75,60 @@ const pickSendFn = (id: ChannelId, deps?: OutboundSendDeps) => { return deps?.[id] as ((...args: unknown[]) => Promise) | undefined; }; +type VitestEvaluatedModuleNode = { + promise?: unknown; + exports?: unknown; + evaluated?: boolean; + importers: Set; +}; + +type VitestEvaluatedModules = { + idToModuleMap: Map; +}; + +const resetVitestWorkerModules = (resetMocks: boolean) => { + const workerState = ( + globalThis as typeof globalThis & { + __vitest_worker__?: { + evaluatedModules?: VitestEvaluatedModules; + }; + } + ).__vitest_worker__; + const modules = workerState?.evaluatedModules; + if (!modules) { + return; + } + + const skipPaths = [ + /\/vitest\/dist\//, + /vitest-virtual-\w+\/dist/u, + /@vitest\/dist/u, + ...(resetMocks ? [] : [/^mock:/u]), + ]; + + modules.idToModuleMap.forEach((node, modulePath) => { + if (skipPaths.some((pattern) => pattern.test(modulePath))) { + return; + } + node.promise = undefined; + node.exports = undefined; + node.evaluated = false; + node.importers.clear(); + }); +}; + +const resetVitestWorkerFileState = () => { + const mocker = ( + globalThis as typeof globalThis & { + __vitest_mocker__?: { + reset?: () => void; + }; + } + ).__vitest_mocker__; + mocker?.reset?.(); + resetVitestWorkerModules(true); +}; + const createStubOutbound = ( id: ChannelId, deliveryMode: ChannelOutboundAdapter["deliveryMode"] = "direct", @@ -274,8 +328,17 @@ afterEach(() => { globalRegistryState.key = null; globalRegistryState.version += 1; } - // Guard against leaked fake timers across test files/workers. - if (vi.isFakeTimers()) { - vi.useRealTimers(); - } + // Always normalize timer/date state. Some suites call `vi.setSystemTime()` + // without leaving fake timers enabled, which still leaks mocked time into + // later files under `--isolate=false`. + vi.useRealTimers(); + // Non-isolated runs reuse the same module graph across files. Clear it so + // hoisted per-file mocks still apply when later files import the same modules. + vi.resetModules(); +}); + +afterAll(() => { + // Mirror Vitest's isolate-mode file cleanup so `--isolate=false` does not + // carry hoisted mocks or stale module graphs into the next test file. + resetVitestWorkerFileState(); }); diff --git a/vitest.unit.config.ts b/vitest.unit.config.ts index 02db81f84bb..31b2379a172 100644 --- a/vitest.unit.config.ts +++ b/vitest.unit.config.ts @@ -41,6 +41,7 @@ export default defineConfig({ ...base, test: { ...baseTest, + runner: "./test/non-isolated-runner.ts", include: loadIncludePatternsFromEnv() ?? unitTestIncludePatterns, exclude: [ ...new Set([