diff --git a/src/agents/pi-embedded-runner.test.ts b/src/agents/pi-embedded-runner.test.ts index 84f48af97..b28b43360 100644 --- a/src/agents/pi-embedded-runner.test.ts +++ b/src/agents/pi-embedded-runner.test.ts @@ -5,6 +5,23 @@ import "./test-helpers/fast-coding-tools.js"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; +function createMockUsage(input: number, output: number) { + return { + input, + output, + cacheRead: 0, + cacheWrite: 0, + totalTokens: input + output, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0, + }, + }; +} + vi.mock("@mariozechner/pi-coding-agent", async () => { const actual = await vi.importActual( "@mariozechner/pi-coding-agent", @@ -40,20 +57,7 @@ vi.mock("@mariozechner/pi-ai", async () => { api: model.api, provider: model.provider, model: model.id, - usage: { - input: 1, - output: 1, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 2, - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - total: 0, - }, - }, + usage: createMockUsage(1, 1), timestamp: Date.now(), }); @@ -65,20 +69,7 @@ vi.mock("@mariozechner/pi-ai", async () => { api: model.api, provider: model.provider, model: model.id, - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - total: 0, - }, - }, + usage: createMockUsage(0, 0), timestamp: Date.now(), }); @@ -314,20 +305,7 @@ describe.concurrent("runEmbeddedPiAgent", () => { api: "openai-responses", provider: "openai", model: "mock-1", - usage: { - input: 1, - output: 1, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 2, - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - total: 0, - }, - }, + usage: createMockUsage(1, 1), timestamp: Date.now(), }); diff --git a/src/agents/pi-embedded-runner/extra-params.openrouter-cache-control.test.ts b/src/agents/pi-embedded-runner/extra-params.openrouter-cache-control.test.ts index 15cd10f5e..71af916cc 100644 --- a/src/agents/pi-embedded-runner/extra-params.openrouter-cache-control.test.ts +++ b/src/agents/pi-embedded-runner/extra-params.openrouter-cache-control.test.ts @@ -4,6 +4,32 @@ import { createAssistantMessageEventStream } from "@mariozechner/pi-ai"; import { describe, expect, it } from "vitest"; import { applyExtraParamsToAgent } from "./extra-params.js"; +type StreamPayload = { + messages: Array<{ + role: string; + content: unknown; + }>; +}; + +function runOpenRouterPayload(payload: StreamPayload, modelId: string) { + const baseStreamFn: StreamFn = (_model, _context, options) => { + options?.onPayload?.(payload); + return createAssistantMessageEventStream(); + }; + const agent = { streamFn: baseStreamFn }; + + applyExtraParamsToAgent(agent, undefined, "openrouter", modelId); + + const model = { + api: "openai-completions", + provider: "openrouter", + id: modelId, + } as Model<"openai-completions">; + const context: Context = { messages: [] }; + + void agent.streamFn?.(model, context, {}); +} + describe("extra-params: OpenRouter Anthropic cache_control", () => { it("injects cache_control into system message for OpenRouter Anthropic models", () => { const payload = { @@ -12,22 +38,8 @@ describe("extra-params: OpenRouter Anthropic cache_control", () => { { role: "user", content: "Hello" }, ], }; - const baseStreamFn: StreamFn = (_model, _context, options) => { - options?.onPayload?.(payload); - return createAssistantMessageEventStream(); - }; - const agent = { streamFn: baseStreamFn }; - applyExtraParamsToAgent(agent, undefined, "openrouter", "anthropic/claude-opus-4-6"); - - const model = { - api: "openai-completions", - provider: "openrouter", - id: "anthropic/claude-opus-4-6", - } as Model<"openai-completions">; - const context: Context = { messages: [] }; - - void agent.streamFn?.(model, context, {}); + runOpenRouterPayload(payload, "anthropic/claude-opus-4-6"); expect(payload.messages[0].content).toEqual([ { type: "text", text: "You are a helpful assistant.", cache_control: { type: "ephemeral" } }, @@ -47,22 +59,8 @@ describe("extra-params: OpenRouter Anthropic cache_control", () => { }, ], }; - const baseStreamFn: StreamFn = (_model, _context, options) => { - options?.onPayload?.(payload); - return createAssistantMessageEventStream(); - }; - const agent = { streamFn: baseStreamFn }; - applyExtraParamsToAgent(agent, undefined, "openrouter", "anthropic/claude-opus-4-6"); - - const model = { - api: "openai-completions", - provider: "openrouter", - id: "anthropic/claude-opus-4-6", - } as Model<"openai-completions">; - const context: Context = { messages: [] }; - - void agent.streamFn?.(model, context, {}); + runOpenRouterPayload(payload, "anthropic/claude-opus-4-6"); const content = payload.messages[0].content as Array>; expect(content[0]).toEqual({ type: "text", text: "Part 1" }); @@ -77,23 +75,19 @@ describe("extra-params: OpenRouter Anthropic cache_control", () => { const payload = { messages: [{ role: "system", content: "You are a helpful assistant." }], }; - const baseStreamFn: StreamFn = (_model, _context, options) => { - options?.onPayload?.(payload); - return createAssistantMessageEventStream(); - }; - const agent = { streamFn: baseStreamFn }; - applyExtraParamsToAgent(agent, undefined, "openrouter", "google/gemini-3-pro"); - - const model = { - api: "openai-completions", - provider: "openrouter", - id: "google/gemini-3-pro", - } as Model<"openai-completions">; - const context: Context = { messages: [] }; - - void agent.streamFn?.(model, context, {}); + runOpenRouterPayload(payload, "google/gemini-3-pro"); expect(payload.messages[0].content).toBe("You are a helpful assistant."); }); + + it("leaves payload unchanged when no system message exists", () => { + const payload = { + messages: [{ role: "user", content: "Hello" }], + }; + + runOpenRouterPayload(payload, "anthropic/claude-opus-4-6"); + + expect(payload.messages[0].content).toBe("Hello"); + }); }); diff --git a/src/browser/bridge-server.auth.test.ts b/src/browser/bridge-server.auth.test.ts index 38e1ff51f..685f43b06 100644 --- a/src/browser/bridge-server.auth.test.ts +++ b/src/browser/bridge-server.auth.test.ts @@ -35,6 +35,23 @@ function buildResolvedConfig(): ResolvedBrowserConfig { describe("startBrowserBridgeServer auth", () => { const servers: Array<{ stop: () => Promise }> = []; + async function expectAuthFlow( + authConfig: { authToken?: string; authPassword?: string }, + headers: Record, + ) { + const bridge = await startBrowserBridgeServer({ + resolved: buildResolvedConfig(), + ...authConfig, + }); + servers.push({ stop: () => stopBrowserBridgeServer(bridge.server) }); + + const unauth = await fetch(`${bridge.baseUrl}/`); + expect(unauth.status).toBe(401); + + const authed = await fetch(`${bridge.baseUrl}/`, { headers }); + expect(authed.status).toBe(200); + } + afterEach(async () => { while (servers.length) { const s = servers.pop(); @@ -45,35 +62,14 @@ describe("startBrowserBridgeServer auth", () => { }); it("rejects unauthenticated requests when authToken is set", async () => { - const bridge = await startBrowserBridgeServer({ - resolved: buildResolvedConfig(), - authToken: "secret-token", - }); - servers.push({ stop: () => stopBrowserBridgeServer(bridge.server) }); - - const unauth = await fetch(`${bridge.baseUrl}/`); - expect(unauth.status).toBe(401); - - const authed = await fetch(`${bridge.baseUrl}/`, { - headers: { Authorization: "Bearer secret-token" }, - }); - expect(authed.status).toBe(200); + await expectAuthFlow({ authToken: "secret-token" }, { Authorization: "Bearer secret-token" }); }); it("accepts x-openclaw-password when authPassword is set", async () => { - const bridge = await startBrowserBridgeServer({ - resolved: buildResolvedConfig(), - authPassword: "secret-password", - }); - servers.push({ stop: () => stopBrowserBridgeServer(bridge.server) }); - - const unauth = await fetch(`${bridge.baseUrl}/`); - expect(unauth.status).toBe(401); - - const authed = await fetch(`${bridge.baseUrl}/`, { - headers: { "x-openclaw-password": "secret-password" }, - }); - expect(authed.status).toBe(200); + await expectAuthFlow( + { authPassword: "secret-password" }, + { "x-openclaw-password": "secret-password" }, + ); }); it("requires auth params", async () => { diff --git a/src/browser/chrome.default-browser.test.ts b/src/browser/chrome.default-browser.test.ts index d81ad8786..ccfdb2fc1 100644 --- a/src/browser/chrome.default-browser.test.ts +++ b/src/browser/chrome.default-browser.test.ts @@ -17,33 +17,42 @@ import { execFileSync } from "node:child_process"; import * as fs from "node:fs"; describe("browser default executable detection", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); + const launchServicesPlist = "com.apple.launchservices.secure.plist"; + const chromeExecutablePath = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; - it("prefers default Chromium browser on macOS", () => { + function mockMacDefaultBrowser(bundleId: string, appPath = ""): void { vi.mocked(execFileSync).mockImplementation((cmd, args) => { const argsStr = Array.isArray(args) ? args.join(" ") : ""; if (cmd === "/usr/bin/plutil" && argsStr.includes("LSHandlers")) { - return JSON.stringify([ - { LSHandlerURLScheme: "http", LSHandlerRoleAll: "com.google.Chrome" }, - ]); + return JSON.stringify([{ LSHandlerURLScheme: "http", LSHandlerRoleAll: bundleId }]); } if (cmd === "/usr/bin/osascript" && argsStr.includes("path to application id")) { - return "/Applications/Google Chrome.app"; + return appPath; } if (cmd === "/usr/bin/defaults") { return "Google Chrome"; } return ""; }); + } + + function mockChromeExecutableExists(): void { vi.mocked(fs.existsSync).mockImplementation((p) => { const value = String(p); - if (value.includes("com.apple.launchservices.secure.plist")) { + if (value.includes(launchServicesPlist)) { return true; } - return value.includes("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"); + return value.includes(chromeExecutablePath); }); + } + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("prefers default Chromium browser on macOS", () => { + mockMacDefaultBrowser("com.google.Chrome", "/Applications/Google Chrome.app"); + mockChromeExecutableExists(); const exe = resolveBrowserExecutableForPlatform( {} as Parameters[0], @@ -55,22 +64,8 @@ describe("browser default executable detection", () => { }); it("falls back when default browser is non-Chromium on macOS", () => { - vi.mocked(execFileSync).mockImplementation((cmd, args) => { - const argsStr = Array.isArray(args) ? args.join(" ") : ""; - if (cmd === "/usr/bin/plutil" && argsStr.includes("LSHandlers")) { - return JSON.stringify([ - { LSHandlerURLScheme: "http", LSHandlerRoleAll: "com.apple.Safari" }, - ]); - } - return ""; - }); - vi.mocked(fs.existsSync).mockImplementation((p) => { - const value = String(p); - if (value.includes("com.apple.launchservices.secure.plist")) { - return true; - } - return value.includes("Google Chrome.app/Contents/MacOS/Google Chrome"); - }); + mockMacDefaultBrowser("com.apple.Safari"); + mockChromeExecutableExists(); const exe = resolveBrowserExecutableForPlatform( {} as Parameters[0], diff --git a/src/browser/chrome.test.ts b/src/browser/chrome.test.ts index 1f57e72d6..84839e98c 100644 --- a/src/browser/chrome.test.ts +++ b/src/browser/chrome.test.ts @@ -22,6 +22,15 @@ async function readJson(filePath: string): Promise> { return JSON.parse(raw) as Record; } +async function readDefaultProfileFromLocalState( + userDataDir: string, +): Promise> { + const localState = await readJson(path.join(userDataDir, "Local State")); + const profile = localState.profile as Record; + const infoCache = profile.info_cache as Record; + return infoCache.Default as Record; +} + describe("browser chrome profile decoration", () => { let fixtureRoot = ""; let fixtureCount = 0; @@ -53,10 +62,7 @@ describe("browser chrome profile decoration", () => { const expectedSignedArgb = ((0xff << 24) | 0xff4500) >> 0; - const localState = await readJson(path.join(userDataDir, "Local State")); - const profile = localState.profile as Record; - const infoCache = profile.info_cache as Record; - const def = infoCache.Default as Record; + const def = await readDefaultProfileFromLocalState(userDataDir); expect(def.name).toBe(DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME); expect(def.shortcut_name).toBe(DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME); @@ -84,10 +90,7 @@ describe("browser chrome profile decoration", () => { it("best-effort writes name when color is invalid", async () => { const userDataDir = await createUserDataDir(); decorateOpenClawProfile(userDataDir, { color: "lobster-orange" }); - const localState = await readJson(path.join(userDataDir, "Local State")); - const profile = localState.profile as Record; - const infoCache = profile.info_cache as Record; - const def = infoCache.Default as Record; + const def = await readDefaultProfileFromLocalState(userDataDir); expect(def.name).toBe(DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME); expect(def.profile_color_seed).toBeUndefined(); diff --git a/src/channels/plugins/plugins-core.test.ts b/src/channels/plugins/plugins-core.test.ts index d71080707..37ab09f64 100644 --- a/src/channels/plugins/plugins-core.test.ts +++ b/src/channels/plugins/plugins-core.test.ts @@ -14,6 +14,7 @@ import type { TelegramProbe } from "../../telegram/probe.js"; import type { TelegramTokenResolution } from "../../telegram/token.js"; import { createChannelTestPluginBase, + createMSTeamsTestPluginBase, createOutboundTestPlugin, createTestRegistry, } from "../../test-utils/channel-plugins.js"; @@ -131,20 +132,7 @@ const msteamsOutbound: ChannelOutboundAdapter = { }; const msteamsPlugin: ChannelPlugin = { - id: "msteams", - meta: { - id: "msteams", - label: "Microsoft Teams", - selectionLabel: "Microsoft Teams (Bot Framework)", - docsPath: "/channels/msteams", - blurb: "Bot Framework; enterprise support.", - aliases: ["teams"], - }, - capabilities: { chatTypes: ["direct"] }, - config: { - listAccountIds: () => [], - resolveAccount: () => ({}), - }, + ...createMSTeamsTestPluginBase(), outbound: msteamsOutbound, }; diff --git a/src/cli/deps.test.ts b/src/cli/deps.test.ts index 34c28cece..3cba4d63a 100644 --- a/src/cli/deps.test.ts +++ b/src/cli/deps.test.ts @@ -50,6 +50,16 @@ vi.mock("../imessage/send.js", () => { }); describe("createDefaultDeps", () => { + function expectUnusedModulesNotLoaded(exclude: keyof typeof moduleLoads): void { + const keys = Object.keys(moduleLoads) as Array; + for (const key of keys) { + if (key === exclude) { + continue; + } + expect(moduleLoads[key]).not.toHaveBeenCalled(); + } + } + beforeEach(() => { vi.clearAllMocks(); }); @@ -71,11 +81,7 @@ describe("createDefaultDeps", () => { expect(moduleLoads.telegram).toHaveBeenCalledTimes(1); expect(sendFns.telegram).toHaveBeenCalledTimes(1); - expect(moduleLoads.whatsapp).not.toHaveBeenCalled(); - expect(moduleLoads.discord).not.toHaveBeenCalled(); - expect(moduleLoads.slack).not.toHaveBeenCalled(); - expect(moduleLoads.signal).not.toHaveBeenCalled(); - expect(moduleLoads.imessage).not.toHaveBeenCalled(); + expectUnusedModulesNotLoaded("telegram"); }); it("reuses module cache after first dynamic import", async () => { diff --git a/src/commands/models.list.test.ts b/src/commands/models.list.test.ts index b46d0a4a1..5b1f6f445 100644 --- a/src/commands/models.list.test.ts +++ b/src/commands/models.list.test.ts @@ -104,6 +104,17 @@ function makeRuntime() { }; } +function expectModelRegistryUnavailable( + runtime: ReturnType, + expectedDetail: string, +) { + expect(runtime.error).toHaveBeenCalledTimes(1); + expect(runtime.error.mock.calls[0]?.[0]).toContain("Model registry unavailable:"); + expect(runtime.error.mock.calls[0]?.[0]).toContain(expectedDetail); + expect(runtime.log).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); +} + beforeEach(() => { previousExitCode = process.exitCode; process.exitCode = undefined; @@ -432,12 +443,8 @@ describe("models list/status", () => { const runtime = makeRuntime(); await modelsListCommand({ json: true }, runtime); - expect(runtime.error).toHaveBeenCalledTimes(1); - expect(runtime.error.mock.calls[0]?.[0]).toContain("Model registry unavailable:"); - expect(runtime.error.mock.calls[0]?.[0]).toContain("model discovery failed"); + expectModelRegistryUnavailable(runtime, "model discovery failed"); expect(runtime.error.mock.calls[0]?.[0]).not.toContain("configured models may appear missing"); - expect(runtime.log).not.toHaveBeenCalled(); - expect(process.exitCode).toBe(1); }); it("models list fails fast when registry model discovery is unavailable", async () => { @@ -452,11 +459,7 @@ describe("models list/status", () => { modelRegistryState.available = []; await modelsListCommand({ json: true }, runtime); - expect(runtime.error).toHaveBeenCalledTimes(1); - expect(runtime.error.mock.calls[0]?.[0]).toContain("Model registry unavailable:"); - expect(runtime.error.mock.calls[0]?.[0]).toContain("model discovery unavailable"); - expect(runtime.log).not.toHaveBeenCalled(); - expect(process.exitCode).toBe(1); + expectModelRegistryUnavailable(runtime, "model discovery unavailable"); }); it("loadModelRegistry throws when model discovery is unavailable", async () => { diff --git a/src/test-utils/channel-plugins.ts b/src/test-utils/channel-plugins.ts index ab74e932c..4de168033 100644 --- a/src/test-utils/channel-plugins.ts +++ b/src/test-utils/channel-plugins.ts @@ -51,6 +51,27 @@ export const createChannelTestPluginBase = (params: { }, }); +export const createMSTeamsTestPluginBase = (): Pick< + ChannelPlugin, + "id" | "meta" | "capabilities" | "config" +> => { + const base = createChannelTestPluginBase({ + id: "msteams", + label: "Microsoft Teams", + docsPath: "/channels/msteams", + config: { listAccountIds: () => [], resolveAccount: () => ({}) }, + }); + return { + ...base, + meta: { + ...base.meta, + selectionLabel: "Microsoft Teams (Bot Framework)", + blurb: "Bot Framework; enterprise support.", + aliases: ["teams"], + }, + }; +}; + export const createOutboundTestPlugin = (params: { id: ChannelId; outbound: ChannelOutboundAdapter; diff --git a/src/utils/message-channel.test.ts b/src/utils/message-channel.test.ts index 9d8ca1935..98bd4fffc 100644 --- a/src/utils/message-channel.test.ts +++ b/src/utils/message-channel.test.ts @@ -1,43 +1,13 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import type { ChannelPlugin } from "../channels/plugins/types.js"; -import type { PluginRegistry } from "../plugins/registry.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; +import { createMSTeamsTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js"; import { resolveGatewayMessageChannel } from "./message-channel.js"; -const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry => ({ - plugins: [], - tools: [], - hooks: [], - typedHooks: [], - channels, - commands: [], - providers: [], - gatewayHandlers: {}, - httpHandlers: [], - httpRoutes: [], - cliRegistrars: [], - services: [], - diagnostics: [], -}); - -const emptyRegistry = createRegistry([]); - -const msteamsPlugin = { - id: "msteams", - meta: { - id: "msteams", - label: "Microsoft Teams", - selectionLabel: "Microsoft Teams (Bot Framework)", - docsPath: "/channels/msteams", - blurb: "Bot Framework; enterprise support.", - aliases: ["teams"], - }, - capabilities: { chatTypes: ["direct"] }, - config: { - listAccountIds: () => [], - resolveAccount: () => ({}), - }, -} satisfies ChannelPlugin; +const emptyRegistry = createTestRegistry([]); +const msteamsPlugin: ChannelPlugin = { + ...createMSTeamsTestPluginBase(), +}; describe("message-channel", () => { beforeEach(() => { @@ -57,7 +27,7 @@ describe("message-channel", () => { it("normalizes plugin aliases when registered", () => { setActivePluginRegistry( - createRegistry([{ pluginId: "msteams", plugin: msteamsPlugin, source: "test" }]), + createTestRegistry([{ pluginId: "msteams", plugin: msteamsPlugin, source: "test" }]), ); expect(resolveGatewayMessageChannel("teams")).toBe("msteams"); });