| 1 | import assert from "node:assert/strict"; |
| 2 | import { JSDOM } from "jsdom"; |
| 3 | import React, { act } from "react"; |
| 4 | |
| 5 | |
| 6 | import { ModelImageInputControl } from "../components/ModelImageInputControl"; |
| 7 | import { LocaleProvider } from "../lib/i18n"; |
| 8 | import { imageInputHardBlocked, imageInputState, mergeImageInputModes } from "../lib/providerImageInput"; |
| 9 | import type { AppBindings } from "../lib/bridge"; |
| 10 | import type { ProviderModelCapabilityView, ProviderView } from "../lib/types"; |
| 11 | import { installDesktopHostStub } from "./desktopHostStub"; |
| 12 | |
| 13 | const dom = new JSDOM('<div id="root"></div>', { url: "http://localhost/", pretendToBeVisual: true }); |
| 14 | Object.assign(globalThis, { window: dom.window, document: dom.window.document, Node: dom.window.Node, HTMLElement: dom.window.HTMLElement, Event: dom.window.Event, CustomEvent: dom.window.CustomEvent, MouseEvent: dom.window.MouseEvent, localStorage: dom.window.localStorage, sessionStorage: dom.window.sessionStorage, IS_REACT_ACT_ENVIRONMENT: true }); |
| 15 | Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator }); |
| 16 | globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window); |
| 17 | globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window); |
| 18 | window.scrollTo = () => {}; |
| 19 | window.HTMLDialogElement.prototype.showModal = function() { this.setAttribute("open", ""); }; |
| 20 | const { createRoot } = await import("react-dom/client"); |
| 21 | await import("../components/ProviderModelDialog"); |
| 22 | const { ProviderEditor, ProviderEditorModelPicker } = await import("../components/SettingsPanel"); |
| 23 | const el = document.getElementById("root")!; |
| 24 | const root = createRoot(el); |
| 25 | const unknown: ProviderModelCapabilityView = { model: "relay-model", state: "unknown", source: "adapter", inputModalities: [], automaticState: "unknown", automaticSource: "adapter", imageInputEnableAllowed: true }; |
| 26 | const initial: ProviderView = { name: "relay", kind: "openai", baseUrl: "https://relay.test/v1", requestUrl: "https://relay.test/v1/chat/completions", modelsUrl: "", models: ["relay-model"], visionModels: [], builtIn: false, added: true, default: "relay-model", apiKeyEnv: "TEST_KEY", keySet: true, balanceUrl: "", contextWindow: 0, reasoningProtocol: "", thinking: "", webSearch: false, supportedEfforts: [], defaultEffort: "", modelCapabilities: [unknown], modelOverrides: [{ model: "relay-model", vision: null, contextWindow: 123456, maxOutputTokens: 4321, reasoningProtocol: "openai", supportedEfforts: ["low"], defaultEffort: "low" }] }; |
| 27 | const saved: ProviderView[] = []; |
| 28 | let cancelled = 0; |
| 29 | const render = async (node: React.ReactNode) => { await act(async () => { root.render(<LocaleProvider>{node}</LocaleProvider>); }); }; |
| 30 | const editor = (key: string) => <ProviderEditor key={key} initial={initial} kinds={["openai"]} busy={false} onSave={(p) => { saved.push(p); }} onCancel={() => { cancelled++; }} />; |
| 31 | const modeInput = (mode: string) => el.querySelector<HTMLInputElement>(`[role="radiogroup"][aria-label="Image input mode for relay-model"] input[value="${mode}"]`)!; |
| 32 | const openModel = async () => { await act(async()=>{(el.querySelector('.provider-model-draft__option button') as HTMLButtonElement).click(); await new Promise(r=>setTimeout(r,30));}); }; |
| 33 | const choose = async (mode: string) => { |
| 34 | await openModel(); |
| 35 | const d=document.querySelector('dialog')!; |
| 36 | await act(async()=>{ |
| 37 | if(mode === 'auto') (d.querySelector('aside > button') as HTMLButtonElement).click(); |
| 38 | else { const input=d.querySelector<HTMLInputElement>('aside input[type="checkbox"]:not(:disabled)')!; assert(input); if(input.checked !== (mode === 'on')) input.click(); } |
| 39 | }); |
| 40 | await act(async()=>d.querySelector('form')!.dispatchEvent(new window.Event('submit',{bubbles:true,cancelable:true}))); |
| 41 | }; |
| 42 | const click = async (label: string) => { await act(async () => { const b = [...el.querySelectorAll("button")].find((b) => (b.textContent?.trim() === label || b.getAttribute("aria-label") === label)); assert(b, `missing button ${label}`); b.click(); }); }; |
| 43 | |
| 44 | await render(editor("first")); |
| 45 | assert(el.textContent?.includes("Image capability unknown")); |
| 46 | assert.equal(el.querySelector('[role="radiogroup"]'),null,"model details stay in the dialog"); |
| 47 | await choose("on"); |
| 48 | assert(el.textContent?.includes("Image input")); |
| 49 | assert.equal(saved.length, 0, "draft must not write config"); |
| 50 | await click("Save changes"); |
| 51 | assert.equal(saved[0].modelOverrides?.[0].vision, true); |
| 52 | assert.equal(saved[0].modelOverrides?.[0].contextWindow, 123456); |
| 53 | assert.equal(saved[0].modelOverrides?.[0].maxOutputTokens, 4321); |
| 54 | await choose("auto"); |
| 55 | await click("Save changes"); |
| 56 | assert.equal(saved[1].modelOverrides?.[0].vision, null); |
| 57 | assert.equal(saved[1].modelOverrides?.[0].reasoningProtocol, "openai"); |
| 58 | await choose("off"); |
| 59 | await click("Cancel"); |
| 60 | assert.equal(cancelled, 1); |
| 61 | assert.equal(saved.length, 2); |
| 62 | |
| 63 | await render(<ProviderEditor key="legacy-backend" initial={{ ...initial, modelCapabilities: undefined, modelOverrides: [{ ...initial.modelOverrides![0], vision: true }] }} kinds={["openai"]} busy={false} onSave={() => {}} onCancel={() => {}} />); |
| 64 | await choose("auto"); |
| 65 | assert(el.textContent?.includes("Image capability unknown"), "auto must not reuse the previous explicit override as its fallback"); |
| 66 | |
| 67 | // A controlled Promise completes only after unmount. It must not modify the |
| 68 | // replacement editor or enable a model from the previous provider request. |
| 69 | let complete!: (v: ProviderModelCapabilityView[]) => void; |
| 70 | installDesktopHostStub(({ main: { App: { FetchProviderModelCatalog: () => new Promise((resolve) => { complete = resolve; }) } as Partial<AppBindings> as AppBindings } }).main.App); |
| 71 | await render(editor("fetching")); |
| 72 | await click("Refresh models"); |
| 73 | await render(editor("replacement")); |
| 74 | await act(async () => { complete([{ ...unknown, model: "stale-model", state: "supported" }]); }); |
| 75 | assert(!el.textContent?.includes("stale-model")); |
| 76 | assert(!el.textContent?.includes("stale-model")); |
| 77 | |
| 78 | await render(<ModelImageInputControl model="relay-model" mode="auto" capability={{ ...unknown, state: "unsupported", automaticState: "unsupported", imageInputEnableAllowed: false, imageInputBlockReason: "official_deepseek_text_model" }} disabled={false} onChange={() => {}} />); |
| 79 | assert(modeInput("on").disabled); |
| 80 | assert(!modeInput("off").disabled); |
| 81 | assert.equal(imageInputState("auto", { ...unknown, automaticState: undefined, source: "override", state: "supported" }), "unknown", "old backend must not fabricate automatic support"); |
| 82 | assert.equal(imageInputState("on", { ...unknown, imageInputEnableAllowed: false }), "unsupported"); |
| 83 | assert(imageInputHardBlocked("https://api.deepseek.com", "deepseek-v4-pro")); |
| 84 | assert(!imageInputHardBlocked("https://eu.deepseek.com/anthropic", "future-vision")); |
| 85 | assert(!imageInputHardBlocked("https://api.deepseek.com.relay.test", "deepseek-v4-pro")); |
| 86 | assert(!imageInputHardBlocked("https://api.deepseek.com", "deepseek-v4-flash")); |
| 87 | assert(!imageInputHardBlocked("https://api.deepseek.com", "deepseek-v4-flash-vision-exp")); |
| 88 | assert.equal(mergeImageInputModes(initial.modelOverrides, initial.models, { "RELAY-MODEL": "off" })[0].vision, null); |
| 89 | |
| 90 | // The real dialog enforces a backend hard block as well as the primitive control. |
| 91 | await render(<ProviderEditor key="blocked" initial={{...initial,modelCapabilities:[{...unknown,imageInputEnableAllowed:false,state:"unsupported"}]}} kinds={["openai"]} busy={false} onSave={()=>{}} onCancel={()=>{}}/>); |
| 92 | await openModel(); |
| 93 | assert.equal(document.querySelectorAll('dialog aside input:disabled').length,5,"blocked image input cannot be enabled"); |
| 94 | await act(async () => root.unmount()); |
| 95 | dom.window.close(); |
| 96 | console.log("PASS provider image input: draft/save/auto/cancel/disabled/hard-limit/stale discovery"); |
| 97 |