返回 DeepSeek-Reasonix
model-switcher-refresh.test.tsx
根目录 / desktop / frontend / src / __tests__ / model-switcher-refresh.test.tsx
1 // Run: tsx src/__tests__/model-switcher-refresh.test.tsx
2
3 import { JSDOM } from "jsdom";
4 import React, { act } from "react";
5 import { ModelSwitcher, normalizeModelInfo } from "../components/ModelSwitcher";
6 import { LocaleProvider } from "../lib/i18n";
7 import type { ModelInfo } from "../lib/types";
8 import { installDesktopHostStub } from "./desktopHostStub";
9
10 class TestResizeObserver {
11 observe() {}
12 disconnect() {}
13 }
14
15 function deferred<T>() {
16 let resolve!: (value: T) => void;
17 const promise = new Promise<T>((done) => { resolve = done; });
18 return { promise, resolve };
19 }
20
21 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
22 pretendToBeVisual: true,
23 url: "http://localhost/",
24 });
25 dom.window.localStorage.clear();
26 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
27 globalThis.window = dom.window as unknown as Window & typeof globalThis;
28 globalThis.document = dom.window.document;
29 globalThis.localStorage = dom.window.localStorage;
30 globalThis.Event = dom.window.Event;
31 globalThis.MouseEvent = dom.window.MouseEvent;
32 globalThis.HTMLElement = dom.window.HTMLElement;
33 globalThis.HTMLInputElement = dom.window.HTMLInputElement;
34 globalThis.Node = dom.window.Node;
35 globalThis.ResizeObserver = TestResizeObserver as unknown as typeof ResizeObserver;
36 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
37 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
38 Object.defineProperty(dom.window.HTMLElement.prototype, "attachEvent", { configurable: true, value: () => {} });
39 Object.defineProperty(dom.window.HTMLElement.prototype, "detachEvent", { configurable: true, value: () => {} });
40 Object.defineProperty(window, "matchMedia", {
41 configurable: true,
42 value: () => ({
43 matches: true,
44 media: "(prefers-reduced-motion: reduce)",
45 onchange: null,
46 addEventListener() {},
47 removeEventListener() {},
48 addListener() {},
49 removeListener() {},
50 dispatchEvent: () => false,
51 }),
52 });
53 const { createRoot } = await import("react-dom/client");
54
55 const stale = deferred<ModelInfo[]>();
56 const fresh = deferred<ModelInfo[]>();
57 let calls = 0;
58 const picked: string[] = [];
59 const pickGates: Array<ReturnType<typeof deferred<boolean>>> = [];
60 let catalogLoader: (() => Promise<ModelInfo[]>) | undefined;
61 const normalizedNullMetadata = normalizeModelInfo({
62 ref: "provider-a/model-a",
63 provider: null,
64 model: null,
65 current: true,
66 } as unknown as ModelInfo);
67 if (normalizedNullMetadata.provider !== "" || normalizedNullMetadata.model !== "") {
68 throw new Error(`null model metadata was not normalized: ${JSON.stringify(normalizedNullMetadata)}`);
69 }
70 let currentCatalog: ModelInfo[] = [
71 { ref: "glm-cn/glm-5.2", provider: "glm-cn", model: "glm-5.2", current: true },
72 ];
73 installDesktopHostStub(({
74 main: {
75 App: {
76 ModelsForTab: async () => {
77 calls += 1;
78 if (calls === 1) return stale.promise;
79 if (calls === 2) return fresh.promise;
80 if (catalogLoader) return catalogLoader();
81 return currentCatalog;
82 },
83 },
84 },
85 }).main.App);
86
87 const root = createRoot(document.getElementById("root")!);
88 const renderSwitcher = (label: string, tabId: string, ready = true, sessionKey?: string, disabled = false, dismissSignal?: number) => (
89 <LocaleProvider>
90 <ModelSwitcher
91 label={label}
92 tabId={tabId}
93 ready={ready}
94 sessionKey={sessionKey}
95 disabled={disabled}
96 dismissSignal={dismissSignal}
97 onPick={(ref) => {
98 picked.push(ref);
99 return pickGates.shift()?.promise ?? Promise.resolve(true);
100 }}
101 />
102 </LocaleProvider>
103 );
104 await act(async () => {
105 root.render(renderSwitcher("deepseek-v4-flash", "tab-a"));
106 });
107
108 await act(async () => {
109 window.dispatchEvent(new Event("reasonix:model-catalog-changed"));
110 fresh.resolve([{ ref: "glm-cn/glm-5.2", provider: "glm-cn", model: "glm-5.2", current: true }]);
111 await fresh.promise;
112 });
113 await act(async () => {
114 stale.resolve([{ ref: "deepseek/deepseek-v4-flash", provider: "deepseek", model: "deepseek-v4-flash", current: true }]);
115 await stale.promise;
116 });
117 await act(async () => {
118 (document.querySelector(".modelsw__trigger") as HTMLButtonElement).click();
119 await new Promise((resolve) => setTimeout(resolve, 0));
120 });
121
122 const options = Array.from(document.querySelectorAll<HTMLElement>("[role='option']")).map((item) => item.textContent?.trim());
123 if (options.length !== 1 || !options[0]?.includes("glm-5.2") || !options[0]?.includes("glm-cn")) {
124 throw new Error(`model catalog did not keep the fresh result: ${JSON.stringify(options)}`);
125 }
126 if (calls < 3) throw new Error(`expected mount, settings refresh, and open loads; got ${calls}`);
127
128 await act(async () => {
129 (document.querySelector("[role='option'][aria-selected='true']") as HTMLButtonElement).click();
130 await new Promise((resolve) => setTimeout(resolve, 0));
131 });
132 if (picked.length !== 0) throw new Error(`current model should be a no-op, picked ${JSON.stringify(picked)}`);
133 if ((document.querySelector(".modelsw__trigger") as HTMLButtonElement).getAttribute("aria-expanded") !== "false") {
134 throw new Error("current-model no-op did not close the menu");
135 }
136
137 currentCatalog = [
138 { ref: "glm-cn/glm-5.2", provider: "glm-cn", model: "glm-5.2", current: true },
139 { ref: "deepseek/deepseek-v4-flash", provider: "deepseek", model: "deepseek-v4-flash", current: false },
140 ];
141 const pendingPickGate = deferred<boolean>();
142 pickGates.push(pendingPickGate, pendingPickGate);
143 await act(async () => {
144 (document.querySelector(".modelsw__trigger") as HTMLButtonElement).click();
145 await new Promise((resolve) => setTimeout(resolve, 0));
146 });
147 await act(async () => {
148 await new Promise((resolve) => setTimeout(resolve, 0));
149 });
150 const next = Array.from(document.querySelectorAll<HTMLButtonElement>("[role='option']"))
151 .find((option) => option.textContent?.includes("deepseek-v4-flash"));
152 if (!next) throw new Error("pending-switch model option did not load");
153 await act(async () => {
154 next.click();
155 await new Promise((resolve) => setTimeout(resolve, 0));
156 });
157 if (picked[0] !== "deepseek/deepseek-v4-flash") {
158 throw new Error(`non-current model was not selected: ${JSON.stringify(picked)}`);
159 }
160
161 // The backend catalog may still identify the outgoing GLM model as current
162 // while the first switch is pending. Selecting it now must enqueue a rollback.
163 await act(async () => {
164 (document.querySelector(".modelsw__trigger") as HTMLButtonElement).click();
165 await new Promise((resolve) => setTimeout(resolve, 0));
166 });
167 await act(async () => {
168 await new Promise((resolve) => setTimeout(resolve, 0));
169 });
170 const rollback = document.querySelector<HTMLButtonElement>("[role='option'][aria-selected='true']");
171 if (!rollback) throw new Error("pending-switch rollback option did not load");
172 await act(async () => {
173 rollback.click();
174 await new Promise((resolve) => setTimeout(resolve, 0));
175 });
176 if (picked[1] !== "glm-cn/glm-5.2") {
177 throw new Error(`pending current-model rollback was swallowed: ${JSON.stringify(picked)}`);
178 }
179
180 // Pending work belongs to tab A. Reusing the mounted switcher for tab B must
181 // not turn B's settled current model into another rollback request.
182 currentCatalog = [
183 { ref: "provider-b/model-b", provider: "provider-b", model: "model-b", current: true },
184 ];
185 await act(async () => {
186 root.render(renderSwitcher("model-b", "tab-b"));
187 await new Promise((resolve) => setTimeout(resolve, 0));
188 });
189 await act(async () => {
190 (document.querySelector(".modelsw__trigger") as HTMLButtonElement).click();
191 await new Promise((resolve) => setTimeout(resolve, 0));
192 });
193 await act(async () => {
194 await new Promise((resolve) => setTimeout(resolve, 0));
195 });
196 const tabBCurrent = document.querySelector<HTMLButtonElement>("[role='option'][aria-selected='true']");
197 if (!tabBCurrent) throw new Error("tab B current model did not load");
198 await act(async () => {
199 tabBCurrent.click();
200 await new Promise((resolve) => setTimeout(resolve, 0));
201 });
202 if (picked.length !== 2) {
203 throw new Error(`tab A pending picks leaked into tab B: ${JSON.stringify(picked)}`);
204 }
205
206 await act(async () => {
207 pendingPickGate.resolve(true);
208 await pendingPickGate.promise;
209 });
210
211 // A failed latest pick must immediately roll back its optimistic selection,
212 // then reconcile from the authoritative backend catalog. Retrying the same
213 // target before that catalog request completes must remain possible.
214 currentCatalog = [
215 { ref: "provider-a/model-a", provider: "provider-a", model: "model-a", current: true },
216 { ref: "provider-b/model-b", provider: "provider-b", model: "model-b", current: false },
217 ];
218 await act(async () => {
219 root.render(renderSwitcher("model-a", "tab-c"));
220 await new Promise((resolve) => setTimeout(resolve, 0));
221 });
222 await act(async () => {
223 (document.querySelector(".modelsw__trigger") as HTMLButtonElement).click();
224 await new Promise((resolve) => setTimeout(resolve, 0));
225 });
226 const failedTarget = Array.from(document.querySelectorAll<HTMLButtonElement>("[role='option']"))
227 .find((option) => option.textContent?.includes("model-b"));
228 if (!failedTarget) throw new Error("failed-switch target did not load");
229 const failedPickGate = deferred<boolean>();
230 pickGates.push(failedPickGate);
231 const catalogReloadGate = deferred<ModelInfo[]>();
232 catalogLoader = () => catalogReloadGate.promise;
233 await act(async () => {
234 failedTarget.click();
235 await new Promise((resolve) => setTimeout(resolve, 0));
236 });
237 failedPickGate.resolve(false);
238 await act(async () => {
239 await failedPickGate.promise;
240 await new Promise((resolve) => setTimeout(resolve, 0));
241 });
242 await act(async () => {
243 (document.querySelector(".modelsw__trigger") as HTMLButtonElement).click();
244 await new Promise((resolve) => setTimeout(resolve, 0));
245 });
246 const rolledBackCurrent = document.querySelector<HTMLButtonElement>("[role='option'][aria-selected='true']");
247 if (!rolledBackCurrent?.textContent?.includes("model-a")) {
248 throw new Error(`failed pick did not immediately roll back: ${rolledBackCurrent?.textContent ?? "missing"}`);
249 }
250 const retryTarget = Array.from(document.querySelectorAll<HTMLButtonElement>("[role='option']"))
251 .find((option) => option.textContent?.includes("model-b"));
252 if (!retryTarget) throw new Error("failed-switch target was not available for immediate retry");
253 const retryPickGate = deferred<boolean>();
254 pickGates.push(retryPickGate);
255 await act(async () => {
256 retryTarget.click();
257 await new Promise((resolve) => setTimeout(resolve, 0));
258 });
259 if (picked.filter((ref) => ref === "provider-b/model-b").length !== 2) {
260 throw new Error(`failed target could not be retried: ${JSON.stringify(picked)}`);
261 }
262
263 // The failed attempt's catalog request describes the pre-retry backend state.
264 // Its late completion must not overwrite the newer optimistic retry.
265 catalogReloadGate.resolve(currentCatalog);
266 await act(async () => {
267 await catalogReloadGate.promise;
268 await new Promise((resolve) => setTimeout(resolve, 0));
269 });
270 const retryTriggerLabel = (document.querySelector(".modelsw__trigger") as HTMLButtonElement)
271 .getAttribute("aria-label") ?? "";
272 if (!retryTriggerLabel.includes("provider-b")) {
273 throw new Error(`stale failure reconciliation overwrote the retry: ${retryTriggerLabel}`);
274 }
275 retryPickGate.resolve(true);
276 await act(async () => {
277 await retryPickGate.promise;
278 await new Promise((resolve) => setTimeout(resolve, 0));
279 });
280 catalogLoader = undefined;
281
282 // An older superseded failure also must not roll back a newer pending pick.
283 currentCatalog = [
284 { ref: "provider-a/model-a", provider: "provider-a", model: "model-a", current: true },
285 { ref: "provider-b/model-b", provider: "provider-b", model: "model-b", current: false },
286 { ref: "provider-c/model-c", provider: "provider-c", model: "model-c", current: false },
287 ];
288 await act(async () => {
289 root.render(renderSwitcher("model-a", "tab-d"));
290 await new Promise((resolve) => setTimeout(resolve, 0));
291 });
292 await act(async () => {
293 (document.querySelector(".modelsw__trigger") as HTMLButtonElement).click();
294 await new Promise((resolve) => setTimeout(resolve, 0));
295 });
296 const olderTarget = Array.from(document.querySelectorAll<HTMLButtonElement>("[role='option']"))
297 .find((option) => option.textContent?.includes("model-b"));
298 if (!olderTarget) throw new Error("older switch target did not load");
299 const olderPickGate = deferred<boolean>();
300 pickGates.push(olderPickGate);
301 await act(async () => {
302 olderTarget.click();
303 await new Promise((resolve) => setTimeout(resolve, 0));
304 });
305 await act(async () => {
306 (document.querySelector(".modelsw__trigger") as HTMLButtonElement).click();
307 await new Promise((resolve) => setTimeout(resolve, 0));
308 });
309 const newerTarget = Array.from(document.querySelectorAll<HTMLButtonElement>("[role='option']"))
310 .find((option) => option.textContent?.includes("model-c"));
311 if (!newerTarget) throw new Error("newer switch target did not load");
312 const newerPickGate = deferred<boolean>();
313 pickGates.push(newerPickGate);
314 await act(async () => {
315 newerTarget.click();
316 await new Promise((resolve) => setTimeout(resolve, 0));
317 });
318 olderPickGate.resolve(false);
319 await act(async () => {
320 await olderPickGate.promise;
321 await new Promise((resolve) => setTimeout(resolve, 0));
322 });
323 const newerTriggerLabel = (document.querySelector(".modelsw__trigger") as HTMLButtonElement)
324 .getAttribute("aria-label") ?? "";
325 if (!newerTriggerLabel.includes("provider-c")) {
326 throw new Error(`superseded failure rolled back the newer pick: ${newerTriggerLabel}`);
327 }
328 newerPickGate.resolve(true);
329 await act(async () => {
330 await newerPickGate.promise;
331 });
332
333 // Legacy or malformed settings can surface null display metadata despite the
334 // bridge contract. Catalog loading must normalize it before grouping/search.
335 currentCatalog = [
336 { ref: "provider-a/model-a", provider: null, model: null, current: true } as unknown as ModelInfo,
337 { ref: "provider-b/model-b", provider: "provider-b", model: "model-b", current: false },
338 ];
339 await act(async () => {
340 root.render(renderSwitcher("model-a", "tab-null-metadata"));
341 await new Promise((resolve) => setTimeout(resolve, 0));
342 });
343 await act(async () => {
344 (document.querySelector(".modelsw__trigger") as HTMLButtonElement).click();
345 await new Promise((resolve) => setTimeout(resolve, 0));
346 });
347 const nullSafeOptions = Array.from(document.querySelectorAll<HTMLElement>("[role='option']"));
348 if (nullSafeOptions.length !== 2) {
349 throw new Error(`null model metadata prevented catalog rendering: ${nullSafeOptions.length}`);
350 }
351
352 // Connection order and duplicate model IDs must survive selection and renaming.
353 currentCatalog = [
354 { ref: "z/shared", provider: "z", displayName: "First connection", model: "shared", current: false },
355 { ref: "a/shared", provider: "a", displayName: "Second connection", model: "shared", current: true },
356 ];
357 await act(async () => {
358 window.dispatchEvent(new Event("reasonix:model-catalog-changed"));
359 await new Promise(resolve => setTimeout(resolve, 0));
360 });
361 const connectionLabels = Array.from(document.querySelectorAll(".modelsw__meta"), el => el.textContent);
362 if (connectionLabels.join("|") !== "First connection|Second connection · Current model") {
363 throw new Error(`connection labels or catalog order differ: ${connectionLabels}`);
364 }
365 if (document.querySelectorAll("[role='option']").length !== 2) {
366 throw new Error("same model ID in separate connections was merged");
367 }
368
369 // Favorites are keyed by the full provider/model ref, remain in the open menu,
370 // and can be used as a rail filter without switching the active model.
371 const firstFavorite = document.querySelector<HTMLButtonElement>(".modelsw__favorite");
372 if (!firstFavorite) throw new Error("favorite control was not rendered");
373 const pickedBeforeFavorite = picked.length;
374 await act(async () => { firstFavorite.click(); });
375 if (picked.length !== pickedBeforeFavorite) throw new Error("favorite action triggered a model switch");
376 if (localStorage.getItem("reasonix-model-favorites-v1") !== JSON.stringify(["z/shared"])) {
377 throw new Error(`favorite ref was not persisted: ${localStorage.getItem("reasonix-model-favorites-v1")}`);
378 }
379 const favoriteFilter = document.querySelector<HTMLButtonElement>('.modelsw__rail-item[aria-label="Favorites"]');
380 if (!favoriteFilter) throw new Error("favorites rail filter was not rendered");
381 await act(async () => { favoriteFilter.click(); });
382 const favoriteOptions = Array.from(document.querySelectorAll<HTMLElement>("[role='option']"));
383 if (favoriteOptions.length !== 1 || !favoriteOptions[0]?.textContent?.includes("First connection")) {
384 throw new Error(`favorites filter did not isolate the saved connection: ${favoriteOptions.map(el => el.textContent)}`);
385 }
386 const allFilter = document.querySelector<HTMLButtonElement>('.modelsw__rail-item[aria-label="All models"]');
387 await act(async () => { allFilter?.click(); });
388
389 // Search is catalog-wide even when the user was browsing one provider. The
390 // top-level field moves back to All models before applying its keyword.
391 currentCatalog = [
392 { ref: "deepseek/deepseek-v4-flash", provider: "deepseek", displayName: "DeepSeek Official", model: "deepseek-v4-flash", current: true },
393 { ref: "moonshot/kimi-k2", provider: "moonshot", displayName: "Moonshot", model: "kimi-k2", current: false },
394 ];
395 await act(async () => {
396 window.dispatchEvent(new Event("reasonix:model-catalog-changed"));
397 await new Promise(resolve => setTimeout(resolve, 0));
398 });
399 const deepseekFilter = document.querySelector<HTMLButtonElement>('.modelsw__rail-item[aria-label="DeepSeek Official"]');
400 if (!deepseekFilter) throw new Error("DeepSeek provider filter was not rendered");
401 await act(async () => { deepseekFilter.click(); });
402 if (document.querySelectorAll("[role='option']").length !== 1) {
403 throw new Error("provider browsing did not narrow the model catalog");
404 }
405 const catalogSearch = document.querySelector<HTMLInputElement>(".modelsw__search-input");
406 if (!catalogSearch) throw new Error("catalog search input was not rendered");
407 await act(async () => {
408 Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!.call(catalogSearch, "kimi");
409 catalogSearch.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
410 await new Promise(resolve => setTimeout(resolve, 0));
411 });
412 const globalSearchOptions = Array.from(document.querySelectorAll<HTMLElement>("[role='option']"));
413 if (globalSearchOptions.length !== 1 || !globalSearchOptions[0]?.textContent?.includes("kimi-k2")) {
414 throw new Error(`provider filter constrained global search: ${globalSearchOptions.map(item => item.textContent)}`);
415 }
416 if (allFilter?.getAttribute("aria-pressed") !== "true") {
417 throw new Error("global search did not activate the All models rail state");
418 }
419
420 currentCatalog = [
421 { ref: "z/shared", provider: "z", displayName: "First connection", model: "shared", current: false },
422 { ref: "a/shared", provider: "a", displayName: "Second connection", model: "shared", current: true },
423 ];
424 await act(async () => {
425 Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!.call(catalogSearch, "");
426 catalogSearch.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
427 window.dispatchEvent(new Event("reasonix:model-catalog-changed"));
428 await new Promise(resolve => setTimeout(resolve, 0));
429 });
430
431 // Cold restoration can finish after the initial catalog read while retaining
432 // the same tab and model label. Readiness and session identity must refresh
433 // the connection without requiring the user to open the menu.
434 await act(async () => {
435 (document.querySelector(".modelsw__trigger") as HTMLButtonElement).click();
436 currentCatalog = currentCatalog.map(m => ({ ...m, current: m.provider === "z" }));
437 root.render(renderSwitcher("shared", "tab-restored", false, "session-one"));
438 });
439 await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); });
440 const restoredLabel = () => document.querySelector(".modelsw__trigger")?.getAttribute("aria-label") ?? "";
441 if (!restoredLabel().includes("First connection")) throw new Error("cold catalog fixture did not load");
442 await act(async () => {
443 currentCatalog = currentCatalog.map(m => ({ ...m, current: m.provider === "a" }));
444 root.render(renderSwitcher("shared", "tab-restored", true, "session-one"));
445 });
446 await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); });
447 if (!restoredLabel().includes("Second connection")) throw new Error("ready session retained the startup connection label");
448 await act(async () => {
449 currentCatalog = currentCatalog.map(m => ({ ...m, current: m.provider === "z" }));
450 root.render(renderSwitcher("shared", "tab-restored", true, "session-two"));
451 });
452 await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); });
453 if (!restoredLabel().includes("First connection")) throw new Error("resumed session retained another session's connection label");
454
455 await act(async () => {
456 (document.querySelector(".modelsw__trigger") as HTMLButtonElement).click();
457 await new Promise(resolve => setTimeout(resolve, 0));
458 });
459 if (!document.querySelector(".modelsw__menu")) throw new Error("model menu did not open before suspension");
460 await act(async () => { root.render(renderSwitcher("shared", "tab-restored", true, "session-two", true, 1)); });
461 await act(async () => { root.render(renderSwitcher("shared", "tab-restored", true, "session-two", false, 1)); });
462 if (document.querySelector(".modelsw__menu")) throw new Error("suspended model menu reopened after recovery");
463
464 await act(async () => root.unmount());
465 console.log("model switcher refresh: PASS");
466
466 lines Plain Text