test: dedupe fixtures and test harness setup

This commit is contained in:
Peter Steinberger
2026-02-23 05:43:30 +00:00
parent 8af19ddc5b
commit 1c753ea786
75 changed files with 1886 additions and 2136 deletions

View File

@@ -1,10 +1,11 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { captureEnv } from "../test-utils/env.js";
const loadConfig = vi.fn();
const resolveGatewayPort = vi.fn();
const pickPrimaryTailnetIPv4 = vi.fn();
const pickPrimaryLanIPv4 = vi.fn();
import {
loadConfigMock as loadConfig,
pickPrimaryLanIPv4Mock as pickPrimaryLanIPv4,
pickPrimaryTailnetIPv4Mock as pickPrimaryTailnetIPv4,
resolveGatewayPortMock as resolveGatewayPort,
} from "./gateway-connection.test-mocks.js";
let lastClientOptions: {
url?: string;
@@ -19,27 +20,6 @@ let startMode: StartMode = "hello";
let closeCode = 1006;
let closeReason = "";
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig,
resolveGatewayPort,
};
});
vi.mock("../infra/tailnet.js", () => ({
pickPrimaryTailnetIPv4,
}));
vi.mock("./net.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./net.js")>();
return {
...actual,
pickPrimaryLanIPv4,
};
});
vi.mock("./client.js", () => ({
describeGatewayCloseCode: (code: number) => {
if (code === 1000) {

View File

@@ -0,0 +1,27 @@
import { vi } from "vitest";
export const loadConfigMock = vi.fn();
export const resolveGatewayPortMock = vi.fn();
export const pickPrimaryTailnetIPv4Mock = vi.fn();
export const pickPrimaryLanIPv4Mock = vi.fn();
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: loadConfigMock,
resolveGatewayPort: resolveGatewayPortMock,
};
});
vi.mock("../infra/tailnet.js", () => ({
pickPrimaryTailnetIPv4: pickPrimaryTailnetIPv4Mock,
}));
vi.mock("./net.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./net.js")>();
return {
...actual,
pickPrimaryLanIPv4: pickPrimaryLanIPv4Mock,
};
});

View File

