diff --git a/extensions/telegram/src/audit.test.ts b/extensions/telegram/src/audit.test.ts index 404796b49f3..d2e38db4864 100644 --- a/extensions/telegram/src/audit.test.ts +++ b/extensions/telegram/src/audit.test.ts @@ -3,6 +3,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; let collectTelegramUnmentionedGroupIds: typeof import("./audit.js").collectTelegramUnmentionedGroupIds; let auditTelegramGroupMembership: typeof import("./audit.js").auditTelegramGroupMembership; const fetchWithTimeoutMock = vi.hoisted(() => vi.fn()); +const resolveTelegramFetchMock = vi.hoisted(() => vi.fn(() => fetchWithTimeoutMock)); +const resolveTelegramApiBaseMock = vi.hoisted(() => vi.fn(() => "https://api.telegram.org")); vi.mock("openclaw/plugin-sdk/text-runtime", async (importOriginal) => { const actual = await importOriginal(); @@ -33,9 +35,15 @@ async function auditSingleGroup() { describe("telegram audit", () => { beforeEach(async () => { vi.resetModules(); + vi.doMock("./fetch.js", () => ({ + resolveTelegramApiBase: resolveTelegramApiBaseMock, + resolveTelegramFetch: resolveTelegramFetchMock, + })); ({ collectTelegramUnmentionedGroupIds, auditTelegramGroupMembership } = await import("./audit.js")); fetchWithTimeoutMock.mockReset(); + resolveTelegramFetchMock.mockClear(); + resolveTelegramApiBaseMock.mockClear(); }); it("collects unmentioned numeric group ids and flags wildcard", async () => { @@ -57,6 +65,7 @@ describe("telegram audit", () => { expect(res.ok).toBe(true); expect(res.groups[0]?.chatId).toBe("-1001"); expect(res.groups[0]?.status).toBe("member"); + expect(resolveTelegramFetchMock).toHaveBeenCalled(); }); it("reports bot not in group when status is left", async () => { diff --git a/extensions/telegram/src/bot-message-context.dm-topic-threadid.test.ts b/extensions/telegram/src/bot-message-context.dm-topic-threadid.test.ts index 4af172d3918..e7ad0338673 100644 --- a/extensions/telegram/src/bot-message-context.dm-topic-threadid.test.ts +++ b/extensions/telegram/src/bot-message-context.dm-topic-threadid.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi, beforeEach } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; // Mock recordInboundSession to capture updateLastRoute parameter const recordInboundSessionMock = vi.fn().mockResolvedValue(undefined); @@ -11,6 +11,7 @@ vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => { }); let buildTelegramMessageContextForTest: typeof import("./bot-message-context.test-harness.js").buildTelegramMessageContextForTest; +let clearRuntimeConfigSnapshot: typeof import("../../../src/config/config.js").clearRuntimeConfigSnapshot; describe("buildTelegramMessageContext DM topic threadId in deliveryContext (#8891)", () => { async function buildCtx(params: { @@ -30,9 +31,14 @@ describe("buildTelegramMessageContext DM topic threadId in deliveryContext (#889 return callArgs?.updateLastRoute; } + afterEach(() => { + clearRuntimeConfigSnapshot(); + recordInboundSessionMock.mockClear(); + }); + beforeEach(async () => { vi.resetModules(); - recordInboundSessionMock.mockClear(); + ({ clearRuntimeConfigSnapshot } = await import("../../../src/config/config.js")); ({ buildTelegramMessageContextForTest } = await import("./bot-message-context.test-harness.js")); }); diff --git a/src/agents/session-write-lock.ts b/src/agents/session-write-lock.ts index 002b41f5751..f67720c14b8 100644 --- a/src/agents/session-write-lock.ts +++ b/src/agents/session-write-lock.ts @@ -177,6 +177,7 @@ async function releaseHeldLock( */ function releaseAllLocksSync(): void { for (const [sessionFile, held] of HELD_LOCKS) { + void held.handle.close().catch(() => undefined); try { fsSync.rmSync(held.lockPath, { force: true }); } catch { @@ -576,6 +577,14 @@ export const __testing = { runLockWatchdogCheck, }; +export async function drainSessionWriteLockStateForTest(): Promise { + for (const [sessionFile, held] of Array.from(HELD_LOCKS.entries())) { + await releaseHeldLock(sessionFile, held, { force: true }).catch(() => undefined); + } + stopWatchdogTimer(); + unregisterCleanupHandlers(); +} + export function resetSessionWriteLockStateForTest(): void { releaseAllLocksSync(); stopWatchdogTimer(); diff --git a/src/browser/server.agent-contract-form-layout-act-commands.test.ts b/src/browser/server.agent-contract-form-layout-act-commands.test.ts index c8b76c4b886..a38621c9889 100644 --- a/src/browser/server.agent-contract-form-layout-act-commands.test.ts +++ b/src/browser/server.agent-contract-form-layout-act-commands.test.ts @@ -1,7 +1,6 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { fetch as realFetch } from "undici"; import { describe, expect, it } from "vitest"; import { DEFAULT_DOWNLOAD_DIR, DEFAULT_TRACE_DIR, DEFAULT_UPLOAD_DIR } from "./paths.js"; import { @@ -14,9 +13,11 @@ import { getPwMocks, setBrowserControlServerEvaluateEnabled, } from "./server.control-server.test-harness.js"; +import { getBrowserTestFetch, type BrowserTestFetch } from "./test-fetch.js"; const state = getBrowserControlServerTestState(); const pwMocks = getPwMocks(); +const realFetch: BrowserTestFetch = (input, init) => getBrowserTestFetch()(input, init); async function withSymlinkPathEscape(params: { rootDir: string; diff --git a/src/browser/server.agent-contract-snapshot-endpoints.test.ts b/src/browser/server.agent-contract-snapshot-endpoints.test.ts index 837a122becd..ed23cb9d8c1 100644 --- a/src/browser/server.agent-contract-snapshot-endpoints.test.ts +++ b/src/browser/server.agent-contract-snapshot-endpoints.test.ts @@ -1,4 +1,3 @@ -import { fetch as realFetch } from "undici"; import { describe, expect, it } from "vitest"; import { DEFAULT_AI_SNAPSHOT_MAX_CHARS } from "./constants.js"; import { @@ -11,6 +10,7 @@ import { getCdpMocks, getPwMocks, } from "./server.control-server.test-harness.js"; +import { getBrowserTestFetch } from "./test-fetch.js"; const state = getBrowserControlServerTestState(); const cdpMocks = getCdpMocks(); @@ -21,6 +21,7 @@ describe("browser control server", () => { it("agent contract: snapshot endpoints", async () => { const base = await startServerAndBase(); + const realFetch = getBrowserTestFetch(); const snapAria = (await realFetch(`${base}/snapshot?format=aria&limit=1`).then((r) => r.json(), @@ -58,6 +59,7 @@ describe("browser control server", () => { it("agent contract: navigation + common act commands", async () => { const base = await startServerAndBase(); + const realFetch = getBrowserTestFetch(); const nav = await postJson<{ ok: boolean; targetId?: string }>(`${base}/navigate`, { url: "https://example.com", diff --git a/src/browser/server.agent-contract.test-harness.ts b/src/browser/server.agent-contract.test-harness.ts index 1332bfde655..ea73714075f 100644 --- a/src/browser/server.agent-contract.test-harness.ts +++ b/src/browser/server.agent-contract.test-harness.ts @@ -1,9 +1,9 @@ -import { fetch as realFetch } from "undici"; import { getBrowserControlServerBaseUrl, installBrowserControlServerHooks, startBrowserControlServerFromConfig, } from "./server.control-server.test-harness.js"; +import { getBrowserTestFetch } from "./test-fetch.js"; export function installAgentContractHooks() { installBrowserControlServerHooks(); @@ -12,11 +12,13 @@ export function installAgentContractHooks() { export async function startServerAndBase(): Promise { await startBrowserControlServerFromConfig(); const base = getBrowserControlServerBaseUrl(); + const realFetch = getBrowserTestFetch(); await realFetch(`${base}/start`, { method: "POST" }).then((r) => r.json()); return base; } export async function postJson(url: string, body?: unknown): Promise { + const realFetch = getBrowserTestFetch(); const res = await realFetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, diff --git a/src/browser/server.auth-token-gates-http.test.ts b/src/browser/server.auth-token-gates-http.test.ts index 9ca60dcd32f..edd7426df53 100644 --- a/src/browser/server.auth-token-gates-http.test.ts +++ b/src/browser/server.auth-token-gates-http.test.ts @@ -1,13 +1,16 @@ import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; -import { fetch as realFetch } from "undici"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { isAuthorizedBrowserRequest } from "./http-auth.js"; +import { getBrowserTestFetch, type BrowserTestFetch } from "./test-fetch.js"; let server: ReturnType | null = null; let port = 0; +let realFetch: BrowserTestFetch; describe("browser control HTTP auth", () => { beforeEach(async () => { + vi.resetModules(); + realFetch = getBrowserTestFetch(); server = createServer((req: IncomingMessage, res: ServerResponse) => { if (!isAuthorizedBrowserRequest(req, { token: "browser-control-secret" })) { res.statusCode = 401; diff --git a/src/browser/server.evaluate-disabled-does-not-block-storage.test.ts b/src/browser/server.evaluate-disabled-does-not-block-storage.test.ts index 9937ae6f8a1..611b559bd75 100644 --- a/src/browser/server.evaluate-disabled-does-not-block-storage.test.ts +++ b/src/browser/server.evaluate-disabled-does-not-block-storage.test.ts @@ -1,5 +1,5 @@ -import { fetch as realFetch } from "undici"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { getBrowserTestFetch } from "./test-fetch.js"; import { getFreePort } from "./test-port.js"; let testPort = 0; @@ -112,6 +112,7 @@ describe("browser control evaluate gating", () => { it("blocks act:evaluate but still allows cookies/storage reads", async () => { await startBrowserControlServerFromConfig(); + const realFetch = getBrowserTestFetch(); const base = `http://127.0.0.1:${testPort}`; diff --git a/src/browser/server.post-tabs-open-profile-unknown-returns-404.test.ts b/src/browser/server.post-tabs-open-profile-unknown-returns-404.test.ts index f416ffb272c..6370e691aaa 100644 --- a/src/browser/server.post-tabs-open-profile-unknown-returns-404.test.ts +++ b/src/browser/server.post-tabs-open-profile-unknown-returns-404.test.ts @@ -1,5 +1,4 @@ import fs from "node:fs"; -import { fetch as realFetch } from "undici"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { cleanupBrowserControlServerTestContext, @@ -10,6 +9,7 @@ import { setBrowserControlServerReachable, startBrowserControlServerFromConfig, } from "./server.control-server.test-harness.js"; +import { getBrowserTestFetch } from "./test-fetch.js"; describe("browser control server", () => { installBrowserControlServerHooks(); @@ -17,6 +17,7 @@ describe("browser control server", () => { it("POST /tabs/open?profile=unknown returns 404", async () => { await startBrowserControlServerFromConfig(); const base = getBrowserControlServerBaseUrl(); + const realFetch = getBrowserTestFetch(); const result = await realFetch(`${base}/tabs/open?profile=unknown`, { method: "POST", @@ -32,6 +33,7 @@ describe("browser control server", () => { setBrowserControlServerReachable(true); await startBrowserControlServerFromConfig(); const base = getBrowserControlServerBaseUrl(); + const realFetch = getBrowserTestFetch(); const result = await realFetch(`${base}/tabs/open`, { method: "POST", @@ -67,6 +69,7 @@ describe("profile CRUD endpoints", () => { it("validates profile create/delete endpoints", async () => { await startBrowserControlServerFromConfig(); const base = getBrowserControlServerBaseUrl(); + const realFetch = getBrowserTestFetch(); const createMissingName = await realFetch(`${base}/profiles/create`, { method: "POST", diff --git a/src/browser/test-fetch.ts b/src/browser/test-fetch.ts new file mode 100644 index 00000000000..310c7728afe --- /dev/null +++ b/src/browser/test-fetch.ts @@ -0,0 +1,30 @@ +import { createRequire } from "node:module"; + +type FetchLike = ((input: string | URL, init?: RequestInit) => Promise) & { + mock?: unknown; +}; + +export type BrowserTestFetch = (input: string | URL, init?: RequestInit) => Promise; + +function isUsableFetch(value: unknown): value is FetchLike { + return typeof value === "function" && !("mock" in (value as FetchLike)); +} + +export function getBrowserTestFetch(): BrowserTestFetch { + const require = createRequire(import.meta.url); + const vitest = (globalThis as { vi?: { doUnmock?: (id: string) => void } }).vi; + vitest?.doUnmock?.("undici"); + try { + delete require.cache[require.resolve("undici")]; + } catch { + // Best-effort cache bust for shared-thread test workers. + } + const { fetch } = require("undici") as typeof import("undici"); + if (isUsableFetch(fetch)) { + return (input, init) => fetch(input, init); + } + if (isUsableFetch(globalThis.fetch)) { + return (input, init) => globalThis.fetch(input, init); + } + throw new TypeError("fetch is not a function"); +} diff --git a/src/gateway/server-methods/chat.directive-tags.test.ts b/src/gateway/server-methods/chat.directive-tags.test.ts index 73a2c0871b4..2aeca24ce02 100644 --- a/src/gateway/server-methods/chat.directive-tags.test.ts +++ b/src/gateway/server-methods/chat.directive-tags.test.ts @@ -1185,6 +1185,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () => ], }, expectBroadcast: false, + waitForCompletion: false, }); await waitForAssertion(() => { diff --git a/src/gateway/server-methods/send.test.ts b/src/gateway/server-methods/send.test.ts index 1d19094e71f..408f789cb9d 100644 --- a/src/gateway/server-methods/send.test.ts +++ b/src/gateway/server-methods/send.test.ts @@ -3,11 +3,13 @@ import { setActivePluginRegistry } from "../../plugins/runtime.js"; import { createTestRegistry } from "../../test-utils/channel-plugins.js"; import type { GatewayRequestContext } from "./types.js"; +type ResolveOutboundTarget = typeof import("../../infra/outbound/targets.js").resolveOutboundTarget; + const mocks = vi.hoisted(() => ({ deliverOutboundPayloads: vi.fn(), appendAssistantMessageToSessionTranscript: vi.fn(async () => ({ ok: true, sessionFile: "x" })), recordSessionMetaFromInbound: vi.fn(async () => ({ ok: true })), - resolveOutboundTarget: vi.fn(() => ({ ok: true, to: "resolved" })), + resolveOutboundTarget: vi.fn(() => ({ ok: true, to: "resolved" })), resolveMessageChannelSelection: vi.fn(), sendPoll: vi.fn(async () => ({ messageId: "poll-1" })), getChannelPlugin: vi.fn(), diff --git a/src/gateway/server-restart-sentinel.test.ts b/src/gateway/server-restart-sentinel.test.ts index 187698b06ed..9b49a22efe9 100644 --- a/src/gateway/server-restart-sentinel.test.ts +++ b/src/gateway/server-restart-sentinel.test.ts @@ -86,7 +86,7 @@ describe("scheduleRestartSentinelWake", () => { expect.objectContaining({ channel: "whatsapp", to: "+15550002", - session: { key: "agent:main:main", agentId: "agent-from-key" }, + session: { key: "agent:main:main", agentId: "main" }, }), ); expect(mocks.enqueueSystemEvent).not.toHaveBeenCalled(); diff --git a/src/gateway/server.auth.modes.suite.ts b/src/gateway/server.auth.modes.suite.ts index 77c23a0d0b2..6c96281d0cd 100644 --- a/src/gateway/server.auth.modes.suite.ts +++ b/src/gateway/server.auth.modes.suite.ts @@ -151,18 +151,19 @@ export function registerAuthModesSuite(): void { test("requires device identity when only tailscale auth is available", async () => { const ws = await openTailscaleWs(port); - const res = await connectReq(ws, { token: "dummy", device: null }); + const res = await connectReq(ws, { skipDefaultAuth: true, device: null }); expect(res.ok).toBe(false); expect(res.error?.message ?? "").toContain("device identity required"); ws.close(); }); - test("allows shared token to skip device when tailscale auth is enabled", async () => { + test("connects with shared token but clears scopes when tailscale auth skips device", async () => { const ws = await openTailscaleWs(port); const res = await connectReq(ws, { token: "secret", device: null }); expect(res.ok).toBe(true); const status = await rpcReq(ws, "status"); - expect(status.ok).toBe(true); + expect(status.ok).toBe(false); + expect(status.error?.message ?? "").toContain("missing scope"); const health = await rpcReq(ws, "health"); expect(health.ok).toBe(true); ws.close(); diff --git a/src/plugin-sdk/file-lock.ts b/src/plugin-sdk/file-lock.ts index 2d2f2535e82..df907da67cc 100644 --- a/src/plugin-sdk/file-lock.ts +++ b/src/plugin-sdk/file-lock.ts @@ -32,9 +32,9 @@ const CLEANUP_REGISTERED_KEY = Symbol.for("openclaw.fileLockCleanupRegistered"); function releaseAllLocksSync(): void { for (const [normalizedFile, held] of HELD_LOCKS) { - // Let the OS close live descriptors on process exit. On Linux/macOS this - // avoids Node's unmanaged-fd warnings while still unlinking the stale - // lock path before the process is fully gone. + // Kick off best-effort async closes before dropping references so tests + // don't leave FileHandle objects for GC to close later. + void held.handle.close().catch(() => undefined); rmLockPathSync(held.lockPath); HELD_LOCKS.delete(normalizedFile); } diff --git a/src/test-helpers/state-dir-env.ts b/src/test-helpers/state-dir-env.ts index e42e14cad21..ad0b5ec5f3f 100644 --- a/src/test-helpers/state-dir-env.ts +++ b/src/test-helpers/state-dir-env.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { captureEnv } from "../test-utils/env.js"; +import { cleanupSessionStateForTest } from "../test-utils/session-state-cleanup.js"; export function snapshotStateDirEnv() { return captureEnv(["OPENCLAW_STATE_DIR"]); @@ -27,6 +28,7 @@ export async function withStateDirEnv( try { return await fn({ tempRoot, stateDir }); } finally { + await cleanupSessionStateForTest().catch(() => undefined); restoreStateDirEnv(snapshot); await fs.rm(tempRoot, { recursive: true, force: true }); } diff --git a/src/test-utils/session-state-cleanup.ts b/src/test-utils/session-state-cleanup.ts new file mode 100644 index 00000000000..0d2fabb3bf0 --- /dev/null +++ b/src/test-utils/session-state-cleanup.ts @@ -0,0 +1,7 @@ +import { drainSessionWriteLockStateForTest } from "../agents/session-write-lock.js"; +import { clearSessionStoreCacheForTest } from "../config/sessions/store.js"; + +export async function cleanupSessionStateForTest(): Promise { + clearSessionStoreCacheForTest(); + await drainSessionWriteLockStateForTest(); +} diff --git a/src/test-utils/temp-home.ts b/src/test-utils/temp-home.ts index 10886cc9aaa..fac3b014efc 100644 --- a/src/test-utils/temp-home.ts +++ b/src/test-utils/temp-home.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { captureEnv } from "./env.js"; +import { cleanupSessionStateForTest } from "./session-state-cleanup.js"; const HOME_ENV_KEYS = [ "HOME", @@ -36,6 +37,7 @@ export async function createTempHomeEnv(prefix: string): Promise { return { home, restore: async () => { + await cleanupSessionStateForTest().catch(() => undefined); snapshot.restore(); await fs.rm(home, { recursive: true, force: true }); }, diff --git a/test/helpers/temp-home.ts b/test/helpers/temp-home.ts index a19df15249a..633310cd6dc 100644 --- a/test/helpers/temp-home.ts +++ b/test/helpers/temp-home.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { cleanupSessionStateForTest } from "../../src/test-utils/session-state-cleanup.js"; type EnvValue = string | undefined | ((home: string) => string | undefined); @@ -129,6 +130,7 @@ export async function withTempHome( try { return await fn(base); } finally { + await cleanupSessionStateForTest().catch(() => undefined); restoreExtraEnv(envSnapshot); restoreEnv(snapshot); try { diff --git a/test/setup.ts b/test/setup.ts index 511e34ad4c3..f01e0a4db9b 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -45,7 +45,10 @@ if (process.getMaxListeners() > 0 && process.getMaxListeners() < TEST_PROCESS_MA import { resetContextWindowCacheForTest } from "../src/agents/context.js"; import { resetModelsJsonReadyCacheForTest } from "../src/agents/models-config.js"; -import { resetSessionWriteLockStateForTest } from "../src/agents/session-write-lock.js"; +import { + drainSessionWriteLockStateForTest, + resetSessionWriteLockStateForTest, +} from "../src/agents/session-write-lock.js"; import { createTopLevelChannelReplyToModeResolver } from "../src/channels/plugins/threading-helpers.js"; import type { ChannelId, @@ -53,6 +56,7 @@ import type { ChannelPlugin, } from "../src/channels/plugins/types.js"; import type { OpenClawConfig } from "../src/config/config.js"; +import { clearSessionStoreCacheForTest } from "../src/config/sessions/store.js"; import { resetFileLockStateForTest } from "../src/infra/file-lock.js"; import type { OutboundSendDeps } from "../src/infra/outbound/deliver.js"; import { installProcessWarningFilter } from "../src/infra/warning-filter.js"; @@ -62,10 +66,6 @@ import { withIsolatedTestHome } from "./test-env.js"; // Set HOME/state isolation before importing any runtime OpenClaw modules. const testEnv = withIsolatedTestHome(); -afterAll(() => { - testEnv.cleanup(); -}); - installProcessWarningFilter(); const REGISTRY_STATE = Symbol.for("openclaw.pluginRegistryState"); @@ -355,6 +355,7 @@ beforeAll(() => { }); afterEach(() => { + clearSessionStoreCacheForTest(); resetContextWindowCacheForTest(); resetFileLockStateForTest(); resetModelsJsonReadyCacheForTest(); @@ -366,7 +367,9 @@ afterEach(() => { } }); -afterAll(() => { +afterAll(async () => { + clearSessionStoreCacheForTest(); + await drainSessionWriteLockStateForTest(); resetFileLockStateForTest(); - resetSessionWriteLockStateForTest(); + testEnv.cleanup(); });