1import { describe, expect, it, vi } from "vite-plus/test";
2import { EnvironmentId } from "@t3tools/contracts";
3
4import {
5 filterAvailableSettingsSearchItems,
6 getSettingsSearchTargetScope,
7 getThreadAutoSettlementSearchAvailability,
8 isSettingsOverviewVisible,
9 isSettingsSearchScopeAvailable,
10 searchableSetting,
11 searchSettings,
12 SETTINGS_SEARCH_ITEMS,
13 type SettingsSearchItem,
14} from "./settingsSearch";
15
16const ITEMS: ReadonlyArray<SettingsSearchItem> = [
17 {
18 id: "word-wrap",
19 title: "Word wrap",
20 to: "/settings/general",
21 searchTerms: ["long lines in code previews"],
22 },
23 {
24 id: "network-access",
25 title: "Network access",
26 to: "/settings/connections",
27 searchTerms: ["remote pairing backend"],
28 },
29 {
30 id: "providers",
31 title: "Providers",
32 to: "/settings/providers",
33 searchTerms: ["claude codex agents"],
34 },
35 {
36 id: "provider-updates",
37 title: "Update checks",
38 to: "/settings/general",
39 },
40 {
41 id: "automatic-updates",
42 title: "Automatic updates",
43 to: "/settings/general",
44 },
45];
46
47describe("searchSettings", () => {
48 it.each(["OTel", "OTLP", "telemetry export", "Grafana"])(
49 "finds environment telemetry settings for %s",
50 (query) => {
51 expect(searchSettings(query)).toContainEqual(
52 expect.objectContaining({
53 id: "telemetry-export",
54 to: "/settings/diagnostics",
55 targetId: "telemetry-export",
56 scope: "environment",
57 }),
58 );
59 },
60 );
61 it.each(["send shortcut", "multiline", "new line"])("finds Send shortcut for %s", (query) => {
62 expect(searchSettings(query).map((item) => item.id)).toContain("send-shortcut");
63 });
64
65 it("matches titles, sections, and remembered setting details", () => {
66 expect(searchSettings("word", ITEMS).map((item) => item.id)).toEqual(["word-wrap"]);
67 expect(searchSettings("network", ITEMS).map((item) => item.id)).toEqual(["network-access"]);
68 expect(searchSettings("connections", ITEMS).map((item) => item.id)).toEqual(["network-access"]);
69 expect(searchSettings("claude", ITEMS).map((item) => item.id)).toEqual(["providers"]);
70 expect(searchSettings("long lines", ITEMS).map((item) => item.id)).toEqual(["word-wrap"]);
71 });
72
73 it("matches normalized title substrings", () => {
74 expect(searchSettings(" WORD WRAP ", ITEMS).map((item) => item.id)).toEqual(["word-wrap"]);
75 expect(searchSettings("glass").map((item) => item.id)).toEqual(["setting-glass-opacity"]);
76 expect(searchSettings("panel animations").map((item) => item.id)).toEqual(["panel-animations"]);
77 expect(searchSettings("thè\u{1ab0}mes")[0]?.id).toBe("theme");
78 const localeLowerCase = vi.spyOn(String.prototype, "toLocaleLowerCase").mockReturnValue("gıt");
79 try {
80 expect(searchSettings("GIT")[0]?.id).toBe("git-fetch-interval");
81 expect(localeLowerCase).not.toHaveBeenCalled();
82 } finally {
83 localeLowerCase.mockRestore();
84 }
85 expect(searchSettings("xyzzy")).toEqual([]);
86 });
87
88 it("keeps catalog order for multiple title matches", () => {
89 expect(searchSettings("update", ITEMS).map((item) => item.id)).toEqual([
90 "provider-updates",
91 "automatic-updates",
92 ]);
93 });
94
95 it("matches query words across fields and ranks the strongest result first", () => {
96 expect(searchSettings("pairing remote", ITEMS).map((item) => item.id)).toEqual([
97 "network-access",
98 ]);
99 expect(
100 searchSettings("remote pairing")
101 .slice(0, 2)
102 .map((item) => item.id),
103 ).toEqual(["network-access", "connections-environment"]);
104 });
105
106 it("finds settings that used to be reachable only through their section", () => {
107 expect(searchSettings("pull request template")[0]?.id).toBe("follow-change-request-templates");
108 expect(searchSettings("git security keys")[0]?.id).toBe("git-fetch-interval");
109 expect(searchSettings("push notifications")[0]?.id).toBe("publish-agent-activity");
110 expect(searchSettings("battery saver")[0]?.id).toBe("background-activity");
111 expect(searchSettings("binary path")[0]?.id).toBe("providers");
112 expect(searchSettings("Antigravity")[0]?.id).toBe("providers");
113 expect(searchSettings("Google sign in")[0]?.id).toBe("providers");
114 expect(searchSettings("authorized clients")[0]?.id).toBe("connections-environment");
115 expect(searchSettings("administrative access")[0]?.id).toBe("connections-environment");
116 });
117
118 it("lists thread confirmations in panel order", () => {
119 expect(searchSettings("confirmation").map((item) => item.id)).toEqual([
120 "unpin-confirmation",
121 "archive-confirmation",
122 "delete-confirmation",
123 ]);
124 });
125
126 it.each(["usage providers", "CLIProxyAPI", "CLI proxy hub", "management key"])(
127 "finds usage-provider management by %s",
128 (query) => {
129 expect(searchSettings(query)[0]).toMatchObject({
130 id: "usage-providers",
131 to: "/settings/providers",
132 });
133 },
134 );
135
136 it("returns no results for an empty query", () => {
137 expect(searchSettings(" ", ITEMS)).toEqual([]);
138 });
139
140 it("hides desktop-only settings from browser search", () => {
141 expect(SETTINGS_SEARCH_ITEMS.some((item) => item.id === "quit-confirmation")).toBe(true);
142 expect(searchSettings("hold to quit")).toEqual([]);
143 expect(searchSettings("wsl")).toEqual([]);
144 expect(searchSettings("update track")).toEqual([]);
145 });
146
147 it("finds the About update rows", () => {
148 expect(searchSettings("check for updates").map((item) => item.id)).toContain("app-version");
149 expect(SETTINGS_SEARCH_ITEMS.find((item) => item.id === "update-track")).toMatchObject({
150 title: "Update track",
151 to: "/settings/general",
152 releaseChannelOnly: true,
153 });
154 });
155
156 it("hides macOS-only settings on other platforms", () => {
157 vi.stubGlobal("navigator", { platform: "Win32" });
158 try {
159 expect(searchSettings("font smoothing")).toEqual([]);
160 } finally {
161 vi.unstubAllGlobals();
162 }
163 });
164
165 it("registers the WSL backend as a desktop-only setting", () => {
166 expect(SETTINGS_SEARCH_ITEMS.find((item) => item.id === "wsl-backend")).toMatchObject({
167 id: "wsl-backend",
168 title: "WSL backend",
169 to: "/settings/connections",
170 desktopOnly: true,
171 windowsOnly: true,
172 });
173 });
174
175 it("hides settings whose controls are unavailable", () => {
176 const available = filterAvailableSettingsSearchItems({
177 hasCloudPublicConfig: false,
178 hasEnvironment: false,
179 hasProviderSettingsEnvironment: false,
180 hasMacProviderSettingsEnvironment: false,
181 canManageLocalBackend: false,
182 isWslSettingsRowVisible: false,
183 hasThreadAutoSettlement: false,
184 });
185
186 const gatedIds = new Set<string>([
187 "follow-change-request-templates",
188 "git-fetch-interval",
189 "network-access",
190 "publish-agent-activity",
191 "provider-health-check-interval",
192 "cursor-keychain-usage",
193 "source-control-writer-model",
194 "review-guide-model",
195 "review-guide-instructions",
196 "source-control-writing-style",
197 "t3-connect",
198 "hold-webhooks-while-offline",
199 "tailscale-https",
200 "wsl-backend",
201 "auto-settle-inactive-threads",
202 "auto-settle-merged-threads",
203 "days-before-auto-settle",
204 ]);
205 expect(available.map((item) => item.id).filter((id) => gatedIds.has(id))).toEqual([]);
206 });
207
208 it("offers Cursor Keychain settings only when a macOS provider environment is available", () => {
209 const availability = {
210 hasCloudPublicConfig: false,
211 hasEnvironment: true,
212 hasProviderSettingsEnvironment: true,
213 hasMacProviderSettingsEnvironment: false,
214 canManageLocalBackend: false,
215 isWslSettingsRowVisible: false,
216 hasThreadAutoSettlement: false,
217 };
218 const itemIds = (macAvailable: boolean) =>
219 filterAvailableSettingsSearchItems({
220 ...availability,
221 hasMacProviderSettingsEnvironment: macAvailable,
222 }).map((item) => item.id);
223 expect(itemIds(false)).not.toContain("cursor-keychain-usage");
224 expect(itemIds(true)).toContain("cursor-keychain-usage");
225 });
226
227 it("keeps the local toggle searchable without offering hidden host publishing controls", () => {
228 const availability = {
229 hasCloudPublicConfig: true,
230 hasEnvironment: true,
231 hasProviderSettingsEnvironment: true,
232 hasMacProviderSettingsEnvironment: false,
233 canManageLocalBackend: false,
234 isWslSettingsRowVisible: false,
235 hasThreadAutoSettlement: false,
236 };
237 const remoteOnly = filterAvailableSettingsSearchItems({
238 ...availability,
239 localEnvironmentDisabled: true,
240 }).map((item) => item.id);
241 expect(remoteOnly).toContain("local-environment");
242 expect(remoteOnly).not.toContain("t3-connect");
243 expect(remoteOnly).not.toContain("publish-agent-activity");
244 expect(remoteOnly).not.toContain("wsl-backend");
245 // Browsers without access:write still render CloudLinkRow for their host.
246 const browser = filterAvailableSettingsSearchItems(availability).map((item) => item.id);
247 expect(browser).toContain("publish-agent-activity");
248 });
249
250 it("offers webhook holding only while the managed tunnel is on, like its row", () => {
251 const availability = {
252 hasCloudPublicConfig: true,
253 hasEnvironment: true,
254 hasProviderSettingsEnvironment: true,
255 hasMacProviderSettingsEnvironment: false,
256 canManageLocalBackend: true,
257 isWslSettingsRowVisible: false,
258 hasThreadAutoSettlement: false,
259 };
260 const itemIds = (managedTunnelActive: boolean) =>
261 filterAvailableSettingsSearchItems({ ...availability, managedTunnelActive }).map(
262 (item) => item.id,
263 );
264 expect(itemIds(false)).not.toContain("hold-webhooks-while-offline");
265 expect(itemIds(true)).toContain("hold-webhooks-while-offline");
266 });
267
268 it("shows automatic settlement settings when the server supports them", () => {
269 const available = filterAvailableSettingsSearchItems({
270 hasCloudPublicConfig: false,
271 hasEnvironment: false,
272 hasProviderSettingsEnvironment: false,
273 hasMacProviderSettingsEnvironment: false,
274 canManageLocalBackend: false,
275 isWslSettingsRowVisible: false,
276 hasThreadAutoSettlement: true,
277 });
278
279 expect(searchSettings("auto-settle", available).map((item) => item.id)).toEqual([
280 "auto-settle-inactive-threads",
281 "auto-settle-merged-threads",
282 "days-before-auto-settle",
283 ]);
284 });
285
286 it("finds keybinding commands by label, command id, and default key", () => {
287 expect(searchSettings("toggle sidebar")[0]?.id).toBe("keybinding-sidebar.toggle");
288 expect(searchSettings("sidebar.toggle")[0]?.id).toBe("keybinding-sidebar.toggle");
289 expect(searchSettings("mod+b")[0]?.id).toBe("keybinding-sidebar.toggle");
290 expect(searchSettings("copy link")[0]).toMatchObject({
291 id: "keybinding-thread.copyReference",
292 to: "/settings/keybindings",
293 });
294 });
295
296 it("ranks keybinding commands after other settings", () => {
297 const ids = searchSettings("model").map((item) => item.id);
298 expect(ids[0]).toBe("default-model");
299 expect(ids.indexOf("keybinding-modelPicker.toggle")).toBeGreaterThan(
300 ids.indexOf("text-generation-model"),
301 );
302 });
303
304 it("sends commands without a default binding to the section", () => {
305 expect(searchSettings("thread.stop")[0]).toMatchObject({
306 id: "keybinding-thread.stop",
307 targetId: "keybindings",
308 });
309 expect(searchSettings("sidebar.toggle")[0]?.targetId).toBeUndefined();
310 });
311
312 it("keeps catalog result ids unique", () => {
313 const ids = SETTINGS_SEARCH_ITEMS.map((item) => item.id);
314 expect(new Set(ids).size).toBe(ids.length);
315 });
316
317 it("serves anchor props to panels from the catalog", () => {
318 expect(searchableSetting("word-wrap")).toEqual({ id: "word-wrap", title: "Word wrap" });
319 expect(searchableSetting("archive")).toEqual({ id: "archive", title: "Archived threads" });
320 });
321
322 it("routes appearance settings to their current section", () => {
323 expect(searchSettings("theme")[0]).toMatchObject({
324 id: "theme",
325 to: "/settings/appearance",
326 });
327 expect(searchSettings("word wrap")[0]).toMatchObject({
328 id: "word-wrap",
329 to: "/settings/appearance",
330 });
331 expect(searchSettings("composer context")[0]).toMatchObject({
332 id: "composer-context",
333 to: "/settings/appearance",
334 });
335 expect(searchSettings("environment identification")[0]).toMatchObject({
336 id: "environment-identification",
337 to: "/settings/appearance",
338 targetId: "appearance-interface",
339 });
340 });
341
342 it.each([
343 "capture accessibility data",
344 "capture shortcut",
345 "capture sound",
346 "capture flash",
347 "capture animations",
348 ])("hides disabled SnapShots settings for %s", (query) => {
349 expect(searchSettings(query).some((item) => item.to === "/settings/snap-shot")).toBe(false);
350 });
351
352 it("routes browser recording quality to integrations", () => {
353 const result = searchSettings("recording frame rate")[0];
354 expect(result).toMatchObject({
355 id: "browser-recording-frame-rate",
356 to: "/settings/integrations",
357 });
358 expect(result).not.toHaveProperty("targetId");
359 });
360
361 it("routes where links open to integrations", () => {
362 expect(searchSettings("open links in")[0]).toMatchObject({
363 id: "browser-link-target",
364 to: "/settings/integrations",
365 });
366 expect(searchSettings("external links")[0]).toMatchObject({ id: "browser-link-target" });
367 });
368
369 it("finds the default browser profile action in the profiles list", () => {
370 expect(searchSettings("default profile")[0]).toMatchObject({
371 id: "browser-default-profile",
372 to: "/settings/integrations",
373 targetId: "browser-profiles",
374 });
375 });
376
377 it.each([
378 ["default model", "default-model", "/settings/general"],
379 ["new threads", "new-threads", "/settings/general"],
380 ["agent browser access", "agent-browser-access", "/settings/integrations"],
381 ["automatically pull", "automatic-pull", "/settings/source-control"],
382 ["actions", "project-actions", "/settings/projects"],
383 ["project overview", "project-overview", "/settings/projects"],
384 ])("routes %s to its owning category", (query, id, to) => {
385 expect(searchSettings(query)[0]).toMatchObject({ id, to });
386 });
387
388 it("keeps environment settings discoverable without a primary environment", () => {
389 const available = filterAvailableSettingsSearchItems({
390 hasCloudPublicConfig: false,
391 hasEnvironment: true,
392 hasProviderSettingsEnvironment: true,
393 hasMacProviderSettingsEnvironment: false,
394 canManageLocalBackend: false,
395 isWslSettingsRowVisible: false,
396 hasThreadAutoSettlement: true,
397 });
398 expect(searchSettings("writing style", available)[0]?.id).toBe("source-control-writing-style");
399 expect(searchSettings("auto-settle", available)).toHaveLength(3);
400 });
401});
402
403describe("settings search targets", () => {
404 it("requires an environment for the resolved telemetry export destination", () => {
405 const result = searchSettings("OpenTelemetry export").find(
406 (item) => item.id === "telemetry-export",
407 )!;
408 const target = getSettingsSearchTargetScope(result.targetId ?? result.id)!;
409 expect(isSettingsSearchScopeAvailable(target.scope, "all")).toBe(false);
410 expect(isSettingsSearchScopeAvailable(target.scope, "project")).toBe(false);
411 expect(isSettingsSearchScopeAvailable(target.scope, "environment")).toBe(true);
412 });
413 it.each([
414 "auto-settle-inactive-threads",
415 "auto-settle-merged-threads",
416 "days-before-auto-settle",
417 ])("retains the capability requirement for %s", (targetId) => {
418 expect(getSettingsSearchTargetScope(targetId)).toMatchObject({
419 scope: "project-defaults",
420 requiresThreadAutoSettlement: true,
421 });
422 });
423
424 it("treats device-local rows as reachable from every selection", () => {
425 const setting = getSettingsSearchTargetScope("time-format")!;
426 expect(setting).toEqual({ title: "Time format", scope: null });
427 expect(isSettingsSearchScopeAvailable(setting.scope, "project")).toBe(true);
428 expect(isSettingsSearchScopeAvailable(setting.scope, "all")).toBe(true);
429 expect(getSettingsSearchTargetScope("appearance")).toMatchObject({ scope: null });
430 expect(getSettingsSearchTargetScope("missing-setting")).toBeNull();
431 });
432
433 it.each(["all", "environment", "project", "checkout"] as const)(
434 "makes browser access editable at the %s scope",
435 (kind) => {
436 const setting = getSettingsSearchTargetScope("agent-browser-access")!;
437 expect(isSettingsSearchScopeAvailable(setting.scope, kind)).toBe(true);
438 expect(isSettingsSearchScopeAvailable(setting.scope, "unavailable")).toBe(false);
439 },
440 );
441
442 it("lets project-scopable rows resolve at every server-backed scope", () => {
443 const model = getSettingsSearchTargetScope("text-generation-model")!;
444 expect(isSettingsSearchScopeAvailable(model.scope, "all")).toBe(true);
445 expect(isSettingsSearchScopeAvailable(model.scope, "environment")).toBe(true);
446 expect(isSettingsSearchScopeAvailable(model.scope, "project")).toBe(true);
447 });
448
449 it("reaches source control discovery and git fetch interval from the default scope", () => {
450 for (const id of ["source-control", "git-fetch-interval"]) {
451 const item = getSettingsSearchTargetScope(id)!;
452 expect(isSettingsSearchScopeAvailable(item.scope, "all")).toBe(true);
453 expect(isSettingsSearchScopeAvailable(item.scope, "environment")).toBe(true);
454 expect(isSettingsSearchScopeAvailable(item.scope, "project")).toBe(false);
455 }
456 });
457
458 it("keeps environment-wide settings out of project scopes", () => {
459 const updates = getSettingsSearchTargetScope("provider-update-checks")!;
460 expect(updates.scope).toBe("environment-defaults");
461 expect(isSettingsSearchScopeAvailable(updates.scope, "environment")).toBe(true);
462 expect(isSettingsSearchScopeAvailable(updates.scope, "all")).toBe(true);
463 expect(isSettingsSearchScopeAvailable(updates.scope, "project")).toBe(false);
464 const streaming = getSettingsSearchTargetScope("response-streaming")!;
465 expect(streaming.scope).toBe("project-defaults");
466 expect(isSettingsSearchScopeAvailable(streaming.scope, "project")).toBe(true);
467 for (const id of ["legacy-plan-mode", "legacy-context-window-indicator", "legacy-sidebar"]) {
468 expect(getSettingsSearchTargetScope(id)!.scope).toBeNull();
469 }
470 });
471});
472
473describe("auto-settlement search availability", () => {
474 function environment(id: string, { connected = true, loaded = true, supported = true } = {}) {
475 return {
476 environmentId: EnvironmentId.make(id),
477 connection: { phase: connected ? ("connected" as const) : ("offline" as const) },
478 serverConfig: loaded
479 ? { environment: { capabilities: { threadAutoSettlement: supported } } }
480 : null,
481 };
482 }
483
484 const capable = environment("capable");
485 const unsupported = environment("unsupported", { supported: false });
486 const offline = environment("offline", { connected: false });
487 const loading = environment("loading", { loaded: false });
488 const environments = [capable, unsupported, offline, loading];
489
490 it("keeps results discoverable when one connected environment supports them", () => {
491 const availability = getThreadAutoSettlementSearchAvailability(environments);
492 expect(availability.eligibleEnvironmentIds).toEqual([capable.environmentId]);
493 const items = filterAvailableSettingsSearchItems({
494 hasCloudPublicConfig: false,
495 hasEnvironment: true,
496 hasProviderSettingsEnvironment: true,
497 hasMacProviderSettingsEnvironment: false,
498 canManageLocalBackend: false,
499 isWslSettingsRowVisible: false,
500 hasThreadAutoSettlement: availability.eligibleEnvironmentIds.length > 0,
501 });
502 expect(searchSettings("auto-settle", items).map((item) => item.id)).toEqual([
503 "auto-settle-inactive-threads",
504 "auto-settle-merged-threads",
505 "days-before-auto-settle",
506 ]);
507 });
508
509 it("offers only capable environments when an aggregate has mixed capabilities", () => {
510 expect(
511 getThreadAutoSettlementSearchAvailability(environments, {
512 kind: "all",
513 environmentIds: environments.map((entry) => entry.environmentId),
514 }),
515 ).toEqual({ eligibleEnvironmentIds: [capable.environmentId], isTargetAvailable: false });
516 });
517
518 it("allows a capable named environment regardless of other environments' capabilities", () => {
519 expect(
520 getThreadAutoSettlementSearchAvailability(environments, {
521 kind: "environment",
522 environmentIds: [capable.environmentId],
523 }).isTargetAvailable,
524 ).toBe(true);
525 });
526
527 it.each([unsupported, offline, loading])(
528 "does not render the target on $environmentId or silently fall back",
529 (selected) => {
530 expect(
531 getThreadAutoSettlementSearchAvailability(environments, {
532 kind: "environment",
533 environmentIds: [selected.environmentId],
534 }),
535 ).toEqual({ eligibleEnvironmentIds: [capable.environmentId], isTargetAvailable: false });
536 },
537 );
538
539 it("offers a capable environment instead of a dead target at an unavailable scope", () => {
540 expect(
541 getThreadAutoSettlementSearchAvailability(environments, {
542 kind: "unavailable",
543 environmentIds: [capable.environmentId],
544 }),
545 ).toEqual({ eligibleEnvironmentIds: [capable.environmentId], isTargetAvailable: false });
546 });
547
548 it("ignores offline and unloaded targets when all connected targets support the setting", () => {
549 const selected = [capable, offline, loading];
550 expect(
551 getThreadAutoSettlementSearchAvailability(selected, {
552 kind: "all",
553 environmentIds: selected.map((entry) => entry.environmentId),
554 }).isTargetAvailable,
555 ).toBe(true);
556 });
557
558 it("offers no unavailable environments when none can render the setting", () => {
559 const selected = [unsupported, offline, loading];
560 expect(
561 getThreadAutoSettlementSearchAvailability(selected, {
562 kind: "all",
563 environmentIds: selected.map((entry) => entry.environmentId),
564 }),
565 ).toEqual({ eligibleEnvironmentIds: [], isTargetAvailable: false });
566 expect(getThreadAutoSettlementSearchAvailability([]).eligibleEnvironmentIds).toEqual([]);
567 });
568});
569
570describe("settings sidebar scope", () => {
571 it("shows Overview only for project and checkout targets", () => {
572 expect(isSettingsOverviewVisible({})).toBe(false);
573 expect(isSettingsOverviewVisible({ machine: "remote" })).toBe(false);
574 expect(isSettingsOverviewVisible({ project: "project" })).toBe(true);
575 expect(isSettingsOverviewVisible({ project: "project", checkout: "checkout" })).toBe(true);
576 });
577});