@@ -17,6 +17,8 @@ vi.mock("./hooks.js", async (importOriginal) => {
import { createHooksRequestHandler } from "./server-http.js";
type HooksHandlerDeps = Parameters<typeof createHooksRequestHandler>[0];
function createHooksConfig(): HooksConfigResolved {
return {
basePath: "/hooks",
@@ -66,6 +68,30 @@ function createResponse(): {
return { res, end, setHeader };
}
function createHandler(params?: {
dispatchWakeHook?: HooksHandlerDeps["dispatchWakeHook"];
dispatchAgentHook?: HooksHandlerDeps["dispatchAgentHook"];
}) {
return createHooksRequestHandler({
getHooksConfig: () => createHooksConfig(),
bindHost: "127.0.0.1",
port: 18789,
logHooks: {
warn: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
error: vi.fn(),
} as unknown as ReturnType<typeof createSubsystemLogger>,
dispatchWakeHook:
params?.dispatchWakeHook ??
((() => {
return;
}) as HooksHandlerDeps["dispatchWakeHook"]),
dispatchAgentHook:
params?.dispatchAgentHook ?? ((() => "run-1") as HooksHandlerDeps["dispatchAgentHook"]),
});
}
describe("createHooksRequestHandler timeout status mapping", () => {
beforeEach(() => {
readJsonBodyMock.mockClear();
@@ -75,19 +101,7 @@ describe("createHooksRequestHandler timeout status mapping", () => {
readJsonBodyMock.mockResolvedValue({ ok: false, error: "request body timeout" });
const dispatchWakeHook = vi.fn();
const dispatchAgentHook = vi.fn(() => "run-1");
const handler = createHooksRequestHandler({
getHooksConfig: () => createHooksConfig(),
bindHost: "127.0.0.1",
port: 18789,
logHooks: {
warn: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
error: vi.fn(),
} as unknown as ReturnType<typeof createSubsystemLogger>,
dispatchWakeHook,
dispatchAgentHook,
});
const handler = createHandler({ dispatchWakeHook, dispatchAgentHook });
const req = createRequest();
const { res, end } = createResponse();
@@ -101,19 +115,7 @@ describe("createHooksRequestHandler timeout status mapping", () => {
});
test("shares hook auth rate-limit bucket across ipv4 and ipv4-mapped ipv6 forms", async () => {
const handler = createHooksRequestHandler({
getHooksConfig: () => createHooksConfig(),
bindHost: "127.0.0.1",
port: 18789,
logHooks: {
warn: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
error: vi.fn(),
} as unknown as ReturnType<typeof createSubsystemLogger>,
dispatchWakeHook: vi.fn(),
dispatchAgentHook: vi.fn(() => "run-1"),
});
const handler = createHandler();
for (let i = 0; i < 20; i++) {
const req = createRequest({

View File

@@ -124,6 +124,40 @@ function createChatContext(): Pick<
};
}
type ChatContext = ReturnType<typeof createChatContext>;
async function runNonStreamingChatSend(params: {
context: ChatContext;
respond: ReturnType<typeof vi.fn>;
idempotencyKey: string;
message?: string;
}) {
await chatHandlers["chat.send"]({
params: {
sessionKey: "main",
message: params.message ?? "hello",
idempotencyKey: params.idempotencyKey,
},
respond: params.respond as unknown as Parameters<
(typeof chatHandlers)["chat.send"]
>[0]["respond"],
req: {} as never,
client: null,
isWebchatConnect: () => false,
context: params.context as GatewayRequestContext,
});
await vi.waitFor(() => {
expect(
(params.context.broadcast as unknown as ReturnType<typeof vi.fn>).mock.calls.length,
).toBe(1);
});
const chatCall = (params.context.broadcast as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
expect(chatCall?.[0]).toBe("chat");
return chatCall?.[1];
}
describe("chat directive tag stripping for non-streaming final payloads", () => {
it("chat.inject keeps message defined when directive tag is the only content", async () => {
createTranscriptFixture("openclaw-chat-inject-directive-only-");
@@ -160,33 +194,20 @@ describe("chat directive tag stripping for non-streaming final payloads", () =>
const respond = vi.fn();
const context = createChatContext();
await chatHandlers["chat.send"]({
params: {
sessionKey: "main",
message: "hello",
idempotencyKey: "idem-directive-only",
},
const payload = await runNonStreamingChatSend({
context,
respond,
req: {} as never,
client: null,
isWebchatConnect: () => false,
context: context as GatewayRequestContext,
idempotencyKey: "idem-directive-only",
});
await vi.waitFor(() => {
expect((context.broadcast as unknown as ReturnType<typeof vi.fn>).mock.calls.length).toBe(1);
});
const chatCall = (context.broadcast as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
expect(chatCall?.[0]).toBe("chat");
expect(chatCall?.[1]).toEqual(
expect(payload).toEqual(
expect.objectContaining({
runId: "idem-directive-only",
state: "final",
message: expect.any(Object),
}),
);
expect(extractFirstTextBlock(chatCall?.[1])).toBe("");
expect(extractFirstTextBlock(payload)).toBe("");
});
it("chat.inject strips external untrusted wrapper metadata from final payload text", async () => {
@@ -218,25 +239,11 @@ describe("chat directive tag stripping for non-streaming final payloads", () =>
const respond = vi.fn();
const context = createChatContext();
await chatHandlers["chat.send"]({
params: {
sessionKey: "main",
message: "hello",
idempotencyKey: "idem-untrusted-context",
},
const payload = await runNonStreamingChatSend({
context,
respond,
req: {} as never,
client: null,
isWebchatConnect: () => false,
context: context as GatewayRequestContext,
idempotencyKey: "idem-untrusted-context",
});
await vi.waitFor(() => {
expect((context.broadcast as unknown as ReturnType<typeof vi.fn>).mock.calls.length).toBe(1);
});
const chatCall = (context.broadcast as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
expect(chatCall?.[0]).toBe("chat");
expect(extractFirstTextBlock(chatCall?.[1])).toBe("hello");
expect(extractFirstTextBlock(payload)).toBe("hello");
});
});

View File

@@ -13,24 +13,32 @@ import {
installGatewayTestHooks({ scope: "suite" });
let server: Awaited<ReturnType<typeof startServerWithClient>>["server"];
let ws: Awaited<ReturnType<typeof startServerWithClient>>["ws"];
let startedServer: Awaited<ReturnType<typeof startServerWithClient>> | null = null;
function requireWs(): Awaited<ReturnType<typeof startServerWithClient>>["ws"] {
if (!startedServer) {
throw new Error("gateway test server not started");
}
return startedServer.ws;
}
beforeAll(async () => {
const started = await startServerWithClient(undefined, { controlUiEnabled: true });
server = started.server;
ws = started.ws;
await connectOk(ws);
startedServer = await startServerWithClient(undefined, { controlUiEnabled: true });
await connectOk(requireWs());
});
afterAll(async () => {
ws.close();
await server.close();
if (!startedServer) {
return;
}
startedServer.ws.close();
await startedServer.server.close();
startedServer = null;
});
describe("gateway config methods", () => {
it("rejects config.patch when raw is not an object", async () => {
const res = await rpcReq<{ ok?: boolean }>(ws, "config.patch", {
const res = await rpcReq<{ ok?: boolean }>(requireWs(), "config.patch", {
raw: "[]",
});
expect(res.ok).toBe(false);
@@ -78,7 +86,7 @@ describe("gateway server sessions", () => {
const homeSessions = await rpcReq<{
sessions: Array<{ key: string }>;
}>(ws, "sessions.list", {
}>(requireWs(), "sessions.list", {
includeGlobal: false,
includeUnknown: false,
agentId: "home",
@@ -91,7 +99,7 @@ describe("gateway server sessions", () => {
const workSessions = await rpcReq<{
sessions: Array<{ key: string }>;
}>(ws, "sessions.list", {
}>(requireWs(), "sessions.list", {
includeGlobal: false,
includeUnknown: false,
agentId: "work",
@@ -119,13 +127,13 @@ describe("gateway server sessions", () => {
},
});
const resolved = await rpcReq<{ ok: true; key: string }>(ws, "sessions.resolve", {
const resolved = await rpcReq<{ ok: true; key: string }>(requireWs(), "sessions.resolve", {
key: "main",
});
expect(resolved.ok).toBe(true);
expect(resolved.payload?.key).toBe("agent:ops:work");
const patched = await rpcReq<{ ok: true; key: string }>(ws, "sessions.patch", {
const patched = await rpcReq<{ ok: true; key: string }>(requireWs(), "sessions.patch", {
key: "main",
thinkingLevel: "medium",
});

View File

@@ -85,6 +85,28 @@ async function waitForCondition(check: () => boolean, timeoutMs = 2000) {
}
}
async function cleanupCronTestRun(params: {
ws: { close: () => void };
server: { close: () => Promise<void> };
dir: string;
prevSkipCron: string | undefined;
clearSessionConfig?: boolean;
}) {
params.ws.close();
await params.server.close();
await rmTempDir(params.dir);
testState.cronStorePath = undefined;
if (params.clearSessionConfig) {
testState.sessionConfig = undefined;
}
testState.cronEnabled = undefined;
if (params.prevSkipCron === undefined) {
delete process.env.OPENCLAW_SKIP_CRON;
return;
}
process.env.OPENCLAW_SKIP_CRON = params.prevSkipCron;
}
describe("gateway server cron", () => {
test("handles cron CRUD, normalization, and patch semantics", { timeout: 120_000 }, async () => {
const prevSkipCron = process.env.OPENCLAW_SKIP_CRON;
@@ -352,17 +374,13 @@ describe("gateway server cron", () => {
const disabled = disableUpdateRes.payload as { enabled?: unknown } | undefined;
expect(disabled?.enabled).toBe(false);
} finally {
ws.close();
await server.close();
await rmTempDir(dir);
testState.cronStorePath = undefined;
testState.sessionConfig = undefined;
testState.cronEnabled = undefined;
if (prevSkipCron === undefined) {
delete process.env.OPENCLAW_SKIP_CRON;
} else {
process.env.OPENCLAW_SKIP_CRON = prevSkipCron;
}
await cleanupCronTestRun({
ws,
server,
dir,
prevSkipCron,
clearSessionConfig: true,
});
}
});
@@ -466,16 +484,7 @@ describe("gateway server cron", () => {
const runs = autoEntries?.entries ?? [];
expect(runs.at(-1)?.jobId).toBe(autoJobId);
} finally {
ws.close();
await server.close();
await rmTempDir(dir);
testState.cronStorePath = undefined;
testState.cronEnabled = undefined;
if (prevSkipCron === undefined) {
delete process.env.OPENCLAW_SKIP_CRON;
} else {
process.env.OPENCLAW_SKIP_CRON = prevSkipCron;
}
await cleanupCronTestRun({ ws, server, dir, prevSkipCron });
}
}, 45_000);
@@ -650,16 +659,7 @@ describe("gateway server cron", () => {
await yieldToEventLoop();
expect(fetchWithSsrFGuardMock).toHaveBeenCalledTimes(2);
} finally {
ws.close();
await server.close();
await rmTempDir(dir);
testState.cronStorePath = undefined;
testState.cronEnabled = undefined;
if (prevSkipCron === undefined) {
delete process.env.OPENCLAW_SKIP_CRON;
} else {
process.env.OPENCLAW_SKIP_CRON = prevSkipCron;
}
await cleanupCronTestRun({ ws, server, dir, prevSkipCron });
}
}, 60_000);
});