test: add fetch mock helper and reaction coverage

This commit is contained in:
Sebastian
2026-02-17 09:01:30 -05:00
parent 0e023e300e
commit cc359d338e
28 changed files with 193 additions and 106 deletions

View File

@@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai
- iOS/Talk: harden barge-in behavior by disabling interrupt-on-speech when output route is built-in speaker/receiver, reducing false interruptions from local TTS bleed-through. Thanks @zeulewan.
- iOS/Talk: add a `Voice Directive Hint` toggle for Talk Mode prompts so users can disable ElevenLabs voice-switching instructions to save tokens when not needed. (#18250) Thanks @zeulewan.
- Telegram/Agents: add inline button `style` support (`primary|success|danger`) across message tool schema, Telegram action parsing, send pipeline, and runtime prompt guidance. (#18241) Thanks @obviyus.
- Telegram: surface user message reactions as system events, with configurable `channels.telegram.reactionNotifications` scope. (#10075) Thanks @Glucksberg.
- Discord: expose native `/exec` command options (host/security/ask/node) so Discord slash commands get autocomplete and structured inputs. Thanks @thewilloftheshadow.
- Discord: allow reusable interactive components with `components.reusable=true` so buttons, selects, and forms can be used multiple times before expiring. Thanks @thewilloftheshadow.
- Cron/Gateway: separate per-job webhook delivery (`delivery.mode = "webhook"`) from announce delivery, enforce valid HTTP(S) webhook URLs, and keep a temporary legacy `notify + cron.webhook` fallback for stored jobs. (#17901) Thanks @advaitpaliwal.

View File

@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { withFetchPreconnect } from "../test-utils/fetch-mock.js";
import {
CHUTES_TOKEN_ENDPOINT,
CHUTES_USERINFO_ENDPOINT,
@@ -15,7 +16,7 @@ const urlToString = (url: Request | URL | string): string => {
describe("chutes-oauth", () => {
it("exchanges code for tokens and stores username as email", async () => {
const fetchFn: typeof fetch = async (input, init) => {
const fetchFn = withFetchPreconnect(async (input, init) => {
const url = urlToString(input);
if (url === CHUTES_TOKEN_ENDPOINT) {
expect(init?.method).toBe("POST");
@@ -41,7 +42,7 @@ describe("chutes-oauth", () => {
});
}
return new Response("not found", { status: 404 });
};
});
const now = 1_000_000;
const creds = await exchangeChutesCodeForTokens({
@@ -65,7 +66,7 @@ describe("chutes-oauth", () => {
});
it("refreshes tokens using stored client id and falls back to old refresh token", async () => {
const fetchFn: typeof fetch = async (input, init) => {
const fetchFn = withFetchPreconnect(async (input, init) => {
const url = urlToString(input);
if (url !== CHUTES_TOKEN_ENDPOINT) {
return new Response("not found", { status: 404 });
@@ -82,7 +83,7 @@ describe("chutes-oauth", () => {
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
};
});
const now = 2_000_000;
const refreshed = await refreshChutesTokens({

View File

@@ -1,4 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { withFetchPreconnect } from "../test-utils/fetch-mock.js";
describe("minimaxUnderstandImage apiKey normalization", () => {
const priorFetch = global.fetch;
@@ -21,7 +22,7 @@ describe("minimaxUnderstandImage apiKey normalization", () => {
{ status: 200, headers: { "Content-Type": "application/json" } },
);
});
global.fetch = fetchSpy;
global.fetch = withFetchPreconnect(fetchSpy);
const { minimaxUnderstandImage } = await import("./minimax-vlm.js");
const text = await minimaxUnderstandImage({

View File

@@ -1,13 +1,16 @@
import { describe, expect, it } from "vitest";
import { captureEnv } from "../test-utils/env.js";
import { withFetchPreconnect } from "../test-utils/fetch-mock.js";
import { scanOpenRouterModels } from "./model-scan.js";
function createFetchFixture(payload: unknown): typeof fetch {
return async () =>
new Response(JSON.stringify(payload), {
status: 200,
headers: { "content-type": "application/json" },
});
return withFetchPreconnect(
async () =>
new Response(JSON.stringify(payload), {
status: 200,
headers: { "content-type": "application/json" },
}),
);
}
describe("scanOpenRouterModels", () => {

View File

@@ -4,6 +4,7 @@ import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import type { ModelDefinitionConfig } from "../../config/types.models.js";
import { withFetchPreconnect } from "../../test-utils/fetch-mock.js";
import { createOpenClawCodingTools } from "../pi-tools.js";
import { createHostSandboxFsBridge } from "../test-helpers/host-sandbox-fs-bridge.js";
import { __testing, createImageTool, resolveImageModelConfigForTool } from "./image-tool.js";
@@ -47,7 +48,7 @@ function stubMinimaxOkFetch() {
base_resp: { status_code: 0, status_msg: "" },
}),
});
global.fetch = fetch;
global.fetch = withFetchPreconnect(fetch);
vi.stubEnv("MINIMAX_API_KEY", "minimax-test");
return fetch;
}
@@ -414,7 +415,7 @@ describe("image tool implicit imageModel config", () => {
base_resp: { status_code: 0, status_msg: "" },
}),
});
global.fetch = fetch;
global.fetch = withFetchPreconnect(fetch);
vi.stubEnv("MINIMAX_API_KEY", "minimax-test");
const cfg: OpenClawConfig = {
@@ -487,7 +488,7 @@ describe("image tool MiniMax VLM routing", () => {
base_resp: baseResp,
}),
});
global.fetch = fetch;
global.fetch = withFetchPreconnect(fetch);
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-minimax-vlm-"));
vi.stubEnv("MINIMAX_API_KEY", "minimax-test");

View File

@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import * as logger from "../../logger.js";
import { withFetchPreconnect } from "../../test-utils/fetch-mock.js";
import {
createBaseWebFetchToolConfig,
installWebFetchSsrfHarness,
@@ -37,7 +38,7 @@ function htmlResponse(body: string): Response {
describe("web_fetch Cloudflare Markdown for Agents", () => {
it("sends Accept header preferring text/markdown", async () => {
const fetchSpy = vi.fn().mockResolvedValue(markdownResponse("# Test Page\n\nHello world."));
global.fetch = fetchSpy;
global.fetch = withFetchPreconnect(fetchSpy);
const tool = createWebFetchTool(baseToolConfig);
@@ -51,7 +52,7 @@ describe("web_fetch Cloudflare Markdown for Agents", () => {
it("uses cf-markdown extractor for text/markdown responses", async () => {
const md = "# CF Markdown\n\nThis is server-rendered markdown.";
const fetchSpy = vi.fn().mockResolvedValue(markdownResponse(md));
global.fetch = fetchSpy;
global.fetch = withFetchPreconnect(fetchSpy);
const tool = createWebFetchTool(baseToolConfig);
@@ -73,7 +74,7 @@ describe("web_fetch Cloudflare Markdown for Agents", () => {
const html =
"<html><body><article><h1>HTML Page</h1><p>Content here.</p></article></body></html>";
const fetchSpy = vi.fn().mockResolvedValue(htmlResponse(html));
global.fetch = fetchSpy;
global.fetch = withFetchPreconnect(fetchSpy);
const tool = createWebFetchTool(baseToolConfig);
@@ -88,7 +89,7 @@ describe("web_fetch Cloudflare Markdown for Agents", () => {
const fetchSpy = vi
.fn()
.mockResolvedValue(markdownResponse("# Tokens Test", { "x-markdown-tokens": "1500" }));
global.fetch = fetchSpy;
global.fetch = withFetchPreconnect(fetchSpy);
const tool = createWebFetchTool(baseToolConfig);
@@ -108,7 +109,7 @@ describe("web_fetch Cloudflare Markdown for Agents", () => {
it("converts markdown to text when extractMode is text", async () => {
const md = "# Heading\n\n**Bold text** and [a link](https://example.com).";
const fetchSpy = vi.fn().mockResolvedValue(markdownResponse(md));
global.fetch = fetchSpy;
global.fetch = withFetchPreconnect(fetchSpy);
const tool = createWebFetchTool(baseToolConfig);
@@ -132,7 +133,7 @@ describe("web_fetch Cloudflare Markdown for Agents", () => {
it("does not log x-markdown-tokens when header is absent", async () => {
const logSpy = vi.spyOn(logger, "logDebug").mockImplementation(() => {});
const fetchSpy = vi.fn().mockResolvedValue(markdownResponse("# No tokens"));
global.fetch = fetchSpy;
global.fetch = withFetchPreconnect(fetchSpy);
const tool = createWebFetchTool(baseToolConfig);

View File

@@ -1,4 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { withFetchPreconnect } from "../../test-utils/fetch-mock.js";
vi.mock("../../infra/net/fetch-guard.js", () => {
return {
@@ -33,7 +34,7 @@ describe("web_fetch firecrawl apiKey normalization", () => {
);
});
global.fetch = fetchSpy;
global.fetch = withFetchPreconnect(fetchSpy);
const { createWebFetchTool } = await import("./web-tools.js");
const tool = createWebFetchTool({

View File

@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { withFetchPreconnect } from "../../test-utils/fetch-mock.js";
import {
createBaseWebFetchToolConfig,
installWebFetchSsrfHarness,
@@ -23,7 +24,7 @@ describe("web_fetch response size limits", () => {
});
const fetchSpy = vi.fn().mockResolvedValue(response);
global.fetch = fetchSpy;
global.fetch = withFetchPreconnect(fetchSpy);
const tool = createWebFetchTool(baseToolConfig);
const result = await tool?.execute?.("call", { url: "https://example.com/stream" });

View File

@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as ssrf from "../../infra/net/ssrf.js";
import { withFetchPreconnect } from "../../test-utils/fetch-mock.js";
const lookupMock = vi.fn();
const resolvePinnedHostname = ssrf.resolvePinnedHostname;
@@ -30,7 +31,7 @@ function textResponse(body: string): Response {
function setMockFetch(impl?: (...args: unknown[]) => unknown) {
const fetchSpy = vi.fn(impl);
global.fetch = fetchSpy as typeof fetch;
global.fetch = withFetchPreconnect(fetchSpy);
return fetchSpy;
}

View File

@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { withFetchPreconnect } from "../../test-utils/fetch-mock.js";
import { createWebFetchTool, createWebSearchTool } from "./web-tools.js";
function installMockFetch(payload: unknown) {
@@ -8,7 +9,7 @@ function installMockFetch(payload: unknown) {
json: () => Promise.resolve(payload),
} as Response),
);
global.fetch = mockFetch;
global.fetch = withFetchPreconnect(mockFetch);
return mockFetch;
}

View File

@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as ssrf from "../../infra/net/ssrf.js";
import { withFetchPreconnect } from "../../test-utils/fetch-mock.js";
import { createWebFetchTool } from "./web-tools.js";
type MockResponse = {
@@ -92,7 +93,7 @@ function requestUrl(input: RequestInfo | URL): string {
function installMockFetch(impl: (input: RequestInfo | URL) => Promise<Response>) {
const mockFetch = vi.fn(async (input: RequestInfo | URL) => await impl(input));
global.fetch = mockFetch as typeof global.fetch;
global.fetch = withFetchPreconnect(mockFetch);
return mockFetch;
}

View File

@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { withFetchPreconnect } from "../test-utils/fetch-mock.js";
import type { BrowserServerState } from "./server-context.js";
import "./server-context.chrome-test-harness.js";
import { createBrowserRouteContext } from "./server-context.js";
@@ -56,7 +57,7 @@ function stubChromeJsonList(responses: unknown[]) {
} as unknown as Response;
});
global.fetch = fetchMock;
global.fetch = withFetchPreconnect(fetchMock);
return fetchMock;
}

View File

@@ -1,4 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { withFetchPreconnect } from "../test-utils/fetch-mock.js";
import * as cdpModule from "./cdp.js";
import * as pwAiModule from "./pw-ai-module.js";
import type { BrowserServerState } from "./server-context.js";
@@ -70,7 +71,7 @@ describe("browser server-context remote profile tab operations", () => {
throw new Error("unexpected fetch");
});
global.fetch = fetchMock;
global.fetch = withFetchPreconnect(fetchMock);
const state = makeState("remote");
const ctx = createBrowserRouteContext({ getState: () => state });
@@ -135,7 +136,7 @@ describe("browser server-context remote profile tab operations", () => {
throw new Error("unexpected fetch");
});
global.fetch = fetchMock;
global.fetch = withFetchPreconnect(fetchMock);
const state = makeState("remote");
const ctx = createBrowserRouteContext({ getState: () => state });
@@ -162,7 +163,7 @@ describe("browser server-context remote profile tab operations", () => {
throw new Error("unexpected fetch");
});
global.fetch = fetchMock;
global.fetch = withFetchPreconnect(fetchMock);
const state = makeState("remote");
const ctx = createBrowserRouteContext({ getState: () => state });
@@ -188,7 +189,7 @@ describe("browser server-context remote profile tab operations", () => {
throw new Error("unexpected fetch");
});
global.fetch = fetchMock;
global.fetch = withFetchPreconnect(fetchMock);
const state = makeState("remote");
const ctx = createBrowserRouteContext({ getState: () => state });
@@ -220,7 +221,7 @@ describe("browser server-context remote profile tab operations", () => {
} as unknown as Response;
});
global.fetch = fetchMock;
global.fetch = withFetchPreconnect(fetchMock);
const state = makeState("remote");
const ctx = createBrowserRouteContext({ getState: () => state });
@@ -255,7 +256,7 @@ describe("browser server-context tab selection state", () => {
} as unknown as Response;
});
global.fetch = fetchMock;
global.fetch = withFetchPreconnect(fetchMock);
const state = makeState("openclaw");
const ctx = createBrowserRouteContext({ getState: () => state });

View File

@@ -1,6 +1,7 @@
import net from "node:net";
import { describe, expect, it, vi } from "vitest";
import { CHUTES_TOKEN_ENDPOINT, CHUTES_USERINFO_ENDPOINT } from "../agents/chutes-oauth.js";
import { withFetchPreconnect } from "../test-utils/fetch-mock.js";
import { loginChutes } from "./chutes-oauth.js";
async function getFreePort(): Promise<number> {
@@ -32,7 +33,7 @@ function createOAuthFetchFn(params: {
username: string;
passthrough?: boolean;
}): typeof fetch {
return async (input, init) => {
return withFetchPreconnect(async (input, init) => {
const url = urlToString(input);
if (url === CHUTES_TOKEN_ENDPOINT) {
return new Response(
@@ -54,7 +55,7 @@ function createOAuthFetchFn(params: {
return fetch(input, init);
}
return new Response("not found", { status: 404 });
};
});
}
describe("loginChutes", () => {
@@ -157,7 +158,7 @@ describe("loginChutes", () => {
});
it("rejects pasted redirect URLs missing state", async () => {
const fetchFn: typeof fetch = async () => new Response("not found", { status: 404 });
const fetchFn = withFetchPreconnect(async () => new Response("not found", { status: 404 }));
await expect(
loginChutes({

View File

@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { withFetchPreconnect } from "../test-utils/fetch-mock.js";
import { fetchDiscord } from "./api.js";
function jsonResponse(body: unknown, status = 200) {
@@ -7,7 +8,7 @@ function jsonResponse(body: unknown, status = 200) {
describe("fetchDiscord", () => {
it("formats rate limit payloads without raw JSON", async () => {
const fetcher = async () =>
const fetcher = withFetchPreconnect(async () =>
jsonResponse(
{
message: "You are being rate limited.",
@@ -15,11 +16,12 @@ describe("fetchDiscord", () => {
global: false,
},
429,
);
),
);
let error: unknown;
try {
await fetchDiscord("/users/@me/guilds", "test", fetcher as typeof fetch, {
await fetchDiscord("/users/@me/guilds", "test", fetcher, {
retry: { attempts: 1 },
});
} catch (err) {
@@ -35,9 +37,9 @@ describe("fetchDiscord", () => {
});
it("preserves non-JSON error text", async () => {
const fetcher = async () => new Response("Not Found", { status: 404 });
const fetcher = withFetchPreconnect(async () => new Response("Not Found", { status: 404 }));
await expect(
fetchDiscord("/users/@me/guilds", "test", fetcher as typeof fetch, {
fetchDiscord("/users/@me/guilds", "test", fetcher, {
retry: { attempts: 1 },
}),
).rejects.toThrow("Discord API /users/@me/guilds failed (404): Not Found");
@@ -45,7 +47,7 @@ describe("fetchDiscord", () => {
it("retries rate limits before succeeding", async () => {
let calls = 0;
const fetcher = async () => {
const fetcher = withFetchPreconnect(async () => {
calls += 1;
if (calls === 1) {
return jsonResponse(
@@ -58,12 +60,12 @@ describe("fetchDiscord", () => {
);
}
return jsonResponse([{ id: "1", name: "Guild" }], 200);
};
});
const result = await fetchDiscord<Array<{ id: string; name: string }>>(
"/users/@me/guilds",
"test",
fetcher as typeof fetch,
fetcher,
{ retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0 } },
);

View File

@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { withFetchPreconnect } from "../test-utils/fetch-mock.js";
import { resolveDiscordChannelAllowlist } from "./resolve-channels.js";
function jsonResponse(body: unknown) {
@@ -14,7 +15,7 @@ const urlToString = (url: Request | URL | string): string => {
describe("resolveDiscordChannelAllowlist", () => {
it("resolves guild/channel by name", async () => {
const fetcher = async (input: RequestInfo | URL) => {
const fetcher = withFetchPreconnect(async (input: RequestInfo | URL) => {
const url = urlToString(input);
if (url.endsWith("/users/@me/guilds")) {
return jsonResponse([{ id: "g1", name: "My Guild" }]);
@@ -26,7 +27,7 @@ describe("resolveDiscordChannelAllowlist", () => {
]);
}
return new Response("not found", { status: 404 });
};
});
const res = await resolveDiscordChannelAllowlist({
token: "test",
@@ -40,7 +41,7 @@ describe("resolveDiscordChannelAllowlist", () => {
});
it("resolves channel id to guild", async () => {
const fetcher = async (input: RequestInfo | URL) => {
const fetcher = withFetchPreconnect(async (input: RequestInfo | URL) => {
const url = urlToString(input);
if (url.endsWith("/users/@me/guilds")) {
return jsonResponse([{ id: "g1", name: "Guild One" }]);
@@ -49,7 +50,7 @@ describe("resolveDiscordChannelAllowlist", () => {
return jsonResponse({ id: "123", name: "general", guild_id: "g1", type: 0 });
}
return new Response("not found", { status: 404 });
};
});
const res = await resolveDiscordChannelAllowlist({
token: "test",
@@ -63,7 +64,7 @@ describe("resolveDiscordChannelAllowlist", () => {
});
it("resolves guild: prefixed id as guild (not channel)", async () => {
const fetcher = async (input: RequestInfo | URL) => {
const fetcher = withFetchPreconnect(async (input: RequestInfo | URL) => {
const url = urlToString(input);
if (url.endsWith("/users/@me/guilds")) {
return jsonResponse([{ id: "111222333444555666", name: "Guild One" }]);
@@ -73,7 +74,7 @@ describe("resolveDiscordChannelAllowlist", () => {
throw new Error("guild id was incorrectly routed to /channels/");
}
return new Response("not found", { status: 404 });
};
});
const res = await resolveDiscordChannelAllowlist({
token: "test",
@@ -90,7 +91,7 @@ describe("resolveDiscordChannelAllowlist", () => {
// Demonstrates why provider.ts must prefix guild-only entries with "guild:"
// In reality, Discord returns 404 when a guild ID is sent to /channels/<guildId>,
// which causes fetchDiscord to throw and the entire resolver to crash.
const fetcher = async (input: RequestInfo | URL) => {
const fetcher = withFetchPreconnect(async (input: RequestInfo | URL) => {
const url = urlToString(input);
if (url.endsWith("/users/@me/guilds")) {
return jsonResponse([{ id: "999", name: "My Server" }]);
@@ -100,7 +101,7 @@ describe("resolveDiscordChannelAllowlist", () => {
return new Response(JSON.stringify({ message: "Unknown Channel" }), { status: 404 });
}
return new Response("not found", { status: 404 });
};
});
// Without the guild: prefix, a bare numeric string hits /channels/999 → 404 → throws
await expect(

View File

@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { withFetchPreconnect } from "../test-utils/fetch-mock.js";
import { resolveFetch, wrapFetchWithAbortSignal } from "./fetch.js";
function createForeignSignalHarness() {
@@ -29,10 +30,12 @@ function createForeignSignalHarness() {
describe("wrapFetchWithAbortSignal", () => {
it("adds duplex for requests with a body", async () => {
let seenInit: RequestInit | undefined;
const fetchImpl = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
seenInit = init;
return {} as Response;
});
const fetchImpl = withFetchPreconnect(
vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
seenInit = init;
return {} as Response;
}),
);
const wrapped = wrapFetchWithAbortSignal(fetchImpl);
@@ -43,10 +46,12 @@ describe("wrapFetchWithAbortSignal", () => {
it("converts foreign abort signals to native controllers", async () => {
let seenSignal: AbortSignal | undefined;
const fetchImpl = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
seenSignal = init?.signal as AbortSignal | undefined;
return {} as Response;
});
const fetchImpl = withFetchPreconnect(
vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
seenSignal = init?.signal as AbortSignal | undefined;
return {} as Response;
}),
);
const wrapped = wrapFetchWithAbortSignal(fetchImpl);
@@ -71,8 +76,8 @@ describe("wrapFetchWithAbortSignal", () => {
process.on("unhandledRejection", onUnhandled);
const fetchError = new TypeError("fetch failed");
const fetchImpl = vi.fn((_input: RequestInfo | URL, _init?: RequestInit) =>
Promise.reject(fetchError),
const fetchImpl = withFetchPreconnect(
vi.fn((_input: RequestInfo | URL, _init?: RequestInit) => Promise.reject(fetchError)),
);
const wrapped = wrapFetchWithAbortSignal(fetchImpl);
@@ -92,9 +97,11 @@ describe("wrapFetchWithAbortSignal", () => {
it("cleans up listener and rethrows when fetch throws synchronously", () => {
const syncError = new TypeError("sync fetch failure");
const fetchImpl = vi.fn(() => {
throw syncError;
});
const fetchImpl = withFetchPreconnect(
vi.fn(() => {
throw syncError;
}),
);
const wrapped = wrapFetchWithAbortSignal(fetchImpl);
const { fakeSignal, removeEventListener } = createForeignSignalHarness();
@@ -106,8 +113,8 @@ describe("wrapFetchWithAbortSignal", () => {
it("preserves original rejection when listener cleanup throws", async () => {
const fetchError = new TypeError("fetch failed");
const cleanupError = new TypeError("cleanup failed");
const fetchImpl = vi.fn((_input: RequestInfo | URL, _init?: RequestInit) =>
Promise.reject(fetchError),
const fetchImpl = withFetchPreconnect(
vi.fn((_input: RequestInfo | URL, _init?: RequestInit) => Promise.reject(fetchError)),
);
const wrapped = wrapFetchWithAbortSignal(fetchImpl);
@@ -128,9 +135,11 @@ describe("wrapFetchWithAbortSignal", () => {
it("preserves original sync throw when listener cleanup throws", () => {
const syncError = new TypeError("sync fetch failure");
const cleanupError = new TypeError("cleanup failed");
const fetchImpl = vi.fn(() => {
throw syncError;
});
const fetchImpl = withFetchPreconnect(
vi.fn(() => {
throw syncError;
}),
);
const wrapped = wrapFetchWithAbortSignal(fetchImpl);
const removeEventListener = vi.fn(() => {
@@ -150,7 +159,7 @@ describe("wrapFetchWithAbortSignal", () => {
it("skips listener cleanup when foreign signal is already aborted", async () => {
const addEventListener = vi.fn();
const removeEventListener = vi.fn();
const fetchImpl = vi.fn(async () => ({ ok: true }) as Response);
const fetchImpl = withFetchPreconnect(vi.fn(async () => ({ ok: true }) as Response));
const wrapped = wrapFetchWithAbortSignal(fetchImpl);
const fakeSignal = {
@@ -166,7 +175,7 @@ describe("wrapFetchWithAbortSignal", () => {
});
it("returns the same function when called with an already wrapped fetch", () => {
const fetchImpl = vi.fn(async () => ({ ok: true }) as Response);
const fetchImpl = withFetchPreconnect(vi.fn(async () => ({ ok: true }) as Response));
const wrapped = wrapFetchWithAbortSignal(fetchImpl);
expect(wrapFetchWithAbortSignal(wrapped)).toBe(wrapped);

View File

@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { withFetchPreconnect } from "../test-utils/fetch-mock.js";
import { fetchAntigravityUsage } from "./provider-usage.fetch.antigravity.js";
const makeResponse = (status: number, body: unknown): Response => {
@@ -12,10 +13,12 @@ const toRequestUrl = (input: Parameters<typeof fetch>[0]): string =>
const createAntigravityFetch = (
handler: (url: string, init?: Parameters<typeof fetch>[1]) => Promise<Response> | Response,
) =>
vi.fn(async (input: string | Request | URL, init?: RequestInit) =>
) => {
const mockFetch = vi.fn(async (input: string | Request | URL, init?: RequestInit) =>
handler(toRequestUrl(input), init),
);
return withFetchPreconnect(mockFetch) as typeof fetch & typeof mockFetch;
};
const getRequestBody = (init?: Parameters<typeof fetch>[1]) =>
typeof init?.body === "string" ? init.body : undefined;

View File

@@ -1,4 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { withFetchPreconnect } from "../test-utils/fetch-mock.js";
import { MediaAttachmentCache } from "./attachments.js";
import { normalizeMediaUnderstandingChatType, resolveMediaUnderstandingScope } from "./scope.js";
@@ -28,7 +29,7 @@ describe("media understanding attachments SSRF", () => {
it("blocks private IP URLs before fetching", async () => {
const fetchSpy = vi.fn();
globalThis.fetch = fetchSpy as typeof fetch;
globalThis.fetch = withFetchPreconnect(fetchSpy);
const cache = new MediaAttachmentCache([{ index: 0, url: "http://127.0.0.1/secret.jpg" }]);

View File

@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { withFetchPreconnect } from "../../../test-utils/fetch-mock.js";
import { installPinnedHostnameTestHooks, resolveRequestUrl } from "../audio.test-helpers.js";
import { transcribeDeepgramAudio } from "./audio.js";
@@ -7,7 +8,7 @@ installPinnedHostnameTestHooks();
describe("transcribeDeepgramAudio", () => {
it("respects lowercase authorization header overrides", async () => {
let seenAuth: string | null = null;
const fetchFn = async (_input: RequestInfo | URL, init?: RequestInit) => {
const fetchFn = withFetchPreconnect(async (_input: RequestInfo | URL, init?: RequestInit) => {
const headers = new Headers(init?.headers);
seenAuth = headers.get("authorization");
return new Response(
@@ -19,7 +20,7 @@ describe("transcribeDeepgramAudio", () => {
headers: { "content-type": "application/json" },
},
);
};
});
const result = await transcribeDeepgramAudio({
buffer: Buffer.from("audio"),
@@ -37,7 +38,7 @@ describe("transcribeDeepgramAudio", () => {
it("builds the expected request payload", async () => {
let seenUrl: string | null = null;
let seenInit: RequestInit | undefined;
const fetchFn = async (input: RequestInfo | URL, init?: RequestInit) => {
const fetchFn = withFetchPreconnect(async (input: RequestInfo | URL, init?: RequestInit) => {
seenUrl = resolveRequestUrl(input);
seenInit = init;
return new Response(
@@ -49,7 +50,7 @@ describe("transcribeDeepgramAudio", () => {
headers: { "content-type": "application/json" },
},
);
};
});
const result = await transcribeDeepgramAudio({
buffer: Buffer.from("audio-bytes"),

View File

@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as ssrf from "../../../infra/net/ssrf.js";
import { withFetchPreconnect } from "../../../test-utils/fetch-mock.js";
import { describeGeminiVideo } from "./video.js";
const TEST_NET_IP = "203.0.113.10";
@@ -47,7 +48,7 @@ describe("describeGeminiVideo", () => {
it("respects case-insensitive x-goog-api-key overrides", async () => {
let seenKey: string | null = null;
const fetchFn = async (_input: RequestInfo | URL, init?: RequestInit) => {
const fetchFn = withFetchPreconnect(async (_input: RequestInfo | URL, init?: RequestInit) => {
const headers = new Headers(init?.headers);
seenKey = headers.get("x-goog-api-key");
return new Response(
@@ -56,7 +57,7 @@ describe("describeGeminiVideo", () => {
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
};
});
const result = await describeGeminiVideo({
buffer: Buffer.from("video"),
@@ -74,7 +75,7 @@ describe("describeGeminiVideo", () => {
it("builds the expected request payload", async () => {
let seenUrl: string | null = null;
let seenInit: RequestInit | undefined;
const fetchFn = async (input: RequestInfo | URL, init?: RequestInit) => {
const fetchFn = withFetchPreconnect(async (input: RequestInfo | URL, init?: RequestInit) => {
seenUrl = resolveRequestUrl(input);
seenInit = init;
return new Response(
@@ -89,7 +90,7 @@ describe("describeGeminiVideo", () => {
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
};
});
const result = await describeGeminiVideo({
buffer: Buffer.from("video-bytes"),

View File

@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { withFetchPreconnect } from "../../../test-utils/fetch-mock.js";
import { installPinnedHostnameTestHooks, resolveRequestUrl } from "../audio.test-helpers.js";
import { transcribeOpenAiCompatibleAudio } from "./audio.js";
@@ -7,14 +8,14 @@ installPinnedHostnameTestHooks();
describe("transcribeOpenAiCompatibleAudio", () => {
it("respects lowercase authorization header overrides", async () => {
let seenAuth: string | null = null;
const fetchFn = async (_input: RequestInfo | URL, init?: RequestInit) => {
const fetchFn = withFetchPreconnect(async (_input: RequestInfo | URL, init?: RequestInit) => {
const headers = new Headers(init?.headers);
seenAuth = headers.get("authorization");
return new Response(JSON.stringify({ text: "ok" }), {
status: 200,
headers: { "content-type": "application/json" },
});
};
});
const result = await transcribeOpenAiCompatibleAudio({
buffer: Buffer.from("audio"),
@@ -32,14 +33,14 @@ describe("transcribeOpenAiCompatibleAudio", () => {
it("builds the expected request payload", async () => {
let seenUrl: string | null = null;
let seenInit: RequestInit | undefined;
const fetchFn = async (input: RequestInfo | URL, init?: RequestInit) => {
const fetchFn = withFetchPreconnect(async (input: RequestInfo | URL, init?: RequestInit) => {
seenUrl = resolveRequestUrl(input);
seenInit = init;
return new Response(JSON.stringify({ text: "hello" }), {
status: 200,
headers: { "content-type": "application/json" },
});
};
});
const result = await transcribeOpenAiCompatibleAudio({
buffer: Buffer.from("audio-bytes"),

View File

@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import * as authModule from "../agents/model-auth.js";
import { withFetchPreconnect } from "../test-utils/fetch-mock.js";
import { createVoyageEmbeddingProvider, normalizeVoyageModel } from "./embeddings-voyage.js";
vi.mock("../agents/model-auth.js", () => ({
@@ -12,12 +13,14 @@ vi.mock("../agents/model-auth.js", () => ({
},
}));
const createFetchMock = () =>
vi.fn(async () => ({
const createFetchMock = () => {
const fetchMock = vi.fn(async () => ({
ok: true,
status: 200,
json: async () => ({ data: [{ embedding: [0.1, 0.2, 0.3] }] }),
})) as unknown as typeof fetch;
}));
return withFetchPreconnect(fetchMock) as typeof fetch & typeof fetchMock;
};
describe("voyage embedding provider", () => {
afterEach(() => {
@@ -26,8 +29,8 @@ describe("voyage embedding provider", () => {
});
it("configures client with correct defaults and headers", async () => {
const fetchMock = createFetchMock() as ReturnType<typeof vi.fn>;
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
const fetchMock = createFetchMock();
vi.stubGlobal("fetch", fetchMock);
vi.mocked(authModule.resolveApiKeyForProvider).mockResolvedValue({
apiKey: "voyage-key-123",
@@ -64,8 +67,8 @@ describe("voyage embedding provider", () => {
});
it("respects remote overrides for baseUrl and apiKey", async () => {
const fetchMock = createFetchMock() as ReturnType<typeof vi.fn>;
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
const fetchMock = createFetchMock();
vi.stubGlobal("fetch", fetchMock);
const result = await createVoyageEmbeddingProvider({
config: {} as never,
@@ -90,14 +93,16 @@ describe("voyage embedding provider", () => {
});
it("passes input_type=document for embedBatch", async () => {
const fetchMock = vi.fn(async () => ({
ok: true,
status: 200,
json: async () => ({
data: [{ embedding: [0.1, 0.2] }, { embedding: [0.3, 0.4] }],
}),
})) as ReturnType<typeof vi.fn>;
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
const fetchMock = withFetchPreconnect(
vi.fn(async () => ({
ok: true,
status: 200,
json: async () => ({
data: [{ embedding: [0.1, 0.2] }, { embedding: [0.3, 0.4] }],
}),
})),
);
vi.stubGlobal("fetch", fetchMock);
vi.mocked(authModule.resolveApiKeyForProvider).mockResolvedValue({
apiKey: "voyage-key-123",

View File

@@ -43,7 +43,8 @@ describe("memory indexing with OpenAI batches", () => {
async function readOpenAIBatchUploadRequests(body: FormData) {
let uploadedRequests: Array<{ custom_id?: string }> = [];
for (const [key, value] of body.entries()) {
const entries = body.entries() as IterableIterator<[string, FormDataEntryValue]>;
for (const [key, value] of entries) {
if (key !== "file") {
continue;
}
@@ -51,7 +52,7 @@ describe("memory indexing with OpenAI batches", () => {
uploadedRequests = text
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line) as { custom_id?: string });
.map((line: string) => JSON.parse(line) as { custom_id?: string });
}
return uploadedRequests;
}

View File

@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as ssrf from "../../infra/net/ssrf.js";
import type { SavedMedia } from "../../media/store.js";
import * as mediaStore from "../../media/store.js";
import { withFetchPreconnect } from "../../test-utils/fetch-mock.js";
import {
fetchWithSlackAuth,
resolveSlackAttachmentContent,
@@ -23,7 +24,7 @@ describe("fetchWithSlackAuth", () => {
beforeEach(() => {
// Create a new mock for each test
mockFetch = vi.fn();
globalThis.fetch = mockFetch as typeof fetch;
globalThis.fetch = withFetchPreconnect(mockFetch);
});
afterEach(() => {
@@ -169,7 +170,7 @@ describe("fetchWithSlackAuth", () => {
describe("resolveSlackMedia", () => {
beforeEach(() => {
mockFetch = vi.fn();
globalThis.fetch = mockFetch as typeof fetch;
globalThis.fetch = withFetchPreconnect(mockFetch);
vi.spyOn(ssrf, "resolvePinnedHostname").mockImplementation(async (hostname) => {
const normalized = hostname.trim().toLowerCase().replace(/\.$/, "");
const addresses = ["93.184.216.34"];
@@ -432,7 +433,7 @@ describe("resolveSlackMedia", () => {
describe("resolveSlackAttachmentContent", () => {
beforeEach(() => {
mockFetch = vi.fn();
globalThis.fetch = mockFetch as typeof fetch;
globalThis.fetch = withFetchPreconnect(mockFetch);
vi.spyOn(ssrf, "resolvePinnedHostnameWithPolicy").mockImplementation(async (hostname) => {
const normalized = hostname.trim().toLowerCase().replace(/\.$/, "");
const addresses = ["93.184.216.34"];

View File

@@ -1001,6 +1001,44 @@ describe("createTelegramBot", () => {
expect(enqueueSystemEventSpy).not.toHaveBeenCalled();
});
it("enqueues one event per added emoji reaction", async () => {
onSpy.mockReset();
enqueueSystemEventSpy.mockReset();
loadConfig.mockReturnValue({
channels: {
telegram: { dmPolicy: "open", reactionNotifications: "all" },
},
});
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message_reaction") as (
ctx: Record<string, unknown>,
) => Promise<void>;
await handler({
update: { update_id: 505 },
messageReaction: {
chat: { id: 1234, type: "private" },
message_id: 42,
user: { id: 9, first_name: "Ada" },
date: 1736380800,
old_reaction: [{ type: "emoji", emoji: "👍" }],
new_reaction: [
{ type: "emoji", emoji: "👍" },
{ type: "emoji", emoji: "🔥" },
{ type: "emoji", emoji: "🎉" },
],
},
});
expect(enqueueSystemEventSpy).toHaveBeenCalledTimes(2);
expect(enqueueSystemEventSpy.mock.calls.map((call) => call[0])).toEqual([
"Telegram reaction added: 🔥 by Ada on msg 42",
"Telegram reaction added: 🎉 by Ada on msg 42",
]);
});
it("routes forum group reactions to the general topic (thread id not available on reactions)", async () => {
onSpy.mockReset();
enqueueSystemEventSpy.mockReset();

View File

@@ -1,4 +1,5 @@
import { type Mock, describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { withFetchPreconnect } from "../test-utils/fetch-mock.js";
import { probeTelegram } from "./probe.js";
describe("probeTelegram retry logic", () => {
@@ -38,7 +39,7 @@ describe("probeTelegram retry logic", () => {
beforeEach(() => {
vi.useFakeTimers();
fetchMock = vi.fn();
global.fetch = fetchMock;
global.fetch = withFetchPreconnect(fetchMock);
});
afterEach(() => {

View File

@@ -0,0 +1,6 @@
export const withFetchPreconnect = <T extends (...args: unknown[]) => unknown>(
fn: T,
): typeof fetch =>
Object.assign(fn, {
preconnect: () => {},
}) as unknown as typeof fetch;