| 1 | import { selectSettingsValue } from "./settingsSelectTestUtils"; |
| 2 | // Run: tsx src/__tests__/settings-refresh-snapshot.test.tsx |
| 3 | |
| 4 | import { JSDOM } from "jsdom"; |
| 5 | import React from "react"; |
| 6 | import { act } from "react"; |
| 7 | import { createRoot } from "react-dom/client"; |
| 8 | import { |
| 9 | SettingsPanel, |
| 10 | } from "../components/SettingsPanel"; |
| 11 | import { LocaleProvider } from "../lib/i18n"; |
| 12 | import type { AppBindings } from "../lib/bridge"; |
| 13 | import type { ProviderModelCapabilityView, ProviderView, SettingsView } from "../lib/types"; |
| 14 | import { |
| 15 | applyTypographyPreferences, |
| 16 | createDefaultTypographyPreferences, |
| 17 | getTypographyPreferences, |
| 18 | } from "../lib/typographyPreferences"; |
| 19 | import { |
| 20 | baseSettings, |
| 21 | flushPromises, |
| 22 | installCanvasMock, |
| 23 | waitFor, |
| 24 | } from "../test-support/settingsTestFixtures"; |
| 25 | import { installDesktopHostStub } from "./desktopHostStub"; |
| 26 | |
| 27 | let passed = 0; |
| 28 | let failed = 0; |
| 29 | |
| 30 | function ok(value: boolean, label: string) { |
| 31 | if (value) { |
| 32 | process.stdout.write(` PASS ${label}\n`); |
| 33 | passed += 1; |
| 34 | } else { |
| 35 | process.stdout.write(` FAIL ${label}\n`); |
| 36 | failed += 1; |
| 37 | } |
| 38 | } |
| 39 | |
| 40 | function eq(actual: unknown, expected: unknown, label: string) { |
| 41 | if (actual === expected) { |
| 42 | ok(true, label); |
| 43 | } else { |
| 44 | ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); |
| 45 | } |
| 46 | } |
| 47 | |
| 48 | console.log("\nsettings refresh snapshot"); |
| 49 | |
| 50 | const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", { |
| 51 | pretendToBeVisual: true, |
| 52 | url: "http://localhost/", |
| 53 | }); |
| 54 | // React's legacy input-event fallback expects these IE hooks when JSDOM does |
| 55 | // not expose native input event support. The custom threshold editor focuses |
| 56 | // its input on open, so keep that production behavior testable without noise. |
| 57 | Object.defineProperty(dom.window.HTMLElement.prototype, "attachEvent", { configurable: true, value: () => {} }); |
| 58 | Object.defineProperty(dom.window.HTMLElement.prototype, "detachEvent", { configurable: true, value: () => {} }); |
| 59 | installCanvasMock(dom.window as unknown as Window); |
| 60 | (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; |
| 61 | globalThis.window = dom.window as unknown as Window & typeof globalThis; |
| 62 | globalThis.document = dom.window.document; |
| 63 | Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator }); |
| 64 | globalThis.Node = dom.window.Node; |
| 65 | globalThis.HTMLElement = dom.window.HTMLElement; |
| 66 | globalThis.Event = dom.window.Event; |
| 67 | globalThis.CustomEvent = dom.window.CustomEvent; |
| 68 | globalThis.KeyboardEvent = dom.window.KeyboardEvent; |
| 69 | globalThis.MouseEvent = dom.window.MouseEvent; |
| 70 | globalThis.localStorage = dom.window.localStorage; |
| 71 | globalThis.sessionStorage = dom.window.sessionStorage; |
| 72 | window.matchMedia = (() => ({matches: true, addEventListener(){}, removeEventListener(){}})) as any; |
| 73 | globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window); |
| 74 | globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window); |
| 75 | window.scrollTo = () => {}; |
| 76 | localStorage.clear(); |
| 77 | |
| 78 | const regionalTypography = createDefaultTypographyPreferences(); |
| 79 | regionalTypography.code = { |
| 80 | followGlobal: false, |
| 81 | fontFamily: "jetbrains", |
| 82 | customFontName: "", |
| 83 | fontSize: 15, |
| 84 | }; |
| 85 | applyTypographyPreferences(regionalTypography); |
| 86 | const regionalCodeFont = document.documentElement.style.getPropertyValue("--typography-code-font"); |
| 87 | |
| 88 | const settingsSnapshots = [baseSettings("standard")]; |
| 89 | let settingsCalls = 0; |
| 90 | let setDisplayModeCalls = 0; |
| 91 | let setSessionExperienceCalls = 0; |
| 92 | |
| 93 | const desktopStub = installDesktopHostStub(({ |
| 94 | main: { |
| 95 | App: { |
| 96 | Settings: async () => settingsSnapshots[Math.min(settingsCalls++, settingsSnapshots.length - 1)], |
| 97 | SetDisplayMode: async () => { |
| 98 | setDisplayModeCalls += 1; |
| 99 | }, |
| 100 | SetSessionExperience: async () => { |
| 101 | setSessionExperienceCalls += 1; |
| 102 | }, |
| 103 | } as Partial<AppBindings> as AppBindings, |
| 104 | }}).main.App); |
| 105 | |
| 106 | const rootEl = document.getElementById("root"); |
| 107 | if (!rootEl) throw new Error("missing root"); |
| 108 | const root = createRoot(rootEl); |
| 109 | |
| 110 | await act(async () => { |
| 111 | root.render( |
| 112 | <LocaleProvider> |
| 113 | <SettingsPanel |
| 114 | initialTab="general" |
| 115 | desktopPlatform="linux" |
| 116 | onClose={() => {}} |
| 117 | onChanged={() => {}} |
| 118 | /> |
| 119 | </LocaleProvider>, |
| 120 | ); |
| 121 | await flushPromises(); |
| 122 | }); |
| 123 | |
| 124 | const generalFieldLabels = Array.from(rootEl.querySelectorAll(".settings-section__body > .settings-field .settings-field__label")) |
| 125 | .map((label) => label.textContent?.trim()); |
| 126 | eq(generalFieldLabels[0], "Language", "general settings place language first"); |
| 127 | eq(document.querySelectorAll(".step-limit-control").length, 0, "general settings hide executor and planner step-limit controls"); |
| 128 | ok(!rootEl.textContent?.includes("Session experience"), "general settings remove the retired session experience field"); |
| 129 | ok(!rootEl.textContent?.includes("Conversation density"), "general settings do not render the retired density field"); |
| 130 | ok(!rootEl.textContent?.includes("Thinking content"), "general settings do not render the retired reasoning field"); |
| 131 | ok(!rootEl.textContent?.includes("After the turn"), "general settings do not render the retired fold field"); |
| 132 | ok(!document.body.textContent?.includes("step limit"), "general settings keep automatic progress free of step-limit copy"); |
| 133 | ok(!document.body.textContent?.includes("Automatic plan mode"), "general settings omit the retired automatic Plan Mode control"); |
| 134 | ok(!document.body.textContent?.includes("planning defaults"), "general settings omit retired automatic Plan Mode copy"); |
| 135 | |
| 136 | eq(setSessionExperienceCalls, 0, "removed session experience cannot invoke its legacy mutation"); |
| 137 | eq(setDisplayModeCalls, 0, "legacy display mode mutation is not invoked"); |
| 138 | eq(settingsCalls, 1, "settings panel reads Settings once for its initial snapshot"); |
| 139 | |
| 140 | await act(async () => { |
| 141 | root.unmount(); |
| 142 | }); |
| 143 | |
| 144 | // Models > Agent runtime: the compaction preference is directly visible, shows |
| 145 | // the effective token threshold, and reloads the persisted Settings snapshot. |
| 146 | const compactRootEl = document.createElement("div"); |
| 147 | document.body.appendChild(compactRootEl); |
| 148 | const compactRoot = createRoot(compactRootEl); |
| 149 | let compactSettings = baseSettings("standard"); |
| 150 | delete compactSettings.agent.compactRatio; // Old backends omit the additive field. |
| 151 | compactSettings.agent.effectiveCompactRatio = 0.75; |
| 152 | compactSettings.agent.compactRatioOverridden = true; |
| 153 | compactSettings.defaultModel = "context-provider/context-model"; |
| 154 | compactSettings.providers = [{ |
| 155 | name: "context-provider", |
| 156 | builtIn: false, |
| 157 | added: true, |
| 158 | kind: "openai", |
| 159 | baseUrl: "https://context.example.com/v1", |
| 160 | chatUrl: "", |
| 161 | models: ["context-model"], |
| 162 | visionModels: [], |
| 163 | visionModelsConfigured: false, |
| 164 | modelsUrl: "", |
| 165 | default: "context-model", |
| 166 | apiKeyEnv: "", |
| 167 | keySet: false, |
| 168 | requiresKey: false, |
| 169 | configured: true, |
| 170 | balanceUrl: "", |
| 171 | contextWindow: 100_000, |
| 172 | reasoningProtocol: "", |
| 173 | thinking: "", |
| 174 | supportedEfforts: [], |
| 175 | defaultEffort: "", |
| 176 | modelOverrides: [], |
| 177 | }]; |
| 178 | let compactRatioCalls: number[] = []; |
| 179 | desktopStub.replaceCommands(({ |
| 180 | main: { |
| 181 | App: { |
| 182 | Settings: async () => compactSettings, |
| 183 | FetchAllProviderModelCatalogs: async () => ({}), |
| 184 | SetCompactRatio: async (ratio: number) => { |
| 185 | compactRatioCalls.push(ratio); |
| 186 | compactSettings = { ...compactSettings, agent: { ...compactSettings.agent, compactRatio: ratio } }; |
| 187 | }, |
| 188 | } as Partial<AppBindings> as AppBindings, |
| 189 | }, |
| 190 | }).main.App); |
| 191 | |
| 192 | await act(async () => { |
| 193 | compactRoot.render( |
| 194 | <LocaleProvider> |
| 195 | <SettingsPanel initialTab="models" desktopPlatform="linux" onClose={() => {}} onChanged={() => {}} /> |
| 196 | </LocaleProvider>, |
| 197 | ); |
| 198 | await flushPromises(); |
| 199 | }); |
| 200 | ok(compactRootEl.textContent?.includes("Advanced context management") === false, "compaction preference has no redundant advanced disclosure"); |
| 201 | ok(compactRootEl.textContent?.includes("Automatic compaction threshold") === true, "compaction preference is visible without expanding a disclosure"); |
| 202 | ok(compactRootEl.textContent?.includes("80,000 tokens") === false, "compact ratio avoids a redundant token estimate under the selected row"); |
| 203 | ok(compactRootEl.textContent?.includes("Balance continuity and cache reuse") === true, "compact ratio explains the recommended preset consequence"); |
| 204 | ok(compactRootEl.textContent?.includes("effective threshold is 75%") === true, "project override shows the active effective threshold"); |
| 205 | const recommendedCompactButton = compactRootEl.querySelector('input[type="radio"][aria-label="80% · Recommended"]') as HTMLInputElement | null; |
| 206 | if (!recommendedCompactButton) throw new Error("recommended compaction preset did not render"); |
| 207 | ok(recommendedCompactButton.checked, "saved compact ratio starts selected"); |
| 208 | const customCompactButton = compactRootEl.querySelector('input[type="radio"][aria-label="Custom threshold…"]') as HTMLInputElement | null; |
| 209 | if (!customCompactButton) throw new Error("custom compaction threshold option did not render"); |
| 210 | ok(customCompactButton.closest(".compact-ratio-choice-list") !== null, "custom compaction is the fourth choice in the shared radio group"); |
| 211 | ok(!customCompactButton.checked, "custom choice does not replace the saved preset before editing"); |
| 212 | const customCompactInput = compactRootEl.querySelector('input[aria-label="Custom compaction threshold percentage"]') as HTMLInputElement | null; |
| 213 | if (!customCompactInput) throw new Error("inline custom compaction threshold input did not render"); |
| 214 | eq(customCompactInput.value, "", "preset selection leaves the inline custom input empty"); |
| 215 | eq(customCompactInput.placeholder, "Enter percentage", "inline custom input carries the requested percentage prompt"); |
| 216 | ok(customCompactInput.closest(".compact-ratio-choice") !== null, "custom input stays inside the fourth choice row"); |
| 217 | ok(customCompactInput.closest(".compact-ratio-choice")?.querySelectorAll("button").length === 0, "custom row has no secondary apply or cancel actions"); |
| 218 | const inputValueSetter = Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, "value")?.set; |
| 219 | const setCustomCompactInput = (input: HTMLInputElement, value: string) => { |
| 220 | const previous = input.value; |
| 221 | inputValueSetter?.call(input, value); |
| 222 | (input as HTMLInputElement & { _valueTracker?: { setValue: (next: string) => void } })._valueTracker?.setValue(previous); |
| 223 | input.dispatchEvent(new Event("input", { bubbles: true })); |
| 224 | input.dispatchEvent(new Event("change", { bubbles: true })); |
| 225 | }; |
| 226 | await act(async () => { |
| 227 | customCompactInput.focus(); |
| 228 | await flushPromises(); |
| 229 | }); |
| 230 | ok(recommendedCompactButton.checked, "focusing an empty custom input preserves the saved preset"); |
| 231 | ok(!customCompactButton.checked, "an empty custom draft is not announced as the saved selection"); |
| 232 | await act(async () => { |
| 233 | customCompactInput.focus(); |
| 234 | setCustomCompactInput(customCompactInput, "29"); |
| 235 | customCompactInput.blur(); |
| 236 | await flushPromises(); |
| 237 | }); |
| 238 | eq(compactRatioCalls.length, 0, "out-of-range inline compact ratio is not saved"); |
| 239 | eq(customCompactInput.value, "29", "invalid inline value stays available for correction"); |
| 240 | eq(customCompactInput.getAttribute("aria-invalid"), "true", "invalid inline value is exposed to assistive technology"); |
| 241 | await act(async () => { |
| 242 | customCompactInput.focus(); |
| 243 | setCustomCompactInput(customCompactInput, "75"); |
| 244 | customCompactInput.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); |
| 245 | await flushPromises(); |
| 246 | }); |
| 247 | eq(compactRatioCalls.length, 1, "Enter saves the inline custom compact ratio once"); |
| 248 | eq(compactRatioCalls[0], 0.75, "custom compact ratio converts percentage to fraction"); |
| 249 | eq(customCompactInput.value, "75", "saved custom compact ratio stays visible in the inline input"); |
| 250 | ok(customCompactButton.checked, "saved custom ratio selects the custom choice"); |
| 251 | ok(customCompactButton.getAttribute("aria-label") === "Custom threshold…", "custom choice keeps a stable accessible label after saving"); |
| 252 | await act(async () => { |
| 253 | customCompactInput.focus(); |
| 254 | setCustomCompactInput(customCompactInput, "74"); |
| 255 | customCompactInput.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); |
| 256 | await flushPromises(); |
| 257 | }); |
| 258 | eq(compactRatioCalls.length, 1, "Escape cancels a custom compact ratio without saving"); |
| 259 | eq(customCompactInput.value, "75", "Escape restores the saved inline custom ratio"); |
| 260 | const activeCompactButton = compactRootEl.querySelector('input[type="radio"][aria-label="70% · Active"]') as HTMLInputElement | null; |
| 261 | if (!activeCompactButton) throw new Error("active compaction preset did not render"); |
| 262 | await act(async () => { |
| 263 | activeCompactButton.click(); |
| 264 | await flushPromises(); |
| 265 | }); |
| 266 | eq(compactRatioCalls.length, 2, "compact ratio preset adds one mutation"); |
| 267 | eq(compactRatioCalls[1], 0.7, "compact ratio preset sends the expected fraction"); |
| 268 | ok(activeCompactButton.checked, "saved compact ratio is selected after Settings reload"); |
| 269 | |
| 270 | // Model native mousedown -> blur -> click with a deliberately slow bridge. |
| 271 | let finishCompactSave: (() => void) | undefined; |
| 272 | (desktopStub.commands as AppBindings).SetCompactRatio = async (ratio: number) => { |
| 273 | compactRatioCalls.push(ratio); |
| 274 | await new Promise<void>((resolve) => { finishCompactSave = resolve; }); |
| 275 | compactSettings = { ...compactSettings, agent: { ...compactSettings.agent, compactRatio: ratio } }; |
| 276 | }; |
| 277 | await act(async () => { customCompactInput.focus(); }); |
| 278 | await act(async () => { setCustomCompactInput(customCompactInput, "74"); }); |
| 279 | const recommendedLabel = recommendedCompactButton.closest("label")!; |
| 280 | const presetDown = new dom.window.MouseEvent("mousedown", { button: 0, bubbles: true, cancelable: true }); |
| 281 | await act(async () => { |
| 282 | recommendedLabel.dispatchEvent(presetDown); |
| 283 | if (!presetDown.defaultPrevented) customCompactInput.blur(); |
| 284 | }); |
| 285 | ok(!recommendedCompactButton.disabled, "draft editing does not disable the preset before its click"); |
| 286 | await act(async () => { recommendedLabel.click(); }); |
| 287 | eq(compactRatioCalls.length, 3, "preset click sends only one mutation while bridge is pending"); |
| 288 | eq(compactRatioCalls[2], 0.8, "explicit preset wins over an unsaved custom draft"); |
| 289 | await act(async () => { finishCompactSave?.(); await flushPromises(); }); |
| 290 | ok(recommendedCompactButton.checked, "clicked preset remains selected after the slow save"); |
| 291 | eq(customCompactInput.value, "", "preset click clears the replaced custom draft"); |
| 292 | await act(async () => { customCompactInput.focus(); }); |
| 293 | await act(async () => { setCustomCompactInput(customCompactInput, "73"); }); |
| 294 | await act(async () => { |
| 295 | const down = new dom.window.MouseEvent("mousedown", { button: 0, bubbles: true, cancelable: true }); |
| 296 | recommendedCompactButton.dispatchEvent(down); |
| 297 | if (!down.defaultPrevented) customCompactInput.blur(); |
| 298 | }); |
| 299 | await act(async () => { recommendedCompactButton.click(); }); |
| 300 | eq(compactRatioCalls.length, 3, "clicking the current preset cancels editing without saving the draft"); |
| 301 | eq(customCompactInput.value, "", "current preset click clears the draft"); |
| 302 | await act(async () => { customCompactInput.focus(); }); |
| 303 | await act(async () => { setCustomCompactInput(customCompactInput, "72"); }); |
| 304 | await act(async () => { customCompactInput.blur(); }); |
| 305 | eq(compactRatioCalls.length, 4, "ordinary blur still saves once"); |
| 306 | eq(compactRatioCalls[3], 0.72, "ordinary blur persists the draft"); |
| 307 | await act(async () => { finishCompactSave?.(); await flushPromises(); }); |
| 308 | ok(customCompactButton.checked, "ordinary blur selects the saved custom threshold"); |
| 309 | |
| 310 | // A rejected save retains the draft for retry while selection stays authoritative. |
| 311 | let rejectCompactSave = true; |
| 312 | (desktopStub.commands as AppBindings).SetCompactRatio = async (ratio: number) => { |
| 313 | compactRatioCalls.push(ratio); |
| 314 | if (rejectCompactSave) throw new Error("Compaction save rejected"); |
| 315 | compactSettings = { ...compactSettings, agent: { ...compactSettings.agent, compactRatio: ratio } }; |
| 316 | }; |
| 317 | await act(async () => { customCompactInput.focus(); }); |
| 318 | await act(async () => { setCustomCompactInput(customCompactInput, "74"); }); |
| 319 | await act(async () => { customCompactInput.blur(); await flushPromises(); }); |
| 320 | eq(customCompactInput.value, "74", "failed save retains the custom draft"); |
| 321 | eq(compactSettings.agent.compactRatio, 0.72, "failed save preserves the persisted threshold"); |
| 322 | ok(compactRootEl.textContent?.includes("Compaction save rejected"), "failed save displays its error"); |
| 323 | rejectCompactSave = false; |
| 324 | await act(async () => { customCompactInput.focus(); }); |
| 325 | await act(async () => { |
| 326 | customCompactInput.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); |
| 327 | await flushPromises(); |
| 328 | }); |
| 329 | eq(compactSettings.agent.compactRatio, 0.74, "Enter retries the retained draft successfully"); |
| 330 | eq(customCompactInput.value, "74", "successful retry keeps the saved custom value visible"); |
| 331 | ok(!compactRootEl.textContent?.includes("Compaction save rejected"), "successful retry clears the error"); |
| 332 | rejectCompactSave = true; |
| 333 | await act(async () => { customCompactInput.focus(); }); |
| 334 | await act(async () => { setCustomCompactInput(customCompactInput, "76"); }); |
| 335 | await act(async () => { customCompactInput.blur(); await flushPromises(); }); |
| 336 | eq(customCompactInput.value, "76", "subsequent rejection also retains the draft"); |
| 337 | const callsBeforeCancel = compactRatioCalls.length; |
| 338 | await act(async () => { customCompactInput.focus(); }); |
| 339 | await act(async () => { |
| 340 | customCompactInput.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); |
| 341 | await flushPromises(); |
| 342 | }); |
| 343 | eq(customCompactInput.value, "74", "Escape after failure restores the persisted value"); |
| 344 | eq(compactRatioCalls.length, callsBeforeCancel, "Escape after failure does not write"); |
| 345 | |
| 346 | await act(async () => { |
| 347 | compactRoot.unmount(); |
| 348 | }); |
| 349 | |
| 350 | const retryRootEl = document.createElement("div"); |
| 351 | document.body.appendChild(retryRootEl); |
| 352 | const retryRoot = createRoot(retryRootEl); |
| 353 | let failingSettingsCalls = 0; |
| 354 | desktopStub.replaceCommands(({ |
| 355 | main: { |
| 356 | App: { |
| 357 | Settings: async () => { |
| 358 | failingSettingsCalls += 1; |
| 359 | if (failingSettingsCalls === 1) throw new Error("/Users/example/.reasonix/settings.toml: permission denied"); |
| 360 | return baseSettings("standard"); |
| 361 | }, |
| 362 | } as Partial<AppBindings> as AppBindings, |
| 363 | }, |
| 364 | }).main.App); |
| 365 | |
| 366 | await act(async () => { |
| 367 | retryRoot.render( |
| 368 | <LocaleProvider> |
| 369 | <SettingsPanel |
| 370 | initialTab="general" |
| 371 | desktopPlatform="linux" |
| 372 | onClose={() => {}} |
| 373 | onChanged={() => {}} |
| 374 | /> |
| 375 | </LocaleProvider>, |
| 376 | ); |
| 377 | await flushPromises(); |
| 378 | }); |
| 379 | await waitFor("settings load failure", () => Boolean(document.querySelector(".banner--error"))); |
| 380 | |
| 381 | ok(document.body.textContent?.includes("Settings could not be loaded.") === true, "failed initial settings load shows a visible error"); |
| 382 | ok(document.body.textContent?.includes("Loading…") === false, "failed initial settings load stops showing the loading state"); |
| 383 | |
| 384 | const retryButton = Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.trim() === "Retry") as HTMLButtonElement | undefined; |
| 385 | if (!retryButton) throw new Error("settings retry button did not render"); |
| 386 | |
| 387 | await act(async () => { |
| 388 | retryButton.click(); |
| 389 | await flushPromises(); |
| 390 | }); |
| 391 | await waitFor("settings retry success", () => document.body.textContent?.includes("Desktop & language") === true); |
| 392 | |
| 393 | eq(failingSettingsCalls, 2, "settings retry calls Settings again"); |
| 394 | ok(document.body.textContent?.includes("Settings could not be loaded.") === false, "settings retry clears the load error"); |
| 395 | |
| 396 | await act(async () => { |
| 397 | retryRoot.unmount(); |
| 398 | }); |
| 399 | |
| 400 | const windowsSandboxRootEl = document.createElement("div"); |
| 401 | document.body.appendChild(windowsSandboxRootEl); |
| 402 | const windowsSandboxRoot = createRoot(windowsSandboxRootEl); |
| 403 | let windowsSetSandboxCalls = 0; |
| 404 | desktopStub.replaceCommands(({ |
| 405 | main: { |
| 406 | App: { |
| 407 | // Deliberately return a stale enforce value: the Windows UI must still |
| 408 | // render the effective immutable off state. |
| 409 | Settings: async () => baseSettings("standard"), |
| 410 | SetSandbox: async () => { |
| 411 | windowsSetSandboxCalls += 1; |
| 412 | }, |
| 413 | } as Partial<AppBindings> as AppBindings, |
| 414 | }, |
| 415 | }).main.App); |
| 416 | |
| 417 | await act(async () => { |
| 418 | windowsSandboxRoot.render( |
| 419 | <LocaleProvider> |
| 420 | <SettingsPanel |
| 421 | initialTab="sandbox" |
| 422 | desktopPlatform="windows" |
| 423 | onClose={() => {}} |
| 424 | onChanged={() => {}} |
| 425 | /> |
| 426 | </LocaleProvider>, |
| 427 | ); |
| 428 | await flushPromises(); |
| 429 | }); |
| 430 | await waitFor("Windows permission boundary settings", () => document.body.textContent?.includes("Effective write roots") === true); |
| 431 | |
| 432 | ok(windowsSandboxRootEl.textContent?.includes("/work") === true, "Windows shows the effective workspace write boundary"); |
| 433 | ok(!windowsSandboxRootEl.textContent?.includes("This setting is fixed to off."), "Windows removes the legacy Bash sandbox mode control"); |
| 434 | eq(windowsSetSandboxCalls, 0, "rendering Windows permission boundaries does not mutate sandbox settings"); |
| 435 | |
| 436 | await act(async () => { |
| 437 | windowsSandboxRoot.unmount(); |
| 438 | }); |
| 439 | |
| 440 | const zoomRootEl = document.createElement("div"); |
| 441 | document.body.appendChild(zoomRootEl); |
| 442 | const zoomRoot = createRoot(zoomRootEl); |
| 443 | let persistedZoom = 0.5; |
| 444 | const savedZoomFactors: number[] = []; |
| 445 | desktopStub.replaceCommands(({ |
| 446 | main: { |
| 447 | App: { |
| 448 | Settings: async () => baseSettings("standard"), |
| 449 | GetDesktopZoomFactor: async () => persistedZoom, |
| 450 | SetDesktopZoomFactor: async (factor: number) => { |
| 451 | persistedZoom = factor; |
| 452 | savedZoomFactors.push(factor); |
| 453 | }, |
| 454 | } as Partial<AppBindings> as AppBindings, |
| 455 | }, |
| 456 | }).main.App); |
| 457 | |
| 458 | localStorage.setItem("reasonix-zoom-restart", "1"); |
| 459 | await act(async () => { |
| 460 | zoomRoot.render( |
| 461 | <LocaleProvider> |
| 462 | <SettingsPanel |
| 463 | initialTab="appearance" |
| 464 | desktopPlatform="windows" |
| 465 | onClose={() => {}} |
| 466 | onChanged={() => {}} |
| 467 | /> |
| 468 | </LocaleProvider>, |
| 469 | ); |
| 470 | await flushPromises(); |
| 471 | }); |
| 472 | await waitFor("persisted display zoom sync", () => document.querySelector(".zoom-slider__value")?.textContent?.trim() === "50%"); |
| 473 | |
| 474 | const monoFontSelect = zoomRootEl.querySelector("button.settings-select[aria-labelledby='appearance-mono-font-family-label']") as HTMLButtonElement | null; |
| 475 | if (!monoFontSelect) throw new Error("monospace font selector did not render"); |
| 476 | await selectSettingsValue(monoFontSelect, "custom"); |
| 477 | |
| 478 | const preservedTypography = getTypographyPreferences(); |
| 479 | eq(preservedTypography.code.followGlobal, false, "global monospace changes preserve an explicit code-region override"); |
| 480 | eq(preservedTypography.code.fontFamily, "jetbrains", "global monospace changes preserve the regional code font choice"); |
| 481 | eq( |
| 482 | document.documentElement.style.getPropertyValue("--typography-code-font"), |
| 483 | regionalCodeFont, |
| 484 | "global monospace changes keep the regional code font CSS variable", |
| 485 | ); |
| 486 | |
| 487 | const resetZoomButton = document.querySelector("button[aria-label='Reset display zoom to 100%']") as HTMLButtonElement | null; |
| 488 | if (!resetZoomButton) throw new Error("display zoom reset button did not render"); |
| 489 | await act(async () => { |
| 490 | resetZoomButton.click(); |
| 491 | await flushPromises(); |
| 492 | }); |
| 493 | await waitFor("display zoom reset", () => document.querySelector(".zoom-slider__value")?.textContent?.trim() === "100%"); |
| 494 | |
| 495 | eq(savedZoomFactors.at(-1), 1, "display zoom reset writes the default zoom factor"); |
| 496 | eq(localStorage.getItem("reasonix-zoom-restart"), "1", "display zoom reset updates the local restart zoom cache"); |
| 497 | |
| 498 | await act(async () => { |
| 499 | zoomRoot.unmount(); |
| 500 | }); |
| 501 | |
| 502 | // Bots tab: direct four-channel bot manager. |
| 503 | const botsRootEl = document.createElement("div"); |
| 504 | document.body.appendChild(botsRootEl); |
| 505 | const botsRoot = createRoot(botsRootEl); |
| 506 | const botsSettings = baseSettings("standard"); |
| 507 | botsSettings.bot.dingtalk = { |
| 508 | enabled: true, |
| 509 | clientId: "dinghuspf88znepnhwfp", |
| 510 | clientSecretEnv: "DINGTALK_CLIENT_SECRET", |
| 511 | secretSet: true, |
| 512 | botName: "", |
| 513 | requireMention: true, |
| 514 | }; |
| 515 | botsSettings.bot.connections = [ |
| 516 | { |
| 517 | id: "conn-feishu-1", |
| 518 | provider: "feishu", |
| 519 | domain: "feishu", |
| 520 | label: "kun", |
| 521 | enabled: true, |
| 522 | status: "connected", |
| 523 | model: "", |
| 524 | toolApprovalMode: "", |
| 525 | workspaceRoot: "", |
| 526 | credential: { appId: "cli_mock", appSecretEnv: "FEISHU_BOT_APP_SECRET", accountId: "", tokenEnv: "", secretSet: true }, |
| 527 | sessionMappings: [], |
| 528 | lastError: "", |
| 529 | createdAt: "", |
| 530 | updatedAt: "", |
| 531 | access: { enabled: true, allowAll: false, pairingEnabled: true, users: ["ou_mock_user_001"], groups: [], approvers: [], admins: [] }, |
| 532 | }, |
| 533 | ]; |
| 534 | desktopStub.replaceCommands(({ |
| 535 | main: { |
| 536 | App: { |
| 537 | Settings: async () => botsSettings, |
| 538 | } as Partial<AppBindings> as AppBindings, |
| 539 | }, |
| 540 | }).main.App); |
| 541 | |
| 542 | await act(async () => { |
| 543 | botsRoot.render( |
| 544 | <LocaleProvider> |
| 545 | <SettingsPanel initialTab="bots" desktopPlatform="linux" onClose={() => {}} onChanged={() => {}} /> |
| 546 | </LocaleProvider>, |
| 547 | ); |
| 548 | await flushPromises(); |
| 549 | }); |
| 550 | await waitFor("bot channel manager", () => Boolean(document.querySelector(".bot-channel-manager"))); |
| 551 | |
| 552 | ok(!document.querySelector(".bot-overview-grid"), "bots tab does not render the removed entry overview"); |
| 553 | ok(!document.getElementById("bot-mobile-remote"), "bots tab no longer renders the mobile remote entry card"); |
| 554 | ok(!document.querySelector(".bot-channel-entry"), "bots tab no longer renders the Bot Channel entry panel"); |
| 555 | ok(!document.getElementById("bot-step-access"), "bots tab omits the old global access step card"); |
| 556 | ok(!document.getElementById("bot-step-behavior"), "bots tab omits global default behavior card"); |
| 557 | eq(document.querySelectorAll(".bot-step-chip").length, 0, "hero no longer shows the old two-step chips"); |
| 558 | |
| 559 | eq(document.querySelectorAll(".bot-channel-tabs [role=\"tab\"]").length, 5, "bot manager uses five fixed channel tabs on the left"); |
| 560 | ok(document.querySelector(".bot-channel-setup-card")?.querySelector("input") !== null, "unconfigured QQ tab shows key setup on the right"); |
| 561 | ok(document.body.textContent?.includes("Back to entry") === false, "bot manager does not show a return-to-entry action"); |
| 562 | |
| 563 | const feishuTab = Array.from(document.querySelectorAll(".bot-channel-tabs [role=\"tab\"]")).find((button) => button.textContent?.includes("Feishu")) as HTMLButtonElement | undefined; |
| 564 | if (!feishuTab) throw new Error("Feishu channel tab did not render"); |
| 565 | await act(async () => { |
| 566 | feishuTab.click(); |
| 567 | await flushPromises(); |
| 568 | }); |
| 569 | await waitFor("selected Feishu detail", () => Boolean(document.querySelector(".bot-channel-manager__detail .bot-detail-card"))); |
| 570 | |
| 571 | ok(Boolean(document.querySelector(".bot-channel-manager__detail .bot-detail-card")), "configured channel renders selected bot detail on the right"); |
| 572 | ok(Boolean(document.querySelector(".bot-channel-manager__detail .bot-detail-section--access")), "selected bot detail owns its access control"); |
| 573 | ok(document.body.textContent?.includes("Access control") === true, "selected bot detail labels per-bot access control"); |
| 574 | const selectedBotDetailText = document.querySelector(".bot-channel-manager__detail .bot-detail-card")?.textContent ?? ""; |
| 575 | const connectionSummaryIndex = selectedBotDetailText.indexOf("Connection summary"); |
| 576 | const enableBotIndex = selectedBotDetailText.indexOf("Enable bot"); |
| 577 | const toolApprovalIndex = selectedBotDetailText.indexOf("Tool approval"); |
| 578 | const modelIndex = selectedBotDetailText.indexOf("Model"); |
| 579 | const accessControlIndex = selectedBotDetailText.indexOf("Access control"); |
| 580 | ok( |
| 581 | connectionSummaryIndex >= 0 && enableBotIndex > connectionSummaryIndex && toolApprovalIndex > enableBotIndex && modelIndex > toolApprovalIndex && accessControlIndex > modelIndex, |
| 582 | "selected bot detail places enable, approval, and model controls between summary and access control", |
| 583 | ); |
| 584 | ok(document.body.textContent?.includes("ou_mock_user_001") === true, "selected bot detail shows its trusted user"); |
| 585 | ok(document.body.textContent?.includes("Legacy global allowlist") === true, "advanced area keeps the legacy global allowlist"); |
| 586 | ok(document.querySelector(".bot-simple-advanced")?.textContent?.includes("local control API") === false, "advanced area no longer owns mobile/control API setup"); |
| 587 | |
| 588 | // DingTalk channel: a persisted ClientID must round-trip back into the UI. |
| 589 | // The unconfigured setup form and the configured detail card both show it; |
| 590 | // blur-save and reload must not blank the field. |
| 591 | const persistedDingtalkSettings = () => { |
| 592 | const s = baseSettings("standard"); |
| 593 | s.bot.dingtalk = { |
| 594 | enabled: true, |
| 595 | clientId: "dinghuspf88znepnhwfp", |
| 596 | clientSecretEnv: "DINGTALK_CLIENT_SECRET", |
| 597 | secretSet: true, |
| 598 | botName: "", |
| 599 | requireMention: true, |
| 600 | }; |
| 601 | return s; |
| 602 | }; |
| 603 | let dingtalkSettings = persistedDingtalkSettings(); |
| 604 | let dingtalkTestCalls = 0; |
| 605 | desktopStub.replaceCommands(({ |
| 606 | main: { |
| 607 | App: { |
| 608 | Settings: async () => dingtalkSettings, |
| 609 | SetBotSettings: async (next: typeof dingtalkSettings) => { |
| 610 | dingtalkSettings = next; |
| 611 | }, |
| 612 | SetBotSecret: async () => {}, |
| 613 | TestDingtalkBot: async () => { |
| 614 | dingtalkTestCalls += 1; |
| 615 | return { id: "dingtalk", label: "DingTalk", status: "ok", message: "测试消息已发送,请检查钉钉会话。", messageId: "mock-dingtalk-id", phase: "send", code: "dingtalk_test_send_ok", reportKind: "", reportDetail: "", occurredAt: new Date().toISOString() }; |
| 616 | }, |
| 617 | } as Partial<AppBindings> as AppBindings, |
| 618 | }, |
| 619 | }).main.App); |
| 620 | const dingtalkTab = Array.from(botsRootEl.querySelectorAll(".bot-channel-tabs [role=\"tab\"]")).find((button) => button.textContent?.includes("DingTalk")) as HTMLButtonElement | undefined; |
| 621 | if (!dingtalkTab) throw new Error("DingTalk channel tab did not render"); |
| 622 | await act(async () => { |
| 623 | dingtalkTab.click(); |
| 624 | await flushPromises(); |
| 625 | }); |
| 626 | // Secret is set and the bot is enabled: the configured detail card |
| 627 | // shows the persisted ClientID (the "input disappeared" regression). |
| 628 | await waitFor("DingTalk detail card with ClientID", () => { |
| 629 | const card = botsRootEl.querySelector(".bot-channel-manager__detail .bot-detail-card"); |
| 630 | const hasClientId = card?.textContent?.includes("dinghuspf88znepnhwfp") === true; |
| 631 | return Boolean(card) && hasClientId; |
| 632 | }); |
| 633 | const dingtalkDetailClientId = Array.from(botsRootEl.querySelectorAll("input[aria-label]")).find((input) => input.getAttribute("aria-label")?.includes("Client ID")) as HTMLInputElement | undefined; |
| 634 | eq(dingtalkDetailClientId?.value, "dinghuspf88znepnhwfp", "persisted ClientID is visible in the DingTalk detail card"); |
| 635 | // DingTalk test-send entry: the detail card exposes a test-send button that |
| 636 | // calls TestDingtalkBot and surfaces the result notice. |
| 637 | const dingtalkTestButtons = Array.from(botsRootEl.querySelectorAll(".bot-channel-manager__detail .bot-detail-card__actions .btn")).filter((button) => /test|测试|測試|傳送/i.test(button.textContent ?? "")); |
| 638 | eq(dingtalkTestButtons.length, 1, "DingTalk detail card exposes a test-send button"); |
| 639 | await act(async () => { |
| 640 | (dingtalkTestButtons[0] as HTMLButtonElement).click(); |
| 641 | await flushPromises(); |
| 642 | }); |
| 643 | eq(dingtalkTestCalls, 1, "test-send button invokes TestDingtalkBot"); |
| 644 | await waitFor("DingTalk test-send result notice", () => |
| 645 | botsRootEl.querySelector(".bot-channel-manager__detail .bot-detail-notice")?.textContent?.includes("测试消息已发送") === true); |
| 646 | // Regression: a ClientID alone must NOT flip the channel into its configured |
| 647 | // detail card. Only an enabled bot with a set secret does. Mount with |
| 648 | // enabled=false + secretSet=true + clientId set; the setup panel (not the |
| 649 | // detail card) must show. |
| 650 | const notEnabledRootEl = document.createElement("div"); |
| 651 | document.body.appendChild(notEnabledRootEl); |
| 652 | const notEnabledRoot = createRoot(notEnabledRootEl); |
| 653 | const notEnabledSettings = baseSettings("standard"); |
| 654 | notEnabledSettings.bot.dingtalk = { |
| 655 | enabled: false, |
| 656 | clientId: "dinghuspf88znepnhwfp", |
| 657 | clientSecretEnv: "DINGTALK_CLIENT_SECRET", |
| 658 | secretSet: true, |
| 659 | botName: "", |
| 660 | requireMention: true, |
| 661 | }; |
| 662 | desktopStub.replaceCommands(({ |
| 663 | main: { App: { Settings: async () => notEnabledSettings } } as Partial<AppBindings> as AppBindings, |
| 664 | }).main.App); |
| 665 | await act(async () => { |
| 666 | notEnabledRoot.render( |
| 667 | <LocaleProvider> |
| 668 | <SettingsPanel initialTab="bots" desktopPlatform="linux" onClose={() => {}} onChanged={() => {}} /> |
| 669 | </LocaleProvider>, |
| 670 | ); |
| 671 | await flushPromises(); |
| 672 | }); |
| 673 | const notEnabledTab = Array.from(notEnabledRootEl.querySelectorAll(".bot-channel-tabs [role=\"tab\"]")).find((button) => button.textContent?.includes("DingTalk")) as HTMLButtonElement | undefined; |
| 674 | if (!notEnabledTab) throw new Error("DingTalk channel tab did not render (not-enabled case)"); |
| 675 | await act(async () => { |
| 676 | notEnabledTab.click(); |
| 677 | await flushPromises(); |
| 678 | }); |
| 679 | await waitFor("DingTalk setup panel instead of detail card when not enabled", () => { |
| 680 | const detailCard = notEnabledRootEl.querySelector(".bot-channel-manager__detail .bot-detail-card"); |
| 681 | const setupCard = notEnabledRootEl.querySelector(".bot-channel-manager__detail .bot-channel-setup-card"); |
| 682 | return Boolean(setupCard) && detailCard === null; |
| 683 | }); |
| 684 | await act(async () => { |
| 685 | notEnabledRoot.unmount(); |
| 686 | }); |
| 687 | desktopStub.replaceCommands(({ |
| 688 | main: { App: { Settings: async () => botsSettings } } as Partial<AppBindings> as AppBindings, |
| 689 | }).main.App); |
| 690 | |
| 691 | await act(async () => { |
| 692 | botsRoot.unmount(); |
| 693 | }); |
| 694 | |
| 695 | // Models tab: switching away invalidates an in-flight background discovery so |
| 696 | // its older completion cannot attempt a stale catalog write. |
| 697 | sessionStorage.clear(); |
| 698 | const providerRaceRootEl = document.createElement("div"); |
| 699 | document.body.appendChild(providerRaceRootEl); |
| 700 | const providerRaceRoot = createRoot(providerRaceRootEl); |
| 701 | const providerRaceSettings = baseSettings("standard"); |
| 702 | providerRaceSettings.defaultModel = "race-provider/old-model"; |
| 703 | providerRaceSettings.providers = [{ |
| 704 | name: "race-provider", |
| 705 | builtIn: false, |
| 706 | added: true, |
| 707 | kind: "openai", |
| 708 | baseUrl: "https://old.example.com/v1", |
| 709 | chatUrl: "", |
| 710 | models: ["old-model"], |
| 711 | visionModels: [], |
| 712 | visionModelsConfigured: false, |
| 713 | modelsUrl: "", |
| 714 | default: "missing-default", |
| 715 | apiKeyEnv: "RACE_PROVIDER_API_KEY", |
| 716 | headers: { "X-Gateway-Token": "private-gateway-secret" }, |
| 717 | extraBody: {}, |
| 718 | authHeader: false, |
| 719 | keySet: true, |
| 720 | requiresKey: true, |
| 721 | configured: true, |
| 722 | keySource: "global", |
| 723 | keySourcePath: "", |
| 724 | balanceUrl: "", |
| 725 | contextWindow: 128_000, |
| 726 | reasoningProtocol: "", |
| 727 | thinking: "", |
| 728 | supportedEfforts: [], |
| 729 | defaultEffort: "", |
| 730 | modelOverrides: [], |
| 731 | modelCatalogFingerprint: "old-fingerprint", |
| 732 | }]; |
| 733 | let resolveProviderBatch: ((models: Record<string, ProviderModelCapabilityView[]>) => void) | undefined; |
| 734 | const providerBatch = new Promise<Record<string, ProviderModelCapabilityView[]>>((resolve) => { |
| 735 | resolveProviderBatch = resolve; |
| 736 | }); |
| 737 | let providerBatchCalls = 0; |
| 738 | let providerCatalogSaveCalls = 0; |
| 739 | desktopStub.replaceCommands(({ |
| 740 | main: { |
| 741 | App: { |
| 742 | Settings: async () => providerRaceSettings, |
| 743 | FetchAllProviderModelCatalogs: async () => { |
| 744 | providerBatchCalls += 1; |
| 745 | return providerBatch; |
| 746 | }, |
| 747 | SaveProviderModelCatalogs: async () => { |
| 748 | providerCatalogSaveCalls += 1; |
| 749 | return ["race-provider"]; |
| 750 | }, |
| 751 | } as Partial<AppBindings> as AppBindings, |
| 752 | }, |
| 753 | }).main.App); |
| 754 | |
| 755 | await act(async () => { |
| 756 | providerRaceRoot.render( |
| 757 | <LocaleProvider> |
| 758 | <SettingsPanel initialTab="models" desktopPlatform="linux" onClose={() => {}} onChanged={() => {}} /> |
| 759 | </LocaleProvider>, |
| 760 | ); |
| 761 | await flushPromises(); |
| 762 | }); |
| 763 | await waitFor("provider background discovery", () => providerBatchCalls === 1); |
| 764 | const providerRefreshStorageKeys = Array.from({ length: sessionStorage.length }, (_, index) => sessionStorage.key(index) ?? ""); |
| 765 | ok(providerRefreshStorageKeys.some((key) => key.includes("old-fingerprint")), "provider auto-refresh cooldown uses the opaque catalog fingerprint"); |
| 766 | ok(providerRefreshStorageKeys.every((key) => !key.includes("private-gateway-secret")), "provider auto-refresh cooldown does not persist header secrets"); |
| 767 | const accessModelsButton = Array.from(providerRaceRootEl.querySelectorAll(".settings-center__navitem")).find( |
| 768 | (button) => button.textContent?.trim() === "Model services", |
| 769 | ) as HTMLButtonElement | undefined; |
| 770 | if (!accessModelsButton) throw new Error("provider Access subtab did not render"); |
| 771 | await act(async () => { |
| 772 | accessModelsButton.click(); |
| 773 | await flushPromises(); |
| 774 | }); |
| 775 | await act(async () => { |
| 776 | resolveProviderBatch?.({ "race-provider": ["old-model", "stale-fetched-model"].map((model) => ({ model, inputModalities: [], state: "unknown", source: "adapter" })) }); |
| 777 | await flushPromises(); |
| 778 | }); |
| 779 | await waitFor("stale provider discovery completion", () => providerBatchCalls === 1); |
| 780 | eq(providerCatalogSaveCalls, 0, "leaving the models usage tab suppresses the stale background catalog write"); |
| 781 | |
| 782 | await act(async () => { |
| 783 | providerRaceRoot.unmount(); |
| 784 | }); |
| 785 | |
| 786 | // Cancelling a freshly fetched model catalog must also clear the success copy |
| 787 | // that asks the user to confirm and save that now-hidden draft. |
| 788 | const providerRefreshCancelRootEl = document.createElement("div"); |
| 789 | document.body.appendChild(providerRefreshCancelRootEl); |
| 790 | const providerRefreshCancelRoot = createRoot(providerRefreshCancelRootEl); |
| 791 | const providerRefreshCancelSettings = baseSettings("standard"); |
| 792 | providerRefreshCancelSettings.defaultModel = "deepseek/deepseek-v4-flash"; |
| 793 | providerRefreshCancelSettings.providers = [{ |
| 794 | name: "deepseek", |
| 795 | builtIn: true, |
| 796 | added: true, |
| 797 | kind: "anthropic", |
| 798 | baseUrl: "https://api.deepseek.com/anthropic", |
| 799 | chatUrl: "", |
| 800 | models: ["deepseek-v4-flash"], |
| 801 | visionModels: [], |
| 802 | visionModelsConfigured: true, |
| 803 | visionCapability: "unsupported", |
| 804 | modelsUrl: "https://api.deepseek.com/models", |
| 805 | default: "deepseek-v4-flash", |
| 806 | apiKeyEnv: "DEEPSEEK_API_KEY", |
| 807 | keySet: true, |
| 808 | requiresKey: true, |
| 809 | configured: true, |
| 810 | balanceUrl: "https://api.deepseek.com/user/balance", |
| 811 | contextWindow: 1_000_000, |
| 812 | reasoningProtocol: "", |
| 813 | thinking: "enabled", |
| 814 | webSearch: true, |
| 815 | serverWebSearchCapability: true, |
| 816 | supportedEfforts: [], |
| 817 | defaultEffort: "", |
| 818 | }]; |
| 819 | desktopStub.replaceCommands(({ |
| 820 | main: { |
| 821 | App: { |
| 822 | Settings: async () => providerRefreshCancelSettings, |
| 823 | FetchAllProviderModelCatalogs: async () => ({}), |
| 824 | FetchProviderModelCatalog: async () => ["deepseek-v4-flash", "deepseek-v4-pro"].map((model) => ({ model, inputModalities: ["text"], state: "unsupported", source: "adapter" })), |
| 825 | } as Partial<AppBindings> as AppBindings, |
| 826 | }, |
| 827 | }).main.App); |
| 828 | |
| 829 | await act(async () => { |
| 830 | providerRefreshCancelRoot.render( |
| 831 | <LocaleProvider> |
| 832 | <SettingsPanel initialTab="models" desktopPlatform="linux" onClose={() => {}} onChanged={() => {}} /> |
| 833 | </LocaleProvider>, |
| 834 | ); |
| 835 | await flushPromises(); |
| 836 | }); |
| 837 | const providerRefreshCancelAccessButton = Array.from(providerRefreshCancelRootEl.querySelectorAll(".settings-center__navitem")).find( |
| 838 | (button) => button.textContent?.trim() === "Model services", |
| 839 | ) as HTMLButtonElement | undefined; |
| 840 | if (!providerRefreshCancelAccessButton) throw new Error("provider refresh cancel Access subtab did not render"); |
| 841 | await act(async () => { |
| 842 | providerRefreshCancelAccessButton.click(); |
| 843 | await flushPromises(); |
| 844 | }); |
| 845 | const providerRefreshCancelButton = Array.from(providerRefreshCancelRootEl.querySelectorAll("button")).find( |
| 846 | (button) => button.getAttribute("aria-label") === "Refresh models", |
| 847 | ) as HTMLButtonElement | undefined; |
| 848 | if (!providerRefreshCancelButton) throw new Error("provider refresh action did not render"); |
| 849 | await act(async () => { |
| 850 | providerRefreshCancelButton.click(); |
| 851 | await flushPromises(); |
| 852 | }); |
| 853 | await waitFor("provider model discovery", () => providerRefreshCancelRootEl.textContent?.includes("deepseek-v4-pro") === true); |
| 854 | const providerModelDraftCancelButton = providerRefreshCancelRootEl.querySelector<HTMLButtonElement>('.provider-editor-footer button'); |
| 855 | if (!providerModelDraftCancelButton) throw new Error("provider draft cancel action did not render"); |
| 856 | await act(async () => { providerModelDraftCancelButton.click(); await flushPromises(); }); |
| 857 | ok(!providerRefreshCancelRootEl.textContent?.includes("deepseek-v4-pro"), "cancelling discovery discards fetched candidates"); |
| 858 | ok(providerRefreshCancelSettings.providers[0].models.length === 1, "cancelling discovery preserves configured models"); |
| 859 | await act(async () => { |
| 860 | providerRefreshCancelRoot.unmount(); |
| 861 | }); |
| 862 | |
| 863 | // A persisted protocol upgrade with a failed runtime refresh must be read back |
| 864 | // so the panel offers application retry without repeating the saved upgrade. |
| 865 | const upgradeFailureRootEl = document.createElement("div"); |
| 866 | document.body.appendChild(upgradeFailureRootEl); |
| 867 | const upgradeFailureRoot = createRoot(upgradeFailureRootEl); |
| 868 | let upgradeFailureSettings = baseSettings("standard"); |
| 869 | upgradeFailureSettings.defaultModel = "deepseek/deepseek-v4-flash"; |
| 870 | upgradeFailureSettings.providers = [{ |
| 871 | name: "deepseek-flash", |
| 872 | builtIn: true, |
| 873 | added: true, |
| 874 | kind: "openai", |
| 875 | baseUrl: "https://api.deepseek.com", |
| 876 | chatUrl: "", |
| 877 | models: ["deepseek-v4-flash"], |
| 878 | visionModels: [], |
| 879 | visionModelsConfigured: true, |
| 880 | visionCapability: "unsupported", |
| 881 | modelsUrl: "https://api.deepseek.com/models", |
| 882 | default: "deepseek-v4-flash", |
| 883 | apiKeyEnv: "DEEPSEEK_API_KEY", |
| 884 | keySet: true, |
| 885 | requiresKey: true, |
| 886 | configured: true, |
| 887 | balanceUrl: "https://api.deepseek.com/user/balance", |
| 888 | contextWindow: 1_000_000, |
| 889 | reasoningProtocol: "deepseek", |
| 890 | thinking: "enabled", |
| 891 | webSearch: false, |
| 892 | serverWebSearchCapability: false, |
| 893 | supportedEfforts: ["low", "high", "max"], |
| 894 | defaultEffort: "high", |
| 895 | recommendedUpgradeAvailable: true, |
| 896 | }]; |
| 897 | let upgradeFailureSettingsCalls = 0; |
| 898 | let upgradeFailureMutationCalls = 0; |
| 899 | let upgradeFailureChanged: SettingsView | undefined; |
| 900 | desktopStub.replaceCommands(({ |
| 901 | main: { |
| 902 | App: { |
| 903 | Settings: async () => { |
| 904 | upgradeFailureSettingsCalls += 1; |
| 905 | return upgradeFailureSettings; |
| 906 | }, |
| 907 | FetchAllProviderModelCatalogs: async () => ({}), |
| 908 | ApplyModelSettings: async (change) => { |
| 909 | eq(change.kind, "protocol_upgrade", "protocol upgrade uses the structured settings service"); |
| 910 | upgradeFailureMutationCalls += 1; |
| 911 | upgradeFailureSettings = { |
| 912 | ...upgradeFailureSettings, |
| 913 | providers: upgradeFailureSettings.providers.map((provider) => ({ |
| 914 | ...provider, |
| 915 | kind: "anthropic", |
| 916 | baseUrl: "https://api.deepseek.com/anthropic", |
| 917 | webSearch: true, |
| 918 | serverWebSearchCapability: true, |
| 919 | recommendedUpgradeAvailable: false, |
| 920 | })), |
| 921 | }; |
| 922 | return { requestId: change.requestId, persisted: true, revision: "upgraded", application: "failed", targets: [{tabId: "session-one", application: "failed", appliedRevision: "old", desiredRevision: "upgraded"}], issues: [{code: "apply_failed", message: "workspace runtime boot failed after protocol upgrade"}], appliedCatalogs: [] }; |
| 923 | }, |
| 924 | } as Partial<AppBindings> as AppBindings, |
| 925 | }, |
| 926 | }).main.App); |
| 927 | |
| 928 | await act(async () => { |
| 929 | upgradeFailureRoot.render( |
| 930 | <LocaleProvider> |
| 931 | <SettingsPanel |
| 932 | initialTab="models" |
| 933 | desktopPlatform="linux" |
| 934 | onClose={() => {}} |
| 935 | onChanged={(settings?: SettingsView) => { |
| 936 | upgradeFailureChanged = settings; |
| 937 | }} |
| 938 | /> |
| 939 | </LocaleProvider>, |
| 940 | ); |
| 941 | await flushPromises(); |
| 942 | }); |
| 943 | const upgradeFailureAccessButton = Array.from(upgradeFailureRootEl.querySelectorAll(".settings-center__navitem")).find( |
| 944 | (button) => button.textContent?.trim() === "Model services", |
| 945 | ) as HTMLButtonElement | undefined; |
| 946 | if (!upgradeFailureAccessButton) throw new Error("upgrade failure Access subtab did not render"); |
| 947 | await act(async () => { |
| 948 | upgradeFailureAccessButton.click(); |
| 949 | await flushPromises(); |
| 950 | }); |
| 951 | await waitFor( |
| 952 | "legacy DeepSeek protocol upgrade action", |
| 953 | () => upgradeFailureRootEl.textContent?.includes("Upgrade to recommended protocol") === true, |
| 954 | ); |
| 955 | let upgradeFailureButton = Array.from(upgradeFailureRootEl.querySelectorAll("button")).find( |
| 956 | (button) => button.textContent?.includes("Upgrade to recommended protocol"), |
| 957 | ) as HTMLButtonElement | undefined; |
| 958 | if (!upgradeFailureButton) throw new Error("DeepSeek protocol upgrade button did not render"); |
| 959 | await act(async () => { |
| 960 | upgradeFailureButton?.click(); |
| 961 | await flushPromises(); |
| 962 | }); |
| 963 | upgradeFailureButton = upgradeFailureRootEl.querySelector<HTMLButtonElement>( |
| 964 | ".provider-protocol-upgrade .inline-confirm > button", |
| 965 | ) ?? undefined; |
| 966 | if (upgradeFailureButton?.textContent?.trim() !== "Confirm") throw new Error("DeepSeek protocol upgrade confirmation did not render"); |
| 967 | await act(async () => { |
| 968 | upgradeFailureButton?.click(); |
| 969 | await flushPromises(); |
| 970 | }); |
| 971 | await waitFor("post-error settings reload", () => upgradeFailureSettingsCalls === 2); |
| 972 | |
| 973 | eq(upgradeFailureMutationCalls, 1, "DeepSeek protocol upgrade mutation is invoked once"); |
| 974 | ok( |
| 975 | upgradeFailureRootEl.textContent?.includes("Upgrade to recommended protocol") === false, |
| 976 | "persisted DeepSeek protocol upgrade disappears after a runtime refresh error", |
| 977 | ); |
| 978 | ok( |
| 979 | upgradeFailureRootEl.textContent?.includes("workspace runtime boot failed after protocol upgrade") === true, |
| 980 | "post-mutation reload preserves the original runtime error", |
| 981 | ); |
| 982 | ok( |
| 983 | upgradeFailureChanged?.providers[0]?.kind === "anthropic", |
| 984 | "onChanged receives the authoritative persisted protocol after a runtime error", |
| 985 | ); |
| 986 | |
| 987 | await act(async () => { |
| 988 | upgradeFailureRoot.unmount(); |
| 989 | }); |
| 990 | dom.window.close(); |
| 991 | |
| 992 | console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`); |
| 993 | if (failed > 0) process.exit(1); |
| 994 |