Skip to content

Commit 2d89a0f

Browse files
author
Zoo (VP)
committed
test(openrouter): cover refresh models flow for provider correlation
1 parent a16c15e commit 2d89a0f

1 file changed

Lines changed: 344 additions & 0 deletions

File tree

Lines changed: 344 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,344 @@
1+
import { render, screen, fireEvent, act } from "@testing-library/react"
2+
3+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
4+
5+
import type { ProviderSettings, OrganizationAllowList } from "@roo-code/types"
6+
import {
7+
openRouterDefaultModelId,
8+
allRouterModelsProvider,
9+
providerIdentifiers,
10+
RouterModelsMessageType,
11+
} from "@roo-code/types"
12+
13+
import { OpenRouter } from "../OpenRouter"
14+
15+
// Explicit toolkit stub (vitest validates factory keys, so no Proxy tricks).
16+
// Uses React.createElement because this factory runs while the spec's own
17+
// imports are still initializing (hoisted vi.mock).
18+
vi.mock("@vscode/webview-ui-toolkit/react", async () => {
19+
const React = await import("react")
20+
const VSCodeTextField = ({ children, value, onInput, type }: any) =>
21+
React.createElement(
22+
"div",
23+
null,
24+
children,
25+
React.createElement("input", {
26+
type,
27+
value,
28+
onChange: (e: any) => onInput(e),
29+
"data-testid": type === "url" ? "base-url-input" : "api-key-input",
30+
}),
31+
)
32+
const VSCodeLink = ({ children, href }: any) =>
33+
React.createElement("a", { href, "data-vscode-stub": "VSCodeLink" }, children)
34+
return { VSCodeTextField, VSCodeLink }
35+
})
36+
37+
vi.mock("@src/i18n/TranslationContext", () => ({
38+
useAppTranslation: () => ({ t: (key: string) => key }),
39+
}))
40+
41+
vi.mock("@src/oauth/urls", () => ({
42+
getOpenRouterAuthUrl: () => "https://openrouter.ai/auth",
43+
}))
44+
45+
// Alias-path mock (reliable across environments): prevents the real balance
46+
// display from issuing network requests during tests.
47+
vi.mock("@src/components/settings/providers/OpenRouterBalanceDisplay", () => ({
48+
OpenRouterBalanceDisplay: () => null,
49+
}))
50+
51+
vi.mock("@src/components/common/VSCodeButtonLink", () => ({
52+
VSCodeButtonLink: ({ children, href }: any) => (
53+
<a href={href} data-testid="get-api-key-link">
54+
{children}
55+
</a>
56+
),
57+
}))
58+
59+
const { postMessageMock } = vi.hoisted(() => ({
60+
postMessageMock: vi.fn(),
61+
}))
62+
63+
vi.mock("@src/utils/vscode", () => ({
64+
vscode: {
65+
postMessage: postMessageMock,
66+
},
67+
}))
68+
69+
// Keep every real export (Popover, Select, ...) so leaked child renders work,
70+
// and only stub Button to assert onClick/disabled without styling deps.
71+
vi.mock("@src/components/ui", async (importOriginal) => ({
72+
...(await importOriginal<typeof import("@src/components/ui")>()),
73+
Button: ({ children, onClick, disabled, className }: any) => (
74+
<button onClick={onClick} disabled={disabled} className={className} data-testid="refresh-button">
75+
{children}
76+
</button>
77+
),
78+
}))
79+
80+
vi.mock("vscrui", async (importOriginal) => ({
81+
...(await importOriginal<typeof import("vscrui")>()),
82+
Checkbox: ({ checked, onChange, children }: any) => (
83+
<label data-testid="base-url-checkbox" onClick={() => onChange(!checked)}>
84+
{children}
85+
</label>
86+
),
87+
}))
88+
89+
// The shared Button stub is also used by the real ModelPicker rendered
90+
// underneath, so identify OUR refresh button via its unique i18n label.
91+
const getRefreshButton = () =>
92+
screen.getByText("settings:providers.refreshModels.label").closest("button") as HTMLElement
93+
94+
describe("OpenRouter", () => {
95+
const organizationAllowList: OrganizationAllowList = { allowAll: true, providers: {} }
96+
const mockSetApiConfigurationField = vi.fn()
97+
98+
let queryClient: QueryClient
99+
let invalidateQueriesSpy: ReturnType<typeof vi.spyOn>
100+
101+
const renderComponent = ({
102+
apiConfiguration = { openRouterApiKey: "key" } as ProviderSettings,
103+
simplifySettings = true,
104+
}: {
105+
apiConfiguration?: ProviderSettings
106+
simplifySettings?: boolean
107+
} = {}) =>
108+
render(
109+
<QueryClientProvider client={queryClient}>
110+
<OpenRouter
111+
apiConfiguration={apiConfiguration}
112+
setApiConfigurationField={mockSetApiConfigurationField}
113+
selectedModelId={openRouterDefaultModelId}
114+
uriScheme="vscode"
115+
simplifySettings={simplifySettings}
116+
organizationAllowList={organizationAllowList}
117+
/>
118+
</QueryClientProvider>,
119+
)
120+
121+
beforeEach(() => {
122+
vi.clearAllMocks()
123+
queryClient = new QueryClient({
124+
defaultOptions: {
125+
queries: { retry: false },
126+
mutations: { retry: false },
127+
},
128+
})
129+
invalidateQueriesSpy = vi.spyOn(queryClient, "invalidateQueries")
130+
})
131+
132+
it("updates the API key via setApiConfigurationField on input", () => {
133+
renderComponent()
134+
135+
fireEvent.change(screen.getByTestId("api-key-input"), { target: { value: "secret-key" } })
136+
137+
expect(mockSetApiConfigurationField).toHaveBeenCalledWith("openRouterApiKey", "secret-key")
138+
})
139+
140+
it("shows the get-API-key CTA only when no API key is set", () => {
141+
const { rerender } = renderComponent({ apiConfiguration: { openRouterApiKey: "" } })
142+
143+
expect(screen.getByTestId("get-api-key-link")).toBeInTheDocument()
144+
expect(screen.getByTestId("get-api-key-link")).toHaveAttribute("href", "https://openrouter.ai/auth")
145+
146+
rerender(
147+
<QueryClientProvider client={queryClient}>
148+
<OpenRouter
149+
apiConfiguration={{ openRouterApiKey: "already-set" }}
150+
setApiConfigurationField={mockSetApiConfigurationField}
151+
selectedModelId={openRouterDefaultModelId}
152+
uriScheme="vscode"
153+
organizationAllowList={organizationAllowList}
154+
/>
155+
</QueryClientProvider>,
156+
)
157+
expect(screen.queryByTestId("get-api-key-link")).not.toBeInTheDocument()
158+
})
159+
160+
it("clears the base URL when the custom base URL checkbox is unchecked", () => {
161+
renderComponent({
162+
apiConfiguration: { openRouterApiKey: "key", openRouterBaseUrl: "https://proxy.example.com" },
163+
simplifySettings: false,
164+
})
165+
166+
fireEvent.click(screen.getByTestId("base-url-checkbox"))
167+
168+
expect(mockSetApiConfigurationField).toHaveBeenCalledWith("openRouterBaseUrl", "")
169+
})
170+
171+
describe("refresh models", () => {
172+
const dispatchMessage = (data: any) =>
173+
act(() => {
174+
window.dispatchEvent(new MessageEvent("message", { data }))
175+
})
176+
177+
it("renders the refresh button in idle state", () => {
178+
renderComponent()
179+
180+
const button = getRefreshButton()
181+
expect(button).not.toBeDisabled()
182+
expect(button.querySelector(".codicon-refresh")).not.toBeNull()
183+
expect(screen.getByText("settings:providers.refreshModels.label")).toBeInTheDocument()
184+
})
185+
186+
it("sends requestRouterModels for the openrouter provider when clicked", () => {
187+
renderComponent()
188+
189+
fireEvent.click(getRefreshButton())
190+
191+
expect(postMessageMock).toHaveBeenCalledWith({
192+
type: RouterModelsMessageType.requestRouterModels,
193+
values: { provider: providerIdentifiers.openrouter, refresh: true },
194+
})
195+
})
196+
197+
it("enters loading state and disables the button while refreshing", () => {
198+
renderComponent()
199+
200+
fireEvent.click(getRefreshButton())
201+
202+
const button = getRefreshButton()
203+
expect(button).toBeDisabled()
204+
expect(button.querySelector(".codicon-loading")).not.toBeNull()
205+
expect(screen.getByText("settings:providers.refreshModels.loading")).toBeInTheDocument()
206+
})
207+
208+
it("shows success and invalidates caches when a scoped routerModels response arrives", () => {
209+
renderComponent()
210+
211+
fireEvent.click(getRefreshButton())
212+
dispatchMessage({
213+
type: RouterModelsMessageType.routerModels,
214+
values: { provider: providerIdentifiers.openrouter },
215+
})
216+
217+
expect(screen.getByText("settings:providers.refreshModels.success")).toBeInTheDocument()
218+
expect(invalidateQueriesSpy).toHaveBeenCalledTimes(2)
219+
expect(invalidateQueriesSpy).toHaveBeenNthCalledWith(1, {
220+
queryKey: [RouterModelsMessageType.routerModels, providerIdentifiers.openrouter],
221+
})
222+
expect(invalidateQueriesSpy).toHaveBeenNthCalledWith(2, {
223+
queryKey: [RouterModelsMessageType.routerModels, allRouterModelsProvider],
224+
})
225+
})
226+
227+
it("still resolves on unscoped (legacy/global) routerModels broadcasts", () => {
228+
renderComponent()
229+
230+
fireEvent.click(getRefreshButton())
231+
dispatchMessage({ type: RouterModelsMessageType.routerModels })
232+
233+
expect(screen.getByText("settings:providers.refreshModels.success")).toBeInTheDocument()
234+
})
235+
236+
it("ignores scoped routerModels responses belonging to other providers", () => {
237+
renderComponent()
238+
239+
fireEvent.click(getRefreshButton())
240+
dispatchMessage({
241+
type: RouterModelsMessageType.routerModels,
242+
values: { provider: providerIdentifiers.moonshot },
243+
})
244+
245+
expect(screen.queryByText("settings:providers.refreshModels.success")).not.toBeInTheDocument()
246+
expect(screen.getByText("settings:providers.refreshModels.loading")).toBeInTheDocument()
247+
expect(invalidateQueriesSpy).not.toHaveBeenCalled()
248+
})
249+
250+
it("shows error state with the received error message on fetch failure", () => {
251+
renderComponent()
252+
253+
fireEvent.click(getRefreshButton())
254+
dispatchMessage({
255+
type: RouterModelsMessageType.singleRouterModelFetchResponse,
256+
success: false,
257+
values: { provider: providerIdentifiers.openrouter },
258+
error: "Invalid API key",
259+
})
260+
261+
expect(screen.getByText("Invalid API key")).toBeInTheDocument()
262+
})
263+
264+
it("falls back to the default error translation when no error is provided", () => {
265+
renderComponent()
266+
267+
fireEvent.click(getRefreshButton())
268+
dispatchMessage({
269+
type: RouterModelsMessageType.singleRouterModelFetchResponse,
270+
success: false,
271+
values: { provider: providerIdentifiers.openrouter },
272+
})
273+
274+
expect(screen.getByText("settings:providers.refreshModels.error")).toBeInTheDocument()
275+
})
276+
277+
it("ignores fetch failures for other providers", () => {
278+
renderComponent()
279+
280+
fireEvent.click(getRefreshButton())
281+
dispatchMessage({
282+
type: RouterModelsMessageType.singleRouterModelFetchResponse,
283+
success: false,
284+
values: { provider: providerIdentifiers.moonshot },
285+
error: "should not show",
286+
})
287+
288+
expect(screen.queryByText("should not show")).not.toBeInTheDocument()
289+
expect(screen.getByText("settings:providers.refreshModels.loading")).toBeInTheDocument()
290+
})
291+
292+
it("does not override an error with success when routerModels arrives after a failure", () => {
293+
renderComponent()
294+
295+
fireEvent.click(getRefreshButton())
296+
297+
// Dispatch both within the same act batch so the handler still sees
298+
// refreshStatus === "loading" and the errorJustReceived guard is exercised.
299+
act(() => {
300+
window.dispatchEvent(
301+
new MessageEvent("message", {
302+
data: {
303+
type: RouterModelsMessageType.singleRouterModelFetchResponse,
304+
success: false,
305+
values: { provider: providerIdentifiers.openrouter },
306+
error: "boom",
307+
},
308+
}),
309+
)
310+
window.dispatchEvent(
311+
new MessageEvent("message", { data: { type: RouterModelsMessageType.routerModels } }),
312+
)
313+
})
314+
315+
expect(screen.getByText("boom")).toBeInTheDocument()
316+
expect(screen.queryByText("settings:providers.refreshModels.success")).not.toBeInTheDocument()
317+
})
318+
319+
it("ignores routerModels messages when not in loading state", () => {
320+
renderComponent()
321+
322+
// No refresh initiated; an unsolicited routerModels message should be a no-op.
323+
dispatchMessage({ type: RouterModelsMessageType.routerModels })
324+
325+
expect(screen.queryByText("settings:providers.refreshModels.success")).not.toBeInTheDocument()
326+
expect(screen.queryByText("settings:providers.refreshModels.loading")).not.toBeInTheDocument()
327+
})
328+
329+
it("stops listening for messages after unmount", () => {
330+
const { unmount } = renderComponent()
331+
332+
unmount()
333+
334+
expect(() =>
335+
act(() => {
336+
window.dispatchEvent(
337+
new MessageEvent("message", { data: { type: RouterModelsMessageType.routerModels } }),
338+
)
339+
}),
340+
).not.toThrow()
341+
expect(screen.queryByText("settings:providers.refreshModels.label")).not.toBeInTheDocument()
342+
})
343+
})
344+
})

0 commit comments

Comments
 (0)