diff --git a/src/browser/profiles.test.ts b/src/browser/profiles.test.ts index 765bda58d52..4e985ffbee5 100644 --- a/src/browser/profiles.test.ts +++ b/src/browser/profiles.test.ts @@ -52,11 +52,6 @@ describe("profile name validation", () => { }); describe("port allocation", () => { - it("allocates first port when none used", () => { - const usedPorts = new Set(); - expect(allocateCdpPort(usedPorts)).toBe(CDP_PORT_RANGE_START); - }); - it("allocates within an explicit range", () => { const usedPorts = new Set(); expect(allocateCdpPort(usedPorts, { start: 20000, end: 20002 })).toBe(20000); @@ -64,17 +59,29 @@ describe("port allocation", () => { expect(allocateCdpPort(usedPorts, { start: 20000, end: 20002 })).toBe(20001); }); - it("skips used ports and returns next available", () => { - const usedPorts = new Set([CDP_PORT_RANGE_START, CDP_PORT_RANGE_START + 1]); - expect(allocateCdpPort(usedPorts)).toBe(CDP_PORT_RANGE_START + 2); - }); + it("allocates next available port from default range", () => { + const cases = [ + { name: "none used", used: new Set(), expected: CDP_PORT_RANGE_START }, + { + name: "sequentially used start ports", + used: new Set([CDP_PORT_RANGE_START, CDP_PORT_RANGE_START + 1]), + expected: CDP_PORT_RANGE_START + 2, + }, + { + name: "first gap wins", + used: new Set([CDP_PORT_RANGE_START, CDP_PORT_RANGE_START + 2]), + expected: CDP_PORT_RANGE_START + 1, + }, + { + name: "ignores outside-range ports", + used: new Set([1, 2, 3, 50000]), + expected: CDP_PORT_RANGE_START, + }, + ] as const; - it("finds first gap in used ports", () => { - const usedPorts = new Set([ - CDP_PORT_RANGE_START, - CDP_PORT_RANGE_START + 2, // gap at +1 - ]); - expect(allocateCdpPort(usedPorts)).toBe(CDP_PORT_RANGE_START + 1); + for (const testCase of cases) { + expect(allocateCdpPort(testCase.used), testCase.name).toBe(testCase.expected); + } }); it("returns null when all ports are exhausted", () => { @@ -84,11 +91,6 @@ describe("port allocation", () => { } expect(allocateCdpPort(usedPorts)).toBeNull(); }); - - it("handles ports outside range in used set", () => { - const usedPorts = new Set([1, 2, 3, 50000]); // ports outside range - expect(allocateCdpPort(usedPorts)).toBe(CDP_PORT_RANGE_START); - }); }); describe("getUsedPorts", () => { @@ -167,23 +169,27 @@ describe("port collision prevention", () => { }); describe("color allocation", () => { - it("allocates first color when none used", () => { - const usedColors = new Set(); - expect(allocateColor(usedColors)).toBe(PROFILE_COLORS[0]); - }); - it("allocates next unused color from palette", () => { - const usedColors = new Set([PROFILE_COLORS[0].toUpperCase()]); - expect(allocateColor(usedColors)).toBe(PROFILE_COLORS[1]); - }); - - it("skips multiple used colors", () => { - const usedColors = new Set([ - PROFILE_COLORS[0].toUpperCase(), - PROFILE_COLORS[1].toUpperCase(), - PROFILE_COLORS[2].toUpperCase(), - ]); - expect(allocateColor(usedColors)).toBe(PROFILE_COLORS[3]); + const cases = [ + { name: "none used", used: new Set(), expected: PROFILE_COLORS[0] }, + { + name: "first color used", + used: new Set([PROFILE_COLORS[0].toUpperCase()]), + expected: PROFILE_COLORS[1], + }, + { + name: "multiple used colors", + used: new Set([ + PROFILE_COLORS[0].toUpperCase(), + PROFILE_COLORS[1].toUpperCase(), + PROFILE_COLORS[2].toUpperCase(), + ]), + expected: PROFILE_COLORS[3], + }, + ] as const; + for (const testCase of cases) { + expect(allocateColor(testCase.used), testCase.name).toBe(testCase.expected); + } }); it("handles case-insensitive color matching", () => { @@ -215,7 +221,7 @@ describe("color allocation", () => { }); describe("getUsedColors", () => { - it("returns empty set for undefined profiles", () => { + it("returns empty set when no color profiles are configured", () => { expect(getUsedColors(undefined)).toEqual(new Set()); }); diff --git a/src/gateway/call.test.ts b/src/gateway/call.test.ts index 68464e4978d..aa18d6fd5d6 100644 --- a/src/gateway/call.test.ts +++ b/src/gateway/call.test.ts @@ -123,7 +123,7 @@ describe("callGateway url resolution", () => { label: "falls back to loopback when local bind is auto without tailnet IP", tailnetIp: undefined, }, - ])("$label", async ({ tailnetIp }) => { + ])("local auto-bind: $label", async ({ tailnetIp }) => { loadConfig.mockReturnValue({ gateway: { mode: "local", bind: "auto" } }); resolveGatewayPort.mockReturnValue(18800); pickPrimaryTailnetIPv4.mockReturnValue(tailnetIp); @@ -218,7 +218,7 @@ describe("callGateway url resolution", () => { call: () => callGatewayCli({ method: "health" }), expectedScopes: ["operator.admin", "operator.approvals", "operator.pairing"], }, - ])("$label", async ({ call, expectedScopes }) => { + ])("scope selection: $label", async ({ call, expectedScopes }) => { setLocalLoopbackGatewayConfig(); await call(); expect(lastClientOptions?.scopes).toEqual(expectedScopes); diff --git a/src/gateway/chat-attachments.test.ts b/src/gateway/chat-attachments.test.ts index 6b4c20310f2..de831449b80 100644 --- a/src/gateway/chat-attachments.test.ts +++ b/src/gateway/chat-attachments.test.ts @@ -32,33 +32,6 @@ describe("buildMessageWithAttachments", () => { }; expect(() => buildMessageWithAttachments("x", [bad])).toThrow(/image/); }); - - it("rejects invalid base64 content", () => { - const bad: ChatAttachment = { - type: "image", - mimeType: "image/png", - fileName: "dot.png", - content: "%not-base64%", - }; - expect(() => buildMessageWithAttachments("x", [bad])).toThrow(/base64/); - }); - - it("rejects images over limit", () => { - const big = "A".repeat(10_000); - const att: ChatAttachment = { - type: "image", - mimeType: "image/png", - fileName: "big.png", - content: big, - }; - const fromSpy = vi.spyOn(Buffer, "from"); - expect(() => buildMessageWithAttachments("x", [att], { maxBytes: 16 })).toThrow( - /exceeds size limit/i, - ); - const base64Calls = fromSpy.mock.calls.filter((args) => (args as unknown[])[1] === "base64"); - expect(base64Calls).toHaveLength(0); - fromSpy.mockRestore(); - }); }); describe("parseMessageWithAttachments", () => { @@ -80,45 +53,6 @@ describe("parseMessageWithAttachments", () => { expect(parsed.images[0]?.data).toBe(PNG_1x1); }); - it("rejects invalid base64 content", async () => { - await expect( - parseMessageWithAttachments( - "x", - [ - { - type: "image", - mimeType: "image/png", - fileName: "dot.png", - content: "%not-base64%", - }, - ], - { log: { warn: () => {} } }, - ), - ).rejects.toThrow(/base64/i); - }); - - it("rejects images over limit", async () => { - const big = "A".repeat(10_000); - const fromSpy = vi.spyOn(Buffer, "from"); - await expect( - parseMessageWithAttachments( - "x", - [ - { - type: "image", - mimeType: "image/png", - fileName: "big.png", - content: big, - }, - ], - { maxBytes: 16, log: { warn: () => {} } }, - ), - ).rejects.toThrow(/exceeds size limit/i); - const base64Calls = fromSpy.mock.calls.filter((args) => (args as unknown[])[1] === "base64"); - expect(base64Calls).toHaveLength(0); - fromSpy.mockRestore(); - }); - it("sniffs mime when missing", async () => { const logs: string[] = []; const parsed = await parseMessageWithAttachments( @@ -219,3 +153,43 @@ describe("parseMessageWithAttachments", () => { expect(logs.some((l) => /non-image/i.test(l))).toBe(true); }); }); + +describe("shared attachment validation", () => { + it("rejects invalid base64 content for both builder and parser", async () => { + const bad: ChatAttachment = { + type: "image", + mimeType: "image/png", + fileName: "dot.png", + content: "%not-base64%", + }; + + expect(() => buildMessageWithAttachments("x", [bad])).toThrow(/base64/i); + await expect( + parseMessageWithAttachments("x", [bad], { log: { warn: () => {} } }), + ).rejects.toThrow(/base64/i); + }); + + it("rejects images over limit for both builder and parser without decoding base64", async () => { + const big = "A".repeat(10_000); + const att: ChatAttachment = { + type: "image", + mimeType: "image/png", + fileName: "big.png", + content: big, + }; + + const fromSpy = vi.spyOn(Buffer, "from"); + try { + expect(() => buildMessageWithAttachments("x", [att], { maxBytes: 16 })).toThrow( + /exceeds size limit/i, + ); + await expect( + parseMessageWithAttachments("x", [att], { maxBytes: 16, log: { warn: () => {} } }), + ).rejects.toThrow(/exceeds size limit/i); + const base64Calls = fromSpy.mock.calls.filter((args) => (args as unknown[])[1] === "base64"); + expect(base64Calls).toHaveLength(0); + } finally { + fromSpy.mockRestore(); + } + }); +}); diff --git a/src/gateway/gateway.e2e.test.ts b/src/gateway/gateway.e2e.test.ts index c106027a1ab..4bbef286ee7 100644 --- a/src/gateway/gateway.e2e.test.ts +++ b/src/gateway/gateway.e2e.test.ts @@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it } from "vitest"; +import { beforeAll, describe, expect, it } from "vitest"; import { captureEnv } from "../test-utils/env.js"; import { startGatewayServer } from "./server.js"; import { extractPayloadText } from "./test-helpers.agent-results.js"; @@ -15,7 +15,14 @@ import { import { installOpenAiResponsesMock } from "./test-helpers.openai-mock.js"; import { buildOpenAiResponsesProviderConfig } from "./test-openai-responses-model.js"; +let writeConfigFile: typeof import("../config/config.js").writeConfigFile; +let resolveConfigPath: typeof import("../config/config.js").resolveConfigPath; + describe("gateway e2e", () => { + beforeAll(async () => { + ({ writeConfigFile, resolveConfigPath } = await import("../config/config.js")); + }); + it( "runs a mock OpenAI tool call end-to-end via gateway agent loop", { timeout: 90_000 }, @@ -148,7 +155,6 @@ describe("gateway e2e", () => { await prompter.intro("Wizard E2E"); await prompter.note("write token"); const token = await prompter.text({ message: "token" }); - const { writeConfigFile } = await import("../config/config.js"); await writeConfigFile({ gateway: { auth: { mode: "token", token: String(token) } }, }); @@ -196,7 +202,6 @@ describe("gateway e2e", () => { expect(didSendToken).toBe(true); expect(next.status).toBe("done"); - const { resolveConfigPath } = await import("../config/config.js"); const parsed = JSON.parse(await fs.readFile(resolveConfigPath(), "utf8")); const token = (parsed as Record)?.gateway as | Record diff --git a/src/gateway/net.test.ts b/src/gateway/net.test.ts index 8e1c1c70bcd..9575e431594 100644 --- a/src/gateway/net.test.ts +++ b/src/gateway/net.test.ts @@ -11,14 +11,16 @@ import { } from "./net.js"; describe("resolveHostName", () => { - it("returns hostname without port for IPv4/hostnames", () => { - expect(resolveHostName("localhost:18789")).toBe("localhost"); - expect(resolveHostName("127.0.0.1:18789")).toBe("127.0.0.1"); - }); - - it("handles bracketed and unbracketed IPv6 loopback hosts", () => { - expect(resolveHostName("[::1]:18789")).toBe("::1"); - expect(resolveHostName("::1")).toBe("::1"); + it("normalizes IPv4/hostname and IPv6 host forms", () => { + const cases = [ + { input: "localhost:18789", expected: "localhost" }, + { input: "127.0.0.1:18789", expected: "127.0.0.1" }, + { input: "[::1]:18789", expected: "::1" }, + { input: "::1", expected: "::1" }, + ] as const; + for (const testCase of cases) { + expect(resolveHostName(testCase.input), testCase.input).toBe(testCase.expected); + } }); }); @@ -204,27 +206,36 @@ describe("resolveClientIp", () => { }); describe("resolveGatewayListenHosts", () => { - it("returns the input host when not loopback", async () => { - const hosts = await resolveGatewayListenHosts("0.0.0.0", { - canBindToHost: async () => { - throw new Error("should not be called"); + it("resolves listen hosts for non-loopback and loopback variants", async () => { + const cases = [ + { + name: "non-loopback host passthrough", + host: "0.0.0.0", + canBindToHost: async () => { + throw new Error("should not be called"); + }, + expected: ["0.0.0.0"], }, - }); - expect(hosts).toEqual(["0.0.0.0"]); - }); + { + name: "loopback with IPv6 available", + host: "127.0.0.1", + canBindToHost: async () => true, + expected: ["127.0.0.1", "::1"], + }, + { + name: "loopback with IPv6 unavailable", + host: "127.0.0.1", + canBindToHost: async () => false, + expected: ["127.0.0.1"], + }, + ] as const; - it("adds ::1 when IPv6 loopback is available", async () => { - const hosts = await resolveGatewayListenHosts("127.0.0.1", { - canBindToHost: async () => true, - }); - expect(hosts).toEqual(["127.0.0.1", "::1"]); - }); - - it("keeps only IPv4 loopback when IPv6 is unavailable", async () => { - const hosts = await resolveGatewayListenHosts("127.0.0.1", { - canBindToHost: async () => false, - }); - expect(hosts).toEqual(["127.0.0.1"]); + for (const testCase of cases) { + const hosts = await resolveGatewayListenHosts(testCase.host, { + canBindToHost: testCase.canBindToHost, + }); + expect(hosts, testCase.name).toEqual(testCase.expected); + } }); }); @@ -233,49 +244,48 @@ describe("pickPrimaryLanIPv4", () => { vi.restoreAllMocks(); }); - it("returns en0 IPv4 address when available", () => { - vi.spyOn(os, "networkInterfaces").mockReturnValue({ - lo0: [ - { address: "127.0.0.1", family: "IPv4", internal: true, netmask: "" }, - ] as unknown as os.NetworkInterfaceInfo[], - en0: [ - { address: "192.168.1.42", family: "IPv4", internal: false, netmask: "" }, - ] as unknown as os.NetworkInterfaceInfo[], - }); - expect(pickPrimaryLanIPv4()).toBe("192.168.1.42"); - }); + it("prefers en0, then eth0, then any non-internal IPv4, otherwise undefined", () => { + const cases = [ + { + name: "prefers en0", + interfaces: { + lo0: [{ address: "127.0.0.1", family: "IPv4", internal: true, netmask: "" }], + en0: [{ address: "192.168.1.42", family: "IPv4", internal: false, netmask: "" }], + }, + expected: "192.168.1.42", + }, + { + name: "falls back to eth0", + interfaces: { + lo: [{ address: "127.0.0.1", family: "IPv4", internal: true, netmask: "" }], + eth0: [{ address: "10.0.0.5", family: "IPv4", internal: false, netmask: "" }], + }, + expected: "10.0.0.5", + }, + { + name: "falls back to any non-internal interface", + interfaces: { + lo: [{ address: "127.0.0.1", family: "IPv4", internal: true, netmask: "" }], + wlan0: [{ address: "172.16.0.99", family: "IPv4", internal: false, netmask: "" }], + }, + expected: "172.16.0.99", + }, + { + name: "no non-internal interface", + interfaces: { + lo: [{ address: "127.0.0.1", family: "IPv4", internal: true, netmask: "" }], + }, + expected: undefined, + }, + ] as const; - it("returns eth0 IPv4 address when en0 is absent", () => { - vi.spyOn(os, "networkInterfaces").mockReturnValue({ - lo: [ - { address: "127.0.0.1", family: "IPv4", internal: true, netmask: "" }, - ] as unknown as os.NetworkInterfaceInfo[], - eth0: [ - { address: "10.0.0.5", family: "IPv4", internal: false, netmask: "" }, - ] as unknown as os.NetworkInterfaceInfo[], - }); - expect(pickPrimaryLanIPv4()).toBe("10.0.0.5"); - }); - - it("falls back to any non-internal IPv4 interface", () => { - vi.spyOn(os, "networkInterfaces").mockReturnValue({ - lo: [ - { address: "127.0.0.1", family: "IPv4", internal: true, netmask: "" }, - ] as unknown as os.NetworkInterfaceInfo[], - wlan0: [ - { address: "172.16.0.99", family: "IPv4", internal: false, netmask: "" }, - ] as unknown as os.NetworkInterfaceInfo[], - }); - expect(pickPrimaryLanIPv4()).toBe("172.16.0.99"); - }); - - it("returns undefined when only internal interfaces exist", () => { - vi.spyOn(os, "networkInterfaces").mockReturnValue({ - lo: [ - { address: "127.0.0.1", family: "IPv4", internal: true, netmask: "" }, - ] as unknown as os.NetworkInterfaceInfo[], - }); - expect(pickPrimaryLanIPv4()).toBeUndefined(); + for (const testCase of cases) { + vi.spyOn(os, "networkInterfaces").mockReturnValue( + testCase.interfaces as unknown as ReturnType, + ); + expect(pickPrimaryLanIPv4(), testCase.name).toBe(testCase.expected); + vi.restoreAllMocks(); + } }); }); @@ -312,40 +322,28 @@ describe("isPrivateOrLoopbackAddress", () => { }); describe("isSecureWebSocketUrl", () => { - describe("wss:// (TLS) URLs", () => { - it("returns true for wss:// regardless of host", () => { - expect(isSecureWebSocketUrl("wss://127.0.0.1:18789")).toBe(true); - expect(isSecureWebSocketUrl("wss://localhost:18789")).toBe(true); - expect(isSecureWebSocketUrl("wss://remote.example.com:18789")).toBe(true); - expect(isSecureWebSocketUrl("wss://192.168.1.100:18789")).toBe(true); - }); - }); + it("accepts secure websocket/loopback ws URLs and rejects unsafe inputs", () => { + const cases = [ + { input: "wss://127.0.0.1:18789", expected: true }, + { input: "wss://localhost:18789", expected: true }, + { input: "wss://remote.example.com:18789", expected: true }, + { input: "wss://192.168.1.100:18789", expected: true }, + { input: "ws://127.0.0.1:18789", expected: true }, + { input: "ws://localhost:18789", expected: true }, + { input: "ws://[::1]:18789", expected: true }, + { input: "ws://127.0.0.42:18789", expected: true }, + { input: "ws://remote.example.com:18789", expected: false }, + { input: "ws://192.168.1.100:18789", expected: false }, + { input: "ws://10.0.0.5:18789", expected: false }, + { input: "ws://100.64.0.1:18789", expected: false }, + { input: "not-a-url", expected: false }, + { input: "", expected: false }, + { input: "http://127.0.0.1:18789", expected: false }, + { input: "https://127.0.0.1:18789", expected: false }, + ] as const; - describe("ws:// (plaintext) URLs", () => { - it("returns true for ws:// to loopback addresses", () => { - expect(isSecureWebSocketUrl("ws://127.0.0.1:18789")).toBe(true); - expect(isSecureWebSocketUrl("ws://localhost:18789")).toBe(true); - expect(isSecureWebSocketUrl("ws://[::1]:18789")).toBe(true); - expect(isSecureWebSocketUrl("ws://127.0.0.42:18789")).toBe(true); - }); - - it("returns false for ws:// to non-loopback addresses (CWE-319)", () => { - expect(isSecureWebSocketUrl("ws://remote.example.com:18789")).toBe(false); - expect(isSecureWebSocketUrl("ws://192.168.1.100:18789")).toBe(false); - expect(isSecureWebSocketUrl("ws://10.0.0.5:18789")).toBe(false); - expect(isSecureWebSocketUrl("ws://100.64.0.1:18789")).toBe(false); - }); - }); - - describe("invalid URLs", () => { - it("returns false for invalid URLs", () => { - expect(isSecureWebSocketUrl("not-a-url")).toBe(false); - expect(isSecureWebSocketUrl("")).toBe(false); - }); - - it("returns false for non-WebSocket protocols", () => { - expect(isSecureWebSocketUrl("http://127.0.0.1:18789")).toBe(false); - expect(isSecureWebSocketUrl("https://127.0.0.1:18789")).toBe(false); - }); + for (const testCase of cases) { + expect(isSecureWebSocketUrl(testCase.input), testCase.input).toBe(testCase.expected); + } }); }); diff --git a/src/gateway/openai-http.e2e.test.ts b/src/gateway/openai-http.e2e.test.ts index dea8472a746..7e5ebd2b39c 100644 --- a/src/gateway/openai-http.e2e.test.ts +++ b/src/gateway/openai-http.e2e.test.ts @@ -13,10 +13,12 @@ import { installGatewayTestHooks({ scope: "suite" }); +let startGatewayServer: typeof import("./server.js").startGatewayServer; let enabledServer: Awaited>; let enabledPort: number; beforeAll(async () => { + ({ startGatewayServer } = await import("./server.js")); enabledPort = await getFreePort(); enabledServer = await startServer(enabledPort); }); @@ -26,7 +28,6 @@ afterAll(async () => { }); async function startServerWithDefaultConfig(port: number) { - const { startGatewayServer } = await import("./server.js"); return await startGatewayServer(port, { host: "127.0.0.1", auth: { mode: "token", token: "secret" }, @@ -36,7 +37,6 @@ async function startServerWithDefaultConfig(port: number) { } async function startServer(port: number, opts?: { openAiChatCompletionsEnabled?: boolean }) { - const { startGatewayServer } = await import("./server.js"); return await startGatewayServer(port, { host: "127.0.0.1", auth: { mode: "token", token: "secret" }, diff --git a/src/gateway/openresponses-parity.e2e.test.ts b/src/gateway/openresponses-parity.e2e.test.ts index 278855b8743..1f4212ab0a6 100644 --- a/src/gateway/openresponses-parity.e2e.test.ts +++ b/src/gateway/openresponses-parity.e2e.test.ts @@ -5,13 +5,29 @@ * support in the OpenResponses `/v1/responses` endpoint. */ -import { describe, it, expect } from "vitest"; +import { beforeAll, describe, it, expect } from "vitest"; + +let InputImageContentPartSchema: typeof import("./open-responses.schema.js").InputImageContentPartSchema; +let InputFileContentPartSchema: typeof import("./open-responses.schema.js").InputFileContentPartSchema; +let ToolDefinitionSchema: typeof import("./open-responses.schema.js").ToolDefinitionSchema; +let CreateResponseBodySchema: typeof import("./open-responses.schema.js").CreateResponseBodySchema; +let OutputItemSchema: typeof import("./open-responses.schema.js").OutputItemSchema; +let buildAgentPrompt: typeof import("./openresponses-http.js").buildAgentPrompt; describe("OpenResponses Feature Parity", () => { + beforeAll(async () => { + ({ + InputImageContentPartSchema, + InputFileContentPartSchema, + ToolDefinitionSchema, + CreateResponseBodySchema, + OutputItemSchema, + } = await import("./open-responses.schema.js")); + ({ buildAgentPrompt } = await import("./openresponses-http.js")); + }); + describe("Schema Validation", () => { it("should validate input_image with url source", async () => { - const { InputImageContentPartSchema } = await import("./open-responses.schema.js"); - const validImage = { type: "input_image" as const, source: { @@ -25,8 +41,6 @@ describe("OpenResponses Feature Parity", () => { }); it("should validate input_image with base64 source", async () => { - const { InputImageContentPartSchema } = await import("./open-responses.schema.js"); - const validImage = { type: "input_image" as const, source: { @@ -41,8 +55,6 @@ describe("OpenResponses Feature Parity", () => { }); it("should reject input_image with invalid mime type", async () => { - const { InputImageContentPartSchema } = await import("./open-responses.schema.js"); - const invalidImage = { type: "input_image" as const, source: { @@ -57,8 +69,6 @@ describe("OpenResponses Feature Parity", () => { }); it("should validate input_file with url source", async () => { - const { InputFileContentPartSchema } = await import("./open-responses.schema.js"); - const validFile = { type: "input_file" as const, source: { @@ -72,8 +82,6 @@ describe("OpenResponses Feature Parity", () => { }); it("should validate input_file with base64 source", async () => { - const { InputFileContentPartSchema } = await import("./open-responses.schema.js"); - const validFile = { type: "input_file" as const, source: { @@ -89,8 +97,6 @@ describe("OpenResponses Feature Parity", () => { }); it("should validate tool definition", async () => { - const { ToolDefinitionSchema } = await import("./open-responses.schema.js"); - const validTool = { type: "function" as const, function: { @@ -111,8 +117,6 @@ describe("OpenResponses Feature Parity", () => { }); it("should reject tool definition without name", async () => { - const { ToolDefinitionSchema } = await import("./open-responses.schema.js"); - const invalidTool = { type: "function" as const, function: { @@ -128,8 +132,6 @@ describe("OpenResponses Feature Parity", () => { describe("CreateResponseBody Schema", () => { it("should validate request with input_image", async () => { - const { CreateResponseBodySchema } = await import("./open-responses.schema.js"); - const validRequest = { model: "claude-sonnet-4-20250514", input: [ @@ -158,8 +160,6 @@ describe("OpenResponses Feature Parity", () => { }); it("should validate request with client tools", async () => { - const { CreateResponseBodySchema } = await import("./open-responses.schema.js"); - const validRequest = { model: "claude-sonnet-4-20250514", input: [ @@ -192,8 +192,6 @@ describe("OpenResponses Feature Parity", () => { }); it("should validate request with function_call_output for turn-based tools", async () => { - const { CreateResponseBodySchema } = await import("./open-responses.schema.js"); - const validRequest = { model: "claude-sonnet-4-20250514", input: [ @@ -210,8 +208,6 @@ describe("OpenResponses Feature Parity", () => { }); it("should validate complete turn-based tool flow", async () => { - const { CreateResponseBodySchema } = await import("./open-responses.schema.js"); - const turn1Request = { model: "claude-sonnet-4-20250514", input: [ @@ -254,8 +250,6 @@ describe("OpenResponses Feature Parity", () => { describe("Response Resource Schema", () => { it("should validate response with function_call output", async () => { - const { OutputItemSchema } = await import("./open-responses.schema.js"); - const functionCallOutput = { type: "function_call" as const, id: "msg_123", @@ -271,8 +265,6 @@ describe("OpenResponses Feature Parity", () => { describe("buildAgentPrompt", () => { it("should convert function_call_output to tool entry", async () => { - const { buildAgentPrompt } = await import("./openresponses-http.js"); - const result = buildAgentPrompt([ { type: "function_call_output" as const, @@ -286,8 +278,6 @@ describe("OpenResponses Feature Parity", () => { }); it("should handle mixed message and function_call_output items", async () => { - const { buildAgentPrompt } = await import("./openresponses-http.js"); - const result = buildAgentPrompt([ { type: "message" as const, diff --git a/src/gateway/server.channels.e2e.test.ts b/src/gateway/server.channels.e2e.test.ts index c8a05c86764..c6976493bda 100644 --- a/src/gateway/server.channels.e2e.test.ts +++ b/src/gateway/server.channels.e2e.test.ts @@ -11,7 +11,8 @@ import { startServerWithClient, } from "./test-helpers.js"; -const loadConfigHelpers = async () => await import("../config/config.js"); +let readConfigFileSnapshot: typeof import("../config/config.js").readConfigFileSnapshot; +let writeConfigFile: typeof import("../config/config.js").writeConfigFile; installGatewayTestHooks({ scope: "suite" }); @@ -77,7 +78,6 @@ const telegramPlugin: ChannelPlugin = { }), gateway: { logoutAccount: async ({ cfg }) => { - const { writeConfigFile } = await import("../config/config.js"); const nextTelegram = cfg.channels?.telegram ? { ...cfg.channels.telegram } : {}; delete nextTelegram.botToken; await writeConfigFile({ @@ -118,6 +118,7 @@ let server: Awaited>["server"]; let ws: Awaited>["ws"]; beforeAll(async () => { + ({ readConfigFileSnapshot, writeConfigFile } = await import("../config/config.js")); setRegistry(defaultRegistry); const started = await startServerWithClient(); server = started.server; @@ -177,7 +178,6 @@ describe("gateway server channels", () => { test("channels.logout clears telegram bot token from config", async () => { vi.stubEnv("TELEGRAM_BOT_TOKEN", undefined); setRegistry(defaultRegistry); - const { readConfigFileSnapshot, writeConfigFile } = await loadConfigHelpers(); await writeConfigFile({ channels: { telegram: { diff --git a/src/gateway/server.talk-config.e2e.test.ts b/src/gateway/server.talk-config.e2e.test.ts index 83aa25d725e..38095c19af5 100644 --- a/src/gateway/server.talk-config.e2e.test.ts +++ b/src/gateway/server.talk-config.e2e.test.ts @@ -4,6 +4,36 @@ import { withServer } from "./test-with-server.js"; installGatewayTestHooks({ scope: "suite" }); +async function createFreshOperatorDevice(scopes: string[]) { + const { randomUUID } = await import("node:crypto"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + const { buildDeviceAuthPayload } = await import("./device-auth.js"); + const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, signDevicePayload } = + await import("../infra/device-identity.js"); + + const identity = loadOrCreateDeviceIdentity( + join(tmpdir(), `openclaw-talk-config-${randomUUID()}.json`), + ); + const signedAtMs = Date.now(); + const payload = buildDeviceAuthPayload({ + deviceId: identity.deviceId, + clientId: "test", + clientMode: "test", + role: "operator", + scopes, + signedAtMs, + token: "secret", + }); + + return { + id: identity.deviceId, + publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem), + signature: signDevicePayload(identity.privateKeyPem, payload), + signedAt: signedAtMs, + }; +} + describe("gateway talk.config", () => { it("returns redacted talk config for read scope", async () => { const { writeConfigFile } = await import("../config/config.js"); @@ -21,7 +51,11 @@ describe("gateway talk.config", () => { }); await withServer(async (ws) => { - await connectOk(ws, { token: "secret", scopes: ["operator.read"] }); + await connectOk(ws, { + token: "secret", + scopes: ["operator.read"], + device: await createFreshOperatorDevice(["operator.read"]), + }); const res = await rpcReq<{ config?: { talk?: { apiKey?: string; voiceId?: string } } }>( ws, "talk.config", @@ -42,7 +76,11 @@ describe("gateway talk.config", () => { }); await withServer(async (ws) => { - await connectOk(ws, { token: "secret", scopes: ["operator.read"] }); + await connectOk(ws, { + token: "secret", + scopes: ["operator.read"], + device: await createFreshOperatorDevice(["operator.read"]), + }); const res = await rpcReq(ws, "talk.config", { includeSecrets: true }); expect(res.ok).toBe(false); expect(res.error?.message).toContain("missing scope: operator.talk.secrets"); @@ -61,6 +99,11 @@ describe("gateway talk.config", () => { await connectOk(ws, { token: "secret", scopes: ["operator.read", "operator.write", "operator.talk.secrets"], + device: await createFreshOperatorDevice([ + "operator.read", + "operator.write", + "operator.talk.secrets", + ]), }); const res = await rpcReq<{ config?: { talk?: { apiKey?: string } } }>(ws, "talk.config", { includeSecrets: true, diff --git a/src/gateway/session-utils.fs.test.ts b/src/gateway/session-utils.fs.test.ts index 554f79b4842..27386fd731f 100644 --- a/src/gateway/session-utils.fs.test.ts +++ b/src/gateway/session-utils.fs.test.ts @@ -38,59 +38,51 @@ describe("readFirstUserMessageFromTranscript", () => { storePath = nextStorePath; }); - test("returns null when transcript file does not exist", () => { - const result = readFirstUserMessageFromTranscript("nonexistent-session", storePath); - expect(result).toBeNull(); - }); + test("extracts first user text across supported content formats", () => { + const cases = [ + { + sessionId: "test-session-1", + lines: [ + JSON.stringify({ type: "session", version: 1, id: "test-session-1" }), + JSON.stringify({ message: { role: "user", content: "Hello world" } }), + JSON.stringify({ message: { role: "assistant", content: "Hi there" } }), + ], + expected: "Hello world", + }, + { + sessionId: "test-session-2", + lines: [ + JSON.stringify({ type: "session", version: 1, id: "test-session-2" }), + JSON.stringify({ + message: { + role: "user", + content: [{ type: "text", text: "Array message content" }], + }, + }), + ], + expected: "Array message content", + }, + { + sessionId: "test-session-2b", + lines: [ + JSON.stringify({ type: "session", version: 1, id: "test-session-2b" }), + JSON.stringify({ + message: { + role: "user", + content: [{ type: "input_text", text: "Input text content" }], + }, + }), + ], + expected: "Input text content", + }, + ] as const; - test("returns first user message from transcript with string content", () => { - const sessionId = "test-session-1"; - const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); - const lines = [ - JSON.stringify({ type: "session", version: 1, id: sessionId }), - JSON.stringify({ message: { role: "user", content: "Hello world" } }), - JSON.stringify({ message: { role: "assistant", content: "Hi there" } }), - ]; - fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); - - const result = readFirstUserMessageFromTranscript(sessionId, storePath); - expect(result).toBe("Hello world"); - }); - - test("returns first user message from transcript with array content", () => { - const sessionId = "test-session-2"; - const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); - const lines = [ - JSON.stringify({ type: "session", version: 1, id: sessionId }), - JSON.stringify({ - message: { - role: "user", - content: [{ type: "text", text: "Array message content" }], - }, - }), - ]; - fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); - - const result = readFirstUserMessageFromTranscript(sessionId, storePath); - expect(result).toBe("Array message content"); - }); - - test("returns first user message from transcript with input_text content", () => { - const sessionId = "test-session-2b"; - const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); - const lines = [ - JSON.stringify({ type: "session", version: 1, id: sessionId }), - JSON.stringify({ - message: { - role: "user", - content: [{ type: "input_text", text: "Input text content" }], - }, - }), - ]; - fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); - - const result = readFirstUserMessageFromTranscript(sessionId, storePath); - expect(result).toBe("Input text content"); + for (const testCase of cases) { + const transcriptPath = path.join(tmpDir, `${testCase.sessionId}.jsonl`); + fs.writeFileSync(transcriptPath, testCase.lines.join("\n"), "utf-8"); + const result = readFirstUserMessageFromTranscript(testCase.sessionId, storePath); + expect(result, testCase.sessionId).toBe(testCase.expected); + } }); test("skips non-user messages to find first user message", () => { const sessionId = "test-session-3"; @@ -155,29 +147,6 @@ describe("readFirstUserMessageFromTranscript", () => { expect(result).toBe("Valid message"); }); - test("uses sessionFile parameter when provided", () => { - const sessionId = "test-session-6"; - const customPath = path.join(tmpDir, "custom-transcript.jsonl"); - const lines = [ - JSON.stringify({ type: "session", version: 1, id: sessionId }), - JSON.stringify({ message: { role: "user", content: "Custom file message" } }), - ]; - fs.writeFileSync(customPath, lines.join("\n"), "utf-8"); - - const result = readFirstUserMessageFromTranscript(sessionId, storePath, customPath); - expect(result).toBe("Custom file message"); - }); - - test("trims whitespace from message content", () => { - const sessionId = "test-session-7"; - const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); - const lines = [JSON.stringify({ message: { role: "user", content: " Padded message " } })]; - fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); - - const result = readFirstUserMessageFromTranscript(sessionId, storePath); - expect(result).toBe("Padded message"); - }); - test("returns null for empty content", () => { const sessionId = "test-session-8"; const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); @@ -201,11 +170,6 @@ describe("readLastMessagePreviewFromTranscript", () => { storePath = nextStorePath; }); - test("returns null when transcript file does not exist", () => { - const result = readLastMessagePreviewFromTranscript("nonexistent-session", storePath); - expect(result).toBeNull(); - }); - test("returns null for empty file", () => { const sessionId = "test-last-empty"; const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); @@ -215,31 +179,33 @@ describe("readLastMessagePreviewFromTranscript", () => { expect(result).toBeNull(); }); - test("returns last user message from transcript", () => { - const sessionId = "test-last-user"; - const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); - const lines = [ - JSON.stringify({ message: { role: "user", content: "First user" } }), - JSON.stringify({ message: { role: "assistant", content: "First assistant" } }), - JSON.stringify({ message: { role: "user", content: "Last user message" } }), - ]; - fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); + test("returns the last user or assistant message from transcript", () => { + const cases = [ + { + sessionId: "test-last-user", + lines: [ + JSON.stringify({ message: { role: "user", content: "First user" } }), + JSON.stringify({ message: { role: "assistant", content: "First assistant" } }), + JSON.stringify({ message: { role: "user", content: "Last user message" } }), + ], + expected: "Last user message", + }, + { + sessionId: "test-last-assistant", + lines: [ + JSON.stringify({ message: { role: "user", content: "User question" } }), + JSON.stringify({ message: { role: "assistant", content: "Final assistant reply" } }), + ], + expected: "Final assistant reply", + }, + ] as const; - const result = readLastMessagePreviewFromTranscript(sessionId, storePath); - expect(result).toBe("Last user message"); - }); - - test("returns last assistant message from transcript", () => { - const sessionId = "test-last-assistant"; - const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); - const lines = [ - JSON.stringify({ message: { role: "user", content: "User question" } }), - JSON.stringify({ message: { role: "assistant", content: "Final assistant reply" } }), - ]; - fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); - - const result = readLastMessagePreviewFromTranscript(sessionId, storePath); - expect(result).toBe("Final assistant reply"); + for (const testCase of cases) { + const transcriptPath = path.join(tmpDir, `${testCase.sessionId}.jsonl`); + fs.writeFileSync(transcriptPath, testCase.lines.join("\n"), "utf-8"); + const result = readLastMessagePreviewFromTranscript(testCase.sessionId, storePath); + expect(result).toBe(testCase.expected); + } }); test("skips system messages to find last user/assistant", () => { @@ -268,7 +234,7 @@ describe("readLastMessagePreviewFromTranscript", () => { expect(result).toBeNull(); }); - test("handles malformed JSON lines gracefully", () => { + test("handles malformed JSON lines gracefully (last preview)", () => { const sessionId = "test-last-malformed"; const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); const lines = [ @@ -281,59 +247,31 @@ describe("readLastMessagePreviewFromTranscript", () => { expect(result).toBe("Valid first"); }); - test("handles array content format", () => { - const sessionId = "test-last-array"; - const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); - const lines = [ - JSON.stringify({ + test("handles array/output_text content formats", () => { + const cases = [ + { + sessionId: "test-last-array", message: { role: "assistant", content: [{ type: "text", text: "Array content response" }], }, - }), - ]; - fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); - - const result = readLastMessagePreviewFromTranscript(sessionId, storePath); - expect(result).toBe("Array content response"); - }); - - test("handles output_text content format", () => { - const sessionId = "test-last-output-text"; - const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); - const lines = [ - JSON.stringify({ + expected: "Array content response", + }, + { + sessionId: "test-last-output-text", message: { role: "assistant", content: [{ type: "output_text", text: "Output text response" }], }, - }), - ]; - fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); - - const result = readLastMessagePreviewFromTranscript(sessionId, storePath); - expect(result).toBe("Output text response"); - }); - test("uses sessionFile parameter when provided", () => { - const sessionId = "test-last-custom"; - const customPath = path.join(tmpDir, "custom-last.jsonl"); - const lines = [JSON.stringify({ message: { role: "user", content: "Custom file last" } })]; - fs.writeFileSync(customPath, lines.join("\n"), "utf-8"); - - const result = readLastMessagePreviewFromTranscript(sessionId, storePath, customPath); - expect(result).toBe("Custom file last"); - }); - - test("trims whitespace from message content", () => { - const sessionId = "test-last-trim"; - const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); - const lines = [ - JSON.stringify({ message: { role: "assistant", content: " Padded response " } }), - ]; - fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); - - const result = readLastMessagePreviewFromTranscript(sessionId, storePath); - expect(result).toBe("Padded response"); + expected: "Output text response", + }, + ] as const; + for (const testCase of cases) { + const transcriptPath = path.join(tmpDir, `${testCase.sessionId}.jsonl`); + fs.writeFileSync(transcriptPath, JSON.stringify({ message: testCase.message }), "utf-8"); + const result = readLastMessagePreviewFromTranscript(testCase.sessionId, storePath); + expect(result, testCase.sessionId).toBe(testCase.expected); + } }); test("skips empty content to find previous message", () => { @@ -394,6 +332,67 @@ describe("readLastMessagePreviewFromTranscript", () => { }); }); +describe("shared transcript read behaviors", () => { + let tmpDir: string; + let storePath: string; + + registerTempSessionStore("openclaw-session-fs-test-", (nextTmpDir, nextStorePath) => { + tmpDir = nextTmpDir; + storePath = nextStorePath; + }); + + test("returns null for missing transcript files", () => { + expect(readFirstUserMessageFromTranscript("missing-session", storePath)).toBeNull(); + expect(readLastMessagePreviewFromTranscript("missing-session", storePath)).toBeNull(); + }); + + test("uses sessionFile overrides when provided", () => { + const sessionId = "test-shared-custom"; + const firstPath = path.join(tmpDir, "custom-first.jsonl"); + const lastPath = path.join(tmpDir, "custom-last.jsonl"); + + fs.writeFileSync( + firstPath, + [ + JSON.stringify({ type: "session", version: 1, id: sessionId }), + JSON.stringify({ message: { role: "user", content: "Custom file message" } }), + ].join("\n"), + "utf-8", + ); + fs.writeFileSync( + lastPath, + JSON.stringify({ message: { role: "assistant", content: "Custom file last" } }), + "utf-8", + ); + + expect(readFirstUserMessageFromTranscript(sessionId, storePath, firstPath)).toBe( + "Custom file message", + ); + expect(readLastMessagePreviewFromTranscript(sessionId, storePath, lastPath)).toBe( + "Custom file last", + ); + }); + + test("trims whitespace in extracted previews", () => { + const firstSessionId = "test-shared-first-trim"; + const lastSessionId = "test-shared-last-trim"; + + fs.writeFileSync( + path.join(tmpDir, `${firstSessionId}.jsonl`), + JSON.stringify({ message: { role: "user", content: " Padded message " } }), + "utf-8", + ); + fs.writeFileSync( + path.join(tmpDir, `${lastSessionId}.jsonl`), + JSON.stringify({ message: { role: "assistant", content: " Padded response " } }), + "utf-8", + ); + + expect(readFirstUserMessageFromTranscript(firstSessionId, storePath)).toBe("Padded message"); + expect(readLastMessagePreviewFromTranscript(lastSessionId, storePath)).toBe("Padded response"); + }); +}); + describe("readSessionTitleFieldsFromTranscript cache", () => { let tmpDir: string; let storePath: string; @@ -496,56 +495,53 @@ describe("readSessionMessages", () => { expect(typeof marker.timestamp).toBe("number"); }); - test("reads cross-agent absolute sessionFile when storePath points to another agent dir", () => { - const sessionId = "cross-agent-default-root"; - const sessionFile = path.join(tmpDir, "agents", "ops", "sessions", `${sessionId}.jsonl`); - fs.mkdirSync(path.dirname(sessionFile), { recursive: true }); - fs.writeFileSync( - sessionFile, - [ - JSON.stringify({ type: "session", version: 1, id: sessionId }), - JSON.stringify({ message: { role: "user", content: "from-ops" } }), - ].join("\n"), - "utf-8", - ); + test("reads cross-agent absolute sessionFile across store-root layouts", () => { + const cases = [ + { + sessionId: "cross-agent-default-root", + sessionFile: path.join( + tmpDir, + "agents", + "ops", + "sessions", + "cross-agent-default-root.jsonl", + ), + wrongStorePath: path.join(tmpDir, "agents", "main", "sessions", "sessions.json"), + message: { role: "user", content: "from-ops" }, + }, + { + sessionId: "cross-agent-custom-root", + sessionFile: path.join( + tmpDir, + "custom", + "agents", + "ops", + "sessions", + "cross-agent-custom-root.jsonl", + ), + wrongStorePath: path.join(tmpDir, "custom", "agents", "main", "sessions", "sessions.json"), + message: { role: "assistant", content: "from-custom-ops" }, + }, + ] as const; - const wrongStorePath = path.join(tmpDir, "agents", "main", "sessions", "sessions.json"); - const out = readSessionMessages(sessionId, wrongStorePath, sessionFile); + for (const testCase of cases) { + fs.mkdirSync(path.dirname(testCase.sessionFile), { recursive: true }); + fs.writeFileSync( + testCase.sessionFile, + [ + JSON.stringify({ type: "session", version: 1, id: testCase.sessionId }), + JSON.stringify({ message: testCase.message }), + ].join("\n"), + "utf-8", + ); - expect(out).toEqual([{ role: "user", content: "from-ops" }]); - }); - - test("reads cross-agent absolute sessionFile for custom per-agent store roots", () => { - const sessionId = "cross-agent-custom-root"; - const sessionFile = path.join( - tmpDir, - "custom", - "agents", - "ops", - "sessions", - `${sessionId}.jsonl`, - ); - fs.mkdirSync(path.dirname(sessionFile), { recursive: true }); - fs.writeFileSync( - sessionFile, - [ - JSON.stringify({ type: "session", version: 1, id: sessionId }), - JSON.stringify({ message: { role: "assistant", content: "from-custom-ops" } }), - ].join("\n"), - "utf-8", - ); - - const wrongStorePath = path.join( - tmpDir, - "custom", - "agents", - "main", - "sessions", - "sessions.json", - ); - const out = readSessionMessages(sessionId, wrongStorePath, sessionFile); - - expect(out).toEqual([{ role: "assistant", content: "from-custom-ops" }]); + const out = readSessionMessages( + testCase.sessionId, + testCase.wrongStorePath, + testCase.sessionFile, + ); + expect(out).toEqual([testCase.message]); + } }); }); @@ -660,20 +656,28 @@ describe("resolveSessionTranscriptCandidates", () => { }); describe("resolveSessionTranscriptCandidates safety", () => { - test("keeps cross-agent absolute sessionFile when storePath agent context differs", () => { - const storePath = "/tmp/openclaw/agents/main/sessions/sessions.json"; - const sessionFile = "/tmp/openclaw/agents/ops/sessions/sess-safe.jsonl"; - const candidates = resolveSessionTranscriptCandidates("sess-safe", storePath, sessionFile); + test("keeps cross-agent absolute sessionFile for standard and custom store roots", () => { + const cases = [ + { + storePath: "/tmp/openclaw/agents/main/sessions/sessions.json", + sessionFile: "/tmp/openclaw/agents/ops/sessions/sess-safe.jsonl", + }, + { + storePath: "/srv/custom/agents/main/sessions/sessions.json", + sessionFile: "/srv/custom/agents/ops/sessions/sess-safe.jsonl", + }, + ] as const; - expect(candidates.map((value) => path.resolve(value))).toContain(path.resolve(sessionFile)); - }); - - test("keeps cross-agent absolute sessionFile for custom per-agent store roots", () => { - const storePath = "/srv/custom/agents/main/sessions/sessions.json"; - const sessionFile = "/srv/custom/agents/ops/sessions/sess-safe.jsonl"; - const candidates = resolveSessionTranscriptCandidates("sess-safe", storePath, sessionFile); - - expect(candidates.map((value) => path.resolve(value))).toContain(path.resolve(sessionFile)); + for (const testCase of cases) { + const candidates = resolveSessionTranscriptCandidates( + "sess-safe", + testCase.storePath, + testCase.sessionFile, + ); + expect(candidates.map((value) => path.resolve(value))).toContain( + path.resolve(testCase.sessionFile), + ); + } }); test("drops unsafe session IDs instead of producing traversal paths", () => { @@ -717,38 +721,33 @@ describe("archiveSessionTranscripts", () => { vi.unstubAllEnvs(); }); - test("archives existing transcript file and returns archived path", () => { - const sessionId = "sess-archive-1"; - const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); - fs.writeFileSync(transcriptPath, '{"type":"session"}\n', "utf-8"); + test("archives transcript from default and explicit sessionFile paths", () => { + const cases = [ + { + sessionId: "sess-archive-1", + transcriptPath: path.join(tmpDir, "sess-archive-1.jsonl"), + args: { sessionId: "sess-archive-1", storePath, reason: "reset" as const }, + }, + { + sessionId: "sess-archive-2", + transcriptPath: path.join(tmpDir, "custom-transcript.jsonl"), + args: { + sessionId: "sess-archive-2", + storePath: undefined, + sessionFile: path.join(tmpDir, "custom-transcript.jsonl"), + reason: "reset" as const, + }, + }, + ] as const; - const archived = archiveSessionTranscripts({ - sessionId, - storePath, - reason: "reset", - }); - - expect(archived).toHaveLength(1); - expect(archived[0]).toContain(".reset."); - expect(fs.existsSync(transcriptPath)).toBe(false); - expect(fs.existsSync(archived[0])).toBe(true); - }); - - test("archives transcript found via explicit sessionFile path", () => { - const sessionId = "sess-archive-2"; - const customPath = path.join(tmpDir, "custom-transcript.jsonl"); - fs.writeFileSync(customPath, '{"type":"session"}\n', "utf-8"); - - const archived = archiveSessionTranscripts({ - sessionId, - storePath: undefined, - sessionFile: customPath, - reason: "reset", - }); - - expect(archived).toHaveLength(1); - expect(fs.existsSync(customPath)).toBe(false); - expect(fs.existsSync(archived[0])).toBe(true); + for (const testCase of cases) { + fs.writeFileSync(testCase.transcriptPath, '{"type":"session"}\n', "utf-8"); + const archived = archiveSessionTranscripts(testCase.args); + expect(archived).toHaveLength(1); + expect(archived[0]).toContain(".reset."); + expect(fs.existsSync(testCase.transcriptPath)).toBe(false); + expect(fs.existsSync(archived[0])).toBe(true); + } }); test("returns empty array when no transcript files exist", () => { diff --git a/src/gateway/session-utils.test.ts b/src/gateway/session-utils.test.ts index 4da01bdb8b5..283acaf0ea0 100644 --- a/src/gateway/session-utils.test.ts +++ b/src/gateway/session-utils.test.ts @@ -382,120 +382,45 @@ describe("listSessionsFromStore search", () => { } as SessionEntry, }); - test("returns all sessions when search is empty", () => { - const store = makeStore(); - const result = listSessionsFromStore({ - cfg: baseCfg, - storePath: "/tmp/sessions.json", - store, - opts: { search: "" }, - }); - expect(result.sessions.length).toBe(3); + test("returns all sessions when search is empty or missing", () => { + const cases = [{ opts: { search: "" } }, { opts: {} }] as const; + for (const testCase of cases) { + const result = listSessionsFromStore({ + cfg: baseCfg, + storePath: "/tmp/sessions.json", + store: makeStore(), + opts: testCase.opts, + }); + expect(result.sessions).toHaveLength(3); + } }); - test("returns all sessions when search is undefined", () => { - const store = makeStore(); - const result = listSessionsFromStore({ - cfg: baseCfg, - storePath: "/tmp/sessions.json", - store, - opts: {}, - }); - expect(result.sessions.length).toBe(3); - }); + test("filters sessions across display metadata and key fields", () => { + const cases = [ + { search: "WORK PROJECT", expectedKey: "agent:main:work-project" }, + { search: "reunion", expectedKey: "agent:main:personal-chat" }, + { search: "discord", expectedKey: "agent:main:discord:group:dev-team" }, + { search: "sess-personal", expectedKey: "agent:main:personal-chat" }, + { search: "dev-team", expectedKey: "agent:main:discord:group:dev-team" }, + { search: "alpha", expectedKey: "agent:main:work-project" }, + { search: " personal ", expectedKey: "agent:main:personal-chat" }, + { search: "nonexistent-term", expectedKey: undefined }, + ] as const; - test("filters by displayName case-insensitively", () => { - const store = makeStore(); - const result = listSessionsFromStore({ - cfg: baseCfg, - storePath: "/tmp/sessions.json", - store, - opts: { search: "WORK PROJECT" }, - }); - expect(result.sessions.length).toBe(1); - expect(result.sessions[0].displayName).toBe("Work Project Alpha"); - }); - - test("filters by subject", () => { - const store = makeStore(); - const result = listSessionsFromStore({ - cfg: baseCfg, - storePath: "/tmp/sessions.json", - store, - opts: { search: "reunion" }, - }); - expect(result.sessions.length).toBe(1); - expect(result.sessions[0].subject).toBe("Family Reunion Planning"); - }); - - test("filters by label", () => { - const store = makeStore(); - const result = listSessionsFromStore({ - cfg: baseCfg, - storePath: "/tmp/sessions.json", - store, - opts: { search: "discord" }, - }); - expect(result.sessions.length).toBe(1); - expect(result.sessions[0].label).toBe("discord"); - }); - - test("filters by sessionId", () => { - const store = makeStore(); - const result = listSessionsFromStore({ - cfg: baseCfg, - storePath: "/tmp/sessions.json", - store, - opts: { search: "sess-personal" }, - }); - expect(result.sessions.length).toBe(1); - expect(result.sessions[0].sessionId).toBe("sess-personal-1"); - }); - - test("filters by key", () => { - const store = makeStore(); - const result = listSessionsFromStore({ - cfg: baseCfg, - storePath: "/tmp/sessions.json", - store, - opts: { search: "dev-team" }, - }); - expect(result.sessions.length).toBe(1); - expect(result.sessions[0].key).toBe("agent:main:discord:group:dev-team"); - }); - - test("returns empty array when no matches", () => { - const store = makeStore(); - const result = listSessionsFromStore({ - cfg: baseCfg, - storePath: "/tmp/sessions.json", - store, - opts: { search: "nonexistent-term" }, - }); - expect(result.sessions.length).toBe(0); - }); - - test("matches partial strings", () => { - const store = makeStore(); - const result = listSessionsFromStore({ - cfg: baseCfg, - storePath: "/tmp/sessions.json", - store, - opts: { search: "alpha" }, - }); - expect(result.sessions.length).toBe(1); - expect(result.sessions[0].displayName).toBe("Work Project Alpha"); - }); - - test("trims whitespace from search query", () => { - const store = makeStore(); - const result = listSessionsFromStore({ - cfg: baseCfg, - storePath: "/tmp/sessions.json", - store, - opts: { search: " personal " }, - }); - expect(result.sessions.length).toBe(1); + for (const testCase of cases) { + const result = listSessionsFromStore({ + cfg: baseCfg, + storePath: "/tmp/sessions.json", + store: makeStore(), + opts: { search: testCase.search }, + }); + if (!testCase.expectedKey) { + expect(result.sessions).toHaveLength(0); + continue; + } + expect(result.sessions).toHaveLength(1); + expect(result.sessions[0].key).toBe(testCase.expectedKey); + } }); test("hides cron run alias session keys from sessions list", () => { diff --git a/src/hooks/internal-hooks.test.ts b/src/hooks/internal-hooks.test.ts index 110e72cde6e..077844d5599 100644 --- a/src/hooks/internal-hooks.test.ts +++ b/src/hooks/internal-hooks.test.ts @@ -215,11 +215,6 @@ describe("hooks", () => { expect(isMessageReceivedEvent(event)).toBe(true); }); - it("returns false for non-message events", () => { - const event = createInternalHookEvent("command", "new", "test-session"); - expect(isMessageReceivedEvent(event)).toBe(false); - }); - it("returns false for message:sent events", () => { const context: MessageSentHookContext = { to: "+1234567890", @@ -230,14 +225,6 @@ describe("hooks", () => { const event = createInternalHookEvent("message", "sent", "test-session", context); expect(isMessageReceivedEvent(event)).toBe(false); }); - - it("returns false when context is missing required fields", () => { - const event = createInternalHookEvent("message", "received", "test-session", { - from: "+1234567890", - // missing channelId - }); - expect(isMessageReceivedEvent(event)).toBe(false); - }); }); describe("isMessageSentEvent", () => { @@ -266,11 +253,6 @@ describe("hooks", () => { expect(isMessageSentEvent(event)).toBe(true); }); - it("returns false for non-message events", () => { - const event = createInternalHookEvent("command", "new", "test-session"); - expect(isMessageSentEvent(event)).toBe(false); - }); - it("returns false for message:received events", () => { const context: MessageReceivedHookContext = { from: "+1234567890", @@ -280,14 +262,41 @@ describe("hooks", () => { const event = createInternalHookEvent("message", "received", "test-session", context); expect(isMessageSentEvent(event)).toBe(false); }); + }); - it("returns false when context is missing required fields", () => { - const event = createInternalHookEvent("message", "sent", "test-session", { + describe("message type-guard shared negatives", () => { + it("returns false for non-message and missing-context shapes", () => { + const cases: Array<{ + match: (event: ReturnType) => boolean; + }> = [ + { + match: isMessageReceivedEvent, + }, + { + match: isMessageSentEvent, + }, + ]; + const nonMessageEvent = createInternalHookEvent("command", "new", "test-session"); + const missingReceivedContext = createInternalHookEvent( + "message", + "received", + "test-session", + { + from: "+1234567890", + // missing channelId + }, + ); + const missingSentContext = createInternalHookEvent("message", "sent", "test-session", { to: "+1234567890", channelId: "whatsapp", // missing success }); - expect(isMessageSentEvent(event)).toBe(false); + + for (const testCase of cases) { + expect(testCase.match(nonMessageEvent)).toBe(false); + } + expect(isMessageReceivedEvent(missingReceivedContext)).toBe(false); + expect(isMessageSentEvent(missingSentContext)).toBe(false); }); }); diff --git a/src/infra/format-time/format-time.test.ts b/src/infra/format-time/format-time.test.ts index d6a2603c0e6..e9a25578edd 100644 --- a/src/infra/format-time/format-time.test.ts +++ b/src/infra/format-time/format-time.test.ts @@ -17,37 +17,26 @@ describe("format-duration", () => { expect(formatDurationCompact(-100)).toBeUndefined(); }); - it("formats milliseconds for sub-second durations", () => { - expect(formatDurationCompact(500)).toBe("500ms"); - expect(formatDurationCompact(999)).toBe("999ms"); - }); - - it("formats seconds", () => { - expect(formatDurationCompact(1000)).toBe("1s"); - expect(formatDurationCompact(45000)).toBe("45s"); - expect(formatDurationCompact(59000)).toBe("59s"); - }); - - it("formats minutes and seconds", () => { - expect(formatDurationCompact(60000)).toBe("1m"); - expect(formatDurationCompact(65000)).toBe("1m5s"); - expect(formatDurationCompact(90000)).toBe("1m30s"); - }); - - it("omits trailing zero components", () => { - expect(formatDurationCompact(60000)).toBe("1m"); // not "1m0s" - expect(formatDurationCompact(3600000)).toBe("1h"); // not "1h0m" - expect(formatDurationCompact(86400000)).toBe("1d"); // not "1d0h" - }); - - it("formats hours and minutes", () => { - expect(formatDurationCompact(3660000)).toBe("1h1m"); - expect(formatDurationCompact(5400000)).toBe("1h30m"); - }); - - it("formats days and hours", () => { - expect(formatDurationCompact(90000000)).toBe("1d1h"); - expect(formatDurationCompact(172800000)).toBe("2d"); + it("formats compact units and omits trailing zero components", () => { + const cases = [ + [500, "500ms"], + [999, "999ms"], + [1000, "1s"], + [45000, "45s"], + [59000, "59s"], + [60000, "1m"], // not "1m0s" + [65000, "1m5s"], + [90000, "1m30s"], + [3600000, "1h"], // not "1h0m" + [3660000, "1h1m"], + [5400000, "1h30m"], + [86400000, "1d"], // not "1d0h" + [90000000, "1d1h"], + [172800000, "2d"], + ] as const; + for (const [input, expected] of cases) { + expect(formatDurationCompact(input), String(input)).toBe(expected); + } }); it("supports spaced option", () => { @@ -65,25 +54,27 @@ describe("format-duration", () => { }); describe("formatDurationHuman", () => { - it("returns fallback for invalid input", () => { + it("returns fallback for invalid duration input", () => { for (const value of [null, undefined, -100]) { expect(formatDurationHuman(value)).toBe("n/a"); } expect(formatDurationHuman(null, "unknown")).toBe("unknown"); }); - it("formats single unit", () => { - expect(formatDurationHuman(500)).toBe("500ms"); - expect(formatDurationHuman(5000)).toBe("5s"); - expect(formatDurationHuman(180000)).toBe("3m"); - expect(formatDurationHuman(7200000)).toBe("2h"); - expect(formatDurationHuman(172800000)).toBe("2d"); - }); - - it("uses 24h threshold for days", () => { - expect(formatDurationHuman(23 * 3600000)).toBe("23h"); - expect(formatDurationHuman(24 * 3600000)).toBe("1d"); - expect(formatDurationHuman(25 * 3600000)).toBe("1d"); // rounds + it("formats single-unit outputs and day threshold behavior", () => { + const cases = [ + [500, "500ms"], + [5000, "5s"], + [180000, "3m"], + [7200000, "2h"], + [23 * 3600000, "23h"], + [24 * 3600000, "1d"], + [25 * 3600000, "1d"], // rounds + [172800000, "2d"], + ] as const; + for (const [input, expected] of cases) { + expect(formatDurationHuman(input), String(input)).toBe(expected); + } }); }); @@ -166,20 +157,27 @@ describe("format-datetime", () => { describe("format-relative", () => { describe("formatTimeAgo", () => { - it("returns fallback for invalid input", () => { + it("returns fallback for invalid elapsed input", () => { for (const value of [null, undefined, -100]) { expect(formatTimeAgo(value)).toBe("unknown"); } expect(formatTimeAgo(null, { fallback: "n/a" })).toBe("n/a"); }); - it("formats with 'ago' suffix by default", () => { - expect(formatTimeAgo(0)).toBe("just now"); - expect(formatTimeAgo(29000)).toBe("just now"); // rounds to <1m - expect(formatTimeAgo(30000)).toBe("1m ago"); // 30s rounds to 1m - expect(formatTimeAgo(300000)).toBe("5m ago"); - expect(formatTimeAgo(7200000)).toBe("2h ago"); - expect(formatTimeAgo(172800000)).toBe("2d ago"); + it("formats relative age around key unit boundaries", () => { + const cases = [ + [0, "just now"], + [29000, "just now"], // rounds to <1m + [30000, "1m ago"], // 30s rounds to 1m + [300000, "5m ago"], + [7200000, "2h ago"], + [47 * 3600000, "47h ago"], + [48 * 3600000, "2d ago"], + [172800000, "2d ago"], + ] as const; + for (const [input, expected] of cases) { + expect(formatTimeAgo(input), String(input)).toBe(expected); + } }); it("omits suffix when suffix: false", () => { @@ -187,15 +185,10 @@ describe("format-relative", () => { expect(formatTimeAgo(300000, { suffix: false })).toBe("5m"); expect(formatTimeAgo(7200000, { suffix: false })).toBe("2h"); }); - - it("uses 48h threshold before switching to days", () => { - expect(formatTimeAgo(47 * 3600000)).toBe("47h ago"); - expect(formatTimeAgo(48 * 3600000)).toBe("2d ago"); - }); }); describe("formatRelativeTimestamp", () => { - it("returns fallback for invalid input", () => { + it("returns fallback for invalid timestamp input", () => { for (const value of [null, undefined]) { expect(formatRelativeTimestamp(value)).toBe("n/a"); } diff --git a/src/infra/heartbeat-runner.returns-default-unset.test.ts b/src/infra/heartbeat-runner.returns-default-unset.test.ts index 7542678b904..260ea1b2821 100644 --- a/src/infra/heartbeat-runner.returns-default-unset.test.ts +++ b/src/infra/heartbeat-runner.returns-default-unset.test.ts @@ -168,15 +168,19 @@ describe("resolveHeartbeatIntervalMs", () => { }); describe("resolveHeartbeatPrompt", () => { - it("uses the default prompt when unset", () => { - expect(resolveHeartbeatPrompt({})).toBe(HEARTBEAT_PROMPT); - }); - - it("uses a trimmed override when configured", () => { - const cfg: OpenClawConfig = { - agents: { defaults: { heartbeat: { prompt: " ping " } } }, - }; - expect(resolveHeartbeatPrompt(cfg)).toBe("ping"); + it("uses default or trimmed override prompts", () => { + const cases = [ + { cfg: {} as OpenClawConfig, expected: HEARTBEAT_PROMPT }, + { + cfg: { + agents: { defaults: { heartbeat: { prompt: " ping " } } }, + } as OpenClawConfig, + expected: "ping", + }, + ] as const; + for (const testCase of cases) { + expect(resolveHeartbeatPrompt(testCase.cfg)).toBe(testCase.expected); + } }); }); @@ -323,67 +327,61 @@ describe("resolveHeartbeatDeliveryTarget", () => { }); }); - it("parses threadId from :topic: suffix in heartbeat to", () => { - const cfg: OpenClawConfig = { - agents: { - defaults: { - heartbeat: { target: "telegram", to: "-100111:topic:42" }, + it("parses optional telegram :topic: threadId suffix", () => { + const cases = [ + { to: "-100111:topic:42", expectedTo: "-100111", expectedThreadId: 42 }, + { to: "-100111", expectedTo: "-100111", expectedThreadId: undefined }, + ] as const; + for (const testCase of cases) { + const cfg: OpenClawConfig = { + agents: { + defaults: { + heartbeat: { target: "telegram", to: testCase.to }, + }, }, - }, - }; - const result = resolveHeartbeatDeliveryTarget({ cfg, entry: baseEntry }); - expect(result.channel).toBe("telegram"); - expect(result.to).toBe("-100111"); - expect(result.threadId).toBe(42); + }; + const result = resolveHeartbeatDeliveryTarget({ cfg, entry: baseEntry }); + expect(result.channel).toBe("telegram"); + expect(result.to).toBe(testCase.expectedTo); + expect(result.threadId).toBe(testCase.expectedThreadId); + } }); - it("heartbeat to without :topic: has no threadId", () => { - const cfg: OpenClawConfig = { - agents: { - defaults: { - heartbeat: { target: "telegram", to: "-100111" }, + it("handles explicit heartbeat accountId allow/deny", () => { + const cases = [ + { + accountId: "work", + expected: { + channel: "telegram", + to: "123", + accountId: "work", + lastChannel: undefined, + lastAccountId: undefined, }, }, - }; - const result = resolveHeartbeatDeliveryTarget({ cfg, entry: baseEntry }); - expect(result.to).toBe("-100111"); - expect(result.threadId).toBeUndefined(); - }); + { + accountId: "missing", + expected: { + channel: "none", + reason: "unknown-account", + accountId: "missing", + lastChannel: undefined, + lastAccountId: undefined, + }, + }, + ] as const; - it("uses explicit heartbeat accountId when provided", () => { - const cfg: OpenClawConfig = { - agents: { - defaults: { - heartbeat: { target: "telegram", to: "123", accountId: "work" }, + for (const testCase of cases) { + const cfg: OpenClawConfig = { + agents: { + defaults: { + heartbeat: { target: "telegram", to: "123", accountId: testCase.accountId }, + }, }, - }, - channels: { telegram: { accounts: { work: { botToken: "token" } } } }, - }; - expect(resolveHeartbeatDeliveryTarget({ cfg, entry: baseEntry })).toEqual({ - channel: "telegram", - to: "123", - accountId: "work", - lastChannel: undefined, - lastAccountId: undefined, - }); - }); - - it("skips when explicit heartbeat accountId is unknown", () => { - const cfg: OpenClawConfig = { - agents: { - defaults: { - heartbeat: { target: "telegram", to: "123", accountId: "missing" }, - }, - }, - channels: { telegram: { accounts: { work: { botToken: "token" } } } }, - }; - expect(resolveHeartbeatDeliveryTarget({ cfg, entry: baseEntry })).toEqual({ - channel: "none", - reason: "unknown-account", - accountId: "missing", - lastChannel: undefined, - lastAccountId: undefined, - }); + channels: { telegram: { accounts: { work: { botToken: "token" } } } }, + }; + expect(resolveHeartbeatDeliveryTarget({ cfg, entry: baseEntry })).toEqual(testCase.expected); + } }); it("prefers per-agent heartbeat overrides when provided", () => { diff --git a/src/infra/net/fetch-guard.ssrf.test.ts b/src/infra/net/fetch-guard.ssrf.test.ts index 8a460a0181a..2a1cfeef73f 100644 --- a/src/infra/net/fetch-guard.ssrf.test.ts +++ b/src/infra/net/fetch-guard.ssrf.test.ts @@ -15,37 +15,22 @@ function okResponse(body = "ok"): Response { describe("fetchWithSsrFGuard hardening", () => { type LookupFn = NonNullable[0]["lookupFn"]>; - it("blocks private IP literal URLs before fetch", async () => { - const fetchImpl = vi.fn(); - await expect( - fetchWithSsrFGuard({ - url: "http://127.0.0.1:8080/internal", - fetchImpl, - }), - ).rejects.toThrow(/private|internal|blocked/i); - expect(fetchImpl).not.toHaveBeenCalled(); - }); - - it("blocks legacy loopback literal URLs before fetch", async () => { - const fetchImpl = vi.fn(); - await expect( - fetchWithSsrFGuard({ - url: "http://0177.0.0.1:8080/internal", - fetchImpl, - }), - ).rejects.toThrow(/private|internal|blocked/i); - expect(fetchImpl).not.toHaveBeenCalled(); - }); - - it("blocks unsupported packed-hex loopback literal URLs before fetch", async () => { - const fetchImpl = vi.fn(); - await expect( - fetchWithSsrFGuard({ - url: "http://0x7f000001/internal", - fetchImpl, - }), - ).rejects.toThrow(/private|internal|blocked/i); - expect(fetchImpl).not.toHaveBeenCalled(); + it("blocks private and legacy loopback literals before fetch", async () => { + const blockedUrls = [ + "http://127.0.0.1:8080/internal", + "http://0177.0.0.1:8080/internal", + "http://0x7f000001/internal", + ]; + for (const url of blockedUrls) { + const fetchImpl = vi.fn(); + await expect( + fetchWithSsrFGuard({ + url, + fetchImpl, + }), + ).rejects.toThrow(/private|internal|blocked/i); + expect(fetchImpl).not.toHaveBeenCalled(); + } }); it("blocks redirect chains that hop to private hosts", async () => { diff --git a/src/infra/net/ssrf.test.ts b/src/infra/net/ssrf.test.ts index 716cc21ebca..c2fbbbacd6c 100644 --- a/src/infra/net/ssrf.test.ts +++ b/src/infra/net/ssrf.test.ts @@ -59,27 +59,23 @@ const unsupportedLegacyIpv4Cases = [ const nonIpHostnameCases = ["example.com", "abc.123.example", "1password.com", "0x.example.com"]; describe("ssrf ip classification", () => { - it.each(privateIpCases)("classifies %s as private", (address) => { - expect(isPrivateIpAddress(address)).toBe(true); - }); - - it.each(publicIpCases)("classifies %s as public", (address) => { - expect(isPrivateIpAddress(address)).toBe(false); - }); - - it.each(malformedIpv6Cases)("fails closed for malformed IPv6 %s", (address) => { - expect(isPrivateIpAddress(address)).toBe(true); - }); - - it.each(unsupportedLegacyIpv4Cases)( - "fails closed for unsupported legacy IPv4 literal %s", - (address) => { + it("classifies blocked ip literals as private", () => { + const blockedCases = [...privateIpCases, ...malformedIpv6Cases, ...unsupportedLegacyIpv4Cases]; + for (const address of blockedCases) { expect(isPrivateIpAddress(address)).toBe(true); - }, - ); + } + }); - it.each(nonIpHostnameCases)("does not treat hostname %s as an IP literal", (hostname) => { - expect(isPrivateIpAddress(hostname)).toBe(false); + it("classifies public ip literals as non-private", () => { + for (const address of publicIpCases) { + expect(isPrivateIpAddress(address)).toBe(false); + } + }); + + it("does not treat hostnames as ip literals", () => { + for (const hostname of nonIpHostnameCases) { + expect(isPrivateIpAddress(hostname)).toBe(false); + } }); }); diff --git a/src/infra/openclaw-root.test.ts b/src/infra/openclaw-root.test.ts index 5f5f41ef1c4..9caf5cf5d22 100644 --- a/src/infra/openclaw-root.test.ts +++ b/src/infra/openclaw-root.test.ts @@ -124,8 +124,6 @@ describe("resolveOpenClawPackageRoot", () => { }); it("falls back when argv1 realpath throws", async () => { - const { resolveOpenClawPackageRootSync } = await import("./openclaw-root.js"); - const project = fx("realpath-throw-scenario"); const argv1 = path.join(project, "node_modules", ".bin", "openclaw"); const pkgRoot = path.join(project, "node_modules", "openclaw"); @@ -158,8 +156,6 @@ describe("resolveOpenClawPackageRoot", () => { }); it("async resolver returns null when no package roots exist", async () => { - const { resolveOpenClawPackageRoot } = await import("./openclaw-root.js"); - await expect(resolveOpenClawPackageRoot({ cwd: fx("missing") })).resolves.toBeNull(); }); }); diff --git a/src/infra/outbound/outbound.test.ts b/src/infra/outbound/outbound.test.ts index be9fe4caf76..6428e73551d 100644 --- a/src/infra/outbound/outbound.test.ts +++ b/src/infra/outbound/outbound.test.ts @@ -161,17 +161,22 @@ describe("delivery-queue", () => { }); describe("computeBackoffMs", () => { - it("returns 0 for retryCount 0", () => { - expect(computeBackoffMs(0)).toBe(0); - }); + it("returns scheduled backoff values and clamps at max retry", () => { + const cases = [ + { retryCount: 0, expected: 0 }, + { retryCount: 1, expected: 5_000 }, + { retryCount: 2, expected: 25_000 }, + { retryCount: 3, expected: 120_000 }, + { retryCount: 4, expected: 600_000 }, + // Beyond defined schedule -- clamps to last value. + { retryCount: 5, expected: 600_000 }, + ] as const; - it("returns correct backoff for each retry", () => { - expect(computeBackoffMs(1)).toBe(5_000); - expect(computeBackoffMs(2)).toBe(25_000); - expect(computeBackoffMs(3)).toBe(120_000); - expect(computeBackoffMs(4)).toBe(600_000); - // Beyond defined schedule -- clamps to last value. - expect(computeBackoffMs(5)).toBe(600_000); + for (const testCase of cases) { + expect(computeBackoffMs(testCase.retryCount), String(testCase.retryCount)).toBe( + testCase.expected, + ); + } }); }); @@ -383,28 +388,36 @@ describe("DirectoryCache", () => { expect(cache.get("a", cfg)).toBeUndefined(); }); - it("evicts oldest keys when max size is exceeded", () => { - const cache = new DirectoryCache(60_000, 2); - cache.set("a", "value-a", cfg); - cache.set("b", "value-b", cfg); - cache.set("c", "value-c", cfg); + it("evicts least-recent entries when capacity is exceeded", () => { + const cases = [ + { + actions: [ + ["set", "a", "value-a"], + ["set", "b", "value-b"], + ["set", "c", "value-c"], + ] as const, + expected: { a: undefined, b: "value-b", c: "value-c" }, + }, + { + actions: [ + ["set", "a", "value-a"], + ["set", "b", "value-b"], + ["set", "a", "value-a2"], + ["set", "c", "value-c"], + ] as const, + expected: { a: "value-a2", b: undefined, c: "value-c" }, + }, + ] as const; - expect(cache.get("a", cfg)).toBeUndefined(); - expect(cache.get("b", cfg)).toBe("value-b"); - expect(cache.get("c", cfg)).toBe("value-c"); - }); - - it("refreshes insertion order on key updates", () => { - const cache = new DirectoryCache(60_000, 2); - cache.set("a", "value-a", cfg); - cache.set("b", "value-b", cfg); - cache.set("a", "value-a2", cfg); - cache.set("c", "value-c", cfg); - - // Updating "a" should keep it and evict older "b". - expect(cache.get("a", cfg)).toBe("value-a2"); - expect(cache.get("b", cfg)).toBeUndefined(); - expect(cache.get("c", cfg)).toBe("value-c"); + for (const testCase of cases) { + const cache = new DirectoryCache(60_000, 2); + for (const action of testCase.actions) { + cache.set(action[1], action[2], cfg); + } + expect(cache.get("a", cfg)).toBe(testCase.expected.a); + expect(cache.get("b", cfg)).toBe(testCase.expected.b); + expect(cache.get("c", cfg)).toBe(testCase.expected.c); + } }); }); @@ -470,103 +483,128 @@ describe("buildOutboundResultEnvelope", () => { }); describe("formatOutboundDeliverySummary", () => { - it("falls back when result is missing", () => { - expect(formatOutboundDeliverySummary("telegram")).toBe( - "✅ Sent via Telegram. Message ID: unknown", - ); - expect(formatOutboundDeliverySummary("imessage")).toBe( - "✅ Sent via iMessage. Message ID: unknown", - ); - }); + it("formats fallback and channel-specific detail variants", () => { + const cases = [ + { + name: "fallback telegram", + channel: "telegram" as const, + result: undefined, + expected: "✅ Sent via Telegram. Message ID: unknown", + }, + { + name: "fallback imessage", + channel: "imessage" as const, + result: undefined, + expected: "✅ Sent via iMessage. Message ID: unknown", + }, + { + name: "telegram with chat detail", + channel: "telegram" as const, + result: { + channel: "telegram" as const, + messageId: "m1", + chatId: "c1", + }, + expected: "✅ Sent via Telegram. Message ID: m1 (chat c1)", + }, + { + name: "discord with channel detail", + channel: "discord" as const, + result: { + channel: "discord" as const, + messageId: "d1", + channelId: "chan", + }, + expected: "✅ Sent via Discord. Message ID: d1 (channel chan)", + }, + ] as const; - it("adds chat or channel details", () => { - expect( - formatOutboundDeliverySummary("telegram", { - channel: "telegram", - messageId: "m1", - chatId: "c1", - }), - ).toBe("✅ Sent via Telegram. Message ID: m1 (chat c1)"); - - expect( - formatOutboundDeliverySummary("discord", { - channel: "discord", - messageId: "d1", - channelId: "chan", - }), - ).toBe("✅ Sent via Discord. Message ID: d1 (channel chan)"); + for (const testCase of cases) { + expect(formatOutboundDeliverySummary(testCase.channel, testCase.result), testCase.name).toBe( + testCase.expected, + ); + } }); }); describe("buildOutboundDeliveryJson", () => { - it("builds direct delivery payloads", () => { - expect( - buildOutboundDeliveryJson({ - channel: "telegram", - to: "123", - result: { channel: "telegram", messageId: "m1", chatId: "c1" }, - mediaUrl: "https://example.com/a.png", - }), - ).toEqual({ - channel: "telegram", - via: "direct", - to: "123", - messageId: "m1", - mediaUrl: "https://example.com/a.png", - chatId: "c1", - }); - }); + it("builds direct delivery payloads across provider-specific fields", () => { + const cases = [ + { + name: "telegram direct payload", + input: { + channel: "telegram" as const, + to: "123", + result: { channel: "telegram" as const, messageId: "m1", chatId: "c1" }, + mediaUrl: "https://example.com/a.png", + }, + expected: { + channel: "telegram", + via: "direct", + to: "123", + messageId: "m1", + mediaUrl: "https://example.com/a.png", + chatId: "c1", + }, + }, + { + name: "whatsapp metadata", + input: { + channel: "whatsapp" as const, + to: "+1", + result: { channel: "whatsapp" as const, messageId: "w1", toJid: "jid" }, + }, + expected: { + channel: "whatsapp", + via: "direct", + to: "+1", + messageId: "w1", + mediaUrl: null, + toJid: "jid", + }, + }, + { + name: "signal timestamp", + input: { + channel: "signal" as const, + to: "+1", + result: { channel: "signal" as const, messageId: "s1", timestamp: 123 }, + }, + expected: { + channel: "signal", + via: "direct", + to: "+1", + messageId: "s1", + mediaUrl: null, + timestamp: 123, + }, + }, + ] as const; - it("supports whatsapp metadata when present", () => { - expect( - buildOutboundDeliveryJson({ - channel: "whatsapp", - to: "+1", - result: { channel: "whatsapp", messageId: "w1", toJid: "jid" }, - }), - ).toEqual({ - channel: "whatsapp", - via: "direct", - to: "+1", - messageId: "w1", - mediaUrl: null, - toJid: "jid", - }); - }); - - it("keeps timestamp for signal", () => { - expect( - buildOutboundDeliveryJson({ - channel: "signal", - to: "+1", - result: { channel: "signal", messageId: "s1", timestamp: 123 }, - }), - ).toEqual({ - channel: "signal", - via: "direct", - to: "+1", - messageId: "s1", - mediaUrl: null, - timestamp: 123, - }); + for (const testCase of cases) { + expect(buildOutboundDeliveryJson(testCase.input), testCase.name).toEqual(testCase.expected); + } }); }); describe("formatGatewaySummary", () => { - it("formats gateway summaries with channel", () => { - expect(formatGatewaySummary({ channel: "whatsapp", messageId: "m1" })).toBe( - "✅ Sent via gateway (whatsapp). Message ID: m1", - ); - }); + it("formats default and custom gateway action summaries", () => { + const cases = [ + { + name: "default send action", + input: { channel: "whatsapp", messageId: "m1" }, + expected: "✅ Sent via gateway (whatsapp). Message ID: m1", + }, + { + name: "custom action", + input: { action: "Poll sent", channel: "discord", messageId: "p1" }, + expected: "✅ Poll sent via gateway (discord). Message ID: p1", + }, + ] as const; - it("supports custom actions", () => { - expect( - formatGatewaySummary({ - action: "Poll sent", - channel: "discord", - messageId: "p1", - }), - ).toBe("✅ Poll sent via gateway (discord). Message ID: p1"); + for (const testCase of cases) { + expect(formatGatewaySummary(testCase.input), testCase.name).toBe(testCase.expected); + } }); }); @@ -741,45 +779,50 @@ describe("resolveOutboundSessionRoute", () => { }); describe("normalizeOutboundPayloadsForJson", () => { - it("normalizes payloads with mediaUrl and mediaUrls", () => { - expect( - normalizeOutboundPayloadsForJson([ - { text: "hi" }, - { text: "photo", mediaUrl: "https://x.test/a.jpg" }, - { text: "multi", mediaUrls: ["https://x.test/1.png"] }, - ]), - ).toEqual([ - { text: "hi", mediaUrl: null, mediaUrls: undefined, channelData: undefined }, + it("normalizes payloads for JSON output", () => { + const cases = [ { - text: "photo", - mediaUrl: "https://x.test/a.jpg", - mediaUrls: ["https://x.test/a.jpg"], - channelData: undefined, + input: [ + { text: "hi" }, + { text: "photo", mediaUrl: "https://x.test/a.jpg" }, + { text: "multi", mediaUrls: ["https://x.test/1.png"] }, + ], + expected: [ + { text: "hi", mediaUrl: null, mediaUrls: undefined, channelData: undefined }, + { + text: "photo", + mediaUrl: "https://x.test/a.jpg", + mediaUrls: ["https://x.test/a.jpg"], + channelData: undefined, + }, + { + text: "multi", + mediaUrl: null, + mediaUrls: ["https://x.test/1.png"], + channelData: undefined, + }, + ], }, { - text: "multi", - mediaUrl: null, - mediaUrls: ["https://x.test/1.png"], - channelData: undefined, + input: [ + { + text: "MEDIA:https://x.test/a.png\nMEDIA:https://x.test/b.png", + }, + ], + expected: [ + { + text: "", + mediaUrl: null, + mediaUrls: ["https://x.test/a.png", "https://x.test/b.png"], + channelData: undefined, + }, + ], }, - ]); - }); + ] as const; - it("keeps mediaUrl null for multi MEDIA tags", () => { - expect( - normalizeOutboundPayloadsForJson([ - { - text: "MEDIA:https://x.test/a.png\nMEDIA:https://x.test/b.png", - }, - ]), - ).toEqual([ - { - text: "", - mediaUrl: null, - mediaUrls: ["https://x.test/a.png", "https://x.test/b.png"], - channelData: undefined, - }, - ]); + for (const testCase of cases) { + expect(normalizeOutboundPayloadsForJson(testCase.input)).toEqual(testCase.expected); + } }); }); @@ -792,22 +835,29 @@ describe("normalizeOutboundPayloads", () => { }); describe("formatOutboundPayloadLog", () => { - it("trims trailing text and appends media lines", () => { - expect( - formatOutboundPayloadLog({ - text: "hello ", - mediaUrls: ["https://x.test/a.png", "https://x.test/b.png"], - }), - ).toBe("hello\nMEDIA:https://x.test/a.png\nMEDIA:https://x.test/b.png"); - }); + it("formats text+media and media-only logs", () => { + const cases = [ + { + name: "text with media lines", + input: { + text: "hello ", + mediaUrls: ["https://x.test/a.png", "https://x.test/b.png"], + }, + expected: "hello\nMEDIA:https://x.test/a.png\nMEDIA:https://x.test/b.png", + }, + { + name: "media only", + input: { + text: "", + mediaUrls: ["https://x.test/a.png"], + }, + expected: "MEDIA:https://x.test/a.png", + }, + ] as const; - it("logs media-only payloads", () => { - expect( - formatOutboundPayloadLog({ - text: "", - mediaUrls: ["https://x.test/a.png"], - }), - ).toBe("MEDIA:https://x.test/a.png"); + for (const testCase of cases) { + expect(formatOutboundPayloadLog(testCase.input), testCase.name).toBe(testCase.expected); + } }); }); @@ -825,22 +875,6 @@ describe("resolveOutboundTarget", () => { setActivePluginRegistry(createTestRegistry()); }); - it("rejects whatsapp with empty target even when allowFrom configured", () => { - const cfg: OpenClawConfig = { - channels: { whatsapp: { allowFrom: ["+1555"] } }, - }; - const res = resolveOutboundTarget({ - channel: "whatsapp", - to: "", - cfg, - mode: "explicit", - }); - expect(res.ok).toBe(false); - if (!res.ok) { - expect(res.error.message).toContain("WhatsApp"); - } - }); - it.each([ { name: "normalizes whatsapp target when provided", @@ -860,6 +894,16 @@ describe("resolveOutboundTarget", () => { }, expected: { ok: true as const, to: "120363401234567890@g.us" }, }, + { + name: "rejects whatsapp with empty target in explicit mode even with cfg allowFrom", + input: { + channel: "whatsapp" as const, + to: "", + cfg: { channels: { whatsapp: { allowFrom: ["+1555"] } } } as OpenClawConfig, + mode: "explicit" as const, + }, + expectedErrorIncludes: "WhatsApp", + }, { name: "rejects whatsapp with empty target and allowFrom (no silent fallback)", input: { channel: "whatsapp" as const, to: "", allowFrom: ["+1555"] }, @@ -901,19 +945,18 @@ describe("resolveOutboundTarget", () => { } }); - it("rejects telegram with missing target", () => { - const res = resolveOutboundTarget({ channel: "telegram", to: " " }); - expect(res.ok).toBe(false); - if (!res.ok) { - expect(res.error.message).toContain("Telegram"); - } - }); + it("rejects invalid non-whatsapp targets", () => { + const cases = [ + { input: { channel: "telegram" as const, to: " " }, expectedErrorIncludes: "Telegram" }, + { input: { channel: "webchat" as const, to: "x" }, expectedErrorIncludes: "WebChat" }, + ] as const; - it("rejects webchat delivery", () => { - const res = resolveOutboundTarget({ channel: "webchat", to: "x" }); - expect(res.ok).toBe(false); - if (!res.ok) { - expect(res.error.message).toContain("WebChat"); + for (const testCase of cases) { + const res = resolveOutboundTarget(testCase.input); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.error.message).toContain(testCase.expectedErrorIncludes); + } } }); }); diff --git a/src/infra/provider-usage.fetch.antigravity.test.ts b/src/infra/provider-usage.fetch.antigravity.test.ts index 1784481ce55..728d6b74229 100644 --- a/src/infra/provider-usage.fetch.antigravity.test.ts +++ b/src/infra/provider-usage.fetch.antigravity.test.ts @@ -164,7 +164,7 @@ describe("fetchAntigravityUsage", () => { project: { id: "projects/beta" }, expectedBody: JSON.stringify({ project: "projects/beta" }), }, - ])("$name", async ({ project, expectedBody }) => { + ])("project payload: $name", async ({ project, expectedBody }) => { let capturedBody: string | undefined; const mockFetch = createEndpointFetch({ loadCodeAssist: () => @@ -228,7 +228,7 @@ describe("fetchAntigravityUsage", () => { }, expectedPlan: "Basic Plan", }, - ])("$name", async ({ loadCodeAssist, expectedPlan }) => { + ])("plan label: $name", async ({ loadCodeAssist, expectedPlan }) => { const mockFetch = createEndpointFetch({ loadCodeAssist: () => makeResponse(200, loadCodeAssist), fetchAvailableModels: () => makeResponse(500, "Error"), diff --git a/src/media/parse.test.ts b/src/media/parse.test.ts index 856e7216e1f..1fab5dc13fa 100644 --- a/src/media/parse.test.ts +++ b/src/media/parse.test.ts @@ -8,40 +8,27 @@ describe("splitMediaFromOutput", () => { expect(result.text).toBe("Hello world"); }); - it("accepts absolute media paths", () => { - const result = splitMediaFromOutput("MEDIA:/Users/pete/My File.png"); - expect(result.mediaUrls).toEqual(["/Users/pete/My File.png"]); - expect(result.text).toBe(""); - }); - - it("accepts quoted absolute media paths", () => { - const result = splitMediaFromOutput('MEDIA:"/Users/pete/My File.png"'); - expect(result.mediaUrls).toEqual(["/Users/pete/My File.png"]); - expect(result.text).toBe(""); - }); - - it("accepts tilde media paths", () => { - const result = splitMediaFromOutput("MEDIA:~/Pictures/My File.png"); - expect(result.mediaUrls).toEqual(["~/Pictures/My File.png"]); - expect(result.text).toBe(""); - }); - - it("accepts traversal-like media paths (validated at load time)", () => { - const result = splitMediaFromOutput("MEDIA:../../etc/passwd"); - expect(result.mediaUrls).toEqual(["../../etc/passwd"]); - expect(result.text).toBe(""); - }); - - it("captures safe relative media paths", () => { - const result = splitMediaFromOutput("MEDIA:./screenshots/image.png"); - expect(result.mediaUrls).toEqual(["./screenshots/image.png"]); - expect(result.text).toBe(""); - }); - - it("accepts sandbox-relative media paths", () => { - const result = splitMediaFromOutput("MEDIA:media/inbound/image.png"); - expect(result.mediaUrls).toEqual(["media/inbound/image.png"]); - expect(result.text).toBe(""); + it("accepts supported media path variants", () => { + const pathCases = [ + ["/Users/pete/My File.png", "MEDIA:/Users/pete/My File.png"], + ["/Users/pete/My File.png", 'MEDIA:"/Users/pete/My File.png"'], + ["~/Pictures/My File.png", "MEDIA:~/Pictures/My File.png"], + ["../../etc/passwd", "MEDIA:../../etc/passwd"], + ["./screenshots/image.png", "MEDIA:./screenshots/image.png"], + ["media/inbound/image.png", "MEDIA:media/inbound/image.png"], + ["./screenshot.png", " MEDIA:./screenshot.png"], + ["C:\\Users\\pete\\Pictures\\snap.png", "MEDIA:C:\\Users\\pete\\Pictures\\snap.png"], + [ + "/tmp/tts-fAJy8C/voice-1770246885083.opus", + "MEDIA:/tmp/tts-fAJy8C/voice-1770246885083.opus", + ], + ["image.png", "MEDIA:image.png"], + ] as const; + for (const [expectedPath, input] of pathCases) { + const result = splitMediaFromOutput(input); + expect(result.mediaUrls).toEqual([expectedPath]); + expect(result.text).toBe(""); + } }); it("keeps audio_as_voice detection stable across calls", () => { @@ -59,30 +46,6 @@ describe("splitMediaFromOutput", () => { expect(result.text).toBe(input); }); - it("parses MEDIA tags with leading whitespace", () => { - const result = splitMediaFromOutput(" MEDIA:./screenshot.png"); - expect(result.mediaUrls).toEqual(["./screenshot.png"]); - expect(result.text).toBe(""); - }); - - it("accepts Windows-style paths", () => { - const result = splitMediaFromOutput("MEDIA:C:\\Users\\pete\\Pictures\\snap.png"); - expect(result.mediaUrls).toEqual(["C:\\Users\\pete\\Pictures\\snap.png"]); - expect(result.text).toBe(""); - }); - - it("accepts TTS temp file paths", () => { - const result = splitMediaFromOutput("MEDIA:/tmp/tts-fAJy8C/voice-1770246885083.opus"); - expect(result.mediaUrls).toEqual(["/tmp/tts-fAJy8C/voice-1770246885083.opus"]); - expect(result.text).toBe(""); - }); - - it("accepts bare filenames with extensions", () => { - const result = splitMediaFromOutput("MEDIA:image.png"); - expect(result.mediaUrls).toEqual(["image.png"]); - expect(result.text).toBe(""); - }); - it("rejects bare words without file extensions", () => { const result = splitMediaFromOutput("MEDIA:screenshot"); expect(result.mediaUrls).toBeUndefined(); diff --git a/src/memory/mmr.test.ts b/src/memory/mmr.test.ts index 434e549d590..ec9135d1082 100644 --- a/src/memory/mmr.test.ts +++ b/src/memory/mmr.test.ts @@ -11,56 +11,59 @@ import { } from "./mmr.js"; describe("tokenize", () => { - it("extracts alphanumeric tokens and lowercases", () => { - const result = tokenize("Hello World 123"); - expect(result).toEqual(new Set(["hello", "world", "123"])); - }); + it("normalizes, filters, and deduplicates token sets", () => { + const cases = [ + { + name: "alphanumeric lowercase", + input: "Hello World 123", + expected: ["hello", "world", "123"], + }, + { name: "empty string", input: "", expected: [] }, + { name: "special chars only", input: "!@#$%^&*()", expected: [] }, + { + name: "underscores", + input: "hello_world test_case", + expected: ["hello_world", "test_case"], + }, + { + name: "dedupe repeated tokens", + input: "hello hello world world", + expected: ["hello", "world"], + }, + ] as const; - it("handles empty string", () => { - expect(tokenize("")).toEqual(new Set()); - }); - - it("handles special characters only", () => { - expect(tokenize("!@#$%^&*()")).toEqual(new Set()); - }); - - it("handles underscores in tokens", () => { - const result = tokenize("hello_world test_case"); - expect(result).toEqual(new Set(["hello_world", "test_case"])); - }); - - it("deduplicates repeated tokens", () => { - const result = tokenize("hello hello world world"); - expect(result).toEqual(new Set(["hello", "world"])); + for (const testCase of cases) { + expect(tokenize(testCase.input), testCase.name).toEqual(new Set(testCase.expected)); + } }); }); describe("jaccardSimilarity", () => { - it("returns 1 for identical sets", () => { - const set = new Set(["a", "b", "c"]); - expect(jaccardSimilarity(set, set)).toBe(1); - }); + it("computes expected scores for overlap edge cases", () => { + const cases = [ + { + name: "identical sets", + left: new Set(["a", "b", "c"]), + right: new Set(["a", "b", "c"]), + expected: 1, + }, + { name: "disjoint sets", left: new Set(["a", "b"]), right: new Set(["c", "d"]), expected: 0 }, + { name: "two empty sets", left: new Set(), right: new Set(), expected: 1 }, + { name: "left non-empty right empty", left: new Set(["a"]), right: new Set(), expected: 0 }, + { name: "left empty right non-empty", left: new Set(), right: new Set(["a"]), expected: 0 }, + { + name: "partial overlap", + left: new Set(["a", "b", "c"]), + right: new Set(["b", "c", "d"]), + expected: 0.5, + }, + ] as const; - it("returns 0 for disjoint sets", () => { - const setA = new Set(["a", "b"]); - const setB = new Set(["c", "d"]); - expect(jaccardSimilarity(setA, setB)).toBe(0); - }); - - it("returns 1 for two empty sets", () => { - expect(jaccardSimilarity(new Set(), new Set())).toBe(1); - }); - - it("returns 0 when one set is empty", () => { - expect(jaccardSimilarity(new Set(["a"]), new Set())).toBe(0); - expect(jaccardSimilarity(new Set(), new Set(["a"]))).toBe(0); - }); - - it("computes correct similarity for partial overlap", () => { - const setA = new Set(["a", "b", "c"]); - const setB = new Set(["b", "c", "d"]); - // Intersection: {b, c} = 2, Union: {a, b, c, d} = 4 - expect(jaccardSimilarity(setA, setB)).toBe(0.5); + for (const testCase of cases) { + expect(jaccardSimilarity(testCase.left, testCase.right), testCase.name).toBe( + testCase.expected, + ); + } }); it("is symmetric", () => { @@ -71,40 +74,47 @@ describe("jaccardSimilarity", () => { }); describe("textSimilarity", () => { - it("returns 1 for identical text", () => { - expect(textSimilarity("hello world", "hello world")).toBe(1); - }); + it("computes expected text-level similarity cases", () => { + const cases = [ + { name: "identical", left: "hello world", right: "hello world", expected: 1 }, + { name: "same words reordered", left: "hello world", right: "world hello", expected: 1 }, + { name: "different text", left: "hello world", right: "foo bar", expected: 0 }, + { name: "case insensitive", left: "Hello World", right: "hello world", expected: 1 }, + ] as const; - it("returns 1 for same words different order", () => { - expect(textSimilarity("hello world", "world hello")).toBe(1); - }); - - it("returns 0 for completely different text", () => { - expect(textSimilarity("hello world", "foo bar")).toBe(0); - }); - - it("handles case insensitivity", () => { - expect(textSimilarity("Hello World", "hello world")).toBe(1); + for (const testCase of cases) { + expect(textSimilarity(testCase.left, testCase.right), testCase.name).toBe(testCase.expected); + } }); }); describe("computeMMRScore", () => { - it("returns pure relevance when lambda=1", () => { - expect(computeMMRScore(0.8, 0.5, 1)).toBe(0.8); - }); + it("balances relevance and diversity across lambda settings", () => { + const cases = [ + { + name: "lambda=1 relevance only", + relevance: 0.8, + similarity: 0.5, + lambda: 1, + expected: 0.8, + }, + { + name: "lambda=0 diversity only", + relevance: 0.8, + similarity: 0.5, + lambda: 0, + expected: -0.5, + }, + { name: "lambda=0.5 mixed", relevance: 0.8, similarity: 0.6, lambda: 0.5, expected: 0.1 }, + { name: "default lambda math", relevance: 1.0, similarity: 0.5, lambda: 0.7, expected: 0.55 }, + ] as const; - it("returns negative similarity when lambda=0", () => { - expect(computeMMRScore(0.8, 0.5, 0)).toBe(-0.5); - }); - - it("balances relevance and diversity at lambda=0.5", () => { - // 0.5 * 0.8 - 0.5 * 0.6 = 0.4 - 0.3 = 0.1 - expect(computeMMRScore(0.8, 0.6, 0.5)).toBeCloseTo(0.1); - }); - - it("computes correctly with default lambda=0.7", () => { - // 0.7 * 1.0 - 0.3 * 0.5 = 0.7 - 0.15 = 0.55 - expect(computeMMRScore(1.0, 0.5, 0.7)).toBeCloseTo(0.55); + for (const testCase of cases) { + expect( + computeMMRScore(testCase.relevance, testCase.similarity, testCase.lambda), + testCase.name, + ).toBeCloseTo(testCase.expected); + } }); }); diff --git a/src/memory/qmd-manager.test.ts b/src/memory/qmd-manager.test.ts index 3e0598b484e..ff69b5a16f7 100644 --- a/src/memory/qmd-manager.test.ts +++ b/src/memory/qmd-manager.test.ts @@ -1507,58 +1507,30 @@ describe("QmdMemoryManager", () => { await manager.close(); }); - it("treats plain-text no-results stdout as an empty result set", async () => { - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "search") { - const child = createMockChild({ autoClose: false }); - emitAndClose(child, "stdout", "No results found."); - return child; - } - return createMockChild(); - }); + it("treats plain-text no-results markers from stdout/stderr as empty result sets", async () => { + const cases = [ + { name: "stdout with punctuation", stream: "stdout", payload: "No results found." }, + { name: "stdout without punctuation", stream: "stdout", payload: "No results found\n\n" }, + { name: "stderr", stream: "stderr", payload: "No results found.\n" }, + ] as const; - const { manager } = await createManager(); + for (const testCase of cases) { + spawnMock.mockImplementation((_cmd: string, args: string[]) => { + if (args[0] === "search") { + const child = createMockChild({ autoClose: false }); + emitAndClose(child, testCase.stream, testCase.payload); + return child; + } + return createMockChild(); + }); - await expect( - manager.search("missing", { sessionKey: "agent:main:slack:dm:u123" }), - ).resolves.toEqual([]); - await manager.close(); - }); - - it("treats plain-text no-results stdout without punctuation as empty", async () => { - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "search") { - const child = createMockChild({ autoClose: false }); - emitAndClose(child, "stdout", "No results found\n\n"); - return child; - } - return createMockChild(); - }); - - const { manager } = await createManager(); - - await expect( - manager.search("missing", { sessionKey: "agent:main:slack:dm:u123" }), - ).resolves.toEqual([]); - await manager.close(); - }); - - it("treats plain-text no-results stderr as an empty result set", async () => { - spawnMock.mockImplementation((_cmd: string, args: string[]) => { - if (args[0] === "search") { - const child = createMockChild({ autoClose: false }); - emitAndClose(child, "stderr", "No results found.\n"); - return child; - } - return createMockChild(); - }); - - const { manager } = await createManager(); - - await expect( - manager.search("missing", { sessionKey: "agent:main:slack:dm:u123" }), - ).resolves.toEqual([]); - await manager.close(); + const { manager } = await createManager(); + await expect( + manager.search("missing", { sessionKey: "agent:main:slack:dm:u123" }), + testCase.name, + ).resolves.toEqual([]); + await manager.close(); + } }); it("throws when stdout is empty without the no-results marker", async () => { diff --git a/src/routing/resolve-route.test.ts b/src/routing/resolve-route.test.ts index 957a740203e..5337731f3e2 100644 --- a/src/routing/resolve-route.test.ts +++ b/src/routing/resolve-route.test.ts @@ -18,66 +18,60 @@ describe("resolveAgentRoute", () => { expect(route.matchedBy).toBe("default"); }); - test("dmScope=per-peer isolates DM sessions by sender id", () => { - const cfg: OpenClawConfig = { - session: { dmScope: "per-peer" }, - }; - const route = resolveAgentRoute({ - cfg, - channel: "whatsapp", - accountId: null, - peer: { kind: "direct", id: "+15551234567" }, - }); - expect(route.sessionKey).toBe("agent:main:direct:+15551234567"); - }); - - test("dmScope=per-channel-peer isolates DM sessions per channel and sender", () => { - const cfg: OpenClawConfig = { - session: { dmScope: "per-channel-peer" }, - }; - const route = resolveAgentRoute({ - cfg, - channel: "whatsapp", - accountId: null, - peer: { kind: "direct", id: "+15551234567" }, - }); - expect(route.sessionKey).toBe("agent:main:whatsapp:direct:+15551234567"); - }); - - test("identityLinks collapses per-peer DM sessions across providers", () => { - const cfg: OpenClawConfig = { - session: { - dmScope: "per-peer", - identityLinks: { - alice: ["telegram:111111111", "discord:222222222222222222"], - }, + test("dmScope controls direct-message session key isolation", () => { + const cases = [ + { dmScope: "per-peer" as const, expected: "agent:main:direct:+15551234567" }, + { + dmScope: "per-channel-peer" as const, + expected: "agent:main:whatsapp:direct:+15551234567", }, - }; - const route = resolveAgentRoute({ - cfg, - channel: "telegram", - accountId: null, - peer: { kind: "direct", id: "111111111" }, - }); - expect(route.sessionKey).toBe("agent:main:direct:alice"); + ]; + for (const testCase of cases) { + const cfg: OpenClawConfig = { + session: { dmScope: testCase.dmScope }, + }; + const route = resolveAgentRoute({ + cfg, + channel: "whatsapp", + accountId: null, + peer: { kind: "direct", id: "+15551234567" }, + }); + expect(route.sessionKey).toBe(testCase.expected); + } }); - test("identityLinks applies to per-channel-peer DM sessions", () => { - const cfg: OpenClawConfig = { - session: { - dmScope: "per-channel-peer", - identityLinks: { - alice: ["telegram:111111111", "discord:222222222222222222"], - }, + test("identityLinks applies to direct-message scopes", () => { + const cases = [ + { + dmScope: "per-peer" as const, + channel: "telegram", + peerId: "111111111", + expected: "agent:main:direct:alice", }, - }; - const route = resolveAgentRoute({ - cfg, - channel: "discord", - accountId: null, - peer: { kind: "direct", id: "222222222222222222" }, - }); - expect(route.sessionKey).toBe("agent:main:discord:direct:alice"); + { + dmScope: "per-channel-peer" as const, + channel: "discord", + peerId: "222222222222222222", + expected: "agent:main:discord:direct:alice", + }, + ]; + for (const testCase of cases) { + const cfg: OpenClawConfig = { + session: { + dmScope: testCase.dmScope, + identityLinks: { + alice: ["telegram:111111111", "discord:222222222222222222"], + }, + }, + }; + const route = resolveAgentRoute({ + cfg, + channel: testCase.channel, + accountId: null, + peer: { kind: "direct", id: testCase.peerId }, + }); + expect(route.sessionKey).toBe(testCase.expected); + } }); test("peer binding wins over account binding", () => { diff --git a/src/shared/text/reasoning-tags.test.ts b/src/shared/text/reasoning-tags.test.ts index d72d0cde2a7..35336f94ffe 100644 --- a/src/shared/text/reasoning-tags.test.ts +++ b/src/shared/text/reasoning-tags.test.ts @@ -8,24 +8,28 @@ describe("stripReasoningTagsFromText", () => { expect(stripReasoningTagsFromText(input)).toBe(input); }); - it("strips proper think tags", () => { - const input = "Hello internal reasoning world!"; - expect(stripReasoningTagsFromText(input)).toBe("Hello world!"); - }); - - it("strips thinking tags", () => { - const input = "Before some thought after"; - expect(stripReasoningTagsFromText(input)).toBe("Before after"); - }); - - it("strips thought tags", () => { - const input = "A hmm B"; - expect(stripReasoningTagsFromText(input)).toBe("A B"); - }); - - it("strips antthinking tags", () => { - const input = "X internal Y"; - expect(stripReasoningTagsFromText(input)).toBe("X Y"); + it("strips reasoning-tag variants", () => { + const cases = [ + { + name: "strips proper think tags", + input: "Hello internal reasoning world!", + expected: "Hello world!", + }, + { + name: "strips thinking tags", + input: "Before some thought after", + expected: "Before after", + }, + { name: "strips thought tags", input: "A hmm B", expected: "A B" }, + { + name: "strips antthinking tags", + input: "X internal Y", + expected: "X Y", + }, + ] as const; + for (const { name, input, expected } of cases) { + expect(stripReasoningTagsFromText(input), name).toBe(expected); + } }); it("strips multiple reasoning blocks", () => { @@ -35,20 +39,19 @@ describe("stripReasoningTagsFromText", () => { }); describe("code block preservation (issue #3952)", () => { - it("preserves think tags inside fenced code blocks", () => { - const input = "Use the tag like this:\n```\nreasoning\n```\nThat's it!"; - expect(stripReasoningTagsFromText(input)).toBe(input); - }); - - it("preserves think tags inside inline code", () => { - const input = - "The `` tag is used for reasoning. Don't forget the closing `` tag."; - expect(stripReasoningTagsFromText(input)).toBe(input); - }); - - it("preserves tags in fenced code blocks with language specifier", () => { - const input = "Example:\n```xml\n\n nested\n\n```\nDone!"; - expect(stripReasoningTagsFromText(input)).toBe(input); + it("preserves tags inside code examples", () => { + const cases = [ + "Use the tag like this:\n```\nreasoning\n```\nThat's it!", + "The `` tag is used for reasoning. Don't forget the closing `` tag.", + "Example:\n```xml\n\n nested\n\n```\nDone!", + "Use `` to open and `` to close.", + "Example:\n```\nreasoning\n```", + "Use `` for final answers in code: ```\n42\n```", + "First `` then ```\nblock\n``` then ``", + ] as const; + for (const input of cases) { + expect(stripReasoningTagsFromText(input)).toBe(input); + } }); it("handles mixed real tags and code tags", () => { @@ -56,30 +59,10 @@ describe("stripReasoningTagsFromText", () => { expect(stripReasoningTagsFromText(input)).toBe("Visible text with `` example."); }); - it("preserves both opening and closing tags in backticks", () => { - const input = "Use `` to open and `` to close."; - expect(stripReasoningTagsFromText(input)).toBe(input); - }); - - it("preserves think tags in code block at EOF without trailing newline", () => { - const input = "Example:\n```\nreasoning\n```"; - expect(stripReasoningTagsFromText(input)).toBe(input); - }); - - it("preserves final tags inside code blocks", () => { - const input = "Use `` for final answers in code: ```\n42\n```"; - expect(stripReasoningTagsFromText(input)).toBe(input); - }); - it("handles code block followed by real tags", () => { const input = "```\ncode\n```\nreal hiddenvisible"; expect(stripReasoningTagsFromText(input)).toBe("```\ncode\n```\nvisible"); }); - - it("handles multiple code blocks with tags", () => { - const input = "First `` then ```\nblock\n``` then ``"; - expect(stripReasoningTagsFromText(input)).toBe(input); - }); }); describe("edge cases", () => { @@ -100,11 +83,8 @@ describe("stripReasoningTagsFromText", () => { expect(stripReasoningTagsFromText(input)).toBe("A B"); }); - it("handles empty input", () => { + it("handles empty and null-ish inputs", () => { expect(stripReasoningTagsFromText("")).toBe(""); - }); - - it("handles null-ish input", () => { expect(stripReasoningTagsFromText(null as unknown as string)).toBe(null); });