mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-25 17:02:46 +00:00
test: harden threaded shared-worker suites
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
|
||||
@@ -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
30
src/browser/test-fetch.ts
Normal 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");
|
||||
}
|
||||
@@ -1185,6 +1185,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () =>
|
||||
],
|
||||
},
|
||||
expectBroadcast: false,
|
||||
waitForCompletion: false,
|
||||
});
|
||||
|
||||
await waitForAssertion(() => {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
7
src/test-utils/session-state-cleanup.ts
Normal file
7
src/test-utils/session-state-cleanup.ts
Normal 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();
|
||||
}
|
||||
@@ -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 });
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user