| 1 | import { EnvironmentId, ProviderInstanceId, ScheduledTaskId } from "@t3tools/contracts"; |
| 2 | import { |
| 3 | createMemoryHistory, |
| 4 | createRootRoute, |
| 5 | createRoute, |
| 6 | createRouter, |
| 7 | redirect, |
| 8 | } from "@tanstack/react-router"; |
| 9 | import { describe, expect, it } from "vite-plus/test"; |
| 10 | |
| 11 | import { resolveSettingsScope } from "./settingsScope"; |
| 12 | import { retainSettingsScope, validateSettingsRouteSearch } from "./settingsScopeNavigation"; |
| 13 | |
| 14 | import { validateScheduledTasksSearch } from "./scheduledTasksSettings.logic"; |
| 15 | |
| 16 | const checkoutSearch = { |
| 17 | project: "repository:t3code", |
| 18 | machine: "remote-server", |
| 19 | checkout: "remote-server:/home/user/T3 Code", |
| 20 | }; |
| 21 | |
| 22 | function 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 | |
| 83 | describe("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 | |
| 268 | describe("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 | }); |