返回 DeepSeek-Reasonix
settings-refresh-snapshot.test.tsx
根目录 / desktop / frontend / src / __tests__ / settings-refresh-snapshot.test.tsx
1 // Run: tsx src/__tests__/settings-refresh-snapshot.test.tsx
2
3 import { JSDOM } from "jsdom";
4 import React from "react";
5 import { act } from "react";
6 import { createRoot } from "react-dom/client";
7 import {
8 SettingsPanel,
9 formatProviderExtraBody,
10 parseProviderExtraBody,
11 providerExtraBodyParseError,
12 providerBaseURLFromChatURL,
13 providerChatURLPreview,
14 providerEditorEffectiveKind,
15 normalizeProviderView,
16 } from "../components/SettingsPanel";
17 import { LocaleProvider } from "../lib/i18n";
18 import type { AppBindings } from "../lib/bridge";
19 import type { ProviderView, SettingsView } from "../lib/types";
20 import {
21 applyTypographyPreferences,
22 createDefaultTypographyPreferences,
23 getTypographyPreferences,
24 } from "../lib/typographyPreferences";
25
26 let passed = 0;
27 let failed = 0;
28
29 function ok(value: boolean, label: string) {
30 if (value) {
31 process.stdout.write(` PASS ${label}\n`);
32 passed += 1;
33 } else {
34 process.stdout.write(` FAIL ${label}\n`);
35 failed += 1;
36 }
37 }
38
39 function eq(actual: unknown, expected: unknown, label: string) {
40 if (actual === expected) {
41 ok(true, label);
42 } else {
43 ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
44 }
45 }
46
47 function flushPromises(): Promise<void> {
48 return new Promise((resolve) => setTimeout(resolve, 0));
49 }
50
51 function installCanvasMock(win: Window) {
52 Object.defineProperty(win.HTMLCanvasElement.prototype, "getContext", {
53 configurable: true,
54 value(type: string) {
55 if (type !== "2d") return null;
56 return {
57 font: "",
58 measureText: () => ({ width: 0 }),
59 } as unknown as CanvasRenderingContext2D;
60 },
61 });
62 }
63
64 async function waitFor(label: string, predicate: () => boolean) {
65 for (let attempt = 0; attempt < 20; attempt += 1) {
66 await act(async () => {
67 await flushPromises();
68 });
69 if (predicate()) return;
70 }
71 throw new Error(`timed out waiting for ${label}`);
72 }
73
74 function baseSettings(displayMode: "standard" | "compact" = "standard"): SettingsView {
75 return {
76 defaultModel: "",
77 plannerModel: "",
78 subagentModel: "",
79 subagentEffort: "",
80 autoPlan: "off",
81 providers: [],
82 officialProviders: [],
83 providerPresets: [],
84 permissions: { mode: "ask", allow: [], ask: [], deny: [] },
85 sandbox: { bash: "enforce", network: false, workspaceRoot: "", allowWrite: [], effectiveWorkspaceRoot: "/work", effectiveWriteRoots: ["/work"], shell: "auto" },
86 network: { proxyMode: "auto", proxyUrl: "", noProxy: "", proxy: { type: "socks5", server: "", port: 0, username: "", password: "" } },
87 agent: { temperature: 0, maxSteps: 0, plannerMaxSteps: 0, maxSubagentDepth: 2, maxSubagentConcurrency: 6, maxParallelWriters: 3, systemPrompt: "", coldResumePrune: true, reasoningLanguage: "auto", compactRatio: 0.8 },
88 bot: {
89 enabled: false,
90 model: "",
91 toolApprovalMode: "",
92 maxSteps: 0,
93 debounceMs: 0,
94 queueMode: "steer",
95 queueCap: 20,
96 queueDrop: "summarize",
97 ignoreSelfMessages: true,
98 selfUserIds: { qq: [], feishu: [], weixin: [] },
99 control: { enabled: false, addr: "127.0.0.1:37913", tokenEnv: "REASONIX_BOT_CONTROL_TOKEN" },
100 pairing: { enabled: true, requestTtlMinutes: 60, maxPendingPerPlatform: 3 },
101 routes: [],
102 allowlist: {
103 enabled: false,
104 allowAll: false,
105 qqUsers: [],
106 feishuUsers: [],
107 weixinUsers: [],
108 qqApprovers: [],
109 feishuApprovers: [],
110 weixinApprovers: [],
111 qqAdmins: [],
112 feishuAdmins: [],
113 weixinAdmins: [],
114 qqGroups: [],
115 feishuGroups: [],
116 weixinGroups: [],
117 },
118 qq: {
119 enabled: false,
120 appId: "",
121 appSecretEnv: "",
122 secretSet: false,
123 sandbox: false,
124 model: "",
125 toolApprovalMode: "ask",
126 workspaceRoot: "",
127 access: { enabled: true, allowAll: false, pairingEnabled: true, users: [], groups: [], approvers: [], admins: [] },
128 },
129 feishu: { enabled: false, domain: "feishu", appId: "", appSecretEnv: "", secretSet: false, verificationToken: "", mode: "webhook", webhookPort: 0, requireMention: false },
130 weixin: { enabled: false, accountId: "", tokenEnv: "", tokenSet: false, apiBase: "" },
131 connections: [],
132 },
133 desktopLanguage: "en",
134 desktopLayoutStyle: "workbench",
135 desktopTheme: "auto",
136 desktopThemeStyle: "graphite",
137 desktopTerminalTheme: "auto",
138 closeBehavior: "background",
139 displayMode,
140 statusBarStyle: "text",
141 statusBarItems: ["model", "workspace", "git_branch", "cache", "balance"],
142 defaultToolApprovalMode: "auto",
143 checkUpdates: true,
144 updateChannel: "stable",
145 telemetry: true,
146 metrics: true,
147 configPath: "/tmp/reasonix/config.toml",
148 providerKinds: [],
149 autoApproveTools: false,
150 bypass: false,
151 };
152 }
153
154 console.log("\nsettings refresh snapshot");
155
156 const nullableProvider = normalizeProviderView({
157 name: null,
158 baseUrl: null,
159 } as unknown as ProviderView);
160 eq(nullableProvider.name, "", "provider snapshots normalize a null name at the settings boundary");
161 eq(nullableProvider.baseUrl, "", "provider snapshots normalize a null base URL at the settings boundary");
162
163 const glmProvider = normalizeProviderView({
164 name: "custom-glm",
165 baseUrl: "https://gateway.example.com/v1",
166 reasoningProtocol: "glm",
167 } as ProviderView);
168 eq(glmProvider.reasoningProtocol, "glm", "provider snapshots preserve the explicit GLM reasoning protocol");
169
170 eq(providerEditorEffectiveKind(true, "anthropic", ["anthropic", "openai"]), "anthropic", "new custom providers keep the selected Anthropic-compatible kind");
171 eq(providerEditorEffectiveKind(false, "anthropic", ["anthropic", "openai"]), "anthropic", "existing providers preserve their stored kind");
172 eq(providerChatURLPreview("https://proxy.example.com/v1", "", false), "https://proxy.example.com/v1/chat/completions", "base URL mode previews chat completions URL");
173 eq(providerChatURLPreview("", "https://proxy.example.com/custom/chat", true), "https://proxy.example.com/custom/chat", "full URL mode previews configured URL");
174 eq(providerBaseURLFromChatURL("https://proxy.example.com/v1/chat/completions"), "https://proxy.example.com/v1", "chat URL derives base URL for model discovery");
175 eq(formatProviderExtraBody({ top_p: 0.7, enable_thinking: true }), "{\n \"enable_thinking\": true,\n \"top_p\": 0.7\n}", "extra body editor formats stable JSON");
176 eq(JSON.stringify(parseProviderExtraBody('{ "enable_thinking": true, "top_p": 0.7 }')), "{\"enable_thinking\":true,\"top_p\":0.7}", "extra body editor parses JSON object");
177 let extraBodyRejected = false;
178 try {
179 parseProviderExtraBody("[true]");
180 } catch {
181 extraBodyRejected = true;
182 }
183 ok(extraBodyRejected, "extra body editor rejects non-object JSON");
184 const extraBodyTestT = ((key: string, vars?: Record<string, string | number>) => {
185 if (key === "settings.providerExtraBodyError") return "localized extra body fallback";
186 if (key === "settings.providerExtraBodyNull") return `${vars?.path} localized null`;
187 return key;
188 }) as any;
189 eq(
190 providerExtraBodyParseError(new SyntaxError("Unexpected token } in JSON"), extraBodyTestT),
191 "localized extra body fallback",
192 "extra body editor localizes JSON syntax errors",
193 );
194 try {
195 parseProviderExtraBody('{ "nested": { "value": null } }', extraBodyTestT);
196 ok(false, "extra body editor rejects localized null validation errors");
197 } catch (e) {
198 eq(
199 providerExtraBodyParseError(e, extraBodyTestT),
200 "extra_body.nested.value localized null",
201 "extra body editor keeps localized structured validation errors",
202 );
203 }
204
205 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
206 pretendToBeVisual: true,
207 url: "http://localhost/",
208 });
209 // React's legacy input-event fallback expects these IE hooks when JSDOM does
210 // not expose native input event support. The custom threshold editor focuses
211 // its input on open, so keep that production behavior testable without noise.
212 Object.defineProperty(dom.window.HTMLElement.prototype, "attachEvent", { configurable: true, value: () => {} });
213 Object.defineProperty(dom.window.HTMLElement.prototype, "detachEvent", { configurable: true, value: () => {} });
214 installCanvasMock(dom.window as unknown as Window);
215 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
216 globalThis.window = dom.window as unknown as Window & typeof globalThis;
217 globalThis.document = dom.window.document;
218 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
219 globalThis.Node = dom.window.Node;
220 globalThis.HTMLElement = dom.window.HTMLElement;
221 globalThis.Event = dom.window.Event;
222 globalThis.CustomEvent = dom.window.CustomEvent;
223 globalThis.KeyboardEvent = dom.window.KeyboardEvent;
224 globalThis.MouseEvent = dom.window.MouseEvent;
225 globalThis.localStorage = dom.window.localStorage;
226 globalThis.sessionStorage = dom.window.sessionStorage;
227 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
228 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
229 window.scrollTo = () => {};
230 localStorage.clear();
231
232 const regionalTypography = createDefaultTypographyPreferences();
233 regionalTypography.code = {
234 followGlobal: false,
235 fontFamily: "jetbrains",
236 customFontName: "",
237 fontSize: 15,
238 };
239 applyTypographyPreferences(regionalTypography);
240 const regionalCodeFont = document.documentElement.style.getPropertyValue("--typography-code-font");
241
242 const settingsSnapshots = [baseSettings("standard"), baseSettings("compact")];
243 let settingsCalls = 0;
244 let setDisplayModeCalls = 0;
245 let onChangedSettings: SettingsView | undefined;
246
247 window.go = {
248 main: {
249 App: {
250 Settings: async () => settingsSnapshots[Math.min(settingsCalls++, settingsSnapshots.length - 1)],
251 SetDisplayMode: async () => {
252 setDisplayModeCalls += 1;
253 },
254 } as Partial<AppBindings> as AppBindings,
255 },
256 };
257
258 const rootEl = document.getElementById("root");
259 if (!rootEl) throw new Error("missing root");
260 const root = createRoot(rootEl);
261
262 await act(async () => {
263 root.render(
264 <LocaleProvider>
265 <SettingsPanel
266 initialTab="general"
267 desktopPlatform="linux"
268 onClose={() => {}}
269 onChanged={(settings?: SettingsView) => {
270 onChangedSettings = settings;
271 }}
272 />
273 </LocaleProvider>,
274 );
275 await flushPromises();
276 });
277
278 const compactButton = Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.trim() === "Compact") as HTMLButtonElement | undefined;
279 if (!compactButton) throw new Error("compact display mode button did not render");
280 const generalFieldLabels = Array.from(rootEl.querySelectorAll(".settings-section__body > .settings-field > .settings-field__copy > .settings-field__label"))
281 .map((label) => label.textContent?.trim());
282 eq(generalFieldLabels[0], "Desktop style", "general settings place desktop style first");
283 eq(document.querySelectorAll(".step-limit-control").length, 0, "general settings hide executor and planner step-limit controls");
284 ok(!document.body.textContent?.includes("step limit"), "general settings keep automatic progress free of step-limit copy");
285 ok(!document.body.textContent?.includes("Automatic plan mode"), "general settings omit the retired automatic Plan Mode control");
286 ok(!document.body.textContent?.includes("planning defaults"), "general settings omit retired automatic Plan Mode copy");
287
288 await act(async () => {
289 compactButton.click();
290 await flushPromises();
291 });
292
293 eq(setDisplayModeCalls, 1, "display mode mutation is invoked once");
294 eq(settingsCalls, 2, "settings panel reads Settings only for initial load and post-save reload");
295 ok(onChangedSettings?.displayMode === "compact", "onChanged receives the post-save SettingsView snapshot");
296
297 await act(async () => {
298 root.unmount();
299 });
300
301 // Models > Agent runtime: the compaction preference is directly visible, shows
302 // the effective token threshold, and reloads the persisted Settings snapshot.
303 const compactRootEl = document.createElement("div");
304 document.body.appendChild(compactRootEl);
305 const compactRoot = createRoot(compactRootEl);
306 let compactSettings = baseSettings("standard");
307 delete compactSettings.agent.compactRatio; // Old backends omit the additive field.
308 compactSettings.agent.effectiveCompactRatio = 0.75;
309 compactSettings.agent.compactRatioOverridden = true;
310 compactSettings.defaultModel = "context-provider/context-model";
311 compactSettings.providers = [{
312 name: "context-provider",
313 builtIn: false,
314 added: true,
315 kind: "openai",
316 baseUrl: "https://context.example.com/v1",
317 chatUrl: "",
318 models: ["context-model"],
319 visionModels: [],
320 visionModelsConfigured: false,
321 modelsUrl: "",
322 default: "context-model",
323 apiKeyEnv: "",
324 keySet: false,
325 requiresKey: false,
326 configured: true,
327 balanceUrl: "",
328 contextWindow: 100_000,
329 reasoningProtocol: "",
330 thinking: "",
331 supportedEfforts: [],
332 defaultEffort: "",
333 modelOverrides: [],
334 }];
335 let compactRatioCalls: number[] = [];
336 window.go = {
337 main: {
338 App: {
339 Settings: async () => compactSettings,
340 FetchAllProviderModels: async () => ({}),
341 SetCompactRatio: async (ratio: number) => {
342 compactRatioCalls.push(ratio);
343 compactSettings = { ...compactSettings, agent: { ...compactSettings.agent, compactRatio: ratio } };
344 },
345 } as Partial<AppBindings> as AppBindings,
346 },
347 };
348
349 await act(async () => {
350 compactRoot.render(
351 <LocaleProvider>
352 <SettingsPanel initialTab="models" desktopPlatform="linux" onClose={() => {}} onChanged={() => {}} />
353 </LocaleProvider>,
354 );
355 await flushPromises();
356 });
357 ok(compactRootEl.textContent?.includes("Advanced context management") === false, "compaction preference has no redundant advanced disclosure");
358 ok(compactRootEl.textContent?.includes("Automatic compaction threshold") === true, "compaction preference is visible without expanding a disclosure");
359 ok(compactRootEl.textContent?.includes("80,000 tokens") === true, "compact ratio shows the default model token threshold");
360 ok(compactRootEl.textContent?.includes("Current threshold: 80% · Balanced") === true, "compact ratio summarizes the saved preset separately");
361 ok(compactRootEl.textContent?.includes("effective threshold is 75%") === true, "project override shows the active effective threshold");
362 ok(compactRootEl.querySelector('input[aria-label="Custom compaction threshold percentage"]') === null, "custom compact ratio editor stays hidden on the default path");
363 const balancedCompactButton = compactRootEl.querySelector('button[aria-label="80% · Balanced"]') as HTMLButtonElement | null;
364 if (!balancedCompactButton) throw new Error("balanced compaction preset did not render");
365 ok(balancedCompactButton.getAttribute("aria-pressed") === "true", "saved compact ratio starts selected");
366 const customCompactButton = Array.from(compactRootEl.querySelectorAll("button")).find((button) => button.textContent?.includes("Custom threshold…")) as HTMLButtonElement | undefined;
367 if (!customCompactButton) throw new Error("custom compaction threshold option did not render");
368 ok(customCompactButton.closest(".compact-ratio-presets") === null, "custom compaction is a separate disclosure rather than a preset value");
369 ok(customCompactButton.hasAttribute("aria-pressed") === false, "custom disclosure does not announce a saved selection state");
370 await act(async () => {
371 customCompactButton.click();
372 await flushPromises();
373 });
374 let customCompactInput = compactRootEl.querySelector('input[aria-label="Custom compaction threshold percentage"]') as HTMLInputElement | null;
375 if (!customCompactInput) throw new Error("custom compaction threshold input did not open");
376 eq(customCompactInput.value, "80", "custom compaction threshold defaults older backends to 80 percent");
377 ok(compactRootEl.textContent?.includes("Tool output is trimmed at 60%") === true, "custom compact ratio explains the lower guard rail");
378 ok(compactRootEl.textContent?.includes("90% forces compaction") === true, "custom compact ratio explains the upper guard rail");
379 ok(document.activeElement === customCompactInput, "opening the custom compact ratio moves focus to its input");
380 ok(customCompactButton.getAttribute("aria-expanded") === "true", "custom compact ratio exposes its expanded state");
381 ok(balancedCompactButton.getAttribute("aria-pressed") === "true", "opening custom editing preserves the saved preset selection");
382 const customCompactApply = Array.from(customCompactInput.closest(".compact-ratio-custom")?.querySelectorAll("button") ?? []).find((button) => button.textContent === "Apply") as HTMLButtonElement | undefined;
383 if (!customCompactApply) throw new Error("custom compaction threshold apply action did not render");
384 const inputValueSetter = Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, "value")?.set;
385 const setCustomCompactInput = (input: HTMLInputElement, value: string) => {
386 const previous = input.value;
387 inputValueSetter?.call(input, value);
388 (input as HTMLInputElement & { _valueTracker?: { setValue: (next: string) => void } })._valueTracker?.setValue(previous);
389 input.dispatchEvent(new Event("input", { bubbles: true }));
390 input.dispatchEvent(new Event("change", { bubbles: true }));
391 };
392 await act(async () => {
393 setCustomCompactInput(customCompactInput, "64");
394 await flushPromises();
395 });
396 ok(customCompactApply.disabled, "out-of-range custom compact ratio cannot be applied");
397 eq(compactRatioCalls.length, 0, "editing a custom compact ratio does not save eagerly");
398 await act(async () => {
399 setCustomCompactInput(customCompactInput, "75");
400 await flushPromises();
401 });
402 ok(!customCompactApply.disabled, "valid custom compact ratio enables explicit apply");
403 await act(async () => {
404 customCompactApply.click();
405 await flushPromises();
406 });
407 eq(compactRatioCalls.length, 1, "custom compact ratio mutation is invoked once after apply");
408 eq(compactRatioCalls[0], 0.75, "custom compact ratio converts percentage to fraction");
409 ok(compactRootEl.querySelector('input[aria-label="Custom compaction threshold percentage"]') === null, "successful custom compact ratio apply collapses the editor");
410 ok(compactRootEl.textContent?.includes("Current threshold: 75% · Custom") === true, "saved custom compact ratio is summarized independently from the disclosure");
411 ok(customCompactButton.textContent?.includes("Custom threshold…") === true, "custom disclosure keeps an action label after saving");
412 await act(async () => {
413 customCompactButton.click();
414 await flushPromises();
415 });
416 customCompactInput = compactRootEl.querySelector('input[aria-label="Custom compaction threshold percentage"]') as HTMLInputElement | null;
417 if (!customCompactInput) throw new Error("saved custom compaction threshold did not reopen");
418 await act(async () => {
419 setCustomCompactInput(customCompactInput, "74");
420 customCompactInput.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
421 await flushPromises();
422 });
423 eq(compactRatioCalls.length, 1, "Escape cancels a custom compact ratio without saving");
424 ok(compactRootEl.querySelector('input[aria-label="Custom compaction threshold percentage"]') === null, "Escape collapses the custom compact ratio editor");
425 await act(async () => {
426 customCompactButton.click();
427 await flushPromises();
428 });
429 customCompactInput = compactRootEl.querySelector('input[aria-label="Custom compaction threshold percentage"]') as HTMLInputElement | null;
430 if (!customCompactInput) throw new Error("custom compaction threshold did not reopen for cancel");
431 const customCompactCancel = Array.from(customCompactInput.closest(".compact-ratio-custom")?.querySelectorAll("button") ?? []).find((button) => button.textContent === "Cancel") as HTMLButtonElement | undefined;
432 if (!customCompactCancel) throw new Error("custom compaction threshold cancel action did not render");
433 await act(async () => {
434 customCompactCancel.click();
435 await flushPromises();
436 });
437 eq(compactRatioCalls.length, 1, "Cancel closes a custom compact ratio without saving");
438 ok(compactRootEl.querySelector('input[aria-label="Custom compaction threshold percentage"]') === null, "Cancel collapses the custom compact ratio editor");
439 const earlierCompactButton = compactRootEl.querySelector('button[aria-label="70% · Earlier"]') as HTMLButtonElement | null;
440 if (!earlierCompactButton) throw new Error("earlier compaction preset did not render");
441 await act(async () => {
442 earlierCompactButton.click();
443 await flushPromises();
444 });
445 eq(compactRatioCalls.length, 2, "compact ratio preset adds one mutation");
446 eq(compactRatioCalls[1], 0.7, "compact ratio preset sends the expected fraction");
447 ok(earlierCompactButton.getAttribute("aria-pressed") === "true", "saved compact ratio is selected after Settings reload");
448
449 await act(async () => {
450 compactRoot.unmount();
451 });
452
453 const retryRootEl = document.createElement("div");
454 document.body.appendChild(retryRootEl);
455 const retryRoot = createRoot(retryRootEl);
456 let failingSettingsCalls = 0;
457 window.go = {
458 main: {
459 App: {
460 Settings: async () => {
461 failingSettingsCalls += 1;
462 if (failingSettingsCalls === 1) throw new Error("/Users/example/.reasonix/settings.toml: permission denied");
463 return baseSettings("standard");
464 },
465 } as Partial<AppBindings> as AppBindings,
466 },
467 };
468
469 await act(async () => {
470 retryRoot.render(
471 <LocaleProvider>
472 <SettingsPanel
473 initialTab="general"
474 desktopPlatform="linux"
475 onClose={() => {}}
476 onChanged={() => {}}
477 />
478 </LocaleProvider>,
479 );
480 await flushPromises();
481 });
482 await waitFor("settings load failure", () => Boolean(document.querySelector(".banner--error")));
483
484 ok(document.body.textContent?.includes("Settings could not be loaded.") === true, "failed initial settings load shows a visible error");
485 ok(document.body.textContent?.includes("Loading…") === false, "failed initial settings load stops showing the loading state");
486
487 const retryButton = Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.trim() === "Retry") as HTMLButtonElement | undefined;
488 if (!retryButton) throw new Error("settings retry button did not render");
489
490 await act(async () => {
491 retryButton.click();
492 await flushPromises();
493 });
494 await waitFor("settings retry success", () => Boolean(Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.trim() === "Compact")));
495
496 eq(failingSettingsCalls, 2, "settings retry calls Settings again");
497 ok(document.body.textContent?.includes("Settings could not be loaded.") === false, "settings retry clears the load error");
498
499 await act(async () => {
500 retryRoot.unmount();
501 });
502
503 const windowsSandboxRootEl = document.createElement("div");
504 document.body.appendChild(windowsSandboxRootEl);
505 const windowsSandboxRoot = createRoot(windowsSandboxRootEl);
506 let windowsSetSandboxCalls = 0;
507 window.go = {
508 main: {
509 App: {
510 // Deliberately return a stale enforce value: the Windows UI must still
511 // render the effective immutable off state.
512 Settings: async () => baseSettings("standard"),
513 SetSandbox: async () => {
514 windowsSetSandboxCalls += 1;
515 },
516 } as Partial<AppBindings> as AppBindings,
517 },
518 };
519
520 await act(async () => {
521 windowsSandboxRoot.render(
522 <LocaleProvider>
523 <SettingsPanel
524 initialTab="sandbox"
525 desktopPlatform="windows"
526 onClose={() => {}}
527 onChanged={() => {}}
528 />
529 </LocaleProvider>,
530 );
531 await flushPromises();
532 });
533 await waitFor("Windows Bash sandbox control", () => document.body.textContent?.includes("This setting is fixed to off.") === true);
534
535 const windowsBashSelect = Array.from(windowsSandboxRootEl.querySelectorAll("select")).find((select) =>
536 Array.from(select.options).some((option) => option.value === "off"),
537 );
538 if (!windowsBashSelect) throw new Error("Windows Bash sandbox select did not render");
539 ok(windowsBashSelect.disabled, "Windows Bash sandbox selector is disabled");
540 eq(windowsBashSelect.value, "off", "Windows Bash sandbox selector is fixed to off");
541 ok(!Array.from(windowsBashSelect.options).some((option) => option.value === "enforce"), "Windows Bash sandbox selector omits enforce");
542 eq(windowsSetSandboxCalls, 0, "Windows immutable Bash sandbox state does not save enforce");
543
544 await act(async () => {
545 windowsSandboxRoot.unmount();
546 });
547
548 const zoomRootEl = document.createElement("div");
549 document.body.appendChild(zoomRootEl);
550 const zoomRoot = createRoot(zoomRootEl);
551 let persistedZoom = 0.5;
552 const savedZoomFactors: number[] = [];
553 window.go = {
554 main: {
555 App: {
556 Settings: async () => baseSettings("standard"),
557 GetDesktopZoomFactor: async () => persistedZoom,
558 SetDesktopZoomFactor: async (factor: number) => {
559 persistedZoom = factor;
560 savedZoomFactors.push(factor);
561 },
562 } as Partial<AppBindings> as AppBindings,
563 },
564 };
565
566 localStorage.setItem("reasonix-zoom-restart", "1");
567 await act(async () => {
568 zoomRoot.render(
569 <LocaleProvider>
570 <SettingsPanel
571 initialTab="appearance"
572 desktopPlatform="windows"
573 onClose={() => {}}
574 onChanged={() => {}}
575 />
576 </LocaleProvider>,
577 );
578 await flushPromises();
579 });
580 await waitFor("persisted display zoom sync", () => document.querySelector(".zoom-slider__value")?.textContent?.trim() === "50%");
581
582 const monoFontSelect = zoomRootEl.querySelector("select[aria-labelledby='appearance-mono-font-family-label']") as HTMLSelectElement | null;
583 if (!monoFontSelect) throw new Error("monospace font selector did not render");
584 await act(async () => {
585 monoFontSelect.value = "custom";
586 monoFontSelect.dispatchEvent(new Event("change", { bubbles: true }));
587 await flushPromises();
588 });
589
590 const preservedTypography = getTypographyPreferences();
591 eq(preservedTypography.code.followGlobal, false, "global monospace changes preserve an explicit code-region override");
592 eq(preservedTypography.code.fontFamily, "jetbrains", "global monospace changes preserve the regional code font choice");
593 eq(
594 document.documentElement.style.getPropertyValue("--typography-code-font"),
595 regionalCodeFont,
596 "global monospace changes keep the regional code font CSS variable",
597 );
598
599 const resetZoomButton = document.querySelector("button[aria-label='Reset display zoom to 100%']") as HTMLButtonElement | null;
600 if (!resetZoomButton) throw new Error("display zoom reset button did not render");
601 await act(async () => {
602 resetZoomButton.click();
603 await flushPromises();
604 });
605 await waitFor("display zoom reset", () => document.querySelector(".zoom-slider__value")?.textContent?.trim() === "100%");
606
607 eq(savedZoomFactors.at(-1), 1, "display zoom reset writes the default zoom factor");
608 eq(localStorage.getItem("reasonix-zoom-restart"), "1", "display zoom reset updates the local restart zoom cache");
609
610 await act(async () => {
611 zoomRoot.unmount();
612 });
613
614 // Bots tab: direct four-channel bot manager.
615 const botsRootEl = document.createElement("div");
616 document.body.appendChild(botsRootEl);
617 const botsRoot = createRoot(botsRootEl);
618 const botsSettings = baseSettings("standard");
619 botsSettings.bot.connections = [
620 {
621 id: "conn-feishu-1",
622 provider: "feishu",
623 domain: "feishu",
624 label: "kun",
625 enabled: true,
626 status: "connected",
627 model: "",
628 toolApprovalMode: "",
629 workspaceRoot: "",
630 credential: { appId: "cli_mock", appSecretEnv: "FEISHU_BOT_APP_SECRET", accountId: "", tokenEnv: "", secretSet: true },
631 sessionMappings: [],
632 lastError: "",
633 createdAt: "",
634 updatedAt: "",
635 access: { enabled: true, allowAll: false, pairingEnabled: true, users: ["ou_mock_user_001"], groups: [], approvers: [], admins: [] },
636 },
637 ];
638 window.go = {
639 main: {
640 App: {
641 Settings: async () => botsSettings,
642 } as Partial<AppBindings> as AppBindings,
643 },
644 };
645
646 await act(async () => {
647 botsRoot.render(
648 <LocaleProvider>
649 <SettingsPanel initialTab="bots" desktopPlatform="linux" onClose={() => {}} onChanged={() => {}} />
650 </LocaleProvider>,
651 );
652 await flushPromises();
653 });
654 await waitFor("bot channel manager", () => Boolean(document.querySelector(".bot-channel-manager")));
655
656 ok(!document.querySelector(".bot-overview-grid"), "bots tab does not render the removed entry overview");
657 ok(!document.getElementById("bot-mobile-remote"), "bots tab no longer renders the mobile remote entry card");
658 ok(!document.querySelector(".bot-channel-entry"), "bots tab no longer renders the Bot Channel entry panel");
659 ok(!document.getElementById("bot-step-access"), "bots tab omits the old global access step card");
660 ok(!document.getElementById("bot-step-behavior"), "bots tab omits global default behavior card");
661 eq(document.querySelectorAll(".bot-step-chip").length, 0, "hero no longer shows the old two-step chips");
662
663 eq(document.querySelectorAll(".bot-channel-tabs [role=\"tab\"]").length, 4, "bot manager uses four fixed channel tabs on the left");
664 ok(document.querySelector(".bot-channel-setup-card")?.textContent?.includes("Configure QQ") === true, "unconfigured QQ tab shows key setup on the right");
665 ok(document.body.textContent?.includes("Back to entry") === false, "bot manager does not show a return-to-entry action");
666
667 const feishuTab = Array.from(document.querySelectorAll(".bot-channel-tabs [role=\"tab\"]")).find((button) => button.textContent?.includes("Feishu")) as HTMLButtonElement | undefined;
668 if (!feishuTab) throw new Error("Feishu channel tab did not render");
669 await act(async () => {
670 feishuTab.click();
671 await flushPromises();
672 });
673 await waitFor("selected Feishu detail", () => Boolean(document.querySelector(".bot-channel-manager__detail .bot-detail-card")));
674
675 ok(Boolean(document.querySelector(".bot-channel-manager__detail .bot-detail-card")), "configured channel renders selected bot detail on the right");
676 ok(Boolean(document.querySelector(".bot-channel-manager__detail .bot-detail-section--access")), "selected bot detail owns its access control");
677 ok(document.body.textContent?.includes("Access control") === true, "selected bot detail labels per-bot access control");
678 const selectedBotDetailText = document.querySelector(".bot-channel-manager__detail .bot-detail-card")?.textContent ?? "";
679 const connectionSummaryIndex = selectedBotDetailText.indexOf("Connection summary");
680 const enableBotIndex = selectedBotDetailText.indexOf("Enable bot");
681 const toolApprovalIndex = selectedBotDetailText.indexOf("Tool approval");
682 const modelIndex = selectedBotDetailText.indexOf("Model");
683 const accessControlIndex = selectedBotDetailText.indexOf("Access control");
684 ok(
685 connectionSummaryIndex >= 0 && enableBotIndex > connectionSummaryIndex && toolApprovalIndex > enableBotIndex && modelIndex > toolApprovalIndex && accessControlIndex > modelIndex,
686 "selected bot detail places enable, approval, and model controls between summary and access control",
687 );
688 ok(document.body.textContent?.includes("ou_mock_user_001") === true, "selected bot detail shows its trusted user");
689 ok(document.body.textContent?.includes("Legacy global allowlist") === true, "advanced area keeps the legacy global allowlist");
690 ok(document.querySelector(".bot-simple-advanced")?.textContent?.includes("local control API") === false, "advanced area no longer owns mobile/control API setup");
691
692 await act(async () => {
693 botsRoot.unmount();
694 });
695
696 // Models tab: switching away invalidates an in-flight background discovery so
697 // its older completion cannot attempt a stale catalog write.
698 sessionStorage.clear();
699 const providerRaceRootEl = document.createElement("div");
700 document.body.appendChild(providerRaceRootEl);
701 const providerRaceRoot = createRoot(providerRaceRootEl);
702 const providerRaceSettings = baseSettings("standard");
703 providerRaceSettings.defaultModel = "race-provider/old-model";
704 providerRaceSettings.providers = [{
705 name: "race-provider",
706 builtIn: false,
707 added: true,
708 kind: "openai",
709 baseUrl: "https://old.example.com/v1",
710 chatUrl: "",
711 models: ["old-model"],
712 visionModels: [],
713 visionModelsConfigured: false,
714 modelsUrl: "",
715 default: "missing-default",
716 apiKeyEnv: "RACE_PROVIDER_API_KEY",
717 headers: { "X-Gateway-Token": "private-gateway-secret" },
718 extraBody: {},
719 authHeader: false,
720 keySet: true,
721 requiresKey: true,
722 configured: true,
723 keySource: "global",
724 keySourcePath: "",
725 balanceUrl: "",
726 contextWindow: 128_000,
727 reasoningProtocol: "",
728 thinking: "",
729 supportedEfforts: [],
730 defaultEffort: "",
731 modelOverrides: [],
732 modelCatalogFingerprint: "old-fingerprint",
733 }];
734 let resolveProviderBatch: ((models: Record<string, string[]>) => void) | undefined;
735 const providerBatch = new Promise<Record<string, string[]>>((resolve) => {
736 resolveProviderBatch = resolve;
737 });
738 let providerBatchCalls = 0;
739 let providerCatalogSaveCalls = 0;
740 window.go = {
741 main: {
742 App: {
743 Settings: async () => providerRaceSettings,
744 FetchAllProviderModels: async () => {
745 providerBatchCalls += 1;
746 return providerBatch;
747 },
748 SaveProviderModelCatalogs: async () => {
749 providerCatalogSaveCalls += 1;
750 return ["race-provider"];
751 },
752 } as Partial<AppBindings> as AppBindings,
753 },
754 };
755
756 await act(async () => {
757 providerRaceRoot.render(
758 <LocaleProvider>
759 <SettingsPanel initialTab="models" desktopPlatform="linux" onClose={() => {}} onChanged={() => {}} />
760 </LocaleProvider>,
761 );
762 await flushPromises();
763 });
764 await waitFor("provider background discovery", () => providerBatchCalls === 1);
765 const providerRefreshStorageKeys = Array.from({ length: sessionStorage.length }, (_, index) => sessionStorage.key(index) ?? "");
766 ok(providerRefreshStorageKeys.some((key) => key.includes("old-fingerprint")), "provider auto-refresh cooldown uses the opaque catalog fingerprint");
767 ok(providerRefreshStorageKeys.every((key) => !key.includes("private-gateway-secret")), "provider auto-refresh cooldown does not persist header secrets");
768 const accessModelsButton = Array.from(providerRaceRootEl.querySelectorAll(".settings-subtab")).find(
769 (button) => button.textContent?.trim() === "Access",
770 ) as HTMLButtonElement | undefined;
771 if (!accessModelsButton) throw new Error("provider Access subtab did not render");
772 await act(async () => {
773 accessModelsButton.click();
774 await flushPromises();
775 });
776 await act(async () => {
777 resolveProviderBatch?.({ "race-provider": ["old-model", "stale-fetched-model"] });
778 await flushPromises();
779 });
780 await waitFor("stale provider discovery completion", () => providerBatchCalls === 1);
781 eq(providerCatalogSaveCalls, 0, "leaving the models usage tab suppresses the stale background catalog write");
782
783 await act(async () => {
784 providerRaceRoot.unmount();
785 });
786 dom.window.close();
787
788 console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
789 if (failed > 0) process.exit(1);
790
790 lines Plain Text