test: harden threaded shared-worker suites

This commit is contained in:
Peter Steinberger
2026-03-24 08:36:10 +00:00
parent e7817ad12a
commit 43131dcc08
20 changed files with 110 additions and 24 deletions

View File

@@ -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<typeof import("openclaw/plugin-sdk/text-runtime")>();
@@ -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 () => {

View File

@@ -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"));
});

View File

@@ -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<void> {
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();

View File

@@ -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<T>(params: {
rootDir: string;

View File

@@ -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",

View File

@@ -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<string> {
await startBrowserControlServerFromConfig();
const base = getBrowserControlServerBaseUrl();
const realFetch = getBrowserTestFetch();
await realFetch(`${base}/start`, { method: "POST" }).then((r) => r.json());
return base;
}
export async function postJson<T>(url: string, body?: unknown): Promise<T> {
const realFetch = getBrowserTestFetch();
const res = await realFetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },

View File

@@ -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<typeof createServer> | 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;

View File

@@ -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}`;

View File

@@ -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",

30
src/browser/test-fetch.ts Normal file
View File

@@ -0,0 +1,30 @@
import { createRequire } from "node:module";
type FetchLike = ((input: string | URL, init?: RequestInit) => Promise<Response>) & {
mock?: unknown;
};
export type BrowserTestFetch = (input: string | URL, init?: RequestInit) => Promise<Response>;
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");
}

View File

@@ -1185,6 +1185,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () =>
],
},
expectBroadcast: false,
waitForCompletion: false,
});
await waitForAssertion(() => {

View File

@@ -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<ResolveOutboundTarget>(() => ({ ok: true, to: "resolved" })),
resolveMessageChannelSelection: vi.fn(),
sendPoll: vi.fn(async () => ({ messageId: "poll-1" })),
getChannelPlugin: vi.fn(),

View File

@@ -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();

View File

@@ -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();

View File

@@ -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);
}

View File

@@ -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<T>(
try {
return await fn({ tempRoot, stateDir });
} finally {
await cleanupSessionStateForTest().catch(() => undefined);
restoreStateDirEnv(snapshot);
await fs.rm(tempRoot, { recursive: true, force: true });
}

View File

@@ -0,0 +1,7 @@
import { drainSessionWriteLockStateForTest } from "../agents/session-write-lock.js";
import { clearSessionStoreCacheForTest } from "../config/sessions/store.js";
export async function cleanupSessionStateForTest(): Promise<void> {
clearSessionStoreCacheForTest();
await drainSessionWriteLockStateForTest();
}

View File

@@ -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<TempHomeEnv> {
return {
home,
restore: async () => {
await cleanupSessionStateForTest().catch(() => undefined);
snapshot.restore();
await fs.rm(home, { recursive: true, force: true });
},

View File

@@ -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<T>(
try {
return await fn(base);
} finally {
await cleanupSessionStateForTest().catch(() => undefined);
restoreExtraEnv(envSnapshot);
restoreEnv(snapshot);
try {

View File

@@ -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();
});