1import { EnvironmentId, ProviderInstanceId, ScheduledTaskId } from "@t3tools/contracts";
2import {
3 createMemoryHistory,
4 createRootRoute,
5 createRoute,
6 createRouter,
7 redirect,
8} from "@tanstack/react-router";
9import { describe, expect, it } from "vite-plus/test";
10
11import { resolveSettingsScope } from "./settingsScope";
12import { retainSettingsScope, validateSettingsRouteSearch } from "./settingsScopeNavigation";
13
14import { validateScheduledTasksSearch } from "./scheduledTasksSettings.logic";
15
16const checkoutSearch = {
17 project: "repository:t3code",
18 machine: "remote-server",
19 checkout: "remote-server:/home/user/T3 Code",
20};
21
22function createSettingsRouter(initialEntry = "/settings/general") {
23 const root = createRootRoute();
24 const settings = createRoute({
25 getParentRoute: () => root,
26 path: "settings",
27 validateSearch: validateSettingsRouteSearch,
28 search: { middlewares: [retainSettingsScope] },
29 beforeLoad: ({ location }) => {
30 if (location.pathname === "/settings") {
31 throw redirect({ to: "/settings/general", replace: true });
32 }
33 },
34 });
35 const general = createRoute({ getParentRoute: () => settings, path: "general" });
36 const projects = createRoute({ getParentRoute: () => settings, path: "projects" });
37 const integrations = createRoute({ getParentRoute: () => settings, path: "integrations" });
38 const sourceControl = createRoute({ getParentRoute: () => settings, path: "source-control" });
39 const providers = createRoute({
40 getParentRoute: () => settings,
41 path: "providers",
42 validateSearch: (raw: Record<string, unknown>) => ({
43 ...(typeof raw.environmentId === "string" && raw.environmentId.trim()
44 ? { environmentId: EnvironmentId.make(raw.environmentId) }
45 : {}),
46 ...(typeof raw.instanceId === "string" && raw.instanceId.trim()
47 ? { instanceId: ProviderInstanceId.make(raw.instanceId) }
48 : {}),
49 }),
50 });
51 const scheduledTasks = createRoute({
52 getParentRoute: () => settings,
53 path: "scheduled-tasks",
54 validateSearch: validateScheduledTasksSearch,
55 });
56 const legacyProject = createRoute({
57 getParentRoute: () => root,
58 path: "projects/$projectKey",
59 beforeLoad: ({ params }) => {
60 throw redirect({
61 to: "/settings/projects",
62 search: { project: params.projectKey, machine: undefined },
63 replace: true,
64 });
65 },
66 });
67 return createRouter({
68 routeTree: root.addChildren([
69 settings.addChildren([
70 general,
71 projects,
72 integrations,
73 sourceControl,
74 providers,
75 scheduledTasks,
76 ]),
77 legacyProject,
78 ]),
79 history: createMemoryHistory({ initialEntries: [initialEntry] }),
80 });
81}
82
83describe("settings scope navigation", () => {
84 it("replaces the default scope with an explicit environment, then replaces it with a project", async () => {
85 const router = createSettingsRouter();
86 await router.load();
87 await router.navigate({
88 to: "/settings/general",
89 search: { machine: "remote-server" },
90 hash: "",
91 });
92 expect(router.state.location.search).toEqual({ machine: "remote-server" });
93 await router.navigate({ to: "/settings/projects", search: { project: "another-project" } });
94 expect(router.state.location.search).toEqual({ project: "another-project" });
95 });
96
97 it("clears a checkout when selecting all environments and all projects", async () => {
98 const router = createSettingsRouter();
99 await router.navigate({ to: "/settings/general", search: checkoutSearch, hash: "old-setting" });
100 // The scope selects send every axis explicitly so "all" does not read as "unchanged".
101 await router.navigate({
102 to: "/settings/general",
103 search: { project: undefined, machine: undefined, checkout: undefined },
104 hash: "",
105 });
106 expect(router.state.location.search).toEqual({});
107 expect(router.state.location.hash).toBe("");
108 });
109
110 it("preserves the checkout through category and settings-search navigation", async () => {
111 const router = createSettingsRouter();
112 await router.navigate({ to: "/settings/general", search: checkoutSearch, hash: "new-threads" });
113 await router.navigate({ to: "/settings/integrations", hash: "", replace: true });
114 expect(router.state.location.search).toEqual(checkoutSearch);
115 expect(router.state.location.hash).toBe("");
116 await router.navigate({ to: "/settings/source-control", hash: "source-control-writing-style" });
117 expect(router.state.location.search).toEqual(checkoutSearch);
118 expect(router.state.location.hash).toBe("source-control-writing-style");
119 await router.navigate({ to: "/settings/projects", hash: "project-defaults" });
120 expect(router.state.location.search).toEqual(checkoutSearch);
121 });
122
123 it.each([
124 "/settings/projects",
125 "/settings/integrations",
126 "/settings/source-control",
127 "/settings/scheduled-tasks",
128 ] as const)(
129 "keeps %s when regrouping or selecting a target from the shared settings layout",
130 async (to) => {
131 const router = createSettingsRouter();
132 await router.navigate({ to, search: checkoutSearch });
133
134 const regroupedCheckout = { ...checkoutSearch, project: "separate:t3code" };
135 await router.navigate({
136 from: "/settings",
137 to: router.state.location.pathname,
138 search: () => regroupedCheckout,
139 replace: true,
140 hashScrollIntoView: false,
141 });
142 expect(router.state.location.pathname).toBe(to);
143 expect(router.state.location.search).toEqual(regroupedCheckout);
144 expect(router.state.redirect).toBeUndefined();
145
146 await router.navigate({
147 from: "/settings",
148 to: router.state.location.pathname,
149 search: () => ({ machine: "another-server" }),
150 hash: "",
151 resetScroll: false,
152 });
153 expect(router.state.location.pathname).toBe(to);
154 expect(router.state.location.search).toEqual({ machine: "another-server" });
155 expect(router.state.location.hash).toBe("");
156
157 const selectedCheckout = {
158 project: "another-project",
159 machine: "another-server",
160 checkout: "another-server:/home/user/Another checkout",
161 };
162 await router.navigate({
163 from: "/settings",
164 to: router.state.location.pathname,
165 search: () => selectedCheckout,
166 hash: "requested-setting",
167 });
168 expect(router.state.location.pathname).toBe(to);
169 expect(router.state.location.search).toEqual(selectedCheckout);
170 expect(router.state.location.hash).toBe("requested-setting");
171 },
172 );
173
174 it("honors an explicit provider environment and drops its instance on category navigation", async () => {
175 const router = createSettingsRouter();
176 await router.navigate({ to: "/settings/general", search: checkoutSearch });
177 await router.navigate({
178 to: "/settings/providers",
179 search: {
180 environmentId: EnvironmentId.make("provider-server"),
181 instanceId: ProviderInstanceId.make("codex-work"),
182 },
183 });
184 expect(router.state.location.search).toEqual({
185 machine: "provider-server",
186 environmentId: "provider-server",
187 instanceId: "codex-work",
188 });
189 await router.navigate({ to: "/settings/general", hash: "" });
190 expect(router.state.location.search).toEqual({ machine: "provider-server" });
191 });
192
193 it("preserves the environment from an initially loaded legacy provider URL", async () => {
194 const router = createSettingsRouter(
195 "/settings/providers?environmentId=provider-server&instanceId=codex-work",
196 );
197 await router.load();
198 await router.navigate({ to: "/settings/general" });
199 expect(router.state.location.search).toEqual({ machine: "provider-server" });
200 });
201
202 it("retains an explicit unavailable target rather than reviving the previous environment", async () => {
203 const router = createSettingsRouter();
204 await router.navigate({ to: "/settings/general", search: { machine: "online" } });
205 await router.navigate({ to: "/settings/general", search: { machine: "removed" } });
206 expect(router.state.location.search).toEqual({ machine: "removed" });
207 expect(
208 resolveSettingsScope(
209 router.state.location.search,
210 [],
211 [{ environmentId: EnvironmentId.make("online"), label: "Online" }],
212 ),
213 ).toMatchObject({
214 kind: "unavailable",
215 reason: "environment-missing",
216 environmentIds: [],
217 });
218 });
219
220 it("respects explicit clearing keys instead of restoring the previous checkout", async () => {
221 const router = createSettingsRouter();
222 await router.navigate({ to: "/settings/general", search: checkoutSearch });
223 await router.navigate({
224 to: "/settings/projects",
225 search: { project: "different-project", machine: undefined },
226 });
227 expect(router.state.location.search).toEqual({ project: "different-project" });
228 });
229
230 it("preserves escaped checkout identifiers across reload and browser history", async () => {
231 const router = createSettingsRouter();
232 await router.navigate({ to: "/settings/general", search: checkoutSearch });
233 const checkoutHref = router.state.location.href;
234 await router.navigate({ to: "/settings/general", search: { machine: "another" } });
235 router.history.back();
236 await router.load();
237 expect(router.state.location.search).toEqual(checkoutSearch);
238 const reloaded = createSettingsRouter(checkoutHref);
239 await reloaded.load();
240 expect(reloaded.state.location.search).toEqual(checkoutSearch);
241 await reloaded.navigate({ to: "/settings/integrations", hash: "agent-browser-access" });
242 expect(reloaded.state.location.search).toEqual(checkoutSearch);
243 });
244
245 it("redirects legacy project links without carrying the prior checkout scope", async () => {
246 const router = createSettingsRouter();
247 await router.navigate({ to: "/settings/general", search: checkoutSearch });
248 await router.navigate({
249 to: "/projects/$projectKey",
250 params: { projectKey: "legacy-project" },
251 });
252 expect(router.state.redirect).not.toBeUndefined();
253 await router.navigate(router.state.redirect!.options);
254 expect(router.state.location.pathname).toBe("/settings/projects");
255 expect(router.state.location.search).toEqual({ project: "legacy-project" });
256 });
257
258 it("keeps scope through the settings index redirect", async () => {
259 const router = createSettingsRouter();
260 await router.navigate({ to: "/settings", search: { machine: "remote-server" } });
261 expect(router.state.redirect).not.toBeUndefined();
262 await router.navigate(router.state.redirect!.options);
263 expect(router.state.location.pathname).toBe("/settings/general");
264 expect(router.state.location.search).toEqual({ machine: "remote-server" });
265 });
266});
267
268describe("scheduled task scope navigation", () => {
269 it("opens a task link on its owning environment, then clears the task when changing filters", async () => {
270 const router = createSettingsRouter();
271 await router.navigate({
272 to: "/settings/scheduled-tasks",
273 search: {
274 environmentId: EnvironmentId.make("remote-server"),
275 taskId: ScheduledTaskId.make("task-1"),
276 },
277 });
278 expect(router.state.matches.at(-1)?.search).toEqual({
279 machine: "remote-server",
280 environmentId: "remote-server",
281 taskId: "task-1",
282 });
283 await router.navigate({
284 from: "/settings",
285 to: router.state.location.pathname,
286 search: () => ({ machine: undefined, project: undefined, checkout: undefined }),
287 });
288 expect(router.state.matches.at(-1)?.search).toEqual({});
289 });
290
291 it("keeps the project and checkout filters when entering scheduled tasks", async () => {
292 const router = createSettingsRouter();
293 await router.navigate({ to: "/settings/projects", search: checkoutSearch });
294 await router.navigate({ to: "/settings/scheduled-tasks" });
295 expect(router.state.matches.at(-1)?.search).toEqual(checkoutSearch);
296 });
297});