diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4621ec9de9..bc71d79765 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/eslint.config.mjs b/webview-ui/eslint.config.mjs index e7b18babca..c8b5b49bfb 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: { @@ -63,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/package.json b/webview-ui/package.json index c8cecf5d4d..2ea7e5eea3 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..2e7bf65c24 --- /dev/null +++ b/webview-ui/src/components/ui/hooks/__tests__/useOpenRouterKeyInfo.spec.tsx @@ -0,0 +1,123 @@ +import { renderHook, waitFor } from "@testing-library/react" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import React from "react" + +import { stubTransport } from "@src/utils/test-utils" + +import { useOpenRouterKeyInfo } from "../useOpenRouterKeyInfo" + +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.objectContaining({ + issues: expect.arrayContaining([expect.objectContaining({ path: ["data", "usage"] })]), + }), + ) + }) + + 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.objectContaining({ message: "Request failed with status code 401" }), + ) + }) + + 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) + }) + + 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 dcf0b5829d..f16edbd8a8 100644 --- a/webview-ui/src/components/ui/hooks/__tests__/useOpenRouterModelProviders.spec.tsx +++ b/webview-ui/src/components/ui/hooks/__tests__/useOpenRouterModelProviders.spec.tsx @@ -1,14 +1,39 @@ -import React from "react" -import axios from "axios" -import { QueryClientProvider } from "@tanstack/react-query" import { renderHook, waitFor } from "@testing-library/react" -import { z } from "zod" +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" -vi.mock("axios") +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", + input_cache_write: "0.0000036", + }, + }, + { + name: "Provider B", + context_length: 32000, + pricing: { prompt: "0.000001", completion: "0.000002" }, + }, + ], + ...overrides, + }, +}) const wrapper = ({ children }: React.PropsWithChildren) => ( {children} @@ -19,21 +44,129 @@ 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: expect.closeTo(3.6, 10), + 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.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() })) + + 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.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 () => { + 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.objectContaining({ message: "Request failed with status code 500" }), + ) }) - 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 }) @@ -42,4 +175,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 new file mode 100644 index 0000000000..22688db698 --- /dev/null +++ b/webview-ui/src/components/ui/hooks/__tests__/useRequestyKeyInfo.spec.tsx @@ -0,0 +1,122 @@ +import { renderHook, waitFor } from "@testing-library/react" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import React from "react" + +import { stubTransport } from "@src/utils/test-utils" + +import { useRequestyKeyInfo } from "../useRequestyKeyInfo" + +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.objectContaining({ + issues: expect.arrayContaining([expect.objectContaining({ path: ["monthly_limit"] })]), + }), + ) + }) + + 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.objectContaining({ message: "Request failed with status code 403" }), + ) + }) + + 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) + }) + + 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) + }) +}) 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..175ee7b8df 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) @@ -86,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/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 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 } +}