From 60a60779d72cd204fc8179703298371fc114bef2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 23 Jan 2026 07:26:11 +0000 Subject: [PATCH] test: streamline slow suites --- ...ded-pi-agent.auth-profile-rotation.test.ts | 12 +- ...aliases-schemas-without-dropping-a.test.ts | 77 +--- ...aliases-schemas-without-dropping-b.test.ts | 68 +-- ...aliases-schemas-without-dropping-d.test.ts | 68 +-- ...aliases-schemas-without-dropping-e.test.ts | 68 +-- ...aliases-schemas-without-dropping-f.test.ts | 68 +-- ...aliases-schemas-without-dropping-g.test.ts | 68 +-- ...ends-status-replies-responseprefix.test.ts | 9 +- src/discord/monitor/threading.ts | 4 + src/gateway/server.auth.test.ts | 397 +++++++++--------- src/gateway/server.cron.test.ts | 14 + src/gateway/server.reload.test.ts | 9 +- src/gateway/test-helpers.mocks.ts | 1 + 13 files changed, 257 insertions(+), 606 deletions(-) diff --git a/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.test.ts b/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.test.ts index f6f395746..f765ed4a7 100644 --- a/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.test.ts +++ b/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.test.ts @@ -3,7 +3,7 @@ import os from "node:os"; import path from "node:path"; import type { AssistantMessage } from "@mariozechner/pi-ai"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { ClawdbotConfig } from "../config/config.js"; import type { EmbeddedRunAttemptResult } from "./pi-embedded-runner/run/types.js"; @@ -16,13 +16,15 @@ vi.mock("./pi-embedded-runner/run/attempt.js", () => ({ let runEmbeddedPiAgent: typeof import("./pi-embedded-runner.js").runEmbeddedPiAgent; -beforeEach(async () => { - vi.useRealTimers(); - vi.resetModules(); - runEmbeddedAttemptMock.mockReset(); +beforeAll(async () => { ({ runEmbeddedPiAgent } = await import("./pi-embedded-runner.js")); }); +beforeEach(() => { + vi.useRealTimers(); + runEmbeddedAttemptMock.mockReset(); +}); + const baseUsage = { input: 0, output: 0, diff --git a/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping-a.test.ts b/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping-a.test.ts index d2791a0af..d66fb555f 100644 --- a/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping-a.test.ts +++ b/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping-a.test.ts @@ -1,72 +1,10 @@ -import type { AgentTool } from "@mariozechner/pi-agent-core"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { __testing, createClawdbotCodingTools } from "./pi-tools.js"; import { createBrowserTool } from "./tools/browser-tool.js"; +const defaultTools = createClawdbotCodingTools(); + describe("createClawdbotCodingTools", () => { - describe("Claude/Gemini alias support", () => { - it("adds Claude-style aliases to schemas without dropping metadata", () => { - const base: AgentTool = { - name: "write", - description: "test", - parameters: { - type: "object", - required: ["path", "content"], - properties: { - path: { type: "string", description: "Path" }, - content: { type: "string", description: "Body" }, - }, - }, - execute: vi.fn(), - }; - - const patched = __testing.patchToolSchemaForClaudeCompatibility(base); - const params = patched.parameters as { - properties?: Record; - required?: string[]; - }; - const props = params.properties ?? {}; - - expect(props.file_path).toEqual(props.path); - expect(params.required ?? []).not.toContain("path"); - expect(params.required ?? []).not.toContain("file_path"); - }); - - it("normalizes file_path to path and enforces required groups at runtime", async () => { - const execute = vi.fn(async (_id, args) => args); - const tool: AgentTool = { - name: "write", - description: "test", - parameters: { - type: "object", - required: ["path", "content"], - properties: { - path: { type: "string" }, - content: { type: "string" }, - }, - }, - execute, - }; - - const wrapped = __testing.wrapToolParamNormalization(tool, [{ keys: ["path", "file_path"] }]); - - await wrapped.execute("tool-1", { file_path: "foo.txt", content: "x" }); - expect(execute).toHaveBeenCalledWith( - "tool-1", - { path: "foo.txt", content: "x" }, - undefined, - undefined, - ); - - await expect(wrapped.execute("tool-2", { content: "x" })).rejects.toThrow( - /Missing required parameter/, - ); - await expect(wrapped.execute("tool-3", { file_path: " ", content: "x" })).rejects.toThrow( - /Missing required parameter/, - ); - }); - }); - it("keeps browser tool schema OpenAI-compatible without normalization", () => { const browser = createBrowserTool(); const schema = browser.parameters as { type?: unknown; anyOf?: unknown }; @@ -79,8 +17,7 @@ describe("createClawdbotCodingTools", () => { expect(browser.description).toMatch(/profile="chrome"/i); }); it("keeps browser tool schema properties after normalization", () => { - const tools = createClawdbotCodingTools(); - const browser = tools.find((tool) => tool.name === "browser"); + const browser = defaultTools.find((tool) => tool.name === "browser"); expect(browser).toBeDefined(); const parameters = browser?.parameters as { anyOf?: unknown[]; @@ -95,8 +32,7 @@ describe("createClawdbotCodingTools", () => { expect(parameters.required ?? []).toContain("action"); }); it("exposes raw for gateway config.apply tool calls", () => { - const tools = createClawdbotCodingTools(); - const gateway = tools.find((tool) => tool.name === "gateway"); + const gateway = defaultTools.find((tool) => tool.name === "gateway"); expect(gateway).toBeDefined(); const parameters = gateway?.parameters as { @@ -109,8 +45,7 @@ describe("createClawdbotCodingTools", () => { expect(parameters.required ?? []).not.toContain("raw"); }); it("flattens anyOf-of-literals to enum for provider compatibility", () => { - const tools = createClawdbotCodingTools(); - const browser = tools.find((tool) => tool.name === "browser"); + const browser = defaultTools.find((tool) => tool.name === "browser"); expect(browser).toBeDefined(); const parameters = browser?.parameters as { diff --git a/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping-b.test.ts b/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping-b.test.ts index 8680422dc..de6bc0a19 100644 --- a/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping-b.test.ts +++ b/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping-b.test.ts @@ -1,72 +1,8 @@ -import type { AgentTool } from "@mariozechner/pi-agent-core"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import type { ClawdbotConfig } from "../config/config.js"; -import { __testing, createClawdbotCodingTools } from "./pi-tools.js"; +import { createClawdbotCodingTools } from "./pi-tools.js"; describe("createClawdbotCodingTools", () => { - describe("Claude/Gemini alias support", () => { - it("adds Claude-style aliases to schemas without dropping metadata", () => { - const base: AgentTool = { - name: "write", - description: "test", - parameters: { - type: "object", - required: ["path", "content"], - properties: { - path: { type: "string", description: "Path" }, - content: { type: "string", description: "Body" }, - }, - }, - execute: vi.fn(), - }; - - const patched = __testing.patchToolSchemaForClaudeCompatibility(base); - const params = patched.parameters as { - properties?: Record; - required?: string[]; - }; - const props = params.properties ?? {}; - - expect(props.file_path).toEqual(props.path); - expect(params.required ?? []).not.toContain("path"); - expect(params.required ?? []).not.toContain("file_path"); - }); - - it("normalizes file_path to path and enforces required groups at runtime", async () => { - const execute = vi.fn(async (_id, args) => args); - const tool: AgentTool = { - name: "write", - description: "test", - parameters: { - type: "object", - required: ["path", "content"], - properties: { - path: { type: "string" }, - content: { type: "string" }, - }, - }, - execute, - }; - - const wrapped = __testing.wrapToolParamNormalization(tool, [{ keys: ["path", "file_path"] }]); - - await wrapped.execute("tool-1", { file_path: "foo.txt", content: "x" }); - expect(execute).toHaveBeenCalledWith( - "tool-1", - { path: "foo.txt", content: "x" }, - undefined, - undefined, - ); - - await expect(wrapped.execute("tool-2", { content: "x" })).rejects.toThrow( - /Missing required parameter/, - ); - await expect(wrapped.execute("tool-3", { file_path: " ", content: "x" })).rejects.toThrow( - /Missing required parameter/, - ); - }); - }); - it("preserves action enums in normalized schemas", () => { const tools = createClawdbotCodingTools(); const toolNames = ["browser", "canvas", "nodes", "cron", "gateway", "message"]; diff --git a/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping-d.test.ts b/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping-d.test.ts index 3fd5b81d7..070452ef8 100644 --- a/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping-d.test.ts +++ b/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping-d.test.ts @@ -1,75 +1,11 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import type { AgentTool } from "@mariozechner/pi-agent-core"; import sharp from "sharp"; -import { describe, expect, it, vi } from "vitest"; -import { __testing, createClawdbotCodingTools } from "./pi-tools.js"; +import { describe, expect, it } from "vitest"; +import { createClawdbotCodingTools } from "./pi-tools.js"; describe("createClawdbotCodingTools", () => { - describe("Claude/Gemini alias support", () => { - it("adds Claude-style aliases to schemas without dropping metadata", () => { - const base: AgentTool = { - name: "write", - description: "test", - parameters: { - type: "object", - required: ["path", "content"], - properties: { - path: { type: "string", description: "Path" }, - content: { type: "string", description: "Body" }, - }, - }, - execute: vi.fn(), - }; - - const patched = __testing.patchToolSchemaForClaudeCompatibility(base); - const params = patched.parameters as { - properties?: Record; - required?: string[]; - }; - const props = params.properties ?? {}; - - expect(props.file_path).toEqual(props.path); - expect(params.required ?? []).not.toContain("path"); - expect(params.required ?? []).not.toContain("file_path"); - }); - - it("normalizes file_path to path and enforces required groups at runtime", async () => { - const execute = vi.fn(async (_id, args) => args); - const tool: AgentTool = { - name: "write", - description: "test", - parameters: { - type: "object", - required: ["path", "content"], - properties: { - path: { type: "string" }, - content: { type: "string" }, - }, - }, - execute, - }; - - const wrapped = __testing.wrapToolParamNormalization(tool, [{ keys: ["path", "file_path"] }]); - - await wrapped.execute("tool-1", { file_path: "foo.txt", content: "x" }); - expect(execute).toHaveBeenCalledWith( - "tool-1", - { path: "foo.txt", content: "x" }, - undefined, - undefined, - ); - - await expect(wrapped.execute("tool-2", { content: "x" })).rejects.toThrow( - /Missing required parameter/, - ); - await expect(wrapped.execute("tool-3", { file_path: " ", content: "x" })).rejects.toThrow( - /Missing required parameter/, - ); - }); - }); - it("keeps read tool image metadata intact", async () => { const tools = createClawdbotCodingTools(); const readTool = tools.find((tool) => tool.name === "read"); diff --git a/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping-e.test.ts b/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping-e.test.ts index 3a34a318e..4bafc4118 100644 --- a/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping-e.test.ts +++ b/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping-e.test.ts @@ -1,71 +1,7 @@ -import type { AgentTool } from "@mariozechner/pi-agent-core"; -import { describe, expect, it, vi } from "vitest"; -import { __testing, createClawdbotCodingTools } from "./pi-tools.js"; +import { describe, expect, it } from "vitest"; +import { createClawdbotCodingTools } from "./pi-tools.js"; describe("createClawdbotCodingTools", () => { - describe("Claude/Gemini alias support", () => { - it("adds Claude-style aliases to schemas without dropping metadata", () => { - const base: AgentTool = { - name: "write", - description: "test", - parameters: { - type: "object", - required: ["path", "content"], - properties: { - path: { type: "string", description: "Path" }, - content: { type: "string", description: "Body" }, - }, - }, - execute: vi.fn(), - }; - - const patched = __testing.patchToolSchemaForClaudeCompatibility(base); - const params = patched.parameters as { - properties?: Record; - required?: string[]; - }; - const props = params.properties ?? {}; - - expect(props.file_path).toEqual(props.path); - expect(params.required ?? []).not.toContain("path"); - expect(params.required ?? []).not.toContain("file_path"); - }); - - it("normalizes file_path to path and enforces required groups at runtime", async () => { - const execute = vi.fn(async (_id, args) => args); - const tool: AgentTool = { - name: "write", - description: "test", - parameters: { - type: "object", - required: ["path", "content"], - properties: { - path: { type: "string" }, - content: { type: "string" }, - }, - }, - execute, - }; - - const wrapped = __testing.wrapToolParamNormalization(tool, [{ keys: ["path", "file_path"] }]); - - await wrapped.execute("tool-1", { file_path: "foo.txt", content: "x" }); - expect(execute).toHaveBeenCalledWith( - "tool-1", - { path: "foo.txt", content: "x" }, - undefined, - undefined, - ); - - await expect(wrapped.execute("tool-2", { content: "x" })).rejects.toThrow( - /Missing required parameter/, - ); - await expect(wrapped.execute("tool-3", { file_path: " ", content: "x" })).rejects.toThrow( - /Missing required parameter/, - ); - }); - }); - it("applies tool profiles before allow/deny policies", () => { const tools = createClawdbotCodingTools({ config: { tools: { profile: "messaging" } }, diff --git a/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping-f.test.ts b/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping-f.test.ts index d01d19735..35549a4d3 100644 --- a/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping-f.test.ts +++ b/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping-f.test.ts @@ -1,74 +1,10 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import type { AgentTool } from "@mariozechner/pi-agent-core"; -import { describe, expect, it, vi } from "vitest"; -import { __testing, createClawdbotCodingTools } from "./pi-tools.js"; +import { describe, expect, it } from "vitest"; +import { createClawdbotCodingTools } from "./pi-tools.js"; describe("createClawdbotCodingTools", () => { - describe("Claude/Gemini alias support", () => { - it("adds Claude-style aliases to schemas without dropping metadata", () => { - const base: AgentTool = { - name: "write", - description: "test", - parameters: { - type: "object", - required: ["path", "content"], - properties: { - path: { type: "string", description: "Path" }, - content: { type: "string", description: "Body" }, - }, - }, - execute: vi.fn(), - }; - - const patched = __testing.patchToolSchemaForClaudeCompatibility(base); - const params = patched.parameters as { - properties?: Record; - required?: string[]; - }; - const props = params.properties ?? {}; - - expect(props.file_path).toEqual(props.path); - expect(params.required ?? []).not.toContain("path"); - expect(params.required ?? []).not.toContain("file_path"); - }); - - it("normalizes file_path to path and enforces required groups at runtime", async () => { - const execute = vi.fn(async (_id, args) => args); - const tool: AgentTool = { - name: "write", - description: "test", - parameters: { - type: "object", - required: ["path", "content"], - properties: { - path: { type: "string" }, - content: { type: "string" }, - }, - }, - execute, - }; - - const wrapped = __testing.wrapToolParamNormalization(tool, [{ keys: ["path", "file_path"] }]); - - await wrapped.execute("tool-1", { file_path: "foo.txt", content: "x" }); - expect(execute).toHaveBeenCalledWith( - "tool-1", - { path: "foo.txt", content: "x" }, - undefined, - undefined, - ); - - await expect(wrapped.execute("tool-2", { content: "x" })).rejects.toThrow( - /Missing required parameter/, - ); - await expect(wrapped.execute("tool-3", { file_path: " ", content: "x" })).rejects.toThrow( - /Missing required parameter/, - ); - }); - }); - it("uses workspaceDir for Read tool path resolution", async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-ws-")); try { diff --git a/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping-g.test.ts b/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping-g.test.ts index ff87ea440..a5cbf2320 100644 --- a/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping-g.test.ts +++ b/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping-g.test.ts @@ -1,75 +1,11 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import type { AgentTool } from "@mariozechner/pi-agent-core"; -import { describe, expect, it, vi } from "vitest"; -import { __testing, createClawdbotCodingTools } from "./pi-tools.js"; +import { describe, expect, it } from "vitest"; +import { createClawdbotCodingTools } from "./pi-tools.js"; import { createSandboxedReadTool } from "./pi-tools.read.js"; describe("createClawdbotCodingTools", () => { - describe("Claude/Gemini alias support", () => { - it("adds Claude-style aliases to schemas without dropping metadata", () => { - const base: AgentTool = { - name: "write", - description: "test", - parameters: { - type: "object", - required: ["path", "content"], - properties: { - path: { type: "string", description: "Path" }, - content: { type: "string", description: "Body" }, - }, - }, - execute: vi.fn(), - }; - - const patched = __testing.patchToolSchemaForClaudeCompatibility(base); - const params = patched.parameters as { - properties?: Record; - required?: string[]; - }; - const props = params.properties ?? {}; - - expect(props.file_path).toEqual(props.path); - expect(params.required ?? []).not.toContain("path"); - expect(params.required ?? []).not.toContain("file_path"); - }); - - it("normalizes file_path to path and enforces required groups at runtime", async () => { - const execute = vi.fn(async (_id, args) => args); - const tool: AgentTool = { - name: "write", - description: "test", - parameters: { - type: "object", - required: ["path", "content"], - properties: { - path: { type: "string" }, - content: { type: "string" }, - }, - }, - execute, - }; - - const wrapped = __testing.wrapToolParamNormalization(tool, [{ keys: ["path", "file_path"] }]); - - await wrapped.execute("tool-1", { file_path: "foo.txt", content: "x" }); - expect(execute).toHaveBeenCalledWith( - "tool-1", - { path: "foo.txt", content: "x" }, - undefined, - undefined, - ); - - await expect(wrapped.execute("tool-2", { content: "x" })).rejects.toThrow( - /Missing required parameter/, - ); - await expect(wrapped.execute("tool-3", { file_path: " ", content: "x" })).rejects.toThrow( - /Missing required parameter/, - ); - }); - }); - it("applies sandbox path guards to file_path alias", async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sbx-")); const outsidePath = path.join(os.tmpdir(), "clawdbot-outside.txt"); diff --git a/src/discord/monitor.tool-result.sends-status-replies-responseprefix.test.ts b/src/discord/monitor.tool-result.sends-status-replies-responseprefix.test.ts index f867e336d..9da41c577 100644 --- a/src/discord/monitor.tool-result.sends-status-replies-responseprefix.test.ts +++ b/src/discord/monitor.tool-result.sends-status-replies-responseprefix.test.ts @@ -1,6 +1,9 @@ import type { Client } from "@buape/carbon"; import { ChannelType, MessageType } from "@buape/carbon"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createDiscordMessageHandler } from "./monitor.js"; +import { __resetDiscordChannelInfoCacheForTest } from "./monitor/message-utils.js"; +import { __resetDiscordThreadStarterCacheForTest } from "./monitor/threading.js"; const sendMock = vi.fn(); const reactMock = vi.fn(); @@ -41,12 +44,12 @@ beforeEach(() => { }); readAllowFromStoreMock.mockReset().mockResolvedValue([]); upsertPairingRequestMock.mockReset().mockResolvedValue({ code: "PAIRCODE", created: true }); - vi.resetModules(); + __resetDiscordChannelInfoCacheForTest(); + __resetDiscordThreadStarterCacheForTest(); }); describe("discord tool result dispatch", () => { it("sends status replies with responsePrefix", async () => { - const { createDiscordMessageHandler } = await import("./monitor.js"); const cfg = { agents: { defaults: { @@ -116,7 +119,6 @@ describe("discord tool result dispatch", () => { }, 30_000); it("caches channel info lookups between messages", async () => { - const { createDiscordMessageHandler } = await import("./monitor.js"); const cfg = { agents: { defaults: { @@ -189,7 +191,6 @@ describe("discord tool result dispatch", () => { }); it("includes forwarded message snapshots in body", async () => { - const { createDiscordMessageHandler } = await import("./monitor.js"); let capturedBody = ""; dispatchMock.mockImplementationOnce(async ({ ctx, dispatcher }) => { capturedBody = ctx.Body ?? ""; diff --git a/src/discord/monitor/threading.ts b/src/discord/monitor/threading.ts index ebbbbb199..bae4ef1c5 100644 --- a/src/discord/monitor/threading.ts +++ b/src/discord/monitor/threading.ts @@ -30,6 +30,10 @@ type DiscordThreadParentInfo = { const DISCORD_THREAD_STARTER_CACHE = new Map(); +export function __resetDiscordThreadStarterCacheForTest() { + DISCORD_THREAD_STARTER_CACHE.clear(); +} + function isDiscordThreadType(type: ChannelType | undefined): boolean { return ( type === ChannelType.PublicThread || diff --git a/src/gateway/server.auth.test.ts b/src/gateway/server.auth.test.ts index 5c90f4d0e..95f61bef7 100644 --- a/src/gateway/server.auth.test.ts +++ b/src/gateway/server.auth.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test, vi } from "vitest"; +import { afterAll, beforeAll, describe, expect, test, vi } from "vitest"; import { WebSocket } from "ws"; import { PROTOCOL_VERSION } from "./protocol/index.js"; import { getHandshakeTimeoutMs } from "./server-constants.js"; @@ -26,129 +26,226 @@ async function waitForWsClose(ws: WebSocket, timeoutMs: number): Promise { + const ws = new WebSocket(`ws://127.0.0.1:${port}`); + await new Promise((resolve) => ws.once("open", resolve)); + return ws; +}; + describe("gateway server auth/connect", () => { - test("closes silent handshakes after timeout", { timeout: 60_000 }, async () => { - vi.useRealTimers(); - const prevHandshakeTimeout = process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS; - process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS = "50"; - try { - const { server, ws } = await startServerWithClient(); - const handshakeTimeoutMs = getHandshakeTimeoutMs(); - const closed = await waitForWsClose(ws, handshakeTimeoutMs + 250); - expect(closed).toBe(true); + describe("default auth", () => { + let server: Awaited>; + let port: number; + + beforeAll(async () => { + port = await getFreePort(); + server = await startGatewayServer(port); + }); + + afterAll(async () => { await server.close(); - } finally { - if (prevHandshakeTimeout === undefined) { - delete process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS; - } else { - process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS = prevHandshakeTimeout; - } - } - }); + }); - test("connect (req) handshake returns hello-ok payload", async () => { - const { CONFIG_PATH_CLAWDBOT, STATE_DIR_CLAWDBOT } = await import("../config/config.js"); - const port = await getFreePort(); - const server = await startGatewayServer(port); - const ws = new WebSocket(`ws://127.0.0.1:${port}`); - await new Promise((resolve) => ws.once("open", resolve)); - - const res = await connectReq(ws); - expect(res.ok).toBe(true); - const payload = res.payload as - | { - type?: unknown; - snapshot?: { configPath?: string; stateDir?: string }; + test("closes silent handshakes after timeout", { timeout: 60_000 }, async () => { + vi.useRealTimers(); + const prevHandshakeTimeout = process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS; + process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS = "50"; + try { + const ws = await openWs(port); + const handshakeTimeoutMs = getHandshakeTimeoutMs(); + const closed = await waitForWsClose(ws, handshakeTimeoutMs + 250); + expect(closed).toBe(true); + } finally { + if (prevHandshakeTimeout === undefined) { + delete process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS; + } else { + process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS = prevHandshakeTimeout; } - | undefined; - expect(payload?.type).toBe("hello-ok"); - expect(payload?.snapshot?.configPath).toBe(CONFIG_PATH_CLAWDBOT); - expect(payload?.snapshot?.stateDir).toBe(STATE_DIR_CLAWDBOT); + } + }); - ws.close(); - await server.close(); - }); + test("connect (req) handshake returns hello-ok payload", async () => { + const { CONFIG_PATH_CLAWDBOT, STATE_DIR_CLAWDBOT } = await import("../config/config.js"); + const ws = await openWs(port); - test("sends connect challenge on open", async () => { - const port = await getFreePort(); - const server = await startGatewayServer(port); - const ws = new WebSocket(`ws://127.0.0.1:${port}`); - const evtPromise = onceMessage<{ payload?: unknown }>( - ws, - (o) => o.type === "event" && o.event === "connect.challenge", + const res = await connectReq(ws); + expect(res.ok).toBe(true); + const payload = res.payload as + | { + type?: unknown; + snapshot?: { configPath?: string; stateDir?: string }; + } + | undefined; + expect(payload?.type).toBe("hello-ok"); + expect(payload?.snapshot?.configPath).toBe(CONFIG_PATH_CLAWDBOT); + expect(payload?.snapshot?.stateDir).toBe(STATE_DIR_CLAWDBOT); + + ws.close(); + }); + + test("sends connect challenge on open", async () => { + const ws = new WebSocket(`ws://127.0.0.1:${port}`); + const evtPromise = onceMessage<{ payload?: unknown }>( + ws, + (o) => o.type === "event" && o.event === "connect.challenge", + ); + await new Promise((resolve) => ws.once("open", resolve)); + const evt = await evtPromise; + const nonce = (evt.payload as { nonce?: unknown } | undefined)?.nonce; + expect(typeof nonce).toBe("string"); + ws.close(); + }); + + test("rejects protocol mismatch", async () => { + const ws = await openWs(port); + try { + const res = await connectReq(ws, { + minProtocol: PROTOCOL_VERSION + 1, + maxProtocol: PROTOCOL_VERSION + 2, + }); + expect(res.ok).toBe(false); + } catch { + // If the server closed before we saw the frame, that's acceptable. + } + ws.close(); + }); + + test("rejects non-connect first request", async () => { + const ws = await openWs(port); + ws.send(JSON.stringify({ type: "req", id: "h1", method: "health" })); + const res = await onceMessage<{ ok: boolean; error?: unknown }>( + ws, + (o) => o.type === "res" && o.id === "h1", + ); + expect(res.ok).toBe(false); + await new Promise((resolve) => ws.once("close", () => resolve())); + }); + + test( + "invalid connect params surface in response and close reason", + { timeout: 60_000 }, + async () => { + const ws = await openWs(port); + const closeInfoPromise = new Promise<{ code: number; reason: string }>((resolve) => { + ws.once("close", (code, reason) => resolve({ code, reason: reason.toString() })); + }); + + ws.send( + JSON.stringify({ + type: "req", + id: "h-bad", + method: "connect", + params: { + minProtocol: PROTOCOL_VERSION, + maxProtocol: PROTOCOL_VERSION, + client: { + id: "bad-client", + version: "dev", + platform: "web", + mode: "webchat", + }, + device: { + id: 123, + publicKey: "bad", + signature: "bad", + signedAt: "bad", + }, + }, + }), + ); + + const res = await onceMessage<{ + ok: boolean; + error?: { message?: string }; + }>( + ws, + (o) => (o as { type?: string }).type === "res" && (o as { id?: string }).id === "h-bad", + ); + expect(res.ok).toBe(false); + expect(String(res.error?.message ?? "")).toContain("invalid connect params"); + + const closeInfo = await closeInfoPromise; + expect(closeInfo.code).toBe(1008); + expect(closeInfo.reason).toContain("invalid connect params"); + }, ); - await new Promise((resolve) => ws.once("open", resolve)); - const evt = await evtPromise; - const nonce = (evt.payload as { nonce?: unknown } | undefined)?.nonce; - expect(typeof nonce).toBe("string"); - ws.close(); - await server.close(); }); - test("rejects protocol mismatch", async () => { - const { server, ws } = await startServerWithClient(); - try { + describe("password auth", () => { + let server: Awaited>; + let port: number; + + beforeAll(async () => { + testState.gatewayAuth = { mode: "password", password: "secret" }; + port = await getFreePort(); + server = await startGatewayServer(port); + }); + + afterAll(async () => { + await server.close(); + }); + + test("accepts password auth when configured", async () => { + const ws = await openWs(port); + const res = await connectReq(ws, { password: "secret" }); + expect(res.ok).toBe(true); + ws.close(); + }); + + test("rejects invalid password", async () => { + const ws = await openWs(port); + const res = await connectReq(ws, { password: "wrong" }); + expect(res.ok).toBe(false); + expect(res.error?.message ?? "").toContain("unauthorized"); + ws.close(); + }); + }); + + describe("token auth", () => { + let server: Awaited>; + let port: number; + let prevToken: string | undefined; + + beforeAll(async () => { + prevToken = process.env.CLAWDBOT_GATEWAY_TOKEN; + process.env.CLAWDBOT_GATEWAY_TOKEN = "secret"; + port = await getFreePort(); + server = await startGatewayServer(port); + }); + + afterAll(async () => { + await server.close(); + if (prevToken === undefined) { + delete process.env.CLAWDBOT_GATEWAY_TOKEN; + } else { + process.env.CLAWDBOT_GATEWAY_TOKEN = prevToken; + } + }); + + test("rejects invalid token", async () => { + const ws = await openWs(port); + const res = await connectReq(ws, { token: "wrong" }); + expect(res.ok).toBe(false); + expect(res.error?.message ?? "").toContain("unauthorized"); + ws.close(); + }); + + test("rejects control ui without device identity by default", async () => { + const ws = await openWs(port); const res = await connectReq(ws, { - minProtocol: PROTOCOL_VERSION + 1, - maxProtocol: PROTOCOL_VERSION + 2, + token: "secret", + device: null, + client: { + id: GATEWAY_CLIENT_NAMES.CONTROL_UI, + version: "1.0.0", + platform: "web", + mode: GATEWAY_CLIENT_MODES.WEBCHAT, + }, }); expect(res.ok).toBe(false); - } catch { - // If the server closed before we saw the frame, that's acceptable. - } - ws.close(); - await server.close(); - }); - - test("rejects invalid token", async () => { - const { server, ws, prevToken } = await startServerWithClient("secret"); - const res = await connectReq(ws, { token: "wrong" }); - expect(res.ok).toBe(false); - expect(res.error?.message ?? "").toContain("unauthorized"); - ws.close(); - await server.close(); - if (prevToken === undefined) { - delete process.env.CLAWDBOT_GATEWAY_TOKEN; - } else { - process.env.CLAWDBOT_GATEWAY_TOKEN = prevToken; - } - }); - - test("accepts password auth when configured", async () => { - testState.gatewayAuth = { mode: "password", password: "secret" }; - const port = await getFreePort(); - const server = await startGatewayServer(port); - const ws = new WebSocket(`ws://127.0.0.1:${port}`); - await new Promise((resolve) => ws.once("open", resolve)); - - const res = await connectReq(ws, { password: "secret" }); - expect(res.ok).toBe(true); - - ws.close(); - await server.close(); - }); - - test("rejects control ui without device identity by default", async () => { - const { server, ws, prevToken } = await startServerWithClient("secret"); - const res = await connectReq(ws, { - token: "secret", - device: null, - client: { - id: GATEWAY_CLIENT_NAMES.CONTROL_UI, - version: "1.0.0", - platform: "web", - mode: GATEWAY_CLIENT_MODES.WEBCHAT, - }, + expect(res.error?.message ?? "").toContain("secure context"); + ws.close(); }); - expect(res.ok).toBe(false); - expect(res.error?.message ?? "").toContain("secure context"); - ws.close(); - await server.close(); - if (prevToken === undefined) { - delete process.env.CLAWDBOT_GATEWAY_TOKEN; - } else { - process.env.CLAWDBOT_GATEWAY_TOKEN = prevToken; - } }); test("allows control ui without device identity when insecure auth is enabled", async () => { @@ -327,81 +424,5 @@ describe("gateway server auth/connect", () => { } }); - test("rejects invalid password", async () => { - testState.gatewayAuth = { mode: "password", password: "secret" }; - const port = await getFreePort(); - const server = await startGatewayServer(port); - const ws = new WebSocket(`ws://127.0.0.1:${port}`); - await new Promise((resolve) => ws.once("open", resolve)); - - const res = await connectReq(ws, { password: "wrong" }); - expect(res.ok).toBe(false); - expect(res.error?.message ?? "").toContain("unauthorized"); - - ws.close(); - await server.close(); - }); - - test("rejects non-connect first request", async () => { - const { server, ws } = await startServerWithClient(); - ws.send(JSON.stringify({ type: "req", id: "h1", method: "health" })); - const res = await onceMessage<{ ok: boolean; error?: unknown }>( - ws, - (o) => o.type === "res" && o.id === "h1", - ); - expect(res.ok).toBe(false); - await new Promise((resolve) => ws.once("close", () => resolve())); - await server.close(); - }); - - test( - "invalid connect params surface in response and close reason", - { timeout: 60_000 }, - async () => { - const { server, ws } = await startServerWithClient(); - const closeInfoPromise = new Promise<{ code: number; reason: string }>((resolve) => { - ws.once("close", (code, reason) => resolve({ code, reason: reason.toString() })); - }); - - ws.send( - JSON.stringify({ - type: "req", - id: "h-bad", - method: "connect", - params: { - minProtocol: PROTOCOL_VERSION, - maxProtocol: PROTOCOL_VERSION, - client: { - id: "bad-client", - version: "dev", - platform: "web", - mode: "webchat", - }, - device: { - id: 123, - publicKey: "bad", - signature: "bad", - signedAt: "bad", - }, - }, - }), - ); - - const res = await onceMessage<{ - ok: boolean; - error?: { message?: string }; - }>( - ws, - (o) => (o as { type?: string }).type === "res" && (o as { id?: string }).id === "h-bad", - ); - expect(res.ok).toBe(false); - expect(String(res.error?.message ?? "")).toContain("invalid connect params"); - - const closeInfo = await closeInfoPromise; - expect(closeInfo.code).toBe(1008); - expect(closeInfo.reason).toContain("invalid connect params"); - - await server.close(); - }, - ); + // Remaining tests require isolated gateway state. }); diff --git a/src/gateway/server.cron.test.ts b/src/gateway/server.cron.test.ts index 2993dccb6..1c6ee8c7c 100644 --- a/src/gateway/server.cron.test.ts +++ b/src/gateway/server.cron.test.ts @@ -67,6 +67,8 @@ async function waitForNonEmptyFile(pathname: string, timeoutMs = 2000) { describe("gateway server cron", () => { test("handles cron CRUD, normalization, and patch semantics", { timeout: 120_000 }, async () => { + const prevSkipCron = process.env.CLAWDBOT_SKIP_CRON; + process.env.CLAWDBOT_SKIP_CRON = "0"; const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-cron-")); testState.cronStorePath = path.join(dir, "cron", "jobs.json"); testState.sessionConfig = { mainKey: "primary" }; @@ -270,10 +272,17 @@ describe("gateway server cron", () => { testState.cronStorePath = undefined; testState.sessionConfig = undefined; testState.cronEnabled = undefined; + if (prevSkipCron === undefined) { + delete process.env.CLAWDBOT_SKIP_CRON; + } else { + process.env.CLAWDBOT_SKIP_CRON = prevSkipCron; + } } }); test("writes cron run history and auto-runs due jobs", async () => { + const prevSkipCron = process.env.CLAWDBOT_SKIP_CRON; + process.env.CLAWDBOT_SKIP_CRON = "0"; const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-cron-log-")); testState.cronStorePath = path.join(dir, "cron", "jobs.json"); testState.cronEnabled = undefined; @@ -365,6 +374,11 @@ describe("gateway server cron", () => { await rmTempDir(dir); testState.cronStorePath = undefined; testState.cronEnabled = undefined; + if (prevSkipCron === undefined) { + delete process.env.CLAWDBOT_SKIP_CRON; + } else { + process.env.CLAWDBOT_SKIP_CRON = prevSkipCron; + } } }, 45_000); }); diff --git a/src/gateway/server.reload.test.ts b/src/gateway/server.reload.test.ts index 5a2ec0035..8fe8eece1 100644 --- a/src/gateway/server.reload.test.ts +++ b/src/gateway/server.reload.test.ts @@ -191,7 +191,7 @@ describe("gateway hot reload", () => { } }); - it("applies hot reload actions for providers + services", async () => { + it("applies hot reload actions and emits restart signal", async () => { const port = await getFreePort(); const server = await startGatewayServer(port); @@ -270,13 +270,6 @@ describe("gateway hot reload", () => { expect(hoisted.providerManager.stopChannel).toHaveBeenCalledWith("imessage"); expect(hoisted.providerManager.startChannel).toHaveBeenCalledWith("imessage"); - await server.close(); - }); - - it("emits SIGUSR1 on restart plan when listener exists", async () => { - const port = await getFreePort(); - const server = await startGatewayServer(port); - const onRestart = hoisted.getOnRestart(); expect(onRestart).toBeTypeOf("function"); diff --git a/src/gateway/test-helpers.mocks.ts b/src/gateway/test-helpers.mocks.ts index 993e64c32..46631ba09 100644 --- a/src/gateway/test-helpers.mocks.ts +++ b/src/gateway/test-helpers.mocks.ts @@ -554,3 +554,4 @@ vi.mock("../cli/deps.js", async () => { }); process.env.CLAWDBOT_SKIP_CHANNELS = "1"; +process.env.CLAWDBOT_SKIP_CRON = "1";