From cc359d338ef959f7a706244922c5c8658ffe2282 Mon Sep 17 00:00:00 2001 From: Sebastian <19554889+sebslight@users.noreply.github.com> Date: Tue, 17 Feb 2026 09:01:30 -0500 Subject: [PATCH] test: add fetch mock helper and reaction coverage --- CHANGELOG.md | 1 + src/agents/chutes-oauth.e2e.test.ts | 9 ++-- ...minimax-vlm.normalizes-api-key.e2e.test.ts | 3 +- src/agents/model-scan.e2e.test.ts | 13 +++-- src/agents/tools/image-tool.e2e.test.ts | 7 +-- .../tools/web-fetch.cf-markdown.test.ts | 13 ++--- ...irecrawl-api-key-normalization.e2e.test.ts | 3 +- .../tools/web-fetch.response-limit.test.ts | 3 +- src/agents/tools/web-fetch.ssrf.e2e.test.ts | 3 +- .../web-tools.enabled-defaults.e2e.test.ts | 3 +- src/agents/tools/web-tools.fetch.e2e.test.ts | 3 +- ...-tab-available.prefers-last-target.test.ts | 3 +- .../server-context.remote-tab-ops.test.ts | 13 ++--- src/commands/chutes-oauth.e2e.test.ts | 7 +-- src/discord/api.test.ts | 18 ++++--- src/discord/resolve-channels.test.ts | 17 ++++--- src/infra/fetch.test.ts | 49 +++++++++++-------- .../provider-usage.fetch.antigravity.test.ts | 7 ++- .../media-understanding-misc.test.ts | 3 +- .../providers/deepgram/audio.test.ts | 9 ++-- .../providers/google/video.test.ts | 9 ++-- .../providers/openai/audio.test.ts | 9 ++-- src/memory/embeddings-voyage.test.ts | 35 +++++++------ src/memory/manager.batch.test.ts | 5 +- src/slack/monitor/media.test.ts | 7 +-- src/telegram/bot.test.ts | 38 ++++++++++++++ src/telegram/probe.test.ts | 3 +- src/test-utils/fetch-mock.ts | 6 +++ 28 files changed, 193 insertions(+), 106 deletions(-) create mode 100644 src/test-utils/fetch-mock.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f80c8a79c7..f414ce9bb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/agents/chutes-oauth.e2e.test.ts b/src/agents/chutes-oauth.e2e.test.ts index 70721bff75..dd960b4998 100644 --- a/src/agents/chutes-oauth.e2e.test.ts +++ b/src/agents/chutes-oauth.e2e.test.ts @@ -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({ diff --git a/src/agents/minimax-vlm.normalizes-api-key.e2e.test.ts b/src/agents/minimax-vlm.normalizes-api-key.e2e.test.ts index 50a8878f37..1b414370ee 100644 --- a/src/agents/minimax-vlm.normalizes-api-key.e2e.test.ts +++ b/src/agents/minimax-vlm.normalizes-api-key.e2e.test.ts @@ -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({ diff --git a/src/agents/model-scan.e2e.test.ts b/src/agents/model-scan.e2e.test.ts index 59f50861ad..87c457445e 100644 --- a/src/agents/model-scan.e2e.test.ts +++ b/src/agents/model-scan.e2e.test.ts @@ -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", () => { diff --git a/src/agents/tools/image-tool.e2e.test.ts b/src/agents/tools/image-tool.e2e.test.ts index da96786914..169b03fa15 100644 --- a/src/agents/tools/image-tool.e2e.test.ts +++ b/src/agents/tools/image-tool.e2e.test.ts @@ -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"); diff --git a/src/agents/tools/web-fetch.cf-markdown.test.ts b/src/agents/tools/web-fetch.cf-markdown.test.ts index 2afdd24346..6e7768fc43 100644 --- a/src/agents/tools/web-fetch.cf-markdown.test.ts +++ b/src/agents/tools/web-fetch.cf-markdown.test.ts @@ -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 Page

Content here.

"; 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); diff --git a/src/agents/tools/web-fetch.firecrawl-api-key-normalization.e2e.test.ts b/src/agents/tools/web-fetch.firecrawl-api-key-normalization.e2e.test.ts index fccff3a9bd..dd477c2b08 100644 --- a/src/agents/tools/web-fetch.firecrawl-api-key-normalization.e2e.test.ts +++ b/src/agents/tools/web-fetch.firecrawl-api-key-normalization.e2e.test.ts @@ -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({ diff --git a/src/agents/tools/web-fetch.response-limit.test.ts b/src/agents/tools/web-fetch.response-limit.test.ts index 48e96b994b..9b246b8a67 100644 --- a/src/agents/tools/web-fetch.response-limit.test.ts +++ b/src/agents/tools/web-fetch.response-limit.test.ts @@ -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" }); diff --git a/src/agents/tools/web-fetch.ssrf.e2e.test.ts b/src/agents/tools/web-fetch.ssrf.e2e.test.ts index db6c031f7b..e61b56952b 100644 --- a/src/agents/tools/web-fetch.ssrf.e2e.test.ts +++ b/src/agents/tools/web-fetch.ssrf.e2e.test.ts @@ -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; } diff --git a/src/agents/tools/web-tools.enabled-defaults.e2e.test.ts b/src/agents/tools/web-tools.enabled-defaults.e2e.test.ts index ff160d5808..b6f2f2f24e 100644 --- a/src/agents/tools/web-tools.enabled-defaults.e2e.test.ts +++ b/src/agents/tools/web-tools.enabled-defaults.e2e.test.ts @@ -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; } diff --git a/src/agents/tools/web-tools.fetch.e2e.test.ts b/src/agents/tools/web-tools.fetch.e2e.test.ts index 1ef58bf4ab..7764322449 100644 --- a/src/agents/tools/web-tools.fetch.e2e.test.ts +++ b/src/agents/tools/web-tools.fetch.e2e.test.ts @@ -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) { const mockFetch = vi.fn(async (input: RequestInfo | URL) => await impl(input)); - global.fetch = mockFetch as typeof global.fetch; + global.fetch = withFetchPreconnect(mockFetch); return mockFetch; } diff --git a/src/browser/server-context.ensure-tab-available.prefers-last-target.test.ts b/src/browser/server-context.ensure-tab-available.prefers-last-target.test.ts index 46e0be4a51..b3f15680de 100644 --- a/src/browser/server-context.ensure-tab-available.prefers-last-target.test.ts +++ b/src/browser/server-context.ensure-tab-available.prefers-last-target.test.ts @@ -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; } diff --git a/src/browser/server-context.remote-tab-ops.test.ts b/src/browser/server-context.remote-tab-ops.test.ts index 9d7f876bef..42cd53c1eb 100644 --- a/src/browser/server-context.remote-tab-ops.test.ts +++ b/src/browser/server-context.remote-tab-ops.test.ts @@ -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 }); diff --git a/src/commands/chutes-oauth.e2e.test.ts b/src/commands/chutes-oauth.e2e.test.ts index 254d25177e..812f0ba0a5 100644 --- a/src/commands/chutes-oauth.e2e.test.ts +++ b/src/commands/chutes-oauth.e2e.test.ts @@ -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 { @@ -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({ diff --git a/src/discord/api.test.ts b/src/discord/api.test.ts index 557f794239..a737e47bf3 100644 --- a/src/discord/api.test.ts +++ b/src/discord/api.test.ts @@ -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>( "/users/@me/guilds", "test", - fetcher as typeof fetch, + fetcher, { retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0 } }, ); diff --git a/src/discord/resolve-channels.test.ts b/src/discord/resolve-channels.test.ts index 5f5163f070..89ca9b416f 100644 --- a/src/discord/resolve-channels.test.ts +++ b/src/discord/resolve-channels.test.ts @@ -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/, // 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( diff --git a/src/infra/fetch.test.ts b/src/infra/fetch.test.ts index b46d75e427..3da1399944 100644 --- a/src/infra/fetch.test.ts +++ b/src/infra/fetch.test.ts @@ -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); diff --git a/src/infra/provider-usage.fetch.antigravity.test.ts b/src/infra/provider-usage.fetch.antigravity.test.ts index 83e01741a3..c85c28c6c8 100644 --- a/src/infra/provider-usage.fetch.antigravity.test.ts +++ b/src/infra/provider-usage.fetch.antigravity.test.ts @@ -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[0]): string => const createAntigravityFetch = ( handler: (url: string, init?: Parameters[1]) => Promise | 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[1]) => typeof init?.body === "string" ? init.body : undefined; diff --git a/src/media-understanding/media-understanding-misc.test.ts b/src/media-understanding/media-understanding-misc.test.ts index 97cd139014..b48b9a2117 100644 --- a/src/media-understanding/media-understanding-misc.test.ts +++ b/src/media-understanding/media-understanding-misc.test.ts @@ -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" }]); diff --git a/src/media-understanding/providers/deepgram/audio.test.ts b/src/media-understanding/providers/deepgram/audio.test.ts index 1ad4d6a929..dab4f9b0fc 100644 --- a/src/media-understanding/providers/deepgram/audio.test.ts +++ b/src/media-understanding/providers/deepgram/audio.test.ts @@ -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"), diff --git a/src/media-understanding/providers/google/video.test.ts b/src/media-understanding/providers/google/video.test.ts index 9675d9eaa8..fb2682d81f 100644 --- a/src/media-understanding/providers/google/video.test.ts +++ b/src/media-understanding/providers/google/video.test.ts @@ -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"), diff --git a/src/media-understanding/providers/openai/audio.test.ts b/src/media-understanding/providers/openai/audio.test.ts index c0b986881c..1e80a9ed52 100644 --- a/src/media-understanding/providers/openai/audio.test.ts +++ b/src/media-understanding/providers/openai/audio.test.ts @@ -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"), diff --git a/src/memory/embeddings-voyage.test.ts b/src/memory/embeddings-voyage.test.ts index e97c01de9f..371ab9bbeb 100644 --- a/src/memory/embeddings-voyage.test.ts +++ b/src/memory/embeddings-voyage.test.ts @@ -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; - 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; - 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; - 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", diff --git a/src/memory/manager.batch.test.ts b/src/memory/manager.batch.test.ts index 3f00114375..2ed5ad0b73 100644 --- a/src/memory/manager.batch.test.ts +++ b/src/memory/manager.batch.test.ts @@ -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; } diff --git a/src/slack/monitor/media.test.ts b/src/slack/monitor/media.test.ts index 81eec0a442..05b30615e8 100644 --- a/src/slack/monitor/media.test.ts +++ b/src/slack/monitor/media.test.ts @@ -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"]; diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index a5c31ad328..e8c2e39e0d 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -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, + ) => Promise; + + 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(); diff --git a/src/telegram/probe.test.ts b/src/telegram/probe.test.ts index fdc2808393..9b65c8e0f4 100644 --- a/src/telegram/probe.test.ts +++ b/src/telegram/probe.test.ts @@ -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(() => { diff --git a/src/test-utils/fetch-mock.ts b/src/test-utils/fetch-mock.ts new file mode 100644 index 0000000000..719152f03e --- /dev/null +++ b/src/test-utils/fetch-mock.ts @@ -0,0 +1,6 @@ +export const withFetchPreconnect = unknown>( + fn: T, +): typeof fetch => + Object.assign(fn, { + preconnect: () => {}, + }) as unknown as typeof fetch;