| 1 | // Run: tsx src/__tests__/model-switcher-refresh.test.tsx |
| 2 | |
| 3 | import { JSDOM } from "jsdom"; |
| 4 | import React, { act } from "react"; |
| 5 | import { createRoot } from "react-dom/client"; |
| 6 | import { ModelSwitcher, normalizeModelInfo } from "../components/ModelSwitcher"; |
| 7 | import { LocaleProvider } from "../lib/i18n"; |
| 8 | import type { ModelInfo } from "../lib/types"; |
| 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 | (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; |
| 26 | globalThis.window = dom.window as unknown as Window & typeof globalThis; |
| 27 | globalThis.document = dom.window.document; |
| 28 | globalThis.Event = dom.window.Event; |
| 29 | globalThis.MouseEvent = dom.window.MouseEvent; |
| 30 | globalThis.HTMLElement = dom.window.HTMLElement; |
| 31 | globalThis.ResizeObserver = TestResizeObserver as unknown as typeof ResizeObserver; |
| 32 | globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window); |
| 33 | globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window); |
| 34 | Object.defineProperty(dom.window.HTMLElement.prototype, "attachEvent", { configurable: true, value: () => {} }); |
| 35 | Object.defineProperty(dom.window.HTMLElement.prototype, "detachEvent", { configurable: true, value: () => {} }); |
| 36 | Object.defineProperty(window, "matchMedia", { |
| 37 | configurable: true, |
| 38 | value: () => ({ |
| 39 | matches: true, |
| 40 | media: "(prefers-reduced-motion: reduce)", |
| 41 | onchange: null, |
| 42 | addEventListener() {}, |
| 43 | removeEventListener() {}, |
| 44 | addListener() {}, |
| 45 | removeListener() {}, |
| 46 | dispatchEvent: () => false, |
| 47 | }), |
| 48 | }); |
| 49 | |
| 50 | const stale = deferred<ModelInfo[]>(); |
| 51 | const fresh = deferred<ModelInfo[]>(); |
| 52 | let calls = 0; |
| 53 | const picked: string[] = []; |
| 54 | const pickGates: Array<ReturnType<typeof deferred<boolean>>> = []; |
| 55 | let catalogLoader: (() => Promise<ModelInfo[]>) | undefined; |
| 56 | const normalizedNullMetadata = normalizeModelInfo({ |
| 57 | ref: "provider-a/model-a", |
| 58 | provider: null, |
| 59 | model: null, |
| 60 | current: true, |
| 61 | } as unknown as ModelInfo); |
| 62 | if (normalizedNullMetadata.provider !== "" || normalizedNullMetadata.model !== "") { |
| 63 | throw new Error(`null model metadata was not normalized: ${JSON.stringify(normalizedNullMetadata)}`); |
| 64 | } |
| 65 | let currentCatalog: ModelInfo[] = [ |
| 66 | { ref: "glm-cn/glm-5.2", provider: "glm-cn", model: "glm-5.2", current: true }, |
| 67 | ]; |
| 68 | (window as unknown as { go: { main: { App: Record<string, unknown> } } }).go = { |
| 69 | main: { |
| 70 | App: { |
| 71 | ModelsForTab: async () => { |
| 72 | calls += 1; |
| 73 | if (calls === 1) return stale.promise; |
| 74 | if (calls === 2) return fresh.promise; |
| 75 | if (catalogLoader) return catalogLoader(); |
| 76 | return currentCatalog; |
| 77 | }, |
| 78 | }, |
| 79 | }, |
| 80 | }; |
| 81 | |
| 82 | const root = createRoot(document.getElementById("root")!); |
| 83 | const renderSwitcher = (label: string, tabId: string) => ( |
| 84 | <LocaleProvider> |
| 85 | <ModelSwitcher |
| 86 | label={label} |
| 87 | tabId={tabId} |
| 88 | onPick={(ref) => { |
| 89 | picked.push(ref); |
| 90 | return pickGates.shift()?.promise ?? Promise.resolve(true); |
| 91 | }} |
| 92 | /> |
| 93 | </LocaleProvider> |
| 94 | ); |
| 95 | await act(async () => { |
| 96 | root.render(renderSwitcher("deepseek-v4-flash", "tab-a")); |
| 97 | }); |
| 98 | |
| 99 | await act(async () => { |
| 100 | window.dispatchEvent(new Event("reasonix:model-catalog-changed")); |
| 101 | fresh.resolve([{ ref: "glm-cn/glm-5.2", provider: "glm-cn", model: "glm-5.2", current: true }]); |
| 102 | await fresh.promise; |
| 103 | }); |
| 104 | await act(async () => { |
| 105 | stale.resolve([{ ref: "deepseek/deepseek-v4-flash", provider: "deepseek", model: "deepseek-v4-flash", current: true }]); |
| 106 | await stale.promise; |
| 107 | }); |
| 108 | await act(async () => { |
| 109 | (document.querySelector(".modelsw__trigger") as HTMLButtonElement).click(); |
| 110 | await new Promise((resolve) => setTimeout(resolve, 0)); |
| 111 | }); |
| 112 | |
| 113 | const options = Array.from(document.querySelectorAll<HTMLElement>("[role='option']")).map((item) => item.textContent?.trim()); |
| 114 | if (JSON.stringify(options) !== JSON.stringify(["glm-5.2"])) { |
| 115 | throw new Error(`model catalog did not keep the fresh result: ${JSON.stringify(options)}`); |
| 116 | } |
| 117 | if (calls < 3) throw new Error(`expected mount, settings refresh, and open loads; got ${calls}`); |
| 118 | |
| 119 | await act(async () => { |
| 120 | (document.querySelector("[role='option'][aria-selected='true']") as HTMLButtonElement).click(); |
| 121 | await new Promise((resolve) => setTimeout(resolve, 0)); |
| 122 | }); |
| 123 | if (picked.length !== 0) throw new Error(`current model should be a no-op, picked ${JSON.stringify(picked)}`); |
| 124 | if ((document.querySelector(".modelsw__trigger") as HTMLButtonElement).getAttribute("aria-expanded") !== "false") { |
| 125 | throw new Error("current-model no-op did not close the menu"); |
| 126 | } |
| 127 | |
| 128 | currentCatalog = [ |
| 129 | { ref: "glm-cn/glm-5.2", provider: "glm-cn", model: "glm-5.2", current: true }, |
| 130 | { ref: "deepseek/deepseek-v4-flash", provider: "deepseek", model: "deepseek-v4-flash", current: false }, |
| 131 | ]; |
| 132 | const pendingPickGate = deferred<boolean>(); |
| 133 | pickGates.push(pendingPickGate, pendingPickGate); |
| 134 | await act(async () => { |
| 135 | (document.querySelector(".modelsw__trigger") as HTMLButtonElement).click(); |
| 136 | await new Promise((resolve) => setTimeout(resolve, 0)); |
| 137 | }); |
| 138 | await act(async () => { |
| 139 | await new Promise((resolve) => setTimeout(resolve, 0)); |
| 140 | }); |
| 141 | const next = Array.from(document.querySelectorAll<HTMLButtonElement>("[role='option']")) |
| 142 | .find((option) => option.textContent?.includes("deepseek-v4-flash")); |
| 143 | if (!next) throw new Error("pending-switch model option did not load"); |
| 144 | await act(async () => { |
| 145 | next.click(); |
| 146 | await new Promise((resolve) => setTimeout(resolve, 0)); |
| 147 | }); |
| 148 | if (picked[0] !== "deepseek/deepseek-v4-flash") { |
| 149 | throw new Error(`non-current model was not selected: ${JSON.stringify(picked)}`); |
| 150 | } |
| 151 | |
| 152 | // The backend catalog may still identify the outgoing GLM model as current |
| 153 | // while the first switch is pending. Selecting it now must enqueue a rollback. |
| 154 | await act(async () => { |
| 155 | (document.querySelector(".modelsw__trigger") as HTMLButtonElement).click(); |
| 156 | await new Promise((resolve) => setTimeout(resolve, 0)); |
| 157 | }); |
| 158 | await act(async () => { |
| 159 | await new Promise((resolve) => setTimeout(resolve, 0)); |
| 160 | }); |
| 161 | const rollback = document.querySelector<HTMLButtonElement>("[role='option'][aria-selected='true']"); |
| 162 | if (!rollback) throw new Error("pending-switch rollback option did not load"); |
| 163 | await act(async () => { |
| 164 | rollback.click(); |
| 165 | await new Promise((resolve) => setTimeout(resolve, 0)); |
| 166 | }); |
| 167 | if (picked[1] !== "glm-cn/glm-5.2") { |
| 168 | throw new Error(`pending current-model rollback was swallowed: ${JSON.stringify(picked)}`); |
| 169 | } |
| 170 | |
| 171 | // Pending work belongs to tab A. Reusing the mounted switcher for tab B must |
| 172 | // not turn B's settled current model into another rollback request. |
| 173 | currentCatalog = [ |
| 174 | { ref: "provider-b/model-b", provider: "provider-b", model: "model-b", current: true }, |
| 175 | ]; |
| 176 | await act(async () => { |
| 177 | root.render(renderSwitcher("model-b", "tab-b")); |
| 178 | await new Promise((resolve) => setTimeout(resolve, 0)); |
| 179 | }); |
| 180 | await act(async () => { |
| 181 | (document.querySelector(".modelsw__trigger") as HTMLButtonElement).click(); |
| 182 | await new Promise((resolve) => setTimeout(resolve, 0)); |
| 183 | }); |
| 184 | await act(async () => { |
| 185 | await new Promise((resolve) => setTimeout(resolve, 0)); |
| 186 | }); |
| 187 | const tabBCurrent = document.querySelector<HTMLButtonElement>("[role='option'][aria-selected='true']"); |
| 188 | if (!tabBCurrent) throw new Error("tab B current model did not load"); |
| 189 | await act(async () => { |
| 190 | tabBCurrent.click(); |
| 191 | await new Promise((resolve) => setTimeout(resolve, 0)); |
| 192 | }); |
| 193 | if (picked.length !== 2) { |
| 194 | throw new Error(`tab A pending picks leaked into tab B: ${JSON.stringify(picked)}`); |
| 195 | } |
| 196 | |
| 197 | await act(async () => { |
| 198 | pendingPickGate.resolve(true); |
| 199 | await pendingPickGate.promise; |
| 200 | }); |
| 201 | |
| 202 | // A failed latest pick must immediately roll back its optimistic selection, |
| 203 | // then reconcile from the authoritative backend catalog. Retrying the same |
| 204 | // target before that catalog request completes must remain possible. |
| 205 | currentCatalog = [ |
| 206 | { ref: "provider-a/model-a", provider: "provider-a", model: "model-a", current: true }, |
| 207 | { ref: "provider-b/model-b", provider: "provider-b", model: "model-b", current: false }, |
| 208 | ]; |
| 209 | await act(async () => { |
| 210 | root.render(renderSwitcher("model-a", "tab-c")); |
| 211 | await new Promise((resolve) => setTimeout(resolve, 0)); |
| 212 | }); |
| 213 | await act(async () => { |
| 214 | (document.querySelector(".modelsw__trigger") as HTMLButtonElement).click(); |
| 215 | await new Promise((resolve) => setTimeout(resolve, 0)); |
| 216 | }); |
| 217 | const failedTarget = Array.from(document.querySelectorAll<HTMLButtonElement>("[role='option']")) |
| 218 | .find((option) => option.textContent?.includes("model-b")); |
| 219 | if (!failedTarget) throw new Error("failed-switch target did not load"); |
| 220 | const failedPickGate = deferred<boolean>(); |
| 221 | pickGates.push(failedPickGate); |
| 222 | const catalogReloadGate = deferred<ModelInfo[]>(); |
| 223 | catalogLoader = () => catalogReloadGate.promise; |
| 224 | await act(async () => { |
| 225 | failedTarget.click(); |
| 226 | await new Promise((resolve) => setTimeout(resolve, 0)); |
| 227 | }); |
| 228 | failedPickGate.resolve(false); |
| 229 | await act(async () => { |
| 230 | await failedPickGate.promise; |
| 231 | await new Promise((resolve) => setTimeout(resolve, 0)); |
| 232 | }); |
| 233 | await act(async () => { |
| 234 | (document.querySelector(".modelsw__trigger") as HTMLButtonElement).click(); |
| 235 | await new Promise((resolve) => setTimeout(resolve, 0)); |
| 236 | }); |
| 237 | const rolledBackCurrent = document.querySelector<HTMLButtonElement>("[role='option'][aria-selected='true']"); |
| 238 | if (!rolledBackCurrent?.textContent?.includes("model-a")) { |
| 239 | throw new Error(`failed pick did not immediately roll back: ${rolledBackCurrent?.textContent ?? "missing"}`); |
| 240 | } |
| 241 | const retryTarget = Array.from(document.querySelectorAll<HTMLButtonElement>("[role='option']")) |
| 242 | .find((option) => option.textContent?.includes("model-b")); |
| 243 | if (!retryTarget) throw new Error("failed-switch target was not available for immediate retry"); |
| 244 | const retryPickGate = deferred<boolean>(); |
| 245 | pickGates.push(retryPickGate); |
| 246 | await act(async () => { |
| 247 | retryTarget.click(); |
| 248 | await new Promise((resolve) => setTimeout(resolve, 0)); |
| 249 | }); |
| 250 | if (picked.filter((ref) => ref === "provider-b/model-b").length !== 2) { |
| 251 | throw new Error(`failed target could not be retried: ${JSON.stringify(picked)}`); |
| 252 | } |
| 253 | |
| 254 | // The failed attempt's catalog request describes the pre-retry backend state. |
| 255 | // Its late completion must not overwrite the newer optimistic retry. |
| 256 | catalogReloadGate.resolve(currentCatalog); |
| 257 | await act(async () => { |
| 258 | await catalogReloadGate.promise; |
| 259 | await new Promise((resolve) => setTimeout(resolve, 0)); |
| 260 | }); |
| 261 | const retryTriggerLabel = (document.querySelector(".modelsw__trigger") as HTMLButtonElement) |
| 262 | .getAttribute("aria-label") ?? ""; |
| 263 | if (!retryTriggerLabel.includes("provider-b")) { |
| 264 | throw new Error(`stale failure reconciliation overwrote the retry: ${retryTriggerLabel}`); |
| 265 | } |
| 266 | retryPickGate.resolve(true); |
| 267 | await act(async () => { |
| 268 | await retryPickGate.promise; |
| 269 | await new Promise((resolve) => setTimeout(resolve, 0)); |
| 270 | }); |
| 271 | catalogLoader = undefined; |
| 272 | |
| 273 | // An older superseded failure also must not roll back a newer pending pick. |
| 274 | currentCatalog = [ |
| 275 | { ref: "provider-a/model-a", provider: "provider-a", model: "model-a", current: true }, |
| 276 | { ref: "provider-b/model-b", provider: "provider-b", model: "model-b", current: false }, |
| 277 | { ref: "provider-c/model-c", provider: "provider-c", model: "model-c", current: false }, |
| 278 | ]; |
| 279 | await act(async () => { |
| 280 | root.render(renderSwitcher("model-a", "tab-d")); |
| 281 | await new Promise((resolve) => setTimeout(resolve, 0)); |
| 282 | }); |
| 283 | await act(async () => { |
| 284 | (document.querySelector(".modelsw__trigger") as HTMLButtonElement).click(); |
| 285 | await new Promise((resolve) => setTimeout(resolve, 0)); |
| 286 | }); |
| 287 | const olderTarget = Array.from(document.querySelectorAll<HTMLButtonElement>("[role='option']")) |
| 288 | .find((option) => option.textContent?.includes("model-b")); |
| 289 | if (!olderTarget) throw new Error("older switch target did not load"); |
| 290 | const olderPickGate = deferred<boolean>(); |
| 291 | pickGates.push(olderPickGate); |
| 292 | await act(async () => { |
| 293 | olderTarget.click(); |
| 294 | await new Promise((resolve) => setTimeout(resolve, 0)); |
| 295 | }); |
| 296 | await act(async () => { |
| 297 | (document.querySelector(".modelsw__trigger") as HTMLButtonElement).click(); |
| 298 | await new Promise((resolve) => setTimeout(resolve, 0)); |
| 299 | }); |
| 300 | const newerTarget = Array.from(document.querySelectorAll<HTMLButtonElement>("[role='option']")) |
| 301 | .find((option) => option.textContent?.includes("model-c")); |
| 302 | if (!newerTarget) throw new Error("newer switch target did not load"); |
| 303 | const newerPickGate = deferred<boolean>(); |
| 304 | pickGates.push(newerPickGate); |
| 305 | await act(async () => { |
| 306 | newerTarget.click(); |
| 307 | await new Promise((resolve) => setTimeout(resolve, 0)); |
| 308 | }); |
| 309 | olderPickGate.resolve(false); |
| 310 | await act(async () => { |
| 311 | await olderPickGate.promise; |
| 312 | await new Promise((resolve) => setTimeout(resolve, 0)); |
| 313 | }); |
| 314 | const newerTriggerLabel = (document.querySelector(".modelsw__trigger") as HTMLButtonElement) |
| 315 | .getAttribute("aria-label") ?? ""; |
| 316 | if (!newerTriggerLabel.includes("provider-c")) { |
| 317 | throw new Error(`superseded failure rolled back the newer pick: ${newerTriggerLabel}`); |
| 318 | } |
| 319 | newerPickGate.resolve(true); |
| 320 | await act(async () => { |
| 321 | await newerPickGate.promise; |
| 322 | }); |
| 323 | |
| 324 | // Legacy or malformed settings can surface null display metadata despite the |
| 325 | // bridge contract. Catalog loading must normalize it before grouping/search. |
| 326 | currentCatalog = [ |
| 327 | { ref: "provider-a/model-a", provider: null, model: null, current: true } as unknown as ModelInfo, |
| 328 | { ref: "provider-b/model-b", provider: "provider-b", model: "model-b", current: false }, |
| 329 | ]; |
| 330 | await act(async () => { |
| 331 | root.render(renderSwitcher("model-a", "tab-null-metadata")); |
| 332 | await new Promise((resolve) => setTimeout(resolve, 0)); |
| 333 | }); |
| 334 | await act(async () => { |
| 335 | (document.querySelector(".modelsw__trigger") as HTMLButtonElement).click(); |
| 336 | await new Promise((resolve) => setTimeout(resolve, 0)); |
| 337 | }); |
| 338 | const nullSafeOptions = Array.from(document.querySelectorAll<HTMLElement>("[role='option']")); |
| 339 | if (nullSafeOptions.length !== 2) { |
| 340 | throw new Error(`null model metadata prevented catalog rendering: ${nullSafeOptions.length}`); |
| 341 | } |
| 342 | |
| 343 | await act(async () => root.unmount()); |
| 344 | console.log("model switcher refresh: PASS"); |
| 345 |