| 1 | import { settingsOptionValues } from "./settingsSelectTestUtils"; |
| 2 | // Run: tsx src/__tests__/provider-editor-model-picker.test.tsx |
| 3 | |
| 4 | import { JSDOM } from "jsdom"; |
| 5 | import React from "react"; |
| 6 | import { act } from "react"; |
| 7 | import type { ProviderView } from "../lib/types"; |
| 8 | |
| 9 | let passed = 0; |
| 10 | let failed = 0; |
| 11 | |
| 12 | function ok(value: boolean, label: string) { |
| 13 | if (value) { |
| 14 | process.stdout.write(` PASS ${label}\n`); |
| 15 | passed += 1; |
| 16 | } else { |
| 17 | process.stdout.write(` FAIL ${label}\n`); |
| 18 | failed += 1; |
| 19 | } |
| 20 | } |
| 21 | |
| 22 | function flushPromises(): Promise<void> { |
| 23 | return new Promise((resolve) => setTimeout(resolve, 0)); |
| 24 | } |
| 25 | |
| 26 | const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", { |
| 27 | pretendToBeVisual: true, |
| 28 | url: "http://localhost/", |
| 29 | }); |
| 30 | (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; |
| 31 | globalThis.window = dom.window as unknown as Window & typeof globalThis; |
| 32 | globalThis.document = dom.window.document; |
| 33 | Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator }); |
| 34 | globalThis.Node = dom.window.Node; |
| 35 | globalThis.HTMLElement = dom.window.HTMLElement; |
| 36 | globalThis.Event = dom.window.Event; |
| 37 | globalThis.CustomEvent = dom.window.CustomEvent; |
| 38 | globalThis.KeyboardEvent = dom.window.KeyboardEvent; |
| 39 | globalThis.MouseEvent = dom.window.MouseEvent; |
| 40 | globalThis.localStorage = dom.window.localStorage; |
| 41 | globalThis.sessionStorage = dom.window.sessionStorage; |
| 42 | globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window); |
| 43 | globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window); |
| 44 | window.scrollTo = () => {}; |
| 45 | window.matchMedia = (() => ({matches: true, addEventListener(){}, removeEventListener(){}})) as any; |
| 46 | const { createRoot } = await import("react-dom/client"); |
| 47 | const { LocaleProvider } = await import("../lib/i18n"); |
| 48 | const { ProviderEditor, ProviderEditorModelPicker, providerSupportsServerWebSearch, providerSupportsServerWebSearchForView, providerVisionCapabilityForView } = await import("../components/SettingsPanel"); |
| 49 | function editDisplayName() { |
| 50 | const input = rootEl!.querySelector<HTMLInputElement>(".provider-name-input")!; |
| 51 | Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!.call(input, "Display label"); |
| 52 | input.dispatchEvent(new window.Event("input", { bubbles: true })); |
| 53 | } |
| 54 | |
| 55 | function renderPicker( |
| 56 | candidates: string[], |
| 57 | options: { |
| 58 | visionModels?: string[]; |
| 59 | visionModelsConfigured?: boolean; |
| 60 | visionCapability?: "configurable" | "unsupported"; |
| 61 | modelCapabilities?: ProviderModelCapabilityView[]; |
| 62 | } = {}, |
| 63 | ) { |
| 64 | const { |
| 65 | visionModels = [], |
| 66 | visionModelsConfigured = false, |
| 67 | visionCapability = "configurable", |
| 68 | modelCapabilities = [], |
| 69 | } = options; |
| 70 | return ( |
| 71 | <LocaleProvider> |
| 72 | <ProviderEditorModelPicker |
| 73 | candidates={candidates} |
| 74 | selectedModels={[]} |
| 75 | visionModels={visionModels} |
| 76 | visionModelsConfigured={visionCapability === "unsupported"} |
| 77 | modelCapabilities={candidates.map(model => ({ model, state: visionModels.includes(model) ? "supported" : "unsupported" }))} |
| 78 | contextWindows={{}} |
| 79 | disabled={false} |
| 80 | onToggleModel={() => undefined} |
| 81 | onContextWindowChange={() => undefined} |
| 82 | onSelectAll={() => undefined} |
| 83 | onClear={() => undefined} |
| 84 | /> |
| 85 | </LocaleProvider> |
| 86 | ); |
| 87 | } |
| 88 | |
| 89 | console.log("\nprovider editor model picker"); |
| 90 | |
| 91 | const rootEl = document.getElementById("root"); |
| 92 | if (!rootEl) throw new Error("missing root"); |
| 93 | const root = createRoot(rootEl); |
| 94 | |
| 95 | let threw = false; |
| 96 | try { |
| 97 | await act(async () => { |
| 98 | root.render(renderPicker([])); |
| 99 | await flushPromises(); |
| 100 | }); |
| 101 | await act(async () => { |
| 102 | root.render(renderPicker(["zen-v1", "zen-v1-pro"])); |
| 103 | await flushPromises(); |
| 104 | }); |
| 105 | } catch (error) { |
| 106 | threw = true; |
| 107 | process.stdout.write(` ERROR ${String(error)}\n`); |
| 108 | } |
| 109 | |
| 110 | ok(!threw, "model picker can render after async model fetch returns candidates"); |
| 111 | ok(rootEl.textContent?.includes("zen-v1") === true, "model picker shows fetched custom provider models"); |
| 112 | |
| 113 | await act(async () => { |
| 114 | root.render(renderPicker(["deepseek-v4-flash"], { |
| 115 | modelCapabilities: [{ |
| 116 | model: "deepseek-v4-flash", |
| 117 | inputModalities: ["text"], |
| 118 | state: "unsupported", |
| 119 | source: "adapter", |
| 120 | }], |
| 121 | })); |
| 122 | await flushPromises(); |
| 123 | }); |
| 124 | ok(rootEl.querySelectorAll(".provider-model-draft__capabilities span").length === 0, "known text-only models omit redundant capability badges"); |
| 125 | ok(rootEl.querySelectorAll('input[type="checkbox"]').length === 1, "text-only model card does not render a second image checkbox"); |
| 126 | await act(async () => { |
| 127 | root.render(renderPicker(["deepseek-v4-flash-vision-exp"], { |
| 128 | visionModels: ["deepseek-v4-flash-vision-exp"], |
| 129 | visionModelsConfigured: true, |
| 130 | })); |
| 131 | await flushPromises(); |
| 132 | }); |
| 133 | ok(rootEl.textContent?.includes("No image input") !== true, "pinned DeepSeek vision SKU does not show the text-only image label"); |
| 134 | ok(rootEl.querySelectorAll('input[type="checkbox"]').length === 1, "pinned DeepSeek vision SKU does not render a configurable image checkbox"); |
| 135 | await act(async () => { |
| 136 | root.render(renderPicker( |
| 137 | ["deepseek-v4-flash", "deepseek-v4-flash-vision-exp"], |
| 138 | { |
| 139 | modelCapabilities: [ |
| 140 | { |
| 141 | model: "deepseek-v4-flash", |
| 142 | inputModalities: ["text"], |
| 143 | state: "unsupported", |
| 144 | source: "adapter", |
| 145 | }, |
| 146 | { |
| 147 | model: "deepseek-v4-flash-vision-exp", |
| 148 | inputModalities: ["text", "image"], |
| 149 | state: "supported", |
| 150 | source: "adapter", |
| 151 | }, |
| 152 | ], |
| 153 | }, |
| 154 | )); |
| 155 | await flushPromises(); |
| 156 | }); |
| 157 | ok(rootEl.querySelectorAll('input[type="checkbox"]').length === 2, "model capabilities remain read-only rather than introducing image-input checkboxes"); |
| 158 | ok(rootEl.textContent?.includes("No image input") !== true, "configurable DeepSeek models do not use the read-only image-unsupported label"); |
| 159 | ok(providerSupportsServerWebSearch("responses", "https://api.deepseek.com"), "DeepSeek Responses exposes server-side web search"); |
| 160 | ok(providerSupportsServerWebSearch("anthropic", "https://api.deepseek.com/anthropic"), "DeepSeek Anthropic exposes server-side web search"); |
| 161 | ok(providerSupportsServerWebSearch("openai", "https://api.deepseek.com"), "DeepSeek Chat Completions exposes independent web search"); |
| 162 | ok(providerSupportsServerWebSearch("openai", "https://api.deepseek.com/v1"), "versioned official Chat Completions supports independent search"); |
| 163 | ok(!providerSupportsServerWebSearch("openai", "https://gateway.example/v1"), "custom Chat Completions is not assumed to support search"); |
| 164 | ok(!providerSupportsServerWebSearch("responses", "https://relay.deepseek.com"), "DeepSeek-like subdomains do not inherit official defaults"); |
| 165 | ok(!providerSupportsServerWebSearch("responses", "https://api.deepseek.com/anthropic"), "Responses rejects the Anthropic base path"); |
| 166 | ok(!providerSupportsServerWebSearch("anthropic", "https://api.deepseek.com"), "Anthropic requires its documented base path"); |
| 167 | ok(!providerSupportsServerWebSearch("responses", "http://api.deepseek.com"), "insecure DeepSeek URLs do not inherit official defaults"); |
| 168 | ok( |
| 169 | providerSupportsServerWebSearchForView({ |
| 170 | kind: "anthropic", |
| 171 | baseUrl: "https://gateway.example/anthropic", |
| 172 | serverWebSearchCapability: true, |
| 173 | }), |
| 174 | "backend verification can enable server-side web search for future curated endpoints", |
| 175 | ); |
| 176 | ok( |
| 177 | !providerSupportsServerWebSearchForView({ |
| 178 | kind: "anthropic", |
| 179 | baseUrl: "https://api.deepseek.com/anthropic", |
| 180 | serverWebSearchCapability: false, |
| 181 | }), |
| 182 | "backend capability overrides endpoint inference when explicitly unsupported", |
| 183 | ); |
| 184 | ok( |
| 185 | providerSupportsServerWebSearchForView({ kind: "anthropic", baseUrl: "https://api.deepseek.com/anthropic" }), |
| 186 | "older backend payloads retain the exact DeepSeek endpoint fallback", |
| 187 | ); |
| 188 | ok( |
| 189 | !providerSupportsServerWebSearchForView({ kind: "anthropic", baseUrl: "https://gateway.example/anthropic" }), |
| 190 | "older backend payloads do not infer support for arbitrary compatible endpoints", |
| 191 | ); |
| 192 | ok( |
| 193 | providerVisionCapabilityForView({ kind: "openai", baseUrl: "https://custom.example/v1", visionCapability: "unsupported" }) === "unsupported", |
| 194 | "backend vision capability overrides frontend endpoint inference", |
| 195 | ); |
| 196 | ok( |
| 197 | providerVisionCapabilityForView({ kind: "openai", baseUrl: "https://api.deepseek.com" }) === "configurable", |
| 198 | "official DeepSeek Settings uses the same image-input checkboxes as other providers", |
| 199 | ); |
| 200 | ok( |
| 201 | providerVisionCapabilityForView({ kind: "anthropic", baseUrl: "https://eu.deepseek.com/anthropic" }) === "configurable", |
| 202 | "regional DeepSeek Settings also expose per-model image-input checkboxes", |
| 203 | ); |
| 204 | ok( |
| 205 | providerVisionCapabilityForView({ kind: "responses", baseUrl: "https://deepseek.com" }) === "configurable", |
| 206 | "the DeepSeek apex does not inherit official vision restrictions", |
| 207 | ); |
| 208 | ok( |
| 209 | providerVisionCapabilityForView({ kind: "openai", baseUrl: "https://deepseek.com.example/v1" }) === "configurable", |
| 210 | "lookalike domains with a DeepSeek prefix do not inherit official vision restrictions", |
| 211 | ); |
| 212 | ok( |
| 213 | providerVisionCapabilityForView({ kind: "openai", baseUrl: "https://api.deepseek.com.example/v1" }) === "configurable", |
| 214 | "lookalike domains with an official-host prefix remain configurable", |
| 215 | ); |
| 216 | |
| 217 | const builtInProvider: ProviderView = { |
| 218 | name: "deepseek", |
| 219 | builtIn: true, |
| 220 | added: true, |
| 221 | kind: "openai", |
| 222 | baseUrl: "https://api.deepseek.com", |
| 223 | models: ["deepseek-chat"], |
| 224 | visionModels: [], |
| 225 | visionModelsConfigured: false, |
| 226 | modelsUrl: "", |
| 227 | default: "deepseek-chat", |
| 228 | apiKeyEnv: "", |
| 229 | keySet: false, |
| 230 | balanceUrl: "", |
| 231 | contextWindow: 128_000, |
| 232 | reasoningProtocol: "deepseek", |
| 233 | thinking: "", |
| 234 | supportedEfforts: [], |
| 235 | defaultEffort: "", |
| 236 | }; |
| 237 | |
| 238 | const deepSeekResponsesProvider: ProviderView = { |
| 239 | ...builtInProvider, |
| 240 | name: "deepseek-responses", |
| 241 | builtIn: false, |
| 242 | kind: "responses", |
| 243 | models: ["deepseek-v4-flash"], |
| 244 | default: "deepseek-v4-flash", |
| 245 | webSearch: true, |
| 246 | serverWebSearchCapability: true, |
| 247 | modelCapabilities: [{ |
| 248 | model: "deepseek-v4-flash", |
| 249 | inputModalities: ["text"], |
| 250 | state: "unsupported", |
| 251 | source: "adapter", |
| 252 | }], |
| 253 | }; |
| 254 | |
| 255 | const longCatAnthropicProvider: ProviderView = { |
| 256 | ...builtInProvider, |
| 257 | name: "longcat-anthropic", |
| 258 | builtIn: false, |
| 259 | kind: "anthropic", |
| 260 | baseUrl: "https://api.longcat.chat/anthropic", |
| 261 | models: ["LongCat-2.0"], |
| 262 | default: "LongCat-2.0", |
| 263 | serverWebSearchCapability: false, |
| 264 | }; |
| 265 | |
| 266 | const backendUnsupportedCustomProvider: ProviderView = { |
| 267 | ...builtInProvider, |
| 268 | name: "deepseek-regional", |
| 269 | builtIn: false, |
| 270 | baseUrl: "https://eu.deepseek.com/v1", |
| 271 | models: ["deepseek-v4-pro"], |
| 272 | default: "deepseek-v4-pro", |
| 273 | modelCapabilities: [ |
| 274 | { model: "deepseek-v4-pro", inputModalities: ["text"], state: "unsupported", source: "adapter" }, |
| 275 | ], |
| 276 | }; |
| 277 | |
| 278 | const legacyChatURLProvider: ProviderView = { |
| 279 | ...backendUnsupportedCustomProvider, |
| 280 | name: "legacy-chat-url", |
| 281 | chatUrl: "https://legacy.example.com/chat/completions/", |
| 282 | }; |
| 283 | |
| 284 | const mismatchedDeepSeekProvider: ProviderView = { |
| 285 | ...builtInProvider, |
| 286 | name: "deepseek-anthropic", |
| 287 | displayName: "Deepseek2", |
| 288 | builtIn: false, |
| 289 | presetId: "deepseek-anthropic", |
| 290 | kind: "openai", |
| 291 | baseUrl: "https://api.deepseek.com/anthropic/v1", |
| 292 | requestUrl: "https://api.deepseek.com/anthropic/v1/chat/completions", |
| 293 | catalog: { |
| 294 | brandId: "deepseek", brandLabel: "DeepSeek", region: "global", product: "api", format: "anthropic", baseUrl: "https://api.deepseek.com/anthropic", |
| 295 | protocols: { |
| 296 | openai: { baseUrl: "https://api.deepseek.com/v1", source: "fixture", checkedOn: "2026-09-08" }, |
| 297 | responses: { baseUrl: "https://api.deepseek.com", source: "fixture", checkedOn: "2026-09-08" }, |
| 298 | anthropic: { baseUrl: "https://api.deepseek.com/anthropic", source: "fixture", checkedOn: "2026-09-08" }, |
| 299 | }, |
| 300 | }, |
| 301 | }; |
| 302 | |
| 303 | function renderProviderEditor(initial?: ProviderView, onSave: (provider: ProviderView) => void | Promise<void> = () => undefined) { |
| 304 | return ( |
| 305 | <LocaleProvider> |
| 306 | <ProviderEditor |
| 307 | key={initial?.name ?? "new-provider"} |
| 308 | initial={initial} |
| 309 | kinds={["openai"]} |
| 310 | busy={false} |
| 311 | onCancel={() => undefined} |
| 312 | onSave={onSave} |
| 313 | /> |
| 314 | </LocaleProvider> |
| 315 | ); |
| 316 | } |
| 317 | |
| 318 | let editorThrew = false; |
| 319 | try { |
| 320 | await act(async () => { |
| 321 | root.render(renderProviderEditor(builtInProvider)); |
| 322 | await flushPromises(); |
| 323 | }); |
| 324 | await act(async () => { |
| 325 | root.render(renderProviderEditor()); |
| 326 | await flushPromises(); |
| 327 | }); |
| 328 | } catch (error) { |
| 329 | editorThrew = true; |
| 330 | process.stdout.write(` ERROR ${String(error)}\n`); |
| 331 | } |
| 332 | |
| 333 | ok(!editorThrew, "provider editor can switch from built-in to custom without changing hook order"); |
| 334 | ok(rootEl.textContent?.includes("Chat Completions (/chat/completions)") === true, "provider editor renders the custom provider fields after the switch"); |
| 335 | ok((await settingsOptionValues(rootEl.querySelector<HTMLButtonElement>('button[aria-label="Model capability mode"]')!)).includes("kimi-k3"), "custom provider editor exposes the explicit Kimi K3 reasoning protocol"); |
| 336 | const providerUrlInput = rootEl.querySelector<HTMLInputElement>(".provider-url-input"); |
| 337 | ok(rootEl.querySelectorAll('input[type="radio"]:not(.sr-only)').length === 0, "custom provider editor exposes only one API address input"); |
| 338 | ok(providerUrlInput?.value === "", "new custom providers start with an empty exact request address"); |
| 339 | const providerUrlLabel = Array.from(rootEl.querySelectorAll<HTMLLabelElement>("label")).find( |
| 340 | (label) => label.htmlFor === providerUrlInput?.id, |
| 341 | ); |
| 342 | ok(Boolean(providerUrlLabel) && providerUrlInput?.getAttribute("aria-describedby") !== null, "provider URL input has a programmatic label and description"); |
| 343 | ok(rootEl.textContent?.includes("uses it unchanged") === true, "provider URL helper explains exact request behavior"); |
| 344 | ok(rootEl.querySelector<HTMLInputElement>('input[placeholder="e.g. my-proxy"]')?.disabled !== true, "new custom provider name stays editable"); |
| 345 | ok(rootEl.querySelector<HTMLInputElement>('input[placeholder="e.g. my-proxy"]')?.nextElementSibling?.classList.contains("mem-hint") !== true, "new custom provider editor omits the rename hint"); |
| 346 | |
| 347 | await act(async () => { |
| 348 | root.render(<div />); |
| 349 | await flushPromises(); |
| 350 | }); |
| 351 | await act(async () => { |
| 352 | root.render(renderProviderEditor(deepSeekResponsesProvider)); |
| 353 | await flushPromises(); |
| 354 | }); |
| 355 | const webSearchSwitch = rootEl.querySelector<HTMLInputElement>('input[role="switch"]'); |
| 356 | ok(rootEl.textContent?.includes("Server-side web search") === true, "DeepSeek Responses editor separates service capabilities from model selection"); |
| 357 | ok(webSearchSwitch?.checked === true, "curated DeepSeek Responses capability is enabled in the editor"); |
| 358 | ok(rootEl.querySelectorAll('.provider-model-draft__option input[type="checkbox"]').length === 1, "DeepSeek Responses exposes one model-selection checkbox"); |
| 359 | ok(rootEl.textContent?.includes("No image input") !== true, "DeepSeek Responses editor does not use the read-only image-unsupported label"); |
| 360 | |
| 361 | await act(async () => { |
| 362 | root.render(renderProviderEditor(longCatAnthropicProvider)); |
| 363 | await flushPromises(); |
| 364 | }); |
| 365 | ok(rootEl.textContent?.includes("Server-side web search") === false, "unverified LongCat Anthropic endpoint hides server-side web search"); |
| 366 | ok(rootEl.querySelector<HTMLInputElement>('input[role="switch"]') === null, "unverified LongCat endpoint exposes no recommended web-search switch"); |
| 367 | |
| 368 | await act(async () => { |
| 369 | root.render(renderProviderEditor(backendUnsupportedCustomProvider)); |
| 370 | await flushPromises(); |
| 371 | }); |
| 372 | ok( |
| 373 | rootEl.querySelectorAll(".provider-model-draft__capabilities span").length === 0, |
| 374 | "backend text-only metadata omits image badges outside the legacy endpoint heuristic", |
| 375 | ); |
| 376 | const displayedRequestURL = (input: HTMLInputElement | null) => |
| 377 | input?.value; |
| 378 | const customProviderUrlInput = rootEl.querySelector<HTMLInputElement>(".provider-url-input"); |
| 379 | ok(rootEl.querySelectorAll('input[type="radio"]').length === 0, "existing custom providers no longer expose an address mode selector"); |
| 380 | ok(customProviderUrlInput?.value === "https://eu.deepseek.com/v1/chat/completions", "legacy base-only providers display their previously effective request URL"); |
| 381 | ok(rootEl.querySelector<HTMLInputElement>(".provider-name-input")?.disabled === false, "existing connection display name remains editable"); |
| 382 | ok(rootEl.querySelector("code")?.textContent === backendUnsupportedCustomProvider.name, "stable provider identity remains separate from display name"); |
| 383 | |
| 384 | let migratedProvider: ProviderView | undefined; |
| 385 | await act(async () => { |
| 386 | root.render(renderProviderEditor(legacyChatURLProvider, (provider) => { |
| 387 | migratedProvider = provider; |
| 388 | })); |
| 389 | await flushPromises(); |
| 390 | }); |
| 391 | const legacyProviderUrlInput = rootEl.querySelector<HTMLInputElement>(".provider-url-input"); |
| 392 | ok( displayedRequestURL(legacyProviderUrlInput) === "https://legacy.example.com/chat/completions", "legacy OpenAI chat URLs display their historically normalized effective endpoint"); |
| 393 | const saveButton = Array.from(rootEl.querySelectorAll<HTMLButtonElement>("button")).find( |
| 394 | (button) => button.textContent?.trim() === "Save changes", |
| 395 | ); |
| 396 | await act(async () => editDisplayName()); |
| 397 | await act(async () => { |
| 398 | saveButton?.click(); |
| 399 | await flushPromises(); |
| 400 | }); |
| 401 | ok(migratedProvider?.requestUrl === "https://legacy.example.com/chat/completions" && migratedProvider?.baseUrl === legacyChatURLProvider.baseUrl, "saving migrates the legacy effective address without rewriting its baseUrl"); |
| 402 | ok(migratedProvider?.chatUrl === migratedProvider?.requestUrl, "saving mirrors the normalized legacy OpenAI endpoint for previous releases"); |
| 403 | |
| 404 | let exactProvider: ProviderView | undefined; |
| 405 | await act(async () => { |
| 406 | root.render(renderProviderEditor({ |
| 407 | ...legacyChatURLProvider, |
| 408 | name: "exact-request-url", |
| 409 | requestUrl: "https://exact.example.com/custom/?token=1", |
| 410 | }, (provider) => { |
| 411 | exactProvider = provider; |
| 412 | })); |
| 413 | await flushPromises(); |
| 414 | }); |
| 415 | const exactSaveButton = Array.from(rootEl.querySelectorAll<HTMLButtonElement>("button")).find( |
| 416 | (button) => button.textContent?.trim() === "Save changes", |
| 417 | ); |
| 418 | await act(async () => editDisplayName()); |
| 419 | await act(async () => { |
| 420 | exactSaveButton?.click(); |
| 421 | await flushPromises(); |
| 422 | }); |
| 423 | ok(exactProvider?.requestUrl === "https://exact.example.com/custom/?token=1" && exactProvider?.baseUrl === legacyChatURLProvider.baseUrl, "saving preserves an explicit requestUrl and independent baseUrl exactly"); |
| 424 | ok(exactProvider?.chatUrl === exactProvider?.requestUrl, "saving mirrors the exact OpenAI request URL for previous releases"); |
| 425 | |
| 426 | await act(async () => { |
| 427 | root.render(renderProviderEditor(mismatchedDeepSeekProvider)); |
| 428 | await flushPromises(); |
| 429 | }); |
| 430 | const mismatchAlert = rootEl.querySelector<HTMLElement>('[role="alert"]'); |
| 431 | const mismatchSave = Array.from(rootEl.querySelectorAll<HTMLButtonElement>("button")).find((button) => button.textContent?.trim() === "Save changes"); |
| 432 | const useRecommended = Array.from(rootEl.querySelectorAll<HTMLButtonElement>("button")).find((button) => button.textContent?.trim() === "Apply" && button.title === "https://api.deepseek.com/v1/chat/completions"); |
| 433 | ok(mismatchAlert?.textContent?.includes("does not match") === true && mismatchSave?.disabled === true, "protocol mismatch is a blocking editor error"); |
| 434 | await act(async () => { useRecommended?.click(); await flushPromises(); }); |
| 435 | ok(rootEl.querySelector<HTMLInputElement>(".provider-url-input")?.value === "https://api.deepseek.com/v1/chat/completions", "recommended action applies the catalog request URL"); |
| 436 | ok(rootEl.querySelector<HTMLElement>('[role="alert"]') === null, "recommended route clears the mismatch gate"); |
| 437 | |
| 438 | await act(async () => { |
| 439 | root.render(renderProviderEditor({ ...legacyChatURLProvider, name: "save-failure" }, () => { throw new Error("storage unavailable"); })); |
| 440 | await flushPromises(); |
| 441 | }); |
| 442 | const failedSaveButton = Array.from(rootEl.querySelectorAll<HTMLButtonElement>("button")).find((button) => button.textContent?.trim() === "Save changes"); |
| 443 | await act(async () => editDisplayName()); |
| 444 | await act(async () => { failedSaveButton?.click(); await flushPromises(); }); |
| 445 | ok(rootEl.textContent?.includes("storage unavailable") === true, "save errors remain visible in the provider form"); |
| 446 | ok(rootEl.querySelector<HTMLInputElement>(".provider-url-input")?.value === "https://legacy.example.com/chat/completions" && rootEl.querySelector(".provider-model-draft__option") !== null, "failed save retains connection and model drafts"); |
| 447 | |
| 448 | await act(async () => { |
| 449 | root.unmount(); |
| 450 | }); |
| 451 | dom.window.close(); |
| 452 | |
| 453 | console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`); |
| 454 | if (failed > 0) process.exit(1); |
| 455 |