diff --git a/src/agents/live-model-filter.test.ts b/src/agents/live-model-filter.test.ts deleted file mode 100644 index d0b2bca8e..000000000 --- a/src/agents/live-model-filter.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { isModernModelRef } from "./live-model-filter.js"; - -describe("isModernModelRef", () => { - it("excludes opencode minimax variants from modern selection", () => { - expect(isModernModelRef({ provider: "opencode", id: "minimax-m2.1" })).toBe(false); - expect(isModernModelRef({ provider: "opencode", id: "minimax-m2.5" })).toBe(false); - }); - - it("keeps non-minimax opencode modern models", () => { - expect(isModernModelRef({ provider: "opencode", id: "claude-opus-4-6" })).toBe(true); - expect(isModernModelRef({ provider: "opencode", id: "gemini-3-pro" })).toBe(true); - }); -}); diff --git a/src/agents/model-catalog.recovery.test.ts b/src/agents/model-catalog.recovery.test.ts deleted file mode 100644 index 4a37e3491..000000000 --- a/src/agents/model-catalog.recovery.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { OpenClawConfig } from "../config/config.js"; -import { loadModelCatalog } from "./model-catalog.js"; -import { - installModelCatalogTestHooks, - mockCatalogImportFailThenRecover, -} from "./model-catalog.test-harness.js"; - -describe("loadModelCatalog e2e smoke", () => { - installModelCatalogTestHooks(); - - it("recovers after an import failure on the next load", async () => { - mockCatalogImportFailThenRecover(); - - const cfg = {} as OpenClawConfig; - expect(await loadModelCatalog({ config: cfg })).toEqual([]); - expect(await loadModelCatalog({ config: cfg })).toEqual([ - { id: "gpt-4.1", name: "GPT-4.1", provider: "openai" }, - ]); - }); -}); diff --git a/src/agents/model-compat.test.ts b/src/agents/model-compat.test.ts index 95b4a0eb2..d6f3066ae 100644 --- a/src/agents/model-compat.test.ts +++ b/src/agents/model-compat.test.ts @@ -1,5 +1,6 @@ import type { Api, Model } from "@mariozechner/pi-ai"; import { describe, expect, it } from "vitest"; +import { isModernModelRef } from "./live-model-filter.js"; import { normalizeModelCompat } from "./model-compat.js"; const baseModel = (): Model => @@ -46,3 +47,15 @@ describe("normalizeModelCompat", () => { ).toBe(false); }); }); + +describe("isModernModelRef", () => { + it("excludes opencode minimax variants from modern selection", () => { + expect(isModernModelRef({ provider: "opencode", id: "minimax-m2.1" })).toBe(false); + expect(isModernModelRef({ provider: "opencode", id: "minimax-m2.5" })).toBe(false); + }); + + it("keeps non-minimax opencode modern models", () => { + expect(isModernModelRef({ provider: "opencode", id: "claude-opus-4-6" })).toBe(true); + expect(isModernModelRef({ provider: "opencode", id: "gemini-3-pro" })).toBe(true); + }); +}); diff --git a/src/agents/pi-tools-agent-config.test.ts b/src/agents/pi-tools-agent-config.test.ts index 96cac0501..868f7bcdc 100644 --- a/src/agents/pi-tools-agent-config.test.ts +++ b/src/agents/pi-tools-agent-config.test.ts @@ -616,7 +616,6 @@ describe("Agent-specific tool filtering", () => { const result = await execTool!.execute("call-implicit-sandbox-default", { command: "echo done", - yieldMs: 10, }); const details = result?.details as { status?: string } | undefined; expect(details?.status).toBe("completed"); diff --git a/src/agents/tools/web-fetch.firecrawl-api-key-normalization.test.ts b/src/agents/tools/web-fetch.firecrawl-api-key-normalization.test.ts deleted file mode 100644 index dd477c2b0..000000000 --- a/src/agents/tools/web-fetch.firecrawl-api-key-normalization.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { withFetchPreconnect } from "../../test-utils/fetch-mock.js"; - -vi.mock("../../infra/net/fetch-guard.js", () => { - return { - fetchWithSsrFGuard: vi.fn(async () => { - throw new Error("network down"); - }), - }; -}); - -describe("web_fetch firecrawl apiKey normalization", () => { - const priorFetch = global.fetch; - - afterEach(() => { - global.fetch = priorFetch; - vi.restoreAllMocks(); - }); - - it("strips embedded CR/LF before sending Authorization header", async () => { - const fetchSpy = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { - const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : ""; - expect(url).toContain("/v2/scrape"); - - const auth = (init?.headers as Record | undefined)?.Authorization; - expect(auth).toBe("Bearer firecrawl-test-key"); - - return new Response( - JSON.stringify({ - success: true, - data: { markdown: "ok", metadata: { title: "t" } }, - }), - { status: 200, headers: { "Content-Type": "application/json" } }, - ); - }); - - global.fetch = withFetchPreconnect(fetchSpy); - - const { createWebFetchTool } = await import("./web-tools.js"); - const tool = createWebFetchTool({ - config: { - tools: { - web: { - fetch: { - cacheTtlMinutes: 0, - firecrawl: { apiKey: "firecrawl-test-\r\nkey" }, - readability: false, - }, - }, - }, - }, - }); - - const result = await tool?.execute?.("call", { - url: "https://example.com", - extractMode: "text", - }); - expect(result?.details).toMatchObject({ extractor: "firecrawl" }); - expect(fetchSpy).toHaveBeenCalled(); - }); -}); diff --git a/src/agents/tools/web-tools.fetch.test.ts b/src/agents/tools/web-tools.fetch.test.ts index bea4e7762..3d65120b5 100644 --- a/src/agents/tools/web-tools.fetch.test.ts +++ b/src/agents/tools/web-tools.fetch.test.ts @@ -91,8 +91,12 @@ function requestUrl(input: RequestInfo | URL): string { return ""; } -function installMockFetch(impl: (input: RequestInfo | URL) => Promise) { - const mockFetch = vi.fn(async (input: RequestInfo | URL) => await impl(input)); +function installMockFetch( + impl: (input: RequestInfo | URL, init?: RequestInit) => Promise, +) { + const mockFetch = vi.fn( + async (input: RequestInfo | URL, init?: RequestInit) => await impl(input, init), + ); global.fetch = withFetchPreconnect(mockFetch); return mockFetch; } @@ -253,6 +257,36 @@ describe("web_fetch extraction fallbacks", () => { expect(details.text).toContain("firecrawl content"); }); + it("normalizes firecrawl Authorization header values", async () => { + const fetchSpy = installMockFetch((input: RequestInfo | URL) => { + const url = requestUrl(input); + if (url.includes("api.firecrawl.dev/v2/scrape")) { + return Promise.resolve(firecrawlResponse("firecrawl normalized")) as Promise; + } + return Promise.resolve( + htmlResponse("", url), + ) as Promise; + }); + + const tool = createFetchTool({ + firecrawl: { apiKey: "firecrawl-test-\r\nkey" }, + }); + + const result = await tool?.execute?.("call", { + url: "https://example.com/firecrawl", + extractMode: "text", + }); + + expect(result?.details).toMatchObject({ extractor: "firecrawl" }); + const firecrawlCall = fetchSpy.mock.calls.find((call) => + requestUrl(call[0]).includes("/v2/scrape"), + ); + expect(firecrawlCall).toBeTruthy(); + const init = firecrawlCall?.[1]; + const authHeader = new Headers(init?.headers).get("Authorization"); + expect(authHeader).toBe("Bearer firecrawl-test-key"); + }); + it("throws when readability is disabled and firecrawl is unavailable", async () => { installMockFetch( (input: RequestInfo | URL) => diff --git a/src/process/child-process-bridge.test.ts b/src/process/child-process-bridge.test.ts deleted file mode 100644 index 04ef5715c..000000000 --- a/src/process/child-process-bridge.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { spawn } from "node:child_process"; -import path from "node:path"; -import process from "node:process"; -import { afterEach, describe, expect, it } from "vitest"; -import { attachChildProcessBridge } from "./child-process-bridge.js"; - -const CHILD_READY_TIMEOUT_MS = 10_000; -const CHILD_EXIT_TIMEOUT_MS = 10_000; - -function waitForLine( - stream: NodeJS.ReadableStream, - timeoutMs = CHILD_READY_TIMEOUT_MS, -): Promise { - return new Promise((resolve, reject) => { - let buffer = ""; - - const timeout = setTimeout(() => { - cleanup(); - reject(new Error("timeout waiting for line")); - }, timeoutMs); - - const onData = (chunk: Buffer | string): void => { - buffer += chunk.toString(); - const idx = buffer.indexOf("\n"); - if (idx >= 0) { - const line = buffer.slice(0, idx).trim(); - cleanup(); - resolve(line); - } - }; - - const onError = (err: unknown): void => { - cleanup(); - reject(err); - }; - - const cleanup = (): void => { - clearTimeout(timeout); - stream.off("data", onData); - stream.off("error", onError); - }; - - stream.on("data", onData); - stream.on("error", onError); - }); -} - -describe("attachChildProcessBridge", () => { - const children: Array<{ kill: (signal?: NodeJS.Signals) => boolean }> = []; - const detachments: Array<() => void> = []; - - afterEach(() => { - for (const detach of detachments) { - try { - detach(); - } catch { - // ignore - } - } - detachments.length = 0; - for (const child of children) { - try { - child.kill("SIGKILL"); - } catch { - // ignore - } - } - children.length = 0; - }); - - it("forwards SIGTERM to the wrapped child", async () => { - const childPath = path.resolve(process.cwd(), "test/fixtures/child-process-bridge/child.js"); - - const beforeSigterm = new Set(process.listeners("SIGTERM")); - const child = spawn(process.execPath, [childPath], { - stdio: ["ignore", "pipe", "inherit"], - env: process.env, - }); - const { detach } = attachChildProcessBridge(child); - detachments.push(detach); - children.push(child); - const afterSigterm = process.listeners("SIGTERM"); - const addedSigterm = afterSigterm.find((listener) => !beforeSigterm.has(listener)); - - if (!child.stdout) { - throw new Error("expected stdout"); - } - const ready = await waitForLine(child.stdout); - expect(ready).toBe("ready"); - - // Simulate systemd sending SIGTERM to the parent process. - if (!addedSigterm) { - throw new Error("expected SIGTERM listener"); - } - addedSigterm("SIGTERM"); - - await new Promise((resolve, reject) => { - const timeout = setTimeout( - () => reject(new Error("timeout waiting for child exit")), - CHILD_EXIT_TIMEOUT_MS, - ); - child.once("exit", () => { - clearTimeout(timeout); - resolve(); - }); - }); - }, 8_000); -}); diff --git a/src/process/exec.test.ts b/src/process/exec.test.ts index 349f3f8c1..a3bfef87c 100644 --- a/src/process/exec.test.ts +++ b/src/process/exec.test.ts @@ -1,7 +1,52 @@ -import { describe, expect, it } from "vitest"; +import { spawn } from "node:child_process"; +import path from "node:path"; +import process from "node:process"; +import { afterEach, describe, expect, it } from "vitest"; import { withEnvAsync } from "../test-utils/env.js"; +import { attachChildProcessBridge } from "./child-process-bridge.js"; import { runCommandWithTimeout, shouldSpawnWithShell } from "./exec.js"; +const CHILD_READY_TIMEOUT_MS = 4_000; +const CHILD_EXIT_TIMEOUT_MS = 4_000; + +function waitForLine( + stream: NodeJS.ReadableStream, + timeoutMs = CHILD_READY_TIMEOUT_MS, +): Promise { + return new Promise((resolve, reject) => { + let buffer = ""; + + const timeout = setTimeout(() => { + cleanup(); + reject(new Error("timeout waiting for line")); + }, timeoutMs); + + const onData = (chunk: Buffer | string): void => { + buffer += chunk.toString(); + const idx = buffer.indexOf("\n"); + if (idx >= 0) { + const line = buffer.slice(0, idx).trim(); + cleanup(); + resolve(line); + } + }; + + const onError = (err: unknown): void => { + cleanup(); + reject(err); + }; + + const cleanup = (): void => { + clearTimeout(timeout); + stream.off("data", onData); + stream.off("error", onError); + }; + + stream.on("data", onData); + stream.on("error", onError); + }); +} + describe("runCommandWithTimeout", () => { it("never enables shell execution (Windows cmd.exe injection hardening)", () => { expect( @@ -56,16 +101,16 @@ describe("runCommandWithTimeout", () => { "let count = 0;", 'const ticker = setInterval(() => { process.stdout.write(".");', "count += 1;", - "if (count === 4) {", + "if (count === 2) {", "clearInterval(ticker);", "process.exit(0);", "}", - "}, 60);", + "}, 40);", ].join(" "), ], { timeoutMs: 5_000, - noOutputTimeoutMs: 250, + noOutputTimeoutMs: 500, }, ); @@ -73,7 +118,7 @@ describe("runCommandWithTimeout", () => { expect(result.code ?? 0).toBe(0); expect(result.termination).toBe("exit"); expect(result.noOutputTimedOut).toBe(false); - expect(result.stdout.length).toBeGreaterThanOrEqual(5); + expect(result.stdout.length).toBeGreaterThanOrEqual(3); }); it("reports global timeout termination when overall timeout elapses", async () => { @@ -89,3 +134,64 @@ describe("runCommandWithTimeout", () => { expect(result.code).not.toBe(0); }); }); + +describe("attachChildProcessBridge", () => { + const children: Array<{ kill: (signal?: NodeJS.Signals) => boolean }> = []; + const detachments: Array<() => void> = []; + + afterEach(() => { + for (const detach of detachments) { + try { + detach(); + } catch { + // ignore + } + } + detachments.length = 0; + for (const child of children) { + try { + child.kill("SIGKILL"); + } catch { + // ignore + } + } + children.length = 0; + }); + + it("forwards SIGTERM to the wrapped child", async () => { + const childPath = path.resolve(process.cwd(), "test/fixtures/child-process-bridge/child.js"); + + const beforeSigterm = new Set(process.listeners("SIGTERM")); + const child = spawn(process.execPath, [childPath], { + stdio: ["ignore", "pipe", "inherit"], + env: process.env, + }); + const { detach } = attachChildProcessBridge(child); + detachments.push(detach); + children.push(child); + const afterSigterm = process.listeners("SIGTERM"); + const addedSigterm = afterSigterm.find((listener) => !beforeSigterm.has(listener)); + + if (!child.stdout) { + throw new Error("expected stdout"); + } + const ready = await waitForLine(child.stdout); + expect(ready).toBe("ready"); + + if (!addedSigterm) { + throw new Error("expected SIGTERM listener"); + } + addedSigterm("SIGTERM"); + + await new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error("timeout waiting for child exit")), + CHILD_EXIT_TIMEOUT_MS, + ); + child.once("exit", () => { + clearTimeout(timeout); + resolve(); + }); + }); + }); +}); diff --git a/src/slack/format.test.ts b/src/slack/format.test.ts index 2b44c63a4..bb2003e2c 100644 --- a/src/slack/format.test.ts +++ b/src/slack/format.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { markdownToSlackMrkdwn } from "./format.js"; +import { escapeSlackMrkdwn } from "./monitor/mrkdwn.js"; describe("markdownToSlackMrkdwn", () => { it("handles core markdown formatting conversions", () => { @@ -57,3 +58,13 @@ describe("markdownToSlackMrkdwn", () => { ); }); }); + +describe("escapeSlackMrkdwn", () => { + it("returns plain text unchanged", () => { + expect(escapeSlackMrkdwn("heartbeat status ok")).toBe("heartbeat status ok"); + }); + + it("escapes slack and mrkdwn control characters", () => { + expect(escapeSlackMrkdwn("mode_*`~<&>\\")).toBe("mode\\_\\*\\`\\~<&>\\\\"); + }); +}); diff --git a/src/slack/monitor/mrkdwn.test.ts b/src/slack/monitor/mrkdwn.test.ts deleted file mode 100644 index 5efba875a..000000000 --- a/src/slack/monitor/mrkdwn.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { escapeSlackMrkdwn } from "./mrkdwn.js"; - -describe("escapeSlackMrkdwn", () => { - it("returns plain text unchanged", () => { - expect(escapeSlackMrkdwn("heartbeat status ok")).toBe("heartbeat status ok"); - }); - - it("escapes slack and mrkdwn control characters", () => { - expect(escapeSlackMrkdwn("mode_*`~<&>\\")).toBe("mode\\_\\*\\`\\~<&>\\\\"); - }); -});