返回 DeepSeek-Reasonix
provider-access-card.test.tsx
根目录 / desktop / frontend / src / __tests__ / provider-access-card.test.tsx
1 import { selectSettingsValue, settingsOptionValues } from "./settingsSelectTestUtils";
2 import catalogData from "../lib/providerCatalog.generated.json";
3 // Run: tsx src/__tests__/provider-access-card.test.tsx
4
5 import { JSDOM } from "jsdom";
6 import React from "react";
7 import { act } from "react";
8 import { createRoot } from "react-dom/client";
9 import {
10 AddProviderPanel,
11 ProviderAccessCard,
12 providerAccessGroups,
13 SettingsPanel,
14 type ProviderAccessGroup,
15 } from "../components/SettingsPanel";
16 import { LocaleProvider } from "../lib/i18n";
17 import type { AppBindings } from "../lib/bridge";
18 import type { ProviderPresetView, ProviderView, SettingsView } from "../lib/types";
19 import { baseSettings } from "../test-support/settingsTestFixtures";
20 import { installDesktopHostStub } from "./desktopHostStub";
21
22 let passed = 0;
23 let failed = 0;
24
25 function ok(value: boolean, label: string) {
26 if (value) {
27 process.stdout.write(` PASS ${label}\n`);
28 passed += 1;
29 } else {
30 process.stdout.write(` FAIL ${label}\n`);
31 failed += 1;
32 }
33 }
34
35 function flushPromises(): Promise<void> {
36 return new Promise((resolve) => setTimeout(resolve, 0));
37 }
38
39 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
40 pretendToBeVisual: true,
41 url: "http://localhost/",
42 });
43 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
44 globalThis.window = dom.window as unknown as Window & typeof globalThis;
45 globalThis.document = dom.window.document;
46 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
47 globalThis.Node = dom.window.Node;
48 globalThis.HTMLElement = dom.window.HTMLElement;
49 globalThis.Event = dom.window.Event;
50 globalThis.CustomEvent = dom.window.CustomEvent;
51 globalThis.MouseEvent = dom.window.MouseEvent;
52 globalThis.localStorage = dom.window.localStorage;
53 globalThis.sessionStorage = dom.window.sessionStorage;
54 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
55 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
56 Object.defineProperty(dom.window.HTMLElement.prototype, "attachEvent", { configurable: true, value: () => undefined });
57 Object.defineProperty(dom.window.HTMLElement.prototype, "detachEvent", { configurable: true, value: () => undefined });
58 window.scrollTo = () => {};
59 window.matchMedia = () => ({
60 matches: true,
61 media: "",
62 onchange: null,
63 addListener: () => undefined,
64 removeListener: () => undefined,
65 addEventListener: () => undefined,
66 removeEventListener: () => undefined,
67 dispatchEvent: () => false,
68 });
69
70 const deepSeekAnthropic: ProviderView = {
71 name: "deepseek-flash",
72 builtIn: true,
73 added: true,
74 kind: "anthropic",
75 baseUrl: "https://api.deepseek.com/anthropic",
76 models: ["deepseek-v4-flash"],
77 visionModels: [],
78 visionModelsConfigured: true,
79 visionCapability: "unsupported",
80 modelsUrl: "",
81 default: "deepseek-v4-flash",
82 apiKeyEnv: "DEEPSEEK_API_KEY",
83 keySet: true,
84 configured: true,
85 balanceUrl: "",
86 contextWindow: 128_000,
87 reasoningProtocol: "deepseek",
88 thinking: "",
89 webSearch: true,
90 serverWebSearchCapability: true,
91 supportedEfforts: [],
92 defaultEffort: "",
93 };
94
95 const deepSeekLegacyOpenAI: ProviderView = {
96 ...deepSeekAnthropic,
97 name: "deepseek-pro",
98 kind: "openai",
99 baseUrl: "https://api.deepseek.com",
100 models: ["deepseek-v4-pro"],
101 default: "deepseek-v4-pro",
102 webSearch: false,
103 serverWebSearchCapability: false,
104 };
105
106 function group(providers: ProviderView[]): ProviderAccessGroup {
107 return {
108 id: "builtin:deepseek",
109 label: "DeepSeek Official",
110 description: "",
111 builtIn: true,
112 providers,
113 apiKeyEnv: "DEEPSEEK_API_KEY",
114 keySet: true,
115 requiresKey: true,
116 configured: true,
117 baseUrl: providers[0]?.baseUrl ?? "",
118 kind: providers[0]?.kind ?? "",
119 models: providers.flatMap((provider) => provider.models),
120 recommendedUpgradeAvailable: false,
121 };
122 }
123
124 function customGroup(provider: ProviderView): ProviderAccessGroup {
125 return {
126 ...group([provider]),
127 id: `custom:${provider.name}`,
128 label: provider.name,
129 builtIn: false,
130 };
131 }
132
133 function renderCard(
134 providerGroup: ProviderAccessGroup,
135 actions: {
136 onRefresh?: (provider: ProviderView) => void;
137 onDelete?: (providers: ProviderView[]) => Promise<void>;
138 } = {},
139 ) {
140 return (
141 <LocaleProvider>
142 <ProviderAccessCard
143 group={providerGroup}
144 busy={false}
145 fetching={false}
146 editing={null}
147 kinds={["anthropic", "openai"]}
148 onEdit={() => undefined}
149 onCancelEdit={() => undefined}
150 onSave={() => undefined}
151 onRefresh={actions.onRefresh ?? (() => undefined)}
152 onToggleDraftModel={() => undefined}
153 onToggleDraftVision={() => undefined}
154 onSelectAllDraftModels={() => undefined}
155 onClearDraftModels={() => undefined}
156 onCancelDraftModels={() => undefined}
157 onSaveDraftModels={() => undefined}
158 onToggleWebSearch={() => undefined}
159 onUpgradeRecommended={() => undefined}
160 onSaveEditorKey={async () => undefined}
161 onDelete={actions.onDelete}
162 />
163 </LocaleProvider>
164 );
165 }
166
167 console.log("\nprovider access card");
168
169 const rootEl = document.getElementById("root");
170 if (!rootEl) throw new Error("missing root");
171 const root = createRoot(rootEl);
172
173 await act(async () => {
174 root.render(renderCard(group([deepSeekAnthropic, deepSeekLegacyOpenAI])));
175 await flushPromises();
176 });
177 ok(
178 rootEl.querySelector<HTMLInputElement>('input[role="switch"]') === null,
179 "mixed supported and unsupported profiles hide the grouped server web-search switch",
180 );
181 ok(
182 rootEl.textContent?.includes("deepseek-v4-flash") === true
183 && rootEl.textContent?.includes("deepseek-v4-pro") === true,
184 "mixed-profile cards keep the complete model summary visible",
185 );
186
187 let refreshedProvider = "";
188 await act(async () => {
189 root.render(renderCard(group([deepSeekAnthropic, deepSeekLegacyOpenAI]), {
190 onRefresh: (provider) => {
191 refreshedProvider = provider.name;
192 },
193 }));
194 await flushPromises();
195 });
196 const refreshButtons = Array.from(rootEl.querySelectorAll("button"))
197 .filter((button) => button.textContent?.trim() === "Refresh models") as HTMLButtonElement[];
198 ok(refreshButtons.length === 2, "multi-profile cards expose one model refresh action per profile");
199 await act(async () => {
200 refreshButtons[1]?.click();
201 await flushPromises();
202 });
203 ok(refreshedProvider === "deepseek-pro", "profile refresh targets the selected provider instead of the first profile");
204
205 let removedProviders: string[] = [];
206 await act(async () => {
207 root.render(renderCard(group([deepSeekAnthropic, deepSeekLegacyOpenAI]), {
208 onDelete: async (providers) => {
209 removedProviders = providers.map((provider) => provider.name);
210 },
211 }));
212 await flushPromises();
213 });
214 const moreButton = rootEl.querySelector<HTMLButtonElement>('button[aria-haspopup="menu"]');
215 await act(async () => {
216 moreButton?.click();
217 await flushPromises();
218 });
219 let removeButton = Array.from(document.querySelectorAll("button"))
220 .find((button) => button.textContent?.trim() === "Remove access") as HTMLButtonElement | undefined;
221 await act(async () => {
222 removeButton?.click();
223 await flushPromises();
224 });
225 removeButton = Array.from(document.querySelectorAll("button"))
226 .find((button) => button.textContent?.trim() === "Confirm remove access") as HTMLButtonElement | undefined;
227 ok(removeButton !== undefined, "grouped provider removal requires inline confirmation");
228 await act(async () => {
229 removeButton?.click();
230 await flushPromises();
231 });
232 ok(
233 removedProviders.join(",") === "deepseek-flash,deepseek-pro",
234 "card-level removal submits every grouped provider in one action",
235 );
236
237 let removedCustomProviders: string[] = [];
238 await act(async () => {
239 root.render(renderCard(customGroup({ ...deepSeekAnthropic, name: "my-proxy", builtIn: false }), {
240 onDelete: async (providers) => {
241 removedCustomProviders = providers.map((provider) => provider.name);
242 },
243 }));
244 await flushPromises();
245 });
246 const defaultCustomMoreButton = rootEl.querySelector<HTMLButtonElement>('button[aria-haspopup="menu"]');
247 ok(defaultCustomMoreButton?.disabled === false, "configured custom providers keep the removal menu available");
248 await act(async () => {
249 defaultCustomMoreButton?.click();
250 await flushPromises();
251 });
252 let deleteDefaultCustomButton = Array.from(document.querySelectorAll("button"))
253 .find((button) => button.textContent?.trim() === "Remove access") as HTMLButtonElement | undefined;
254 await act(async () => {
255 deleteDefaultCustomButton?.click();
256 await flushPromises();
257 });
258 deleteDefaultCustomButton = Array.from(document.querySelectorAll("button"))
259 .find((button) => button.textContent?.trim() === "Confirm delete provider") as HTMLButtonElement | undefined;
260 ok(deleteDefaultCustomButton !== undefined, "configured custom provider can be confirmed for deletion");
261 await act(async () => {
262 deleteDefaultCustomButton?.click();
263 await flushPromises();
264 });
265 ok(
266 removedCustomProviders.join(",") === "my-proxy",
267 "configured custom provider removal submits the selected provider",
268 );
269
270 const defaultCustomSettings: SettingsView = baseSettings("standard");
271 const defaultCustomProvider: ProviderView = {
272 ...deepSeekAnthropic,
273 name: "my-proxy",
274 builtIn: false,
275 added: true,
276 kind: "openai",
277 baseUrl: "https://proxy.example/v1",
278 models: ["my-model"],
279 default: "my-model",
280 apiKeyEnv: "",
281 keySet: true,
282 configured: true,
283 webSearch: false,
284 serverWebSearchCapability: false,
285 };
286 defaultCustomSettings.defaultModel = "my-proxy/my-model";
287 defaultCustomSettings.providers = [defaultCustomProvider];
288 defaultCustomSettings.providerKinds = ["openai"];
289 let removedDefaultCustomProviders: string[] = [];
290 installDesktopHostStub(({
291 main: {
292 App: {
293 Settings: async () => defaultCustomSettings,
294 ApplyModelSettings: async (change) => {
295 if (change.kind !== "access_remove") throw new Error("unexpected settings operation");
296 const names = change.names;
297 removedDefaultCustomProviders = [...names];
298 defaultCustomSettings.providers = defaultCustomSettings.providers.filter((provider) => !names.includes(provider.name));
299 return {requestId: change.requestId, persisted: true, revision: "removed", application: "not_required", targets: [], issues: [], appliedCatalogs: []};
300 },
301 } as Partial<AppBindings> as AppBindings,
302 },
303 }).main.App);
304 const settingsRootEl = document.createElement("div");
305 document.body.appendChild(settingsRootEl);
306 const settingsRoot = createRoot(settingsRootEl);
307 await act(async () => {
308 settingsRoot.render(
309 <LocaleProvider>
310 <SettingsPanel
311 initialTab="models"
312 initialFocus={{ target: "model-access", requestId: 1 }}
313 desktopPlatform="linux"
314 onClose={() => undefined}
315 onChanged={() => undefined}
316 onUseSubagent={() => undefined}
317 />
318 </LocaleProvider>,
319 );
320 await flushPromises();
321 await flushPromises();
322 });
323 const defaultCustomSettingsMoreButton = settingsRootEl.querySelector<HTMLButtonElement>('button[aria-haspopup="menu"]');
324 ok(
325 defaultCustomSettingsMoreButton?.disabled === false,
326 "current default custom provider keeps the removal menu available",
327 );
328 await act(async () => {
329 defaultCustomSettingsMoreButton?.click();
330 await flushPromises();
331 });
332 let deleteCurrentDefaultButton = Array.from(document.querySelectorAll("button"))
333 .find((button) => button.textContent?.trim() === "Remove access") as HTMLButtonElement | undefined;
334 await act(async () => {
335 deleteCurrentDefaultButton?.click();
336 await flushPromises();
337 });
338 deleteCurrentDefaultButton = Array.from(document.querySelectorAll("button"))
339 .find((button) => button.textContent?.trim() === "Confirm delete provider") as HTMLButtonElement | undefined;
340 ok(deleteCurrentDefaultButton !== undefined, "current default custom provider can be confirmed for deletion");
341 await act(async () => {
342 deleteCurrentDefaultButton?.click();
343 await flushPromises();
344 await flushPromises();
345 });
346 ok(
347 removedDefaultCustomProviders.join(",") === "my-proxy",
348 "current default custom provider removal reaches the settings backend",
349 );
350 await act(async () => {
351 settingsRoot.unmount();
352 });
353 settingsRootEl.remove();
354
355 await act(async () => {
356 root.render(renderCard(group([
357 deepSeekAnthropic,
358 { ...deepSeekAnthropic, name: "deepseek-pro", models: ["deepseek-v4-pro"], default: "deepseek-v4-pro", webSearch: false },
359 ])));
360 await flushPromises();
361 });
362 const mixedStateSwitch = rootEl.querySelector<HTMLInputElement>('input[role="switch"]');
363 ok(mixedStateSwitch !== null, "all supported grouped profiles expose server web search");
364 ok(mixedStateSwitch?.checked === false, "grouped switch is off until every profile has web search enabled");
365
366 await act(async () => {
367 root.render(renderCard(group([
368 deepSeekAnthropic,
369 { ...deepSeekAnthropic, name: "deepseek-pro", models: ["deepseek-v4-pro"], default: "deepseek-v4-pro" },
370 ])));
371 await flushPromises();
372 });
373 ok(
374 rootEl.querySelector<HTMLInputElement>('input[role="switch"]')?.checked === true,
375 "grouped switch is on when every supported profile has web search enabled",
376 );
377
378 await act(async () => {
379 root.render(
380 <LocaleProvider>
381 <AddProviderPanel
382 key="official-installed"
383 mode="official"
384 kinds={["anthropic", "openai"]}
385 officialProviders={[{ ...deepSeekAnthropic, name: "deepseek" }]}
386 providerPresets={[]}
387 busy={false}
388 onMode={() => undefined}
389 onCancel={() => undefined}
390 onAddOfficial={async () => undefined}
391 onAddPreset={async () => undefined}
392 onViewPresetConflict={() => undefined}
393 onResetPreset={async () => undefined}
394 onAddCustom={() => undefined}
395 />
396 </LocaleProvider>,
397 );
398 await flushPromises();
399 });
400 ok(rootEl.querySelector<HTMLButtonElement>(".provider-catalog__detail .btn--primary")?.disabled === false, "installed brands allow independent new connections");
401 ok(rootEl.querySelector(".provider-catalog__detail")?.textContent?.includes("API Key") === true, "new connection has its own credential field");
402
403 const openCodePreset = (
404 id: string,
405 label: string,
406 providerNames: string[],
407 displayTier: "primary" | "advanced" | "compatibility",
408 displaySection: "go" | "zen" = "go",
409 ): ProviderPresetView => ({
410 catalog: (catalogData.catalogs as Record<string, NonNullable<ProviderPresetView["catalog"]>>)[id],
411 id,
412 label,
413 description: `${label} description`,
414 keyEnv: displaySection === "zen" ? "OPENCODE_API_KEY" : "OPENCODE_GO_API_KEY",
415 recommended: id === "opencode-go-recommended",
416 displayGroup: "opencode",
417 displaySection,
418 displayTier,
419 routeKind: id === "opencode-go-recommended" ? "bundle" : "anthropic",
420 providerNames,
421 models: ["model"],
422 added: false,
423 status: "available",
424 statusProviderNames: [],
425 keySet: false,
426 requiresKey: true,
427 configured: false,
428 });
429
430 await act(async () => {
431 root.render(
432 <LocaleProvider>
433 <AddProviderPanel
434 key="opencode-quick-setup"
435 mode="official"
436 kinds={["anthropic", "openai", "responses"]}
437 officialProviders={[{ ...deepSeekAnthropic, name: "deepseek" }]}
438 providerPresets={[
439 openCodePreset("opencode-go-recommended", "OpenCode Go (Recommended)", ["opencode-go", "opencode-go-anthropic", "opencode-go-responses"], "primary"),
440 openCodePreset("opencode-go-anthropic", "OpenCode Go Anthropic", ["opencode-go-anthropic"], "advanced"),
441 openCodePreset("opencode-go-responses", "OpenCode Go Responses", ["opencode-go-responses"], "advanced"),
442 openCodePreset("opencode-go", "OpenCode Go Chat", ["opencode-go"], "compatibility"),
443 openCodePreset("opencode-zen-anthropic", "OpenCode Zen", ["opencode-zen-anthropic"], "primary", "zen"),
444 ]}
445 busy={false}
446 onMode={() => undefined}
447 onCancel={() => undefined}
448 onAddOfficial={async () => undefined}
449 onAddPreset={async () => undefined}
450 onViewPresetConflict={() => undefined}
451 onResetPreset={async () => undefined}
452 onAddCustom={() => undefined}
453 />
454 </LocaleProvider>,
455 );
456 await flushPromises();
457 });
458 const brandButtons = rootEl.querySelectorAll(".provider-catalog__brand");
459 ok(brandButtons.length === 2, "DeepSeek and all OpenCode products render as two brands");
460 ok(Array.from(brandButtons).some(button => button.textContent?.includes("OpenCode")), "OpenCode has one selectable brand entry");
461 ok(Array.from(brandButtons).every(button => !button.textContent?.includes("Anthropic")), "protocols never appear as top-level brands");
462 await act(async () => { (Array.from(brandButtons).find(button => button.textContent?.includes("OpenCode")) as HTMLButtonElement).click(); });
463 const products = rootEl.querySelector<HTMLButtonElement>('button.settings-select[id$="-product"]');
464 ok((products ? await settingsOptionValues(products) : []).join(",") === "go,zen", "Go and Zen remain distinct access plans within OpenCode");
465 if (products) await selectSettingsValue(products, "zen");
466 ok(rootEl.querySelector<HTMLInputElement>('input[placeholder*="OPENCODE_API_KEY"]') !== null, "selecting Zen changes the credential reference");
467 ok(rootEl.querySelector<HTMLButtonElement>('button.settings-select[id$="-format"]')?.value === "anthropic", "Zen selects its supported API format");
468
469 const openCodeProviders: ProviderView[] = [
470 { ...deepSeekAnthropic, builtIn: false, name: "opencode-go", kind: "openai", baseUrl: "https://opencode.ai/zen/go/v1", apiKeyEnv: "OPENCODE_GO_API_KEY", webSearch: false },
471 { ...deepSeekAnthropic, builtIn: false, name: "opencode-go-anthropic", baseUrl: "https://opencode.ai/zen/go", apiKeyEnv: "OPENCODE_GO_API_KEY", webSearch: false },
472 { ...deepSeekAnthropic, builtIn: false, name: "opencode-go-responses", kind: "responses", baseUrl: "https://opencode.ai/zen/go/v1", apiKeyEnv: "OPENCODE_GO_API_KEY", webSearch: false },
473 ];
474 const openCodeGroups = providerAccessGroups(openCodeProviders, ((key: string) => key) as never);
475 ok(openCodeGroups.length === 1, "installed OpenCode Go routes render as one product connection");
476 ok(openCodeGroups[0]?.id === "custom:opencode-go", "OpenCode Go connection has a stable product-level identity");
477
478 await act(async () => {
479 root.render(renderCard(openCodeGroups[0]));
480 await flushPromises();
481 });
482 const routeSettings = rootEl.querySelector<HTMLDetailsElement>("details.provider-route-settings");
483 ok(routeSettings !== null && routeSettings.open === false, "installed OpenCode Go keeps protocol routes collapsed by default");
484 ok(
485 routeSettings?.querySelector("summary")?.textContent?.trim() === "Advanced route settings",
486 "installed OpenCode Go exposes route controls only through an advanced disclosure",
487 );
488
489 let removedOpenCodeProviders: string[] = [];
490 await act(async () => {
491 root.render(renderCard(openCodeGroups[0], {
492 onDelete: async (providers) => {
493 removedOpenCodeProviders = providers.map((provider) => provider.name);
494 },
495 }));
496 await flushPromises();
497 });
498 const openCodeMoreButton = rootEl.querySelector<HTMLButtonElement>('button[aria-haspopup="menu"]');
499 await act(async () => {
500 openCodeMoreButton?.click();
501 await flushPromises();
502 });
503 let removeOpenCodeButton = Array.from(document.querySelectorAll("button"))
504 .find((button) => button.textContent?.trim() === "Remove access") as HTMLButtonElement | undefined;
505 await act(async () => {
506 removeOpenCodeButton?.click();
507 await flushPromises();
508 });
509 removeOpenCodeButton = Array.from(document.querySelectorAll("button"))
510 .find((button) => button.textContent?.trim() === "Confirm delete provider") as HTMLButtonElement | undefined;
511 await act(async () => {
512 removeOpenCodeButton?.click();
513 await flushPromises();
514 });
515 ok(
516 removedOpenCodeProviders.join(",") === "opencode-go,opencode-go-anthropic,opencode-go-responses",
517 "OpenCode Go group removal submits every installed route atomically",
518 );
519
520 await act(async () => {
521 root.unmount();
522 });
523 dom.window.close();
524
525 console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
526 if (failed > 0) process.exit(1);
527
527 lines Plain Text