From a6b7f4aa09a0369b4278219e875428c5cb4a28ff Mon Sep 17 00:00:00 2001 From: myk1yt Date: Sat, 31 Oct 2026 02:25:47 +0900 Subject: [PATCH 1/5] perf(webview): replace axios in key-info hooks with fetch What: useOpenRouterKeyInfo, useRequestyKeyInfo and useOpenRouterModelProviders now call global fetch instead of axios.get, and axios is removed from webview-ui/package.json plus the webview importer block in pnpm-lock.yaml. Why: these three hooks were the only axios consumers in the webview bundle. axios shipped ~50 kB of entry-chunk code for three plain GET requests with no interceptors, no timeout config and no axios-specific error handling anywhere in the webview. Dropping it removes that weight from every webview load. Impact: entry chunk 6,183,743 B -> 6,133,127 B (-50,616 B, -0.82%). axios no longer appears in the entry bundle at all. Behavior is preserved by construction: characterization tests pin URL, method, headers, response shaping and the non-2xx throw decision, and the same 19 assertions pass unchanged against both the axios and the fetch implementation. src/ keeps its axios dependency; 14 production files there still import it. --- pnpm-lock.yaml | 3 - webview-ui/package.json | 1 - .../__tests__/useOpenRouterKeyInfo.spec.tsx | 131 +++++++++++++++ .../useOpenRouterModelProviders.spec.tsx | 157 ++++++++++++++++-- .../__tests__/useRequestyKeyInfo.spec.tsx | 130 +++++++++++++++ .../ui/hooks/useOpenRouterKeyInfo.ts | 10 +- .../ui/hooks/useOpenRouterModelProviders.ts | 10 +- .../components/ui/hooks/useRequestyKeyInfo.ts | 10 +- 8 files changed, 428 insertions(+), 24 deletions(-) create mode 100644 webview-ui/src/components/ui/hooks/__tests__/useOpenRouterKeyInfo.spec.tsx create mode 100644 webview-ui/src/components/ui/hooks/__tests__/useRequestyKeyInfo.spec.tsx diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4ca6b8edc6..04a8cd4f41 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -776,9 +776,6 @@ importers: ansi-to-html: specifier: ^0.7.2 version: 0.7.2 - axios: - specifier: ^1.18.1 - version: 1.20.0 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 diff --git a/webview-ui/package.json b/webview-ui/package.json index 2b455b95e4..efed2e76dd 100644 --- a/webview-ui/package.json +++ b/webview-ui/package.json @@ -38,7 +38,6 @@ "@vscode/codicons": "^0.0.45", "@vscode/webview-ui-toolkit": "^1.4.0", "ansi-to-html": "^0.7.2", - "axios": "^1.18.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.0.0", diff --git a/webview-ui/src/components/ui/hooks/__tests__/useOpenRouterKeyInfo.spec.tsx b/webview-ui/src/components/ui/hooks/__tests__/useOpenRouterKeyInfo.spec.tsx new file mode 100644 index 0000000000..472a43eac0 --- /dev/null +++ b/webview-ui/src/components/ui/hooks/__tests__/useOpenRouterKeyInfo.spec.tsx @@ -0,0 +1,131 @@ +import { renderHook, waitFor } from "@testing-library/react" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import React from "react" + +import { useOpenRouterKeyInfo } from "../useOpenRouterKeyInfo" + +// --- transport seam (phase 2: fetch) ----------------------------------------- +// Only this block changed between phase 1 (axios) and phase 2 (fetch). Every +// assertion below is shared by both phases, so the unchanged green run is the +// behavior-preservation proof. +type TransportCall = { url: string; method: string; headers: Record } +type TransportResult = { status: number; body: unknown } + +const stubTransport = (handler: (call: TransportCall) => TransportResult | Promise) => { + const calls: TransportCall[] = [] + + vi.spyOn(globalThis, "fetch").mockImplementation((async (input: string, init?: RequestInit) => { + const call: TransportCall = { + url: String(input), + method: init?.method ?? "GET", + headers: { ...((init?.headers ?? {}) as Record) }, + } + calls.push(call) + const result = await handler(call) + + return { + ok: result.status >= 200 && result.status < 300, + status: result.status, + json: async () => result.body, + } as Response + }) as typeof fetch) + + return { calls } +} +// ----------------------------------------------------------------------------- + +const KEY_INFO = { + label: "sk-or-v1-...abc", + usage: 12.5, + is_free_tier: false, + is_provisioning_key: false, + rate_limit: { requests: 60, interval: "1m" }, + limit: 100, +} + +const wrapper = ({ children }: React.PropsWithChildren) => ( + + {children} + +) + +describe("useOpenRouterKeyInfo", () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it("returns undefined and issues no request when apiKey is missing", async () => { + const { calls } = stubTransport(() => ({ status: 200, body: { data: KEY_INFO } })) + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}) + + const { result } = renderHook(() => useOpenRouterKeyInfo(undefined), { wrapper }) + + await waitFor(() => expect(result.current.fetchStatus).toBe("idle")) + expect(result.current.data).toBeUndefined() + expect(calls).toHaveLength(0) + expect(consoleError).not.toHaveBeenCalled() + }) + + it("requests the default key endpoint with a bearer token and returns the parsed key info", async () => { + const { calls } = stubTransport(() => ({ status: 200, body: { data: KEY_INFO } })) + + const { result } = renderHook(() => useOpenRouterKeyInfo("sk-or-v1-test"), { wrapper }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(result.current.data).toEqual(KEY_INFO) + expect(calls).toEqual([ + { + url: "https://openrouter.ai/api/v1/key", + method: "GET", + headers: { Authorization: "Bearer sk-or-v1-test" }, + }, + ]) + }) + + it("uses a caller-supplied baseUrl verbatim", async () => { + const { calls } = stubTransport(() => ({ status: 200, body: { data: KEY_INFO } })) + + const { result } = renderHook(() => useOpenRouterKeyInfo("sk-or-v1-test", "https://proxy.example.com/api/v1"), { + wrapper, + }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(calls[0].url).toBe("https://proxy.example.com/api/v1/key") + }) + + it("returns null and logs a validation failure when the payload does not match the schema", async () => { + stubTransport(() => ({ status: 200, body: { data: { label: "only-label" } } })) + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}) + + const { result } = renderHook(() => useOpenRouterKeyInfo("sk-or-v1-test"), { wrapper }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(result.current.data).toBeNull() + expect(consoleError).toHaveBeenCalledWith("OpenRouter API key info validation failed:", expect.anything()) + }) + + it("returns null and logs a fetch failure when the response is not 2xx", async () => { + stubTransport(() => ({ status: 401, body: { error: { message: "No auth credentials found" } } })) + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}) + + const { result } = renderHook(() => useOpenRouterKeyInfo("sk-or-v1-test"), { wrapper }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(result.current.data).toBeNull() + expect(consoleError).toHaveBeenCalledWith("Error fetching OpenRouter key info:", expect.anything()) + }) + + it("returns null and logs a fetch failure when the request rejects", async () => { + const failure = new Error("network down") + stubTransport(() => { + throw failure + }) + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}) + + const { result } = renderHook(() => useOpenRouterKeyInfo("sk-or-v1-test"), { wrapper }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(result.current.data).toBeNull() + expect(consoleError).toHaveBeenCalledWith("Error fetching OpenRouter key info:", failure) + }) +}) diff --git a/webview-ui/src/components/ui/hooks/__tests__/useOpenRouterModelProviders.spec.tsx b/webview-ui/src/components/ui/hooks/__tests__/useOpenRouterModelProviders.spec.tsx index 7b3dda7e24..c344db8e36 100644 --- a/webview-ui/src/components/ui/hooks/__tests__/useOpenRouterModelProviders.spec.tsx +++ b/webview-ui/src/components/ui/hooks/__tests__/useOpenRouterModelProviders.spec.tsx @@ -1,12 +1,62 @@ -import React from "react" -import axios from "axios" -import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import { renderHook, waitFor } from "@testing-library/react" -import { z } from "zod" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import React from "react" import { useOpenRouterModelProviders } from "../useOpenRouterModelProviders" -vi.mock("axios") +// --- transport seam (phase 2: fetch) ----------------------------------------- +// Only this block changed between phase 1 (axios) and phase 2 (fetch). Every +// assertion below is shared by both phases, so the unchanged green run is the +// behavior-preservation proof. +type TransportCall = { url: string; method: string; headers: Record } +type TransportResult = { status: number; body: unknown } + +const stubTransport = (handler: (call: TransportCall) => TransportResult | Promise) => { + const calls: TransportCall[] = [] + + vi.spyOn(globalThis, "fetch").mockImplementation((async (input: string, init?: RequestInit) => { + const call: TransportCall = { + url: String(input), + method: init?.method ?? "GET", + headers: { ...((init?.headers ?? {}) as Record) }, + } + calls.push(call) + const result = await handler(call) + + return { + ok: result.status >= 200 && result.status < 300, + status: result.status, + json: async () => result.body, + } as Response + }) as typeof fetch) + + return { calls } +} +// ----------------------------------------------------------------------------- + +const endpointsPayload = (overrides: Record = {}) => ({ + data: { + id: "vendor/model", + name: "Vendor Model", + description: "A test model", + architecture: { input_modalities: ["text", "image"], output_modalities: ["text"] }, + endpoints: [ + { + name: "Provider A", + tag: "provider-a", + context_length: 128000, + max_completion_tokens: 4096, + pricing: { prompt: "0.000003", completion: "0.000015", input_cache_read: "0.0000003" }, + }, + { + name: "Provider B", + context_length: 32000, + pricing: { prompt: "0.000001", completion: "0.000002" }, + }, + ], + ...overrides, + }, +}) const wrapper = ({ children }: React.PropsWithChildren) => ( @@ -19,21 +69,106 @@ describe("useOpenRouterModelProviders", () => { vi.restoreAllMocks() }) - it("logs the zod issues and returns no providers when validation throws", async () => { - const zodError = new z.ZodError([{ code: "custom", path: ["data"], message: "bad data" }]) - vi.mocked(axios.get).mockRejectedValue(zodError) + it("returns an empty record without a request when modelId is missing", async () => { + const { calls } = stubTransport(() => ({ status: 200, body: endpointsPayload() })) + + const { result } = renderHook(() => useOpenRouterModelProviders(undefined), { wrapper }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(result.current.data).toEqual({}) + expect(calls).toHaveLength(0) + }) + + it("requests the endpoints URL and maps each endpoint to provider model info", async () => { + const { calls } = stubTransport(() => ({ status: 200, body: endpointsPayload() })) + + const { result } = renderHook(() => useOpenRouterModelProviders("vendor/model"), { wrapper }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(calls).toEqual([ + { url: "https://openrouter.ai/api/v1/models/vendor/model/endpoints", method: "GET", headers: {} }, + ]) + + expect(result.current.data).toEqual({ + "provider-a": { + maxTokens: 4096, + contextWindow: 128000, + supportsImages: true, + supportsPromptCache: true, + cacheReadsPrice: 0.3, + cacheWritesPrice: undefined, + inputPrice: 3, + outputPrice: 15, + description: "A test model", + label: "provider-a", + }, + "Provider B": { + maxTokens: 32000, + contextWindow: 32000, + supportsImages: true, + supportsPromptCache: false, + cacheReadsPrice: undefined, + cacheWritesPrice: undefined, + inputPrice: 1, + outputPrice: 2, + description: "A test model", + label: "Provider B", + }, + }) + }) + + it("uses a caller-supplied baseUrl", async () => { + const { calls } = stubTransport(() => ({ status: 200, body: endpointsPayload() })) + + const { result } = renderHook( + () => useOpenRouterModelProviders("vendor/model", "https://proxy.example.com/api/v1"), + { wrapper }, + ) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(calls[0].url).toBe("https://proxy.example.com/api/v1/models/vendor/model/endpoints") + }) + + it("returns no providers for image-generation models", async () => { + stubTransport(() => ({ + status: 200, + body: endpointsPayload({ architecture: { input_modalities: ["text"], output_modalities: ["image"] } }), + })) + + const { result } = renderHook(() => useOpenRouterModelProviders("vendor/model"), { wrapper }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(result.current.data).toEqual({}) + }) + + it("logs a validation failure and returns no providers when the payload does not match the schema", async () => { + stubTransport(() => ({ status: 200, body: { data: { id: "vendor/model" } } })) + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}) + + const { result } = renderHook(() => useOpenRouterModelProviders("vendor/model"), { wrapper }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(result.current.data).toEqual({}) + expect(consoleError).toHaveBeenCalledWith("OpenRouter API response validation failed:", expect.anything()) + }) + + it("logs a fetch failure and returns no providers when the response is not 2xx", async () => { + stubTransport(() => ({ status: 500, body: { error: "boom" } })) const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}) const { result } = renderHook(() => useOpenRouterModelProviders("vendor/model"), { wrapper }) await waitFor(() => expect(result.current.isSuccess).toBe(true)) expect(result.current.data).toEqual({}) - expect(consoleError).toHaveBeenCalledWith("OpenRouter API response validation failed:", zodError.issues) + expect(consoleError).toHaveBeenCalledWith("Error fetching OpenRouter providers:", expect.anything()) }) - it("logs other errors and returns no providers", async () => { + it("logs a fetch failure and returns no providers when the request rejects", async () => { const failure = new Error("network down") - vi.mocked(axios.get).mockRejectedValue(failure) + stubTransport(() => { + throw failure + }) const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}) const { result } = renderHook(() => useOpenRouterModelProviders("vendor/model"), { wrapper }) diff --git a/webview-ui/src/components/ui/hooks/__tests__/useRequestyKeyInfo.spec.tsx b/webview-ui/src/components/ui/hooks/__tests__/useRequestyKeyInfo.spec.tsx new file mode 100644 index 0000000000..1c76c4db98 --- /dev/null +++ b/webview-ui/src/components/ui/hooks/__tests__/useRequestyKeyInfo.spec.tsx @@ -0,0 +1,130 @@ +import { renderHook, waitFor } from "@testing-library/react" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import React from "react" + +import { useRequestyKeyInfo } from "../useRequestyKeyInfo" + +// --- transport seam (phase 2: fetch) ----------------------------------------- +// Only this block changed between phase 1 (axios) and phase 2 (fetch). Every +// assertion below is shared by both phases, so the unchanged green run is the +// behavior-preservation proof. +type TransportCall = { url: string; method: string; headers: Record } +type TransportResult = { status: number; body: unknown } + +const stubTransport = (handler: (call: TransportCall) => TransportResult | Promise) => { + const calls: TransportCall[] = [] + + vi.spyOn(globalThis, "fetch").mockImplementation((async (input: string, init?: RequestInit) => { + const call: TransportCall = { + url: String(input), + method: init?.method ?? "GET", + headers: { ...((init?.headers ?? {}) as Record) }, + } + calls.push(call) + const result = await handler(call) + + return { + ok: result.status >= 200 && result.status < 300, + status: result.status, + json: async () => result.body, + } as Response + }) as typeof fetch) + + return { calls } +} +// ----------------------------------------------------------------------------- + +const KEY_INFO = { + name: "my-key", + monthly_limit: "20", + monthly_spend: "1.25", + org_balance: "18.75", + config: { aliases: { "gpt-4o": "openai/gpt-4o" } }, +} + +const wrapper = ({ children }: React.PropsWithChildren) => ( + + {children} + +) + +describe("useRequestyKeyInfo", () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it("returns undefined and issues no request when apiKey is missing", async () => { + const { calls } = stubTransport(() => ({ status: 200, body: KEY_INFO })) + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}) + + const { result } = renderHook(() => useRequestyKeyInfo(undefined, undefined), { wrapper }) + + await waitFor(() => expect(result.current.fetchStatus).toBe("idle")) + expect(result.current.data).toBeUndefined() + expect(calls).toHaveLength(0) + expect(consoleError).not.toHaveBeenCalled() + }) + + it("requests the default api key endpoint with bearer auth and returns the parsed key info", async () => { + const { calls } = stubTransport(() => ({ status: 200, body: KEY_INFO })) + + const { result } = renderHook(() => useRequestyKeyInfo(undefined, "test-key"), { wrapper }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(result.current.data).toEqual(KEY_INFO) + expect(calls).toEqual([ + { + url: "https://api.requesty.ai/x/apikey", + method: "GET", + headers: { Authorization: "Bearer test-key", "Content-Type": "application/json" }, + }, + ]) + }) + + it("keeps a caller-supplied host that does not contain 'router' and drops the version suffix", async () => { + const { calls } = stubTransport(() => ({ status: 200, body: KEY_INFO })) + + const { result } = renderHook(() => useRequestyKeyInfo("https://custom.requesty.ai/v1", "test-key"), { + wrapper, + }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(calls[0].url).toBe("https://custom.requesty.ai/x/apikey") + }) + + it("returns null and logs a validation failure when the payload does not match the schema", async () => { + stubTransport(() => ({ status: 200, body: { name: "my-key" } })) + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}) + + const { result } = renderHook(() => useRequestyKeyInfo(undefined, "test-key"), { wrapper }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(result.current.data).toBeNull() + expect(consoleError).toHaveBeenCalledWith("Requesty API key info validation failed:", expect.anything()) + }) + + it("returns null and logs a fetch failure when the response is not 2xx", async () => { + stubTransport(() => ({ status: 403, body: { detail: "forbidden" } })) + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}) + + const { result } = renderHook(() => useRequestyKeyInfo(undefined, "test-key"), { wrapper }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(result.current.data).toBeNull() + expect(consoleError).toHaveBeenCalledWith("Error fetching Requesty key info:", expect.anything()) + }) + + it("returns null and logs a fetch failure when the request rejects", async () => { + const failure = new Error("network down") + stubTransport(() => { + throw failure + }) + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}) + + const { result } = renderHook(() => useRequestyKeyInfo(undefined, "test-key"), { wrapper }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(result.current.data).toBeNull() + expect(consoleError).toHaveBeenCalledWith("Error fetching Requesty key info:", failure) + }) +}) diff --git a/webview-ui/src/components/ui/hooks/useOpenRouterKeyInfo.ts b/webview-ui/src/components/ui/hooks/useOpenRouterKeyInfo.ts index 44b7aaade5..406da24eb9 100644 --- a/webview-ui/src/components/ui/hooks/useOpenRouterKeyInfo.ts +++ b/webview-ui/src/components/ui/hooks/useOpenRouterKeyInfo.ts @@ -1,4 +1,3 @@ -import axios from "axios" import { z } from "zod" import { useQuery, UseQueryOptions } from "@tanstack/react-query" @@ -28,13 +27,18 @@ async function getOpenRouterKeyInfo(apiKey?: string, baseUrl?: string) { const keyEndpoint = `${apiBaseUrl}/key` - const response = await axios.get(keyEndpoint, { + const response = await fetch(keyEndpoint, { + method: "GET", headers: { Authorization: `Bearer ${apiKey}`, }, }) - const result = openRouterKeyInfoSchema.safeParse(response.data) + if (!response.ok) { + throw new Error(`Request failed with status code ${response.status}`) + } + + const result = openRouterKeyInfoSchema.safeParse(await response.json()) if (!result.success) { console.error("OpenRouter API key info validation failed:", result.error) return null diff --git a/webview-ui/src/components/ui/hooks/useOpenRouterModelProviders.ts b/webview-ui/src/components/ui/hooks/useOpenRouterModelProviders.ts index 1994bdb9a8..038829cbad 100644 --- a/webview-ui/src/components/ui/hooks/useOpenRouterModelProviders.ts +++ b/webview-ui/src/components/ui/hooks/useOpenRouterModelProviders.ts @@ -1,4 +1,3 @@ -import axios from "axios" import { z } from "zod" import { useQuery, UseQueryOptions } from "@tanstack/react-query" @@ -48,8 +47,13 @@ async function getOpenRouterProvidersForModel(modelId: string, baseUrl?: string) try { const apiBaseUrl = baseUrl || "https://openrouter.ai/api/v1" - const response = await axios.get(`${apiBaseUrl}/models/${modelId}/endpoints`) - const result = openRouterEndpointsSchema.safeParse(response.data) + const response = await fetch(`${apiBaseUrl}/models/${modelId}/endpoints`, { method: "GET" }) + + if (!response.ok) { + throw new Error(`Request failed with status code ${response.status}`) + } + + const result = openRouterEndpointsSchema.safeParse(await response.json()) if (!result.success) { console.error("OpenRouter API response validation failed:", result.error) diff --git a/webview-ui/src/components/ui/hooks/useRequestyKeyInfo.ts b/webview-ui/src/components/ui/hooks/useRequestyKeyInfo.ts index dd61c17eee..defc295173 100644 --- a/webview-ui/src/components/ui/hooks/useRequestyKeyInfo.ts +++ b/webview-ui/src/components/ui/hooks/useRequestyKeyInfo.ts @@ -1,4 +1,3 @@ -import axios from "axios" import { z } from "zod" import { useQuery, UseQueryOptions } from "@tanstack/react-query" import { toRequestyServiceUrl } from "@roo/utils/requesty" @@ -22,14 +21,19 @@ async function getRequestyKeyInfo(baseUrl?: string, apiKey?: string) { const apiKeyUrl = new URL("x/apikey", url) try { - const response = await axios.get(apiKeyUrl.toString(), { + const response = await fetch(apiKeyUrl.toString(), { + method: "GET", headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", }, }) - const result = requestyKeyInfoSchema.safeParse(response.data) + if (!response.ok) { + throw new Error(`Request failed with status code ${response.status}`) + } + + const result = requestyKeyInfoSchema.safeParse(await response.json()) if (!result.success) { console.error("Requesty API key info validation failed:", result.error) return null From ff30dc207963890c309209228067fdb81dc48191 Mon Sep 17 00:00:00 2001 From: myk1yt Date: Sat, 31 Oct 2026 02:51:30 +0900 Subject: [PATCH 2/5] lint(webview): forbid re-importing axios in webview sources What: webview-ui/eslint.config.mjs gains a no-restricted-imports block scoped to src/**/*.{ts,tsx} that rejects `axios` with a message explaining the scoped dependency drop. Why: axios was removed from webview-ui/package.json, but pnpm's hoisted store still resolves it, so a new `import axios from "axios"` would silently reintroduce the dependency instead of failing loudly. The guard turns the "axios removed" claim from incidental into enforced. Impact: no runtime change. A webview file importing axios now fails lint with exit 1. src/ is unaffected and keeps its axios dependency. --- webview-ui/eslint.config.mjs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/webview-ui/eslint.config.mjs b/webview-ui/eslint.config.mjs index 11a49e9b41..8deee268b5 100644 --- a/webview-ui/eslint.config.mjs +++ b/webview-ui/eslint.config.mjs @@ -40,6 +40,23 @@ export default [ "no-case-declarations": "off", }, }, + { + files: ["src/**/*.{ts,tsx}"], + rules: { + "no-restricted-imports": [ + "error", + { + paths: [ + { + name: "axios", + message: + "axios was removed from the webview bundle in favour of global fetch. Use fetch() for network calls. The dependency is no longer declared in webview-ui/package.json, so an import here only resolves through the hoisted store and would fail at build time.", + }, + ], + }, + ], + }, + }, { files: ["src/__mocks__/**/*.js"], rules: { From 31410754d2874dec43b81553a222fb5dbfc663a2 Mon Sep 17 00:00:00 2001 From: myk1yt Date: Sat, 31 Oct 2026 02:51:35 +0900 Subject: [PATCH 3/5] test(webview): pin the unparseable-2xx-body path in the key-info hooks What: one new case per hook covering a 2xx response whose body is not valid JSON, so response.json() rejects inside the try block. Each asserts the same log call and fallback value as the existing blanket-catch cases. Why: the fetch swap moved body parsing inside the try, so a malformed 2xx body now flows through the catch instead of axios' parse step. That path was uncovered. The transport seam gained an optional jsonRejects field so json() can reject while ok stays true. Impact: no production change. Verified fail-on-shape: with the hook's catch changed to rethrow, the new case fails at isSuccess, so it pins the swallow rather than merely exercising the line. --- .../__tests__/useOpenRouterKeyInfo.spec.tsx | 21 +++++++++++++++++-- .../useOpenRouterModelProviders.spec.tsx | 21 +++++++++++++++++-- .../__tests__/useRequestyKeyInfo.spec.tsx | 21 +++++++++++++++++-- 3 files changed, 57 insertions(+), 6 deletions(-) diff --git a/webview-ui/src/components/ui/hooks/__tests__/useOpenRouterKeyInfo.spec.tsx b/webview-ui/src/components/ui/hooks/__tests__/useOpenRouterKeyInfo.spec.tsx index 472a43eac0..c7db38df4a 100644 --- a/webview-ui/src/components/ui/hooks/__tests__/useOpenRouterKeyInfo.spec.tsx +++ b/webview-ui/src/components/ui/hooks/__tests__/useOpenRouterKeyInfo.spec.tsx @@ -9,7 +9,7 @@ import { useOpenRouterKeyInfo } from "../useOpenRouterKeyInfo" // assertion below is shared by both phases, so the unchanged green run is the // behavior-preservation proof. type TransportCall = { url: string; method: string; headers: Record } -type TransportResult = { status: number; body: unknown } +type TransportResult = { status: number; body: unknown; jsonRejects?: Error } const stubTransport = (handler: (call: TransportCall) => TransportResult | Promise) => { const calls: TransportCall[] = [] @@ -26,7 +26,12 @@ const stubTransport = (handler: (call: TransportCall) => TransportResult | Promi return { ok: result.status >= 200 && result.status < 300, status: result.status, - json: async () => result.body, + json: async () => { + if (result.jsonRejects) { + throw result.jsonRejects + } + return result.body + }, } as Response }) as typeof fetch) @@ -128,4 +133,16 @@ describe("useOpenRouterKeyInfo", () => { expect(result.current.data).toBeNull() expect(consoleError).toHaveBeenCalledWith("Error fetching OpenRouter key info:", failure) }) + + it("returns null and logs a fetch failure when a 2xx body is not valid JSON", async () => { + const parseFailure = new SyntaxError("Unexpected token '<', \"\" is not valid JSON") + stubTransport(() => ({ status: 200, body: undefined, jsonRejects: parseFailure })) + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}) + + const { result } = renderHook(() => useOpenRouterKeyInfo("sk-or-v1-test"), { wrapper }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(result.current.data).toBeNull() + expect(consoleError).toHaveBeenCalledWith("Error fetching OpenRouter key info:", parseFailure) + }) }) diff --git a/webview-ui/src/components/ui/hooks/__tests__/useOpenRouterModelProviders.spec.tsx b/webview-ui/src/components/ui/hooks/__tests__/useOpenRouterModelProviders.spec.tsx index c344db8e36..656b1cd55b 100644 --- a/webview-ui/src/components/ui/hooks/__tests__/useOpenRouterModelProviders.spec.tsx +++ b/webview-ui/src/components/ui/hooks/__tests__/useOpenRouterModelProviders.spec.tsx @@ -9,7 +9,7 @@ import { useOpenRouterModelProviders } from "../useOpenRouterModelProviders" // assertion below is shared by both phases, so the unchanged green run is the // behavior-preservation proof. type TransportCall = { url: string; method: string; headers: Record } -type TransportResult = { status: number; body: unknown } +type TransportResult = { status: number; body: unknown; jsonRejects?: Error } const stubTransport = (handler: (call: TransportCall) => TransportResult | Promise) => { const calls: TransportCall[] = [] @@ -26,7 +26,12 @@ const stubTransport = (handler: (call: TransportCall) => TransportResult | Promi return { ok: result.status >= 200 && result.status < 300, status: result.status, - json: async () => result.body, + json: async () => { + if (result.jsonRejects) { + throw result.jsonRejects + } + return result.body + }, } as Response }) as typeof fetch) @@ -177,4 +182,16 @@ describe("useOpenRouterModelProviders", () => { expect(result.current.data).toEqual({}) expect(consoleError).toHaveBeenCalledWith("Error fetching OpenRouter providers:", failure) }) + + it("logs a fetch failure and returns no providers when a 2xx body is not valid JSON", async () => { + const parseFailure = new SyntaxError("Unexpected token '<', \"\" is not valid JSON") + stubTransport(() => ({ status: 200, body: undefined, jsonRejects: parseFailure })) + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}) + + const { result } = renderHook(() => useOpenRouterModelProviders("vendor/model"), { wrapper }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(result.current.data).toEqual({}) + expect(consoleError).toHaveBeenCalledWith("Error fetching OpenRouter providers:", parseFailure) + }) }) diff --git a/webview-ui/src/components/ui/hooks/__tests__/useRequestyKeyInfo.spec.tsx b/webview-ui/src/components/ui/hooks/__tests__/useRequestyKeyInfo.spec.tsx index 1c76c4db98..2ceff0ffe6 100644 --- a/webview-ui/src/components/ui/hooks/__tests__/useRequestyKeyInfo.spec.tsx +++ b/webview-ui/src/components/ui/hooks/__tests__/useRequestyKeyInfo.spec.tsx @@ -9,7 +9,7 @@ import { useRequestyKeyInfo } from "../useRequestyKeyInfo" // assertion below is shared by both phases, so the unchanged green run is the // behavior-preservation proof. type TransportCall = { url: string; method: string; headers: Record } -type TransportResult = { status: number; body: unknown } +type TransportResult = { status: number; body: unknown; jsonRejects?: Error } const stubTransport = (handler: (call: TransportCall) => TransportResult | Promise) => { const calls: TransportCall[] = [] @@ -26,7 +26,12 @@ const stubTransport = (handler: (call: TransportCall) => TransportResult | Promi return { ok: result.status >= 200 && result.status < 300, status: result.status, - json: async () => result.body, + json: async () => { + if (result.jsonRejects) { + throw result.jsonRejects + } + return result.body + }, } as Response }) as typeof fetch) @@ -127,4 +132,16 @@ describe("useRequestyKeyInfo", () => { expect(result.current.data).toBeNull() expect(consoleError).toHaveBeenCalledWith("Error fetching Requesty key info:", failure) }) + + it("returns null and logs a fetch failure when a 2xx body is not valid JSON", async () => { + const parseFailure = new SyntaxError("Unexpected token '<', \"\" is not valid JSON") + stubTransport(() => ({ status: 200, body: undefined, jsonRejects: parseFailure })) + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}) + + const { result } = renderHook(() => useRequestyKeyInfo(undefined, "test-key"), { wrapper }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(result.current.data).toBeNull() + expect(consoleError).toHaveBeenCalledWith("Error fetching Requesty key info:", parseFailure) + }) }) From 1d00d075160df5d19301cbb698f872f1858ea366 Mon Sep 17 00:00:00 2001 From: myk1yt Date: Sat, 31 Oct 2026 04:02:21 +0900 Subject: [PATCH 4/5] test(webview): assert the diagnostic in key-info hook error matchers What: the non-2xx cases now match the logged Error's message exactly ("Request failed with status code 401/403/500") instead of expect.anything(). The schema-validation cases now match the logged ZodError's issues array and a real issue path instead of expect.anything(). Why: expect.anything() accepts any value, including an Error with an empty message, so a regression that dropped the HTTP status from the thrown message would still pass. The same looseness applied to the validation-failure logs, which is the adjacent instance of the pattern. Impact: no production change. Verified the tightened matchers bite by temporarily degrading each hook (message without status, validation log replaced by a generic Error): in both cases exactly the targeted test fails. --- .../ui/hooks/__tests__/useOpenRouterKeyInfo.spec.tsx | 12 ++++++++++-- .../__tests__/useOpenRouterModelProviders.spec.tsx | 12 ++++++++++-- .../ui/hooks/__tests__/useRequestyKeyInfo.spec.tsx | 12 ++++++++++-- 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/webview-ui/src/components/ui/hooks/__tests__/useOpenRouterKeyInfo.spec.tsx b/webview-ui/src/components/ui/hooks/__tests__/useOpenRouterKeyInfo.spec.tsx index c7db38df4a..030b64b614 100644 --- a/webview-ui/src/components/ui/hooks/__tests__/useOpenRouterKeyInfo.spec.tsx +++ b/webview-ui/src/components/ui/hooks/__tests__/useOpenRouterKeyInfo.spec.tsx @@ -106,7 +106,12 @@ describe("useOpenRouterKeyInfo", () => { await waitFor(() => expect(result.current.isSuccess).toBe(true)) expect(result.current.data).toBeNull() - expect(consoleError).toHaveBeenCalledWith("OpenRouter API key info validation failed:", expect.anything()) + expect(consoleError).toHaveBeenCalledWith( + "OpenRouter API key info validation failed:", + expect.objectContaining({ + issues: expect.arrayContaining([expect.objectContaining({ path: ["data", "usage"] })]), + }), + ) }) it("returns null and logs a fetch failure when the response is not 2xx", async () => { @@ -117,7 +122,10 @@ describe("useOpenRouterKeyInfo", () => { await waitFor(() => expect(result.current.isSuccess).toBe(true)) expect(result.current.data).toBeNull() - expect(consoleError).toHaveBeenCalledWith("Error fetching OpenRouter key info:", expect.anything()) + expect(consoleError).toHaveBeenCalledWith( + "Error fetching OpenRouter key info:", + expect.objectContaining({ message: "Request failed with status code 401" }), + ) }) it("returns null and logs a fetch failure when the request rejects", async () => { diff --git a/webview-ui/src/components/ui/hooks/__tests__/useOpenRouterModelProviders.spec.tsx b/webview-ui/src/components/ui/hooks/__tests__/useOpenRouterModelProviders.spec.tsx index 656b1cd55b..22cc5e06a0 100644 --- a/webview-ui/src/components/ui/hooks/__tests__/useOpenRouterModelProviders.spec.tsx +++ b/webview-ui/src/components/ui/hooks/__tests__/useOpenRouterModelProviders.spec.tsx @@ -155,7 +155,12 @@ describe("useOpenRouterModelProviders", () => { await waitFor(() => expect(result.current.isSuccess).toBe(true)) expect(result.current.data).toEqual({}) - expect(consoleError).toHaveBeenCalledWith("OpenRouter API response validation failed:", expect.anything()) + expect(consoleError).toHaveBeenCalledWith( + "OpenRouter API response validation failed:", + expect.objectContaining({ + issues: expect.arrayContaining([expect.objectContaining({ path: ["data", "endpoints"] })]), + }), + ) }) it("logs a fetch failure and returns no providers when the response is not 2xx", async () => { @@ -166,7 +171,10 @@ describe("useOpenRouterModelProviders", () => { await waitFor(() => expect(result.current.isSuccess).toBe(true)) expect(result.current.data).toEqual({}) - expect(consoleError).toHaveBeenCalledWith("Error fetching OpenRouter providers:", expect.anything()) + expect(consoleError).toHaveBeenCalledWith( + "Error fetching OpenRouter providers:", + expect.objectContaining({ message: "Request failed with status code 500" }), + ) }) it("logs a fetch failure and returns no providers when the request rejects", async () => { diff --git a/webview-ui/src/components/ui/hooks/__tests__/useRequestyKeyInfo.spec.tsx b/webview-ui/src/components/ui/hooks/__tests__/useRequestyKeyInfo.spec.tsx index 2ceff0ffe6..5677220268 100644 --- a/webview-ui/src/components/ui/hooks/__tests__/useRequestyKeyInfo.spec.tsx +++ b/webview-ui/src/components/ui/hooks/__tests__/useRequestyKeyInfo.spec.tsx @@ -105,7 +105,12 @@ describe("useRequestyKeyInfo", () => { await waitFor(() => expect(result.current.isSuccess).toBe(true)) expect(result.current.data).toBeNull() - expect(consoleError).toHaveBeenCalledWith("Requesty API key info validation failed:", expect.anything()) + expect(consoleError).toHaveBeenCalledWith( + "Requesty API key info validation failed:", + expect.objectContaining({ + issues: expect.arrayContaining([expect.objectContaining({ path: ["monthly_limit"] })]), + }), + ) }) it("returns null and logs a fetch failure when the response is not 2xx", async () => { @@ -116,7 +121,10 @@ describe("useRequestyKeyInfo", () => { await waitFor(() => expect(result.current.isSuccess).toBe(true)) expect(result.current.data).toBeNull() - expect(consoleError).toHaveBeenCalledWith("Error fetching Requesty key info:", expect.anything()) + expect(consoleError).toHaveBeenCalledWith( + "Error fetching Requesty key info:", + expect.objectContaining({ message: "Request failed with status code 403" }), + ) }) it("returns null and logs a fetch failure when the request rejects", async () => { From 1eac04389bed39b019c3ccf4dd8bd2341d67e2d9 Mon Sep 17 00:00:00 2001 From: Elliott de Launay Date: Sat, 31 Oct 2026 19:37:12 +0000 Subject: [PATCH 5/5] test(webview): strengthen fetch hook coverage and lint guardrails --- webview-ui/eslint.config.mjs | 5 ++ .../__tests__/useOpenRouterKeyInfo.spec.tsx | 39 +----------- .../useOpenRouterModelProviders.spec.tsx | 61 +++++++------------ .../__tests__/useRequestyKeyInfo.spec.tsx | 39 +----------- .../ui/hooks/useOpenRouterModelProviders.ts | 6 +- webview-ui/src/utils/test-utils.tsx | 33 ++++++++++ 6 files changed, 68 insertions(+), 115 deletions(-) diff --git a/webview-ui/eslint.config.mjs b/webview-ui/eslint.config.mjs index 8b2cd5f3b3..c8b5b49bfb 100644 --- a/webview-ui/eslint.config.mjs +++ b/webview-ui/eslint.config.mjs @@ -80,6 +80,11 @@ export default [ "error", { paths: [ + { + name: "axios", + message: + "axios was removed from the webview bundle in favour of global fetch. Use fetch() for network calls. The dependency is no longer declared in webview-ui/package.json, so an import here only resolves through the hoisted store and would fail at build time.", + }, { name: "@tanstack/react-query", importNames: ["QueryClientProvider"], diff --git a/webview-ui/src/components/ui/hooks/__tests__/useOpenRouterKeyInfo.spec.tsx b/webview-ui/src/components/ui/hooks/__tests__/useOpenRouterKeyInfo.spec.tsx index 030b64b614..2e7bf65c24 100644 --- a/webview-ui/src/components/ui/hooks/__tests__/useOpenRouterKeyInfo.spec.tsx +++ b/webview-ui/src/components/ui/hooks/__tests__/useOpenRouterKeyInfo.spec.tsx @@ -2,42 +2,9 @@ import { renderHook, waitFor } from "@testing-library/react" import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import React from "react" -import { useOpenRouterKeyInfo } from "../useOpenRouterKeyInfo" - -// --- transport seam (phase 2: fetch) ----------------------------------------- -// Only this block changed between phase 1 (axios) and phase 2 (fetch). Every -// assertion below is shared by both phases, so the unchanged green run is the -// behavior-preservation proof. -type TransportCall = { url: string; method: string; headers: Record } -type TransportResult = { status: number; body: unknown; jsonRejects?: Error } - -const stubTransport = (handler: (call: TransportCall) => TransportResult | Promise) => { - const calls: TransportCall[] = [] - - vi.spyOn(globalThis, "fetch").mockImplementation((async (input: string, init?: RequestInit) => { - const call: TransportCall = { - url: String(input), - method: init?.method ?? "GET", - headers: { ...((init?.headers ?? {}) as Record) }, - } - calls.push(call) - const result = await handler(call) - - return { - ok: result.status >= 200 && result.status < 300, - status: result.status, - json: async () => { - if (result.jsonRejects) { - throw result.jsonRejects - } - return result.body - }, - } as Response - }) as typeof fetch) +import { stubTransport } from "@src/utils/test-utils" - return { calls } -} -// ----------------------------------------------------------------------------- +import { useOpenRouterKeyInfo } from "../useOpenRouterKeyInfo" const KEY_INFO = { label: "sk-or-v1-...abc", @@ -82,7 +49,7 @@ describe("useOpenRouterKeyInfo", () => { { url: "https://openrouter.ai/api/v1/key", method: "GET", - headers: { Authorization: "Bearer sk-or-v1-test" }, + headers: { authorization: "Bearer sk-or-v1-test" }, }, ]) }) diff --git a/webview-ui/src/components/ui/hooks/__tests__/useOpenRouterModelProviders.spec.tsx b/webview-ui/src/components/ui/hooks/__tests__/useOpenRouterModelProviders.spec.tsx index eb99d4d819..f16edbd8a8 100644 --- a/webview-ui/src/components/ui/hooks/__tests__/useOpenRouterModelProviders.spec.tsx +++ b/webview-ui/src/components/ui/hooks/__tests__/useOpenRouterModelProviders.spec.tsx @@ -2,45 +2,10 @@ import { renderHook, waitFor } from "@testing-library/react" import { QueryClientProvider } from "@tanstack/react-query" import React from "react" -import { createTestQueryClient } from "@src/utils/test-utils" +import { createTestQueryClient, stubTransport } from "@src/utils/test-utils" import { useOpenRouterModelProviders } from "../useOpenRouterModelProviders" -// --- transport seam (phase 2: fetch) ----------------------------------------- -// Only this block changed between phase 1 (axios) and phase 2 (fetch). Every -// assertion below is shared by both phases, so the unchanged green run is the -// behavior-preservation proof. -type TransportCall = { url: string; method: string; headers: Record } -type TransportResult = { status: number; body: unknown; jsonRejects?: Error } - -const stubTransport = (handler: (call: TransportCall) => TransportResult | Promise) => { - const calls: TransportCall[] = [] - - vi.spyOn(globalThis, "fetch").mockImplementation((async (input: string, init?: RequestInit) => { - const call: TransportCall = { - url: String(input), - method: init?.method ?? "GET", - headers: { ...((init?.headers ?? {}) as Record) }, - } - calls.push(call) - const result = await handler(call) - - return { - ok: result.status >= 200 && result.status < 300, - status: result.status, - json: async () => { - if (result.jsonRejects) { - throw result.jsonRejects - } - return result.body - }, - } as Response - }) as typeof fetch) - - return { calls } -} -// ----------------------------------------------------------------------------- - const endpointsPayload = (overrides: Record = {}) => ({ data: { id: "vendor/model", @@ -53,7 +18,12 @@ const endpointsPayload = (overrides: Record = {}) => ({ tag: "provider-a", context_length: 128000, max_completion_tokens: 4096, - pricing: { prompt: "0.000003", completion: "0.000015", input_cache_read: "0.0000003" }, + pricing: { + prompt: "0.000003", + completion: "0.000015", + input_cache_read: "0.0000003", + input_cache_write: "0.0000036", + }, }, { name: "Provider B", @@ -102,7 +72,7 @@ describe("useOpenRouterModelProviders", () => { supportsImages: true, supportsPromptCache: true, cacheReadsPrice: 0.3, - cacheWritesPrice: undefined, + cacheWritesPrice: expect.closeTo(3.6, 10), inputPrice: 3, outputPrice: 15, description: "A test model", @@ -123,6 +93,21 @@ describe("useOpenRouterModelProviders", () => { }) }) + it.each([ + { name: "text-only architecture", architecture: { input_modalities: ["text"], output_modalities: ["text"] } }, + { name: "omitted architecture", architecture: undefined }, + ])("disables image support for $name", async ({ architecture }) => { + stubTransport(() => ({ status: 200, body: endpointsPayload({ architecture }) })) + + const { result } = renderHook(() => useOpenRouterModelProviders("vendor/model"), { wrapper }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(result.current.data).toMatchObject({ + "provider-a": { supportsImages: false }, + "Provider B": { supportsImages: false }, + }) + }) + it("uses a caller-supplied baseUrl", async () => { const { calls } = stubTransport(() => ({ status: 200, body: endpointsPayload() })) diff --git a/webview-ui/src/components/ui/hooks/__tests__/useRequestyKeyInfo.spec.tsx b/webview-ui/src/components/ui/hooks/__tests__/useRequestyKeyInfo.spec.tsx index 5677220268..22688db698 100644 --- a/webview-ui/src/components/ui/hooks/__tests__/useRequestyKeyInfo.spec.tsx +++ b/webview-ui/src/components/ui/hooks/__tests__/useRequestyKeyInfo.spec.tsx @@ -2,42 +2,9 @@ import { renderHook, waitFor } from "@testing-library/react" import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import React from "react" -import { useRequestyKeyInfo } from "../useRequestyKeyInfo" - -// --- transport seam (phase 2: fetch) ----------------------------------------- -// Only this block changed between phase 1 (axios) and phase 2 (fetch). Every -// assertion below is shared by both phases, so the unchanged green run is the -// behavior-preservation proof. -type TransportCall = { url: string; method: string; headers: Record } -type TransportResult = { status: number; body: unknown; jsonRejects?: Error } - -const stubTransport = (handler: (call: TransportCall) => TransportResult | Promise) => { - const calls: TransportCall[] = [] - - vi.spyOn(globalThis, "fetch").mockImplementation((async (input: string, init?: RequestInit) => { - const call: TransportCall = { - url: String(input), - method: init?.method ?? "GET", - headers: { ...((init?.headers ?? {}) as Record) }, - } - calls.push(call) - const result = await handler(call) - - return { - ok: result.status >= 200 && result.status < 300, - status: result.status, - json: async () => { - if (result.jsonRejects) { - throw result.jsonRejects - } - return result.body - }, - } as Response - }) as typeof fetch) +import { stubTransport } from "@src/utils/test-utils" - return { calls } -} -// ----------------------------------------------------------------------------- +import { useRequestyKeyInfo } from "../useRequestyKeyInfo" const KEY_INFO = { name: "my-key", @@ -81,7 +48,7 @@ describe("useRequestyKeyInfo", () => { { url: "https://api.requesty.ai/x/apikey", method: "GET", - headers: { Authorization: "Bearer test-key", "Content-Type": "application/json" }, + headers: { authorization: "Bearer test-key", "content-type": "application/json" }, }, ]) }) diff --git a/webview-ui/src/components/ui/hooks/useOpenRouterModelProviders.ts b/webview-ui/src/components/ui/hooks/useOpenRouterModelProviders.ts index 038829cbad..175ee7b8df 100644 --- a/webview-ui/src/components/ui/hooks/useOpenRouterModelProviders.ts +++ b/webview-ui/src/components/ui/hooks/useOpenRouterModelProviders.ts @@ -90,11 +90,7 @@ async function getOpenRouterProvidersForModel(modelId: string, baseUrl?: string) models[providerName] = modelInfo } } catch (error) { - if (error instanceof z.ZodError) { - console.error(`OpenRouter API response validation failed:`, error.issues) - } else { - console.error(`Error fetching OpenRouter providers:`, error) - } + console.error(`Error fetching OpenRouter providers:`, error) } return models diff --git a/webview-ui/src/utils/test-utils.tsx b/webview-ui/src/utils/test-utils.tsx index 847c401f2c..b64d88e0b6 100644 --- a/webview-ui/src/utils/test-utils.tsx +++ b/webview-ui/src/utils/test-utils.tsx @@ -84,3 +84,36 @@ export * from "@testing-library/react" // override render method export { customRender as render } + +type TransportCall = { url: string; method: string; headers: Record } +type TransportResult = { status: number; body: unknown; jsonRejects?: Error } + +// Stubs global fetch and records the calls. +export const stubTransport = (handler: (call: TransportCall) => TransportResult | Promise) => { + const calls: TransportCall[] = [] + + vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { + const request = typeof input === "object" && "url" in input ? input : undefined + const call: TransportCall = { + url: request?.url ?? String(input), + method: init?.method ?? request?.method ?? "GET", + headers: Object.fromEntries(new Headers(init?.headers ?? request?.headers).entries()), + } + calls.push(call) + const result = await handler(call) + + // The hooks consume only ok, status, and json from this response double. + return { + ok: result.status >= 200 && result.status < 300, + status: result.status, + json: async () => { + if (result.jsonRejects) { + throw result.jsonRejects + } + return result.body + }, + } as Response + }) + + return { calls } +}