返回 DeepSeek-Reasonix
settings-refresh-snapshot.test.tsx
根目录 / desktop / frontend / src / __tests__ / settings-refresh-snapshot.test.tsx
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
994 lines Plain Text