| 1 | import { describe, expect, it, vi } from "vite-plus/test"; |
| 2 | import type { RuntimeMode } from "@t3tools/contracts"; |
| 3 | |
| 4 | const testState = vi.hoisted(() => { |
| 5 | let completeProjectFileRead: (value: null) => void = () => undefined; |
| 6 | let projectFileRead = Promise.resolve<null>(null); |
| 7 | let projectRemoved = false; |
| 8 | let targetSettings = { |
| 9 | defaultThreadEnvMode: "local" as "local" | "worktree", |
| 10 | newWorktreesStartFromOrigin: false, |
| 11 | defaultModelSelection: null, |
| 12 | defaultRuntimeMode: "full-access" as RuntimeMode, |
| 13 | }; |
| 14 | let storedDraft: { |
| 15 | readonly draftId: string; |
| 16 | readonly environmentId: string; |
| 17 | readonly promotedTo: null; |
| 18 | readonly threadId: string; |
| 19 | } | null = null; |
| 20 | const router = { |
| 21 | state: { |
| 22 | location: { href: "/" }, |
| 23 | matches: [{ params: {} }], |
| 24 | }, |
| 25 | navigate: vi.fn(async (request: { readonly params: { readonly draftId: string } }) => { |
| 26 | router.state.location.href = `/draft/${request.params.draftId}`; |
| 27 | }), |
| 28 | }; |
| 29 | const draftStore = { |
| 30 | getComposerDraft: vi.fn(() => ({})), |
| 31 | getDraftSessionByLogicalProjectKey: vi.fn(() => storedDraft), |
| 32 | getDraftSession: vi.fn(() => null), |
| 33 | getDraftThread: vi.fn(() => null), |
| 34 | applyStickyState: vi.fn(), |
| 35 | setDraftThreadContext: vi.fn(), |
| 36 | setLogicalProjectDraftThreadId: vi.fn(), |
| 37 | setModelSelection: vi.fn(), |
| 38 | }; |
| 39 | |
| 40 | return { |
| 41 | completeProjectFileRead: (value: null) => completeProjectFileRead(value), |
| 42 | draftStore, |
| 43 | get projectFileRead() { |
| 44 | return projectFileRead; |
| 45 | }, |
| 46 | get projectRemoved() { |
| 47 | return projectRemoved; |
| 48 | }, |
| 49 | removeProject() { |
| 50 | projectRemoved = true; |
| 51 | }, |
| 52 | get targetSettings() { |
| 53 | return targetSettings; |
| 54 | }, |
| 55 | reset( |
| 56 | nextStoredDraft: typeof storedDraft, |
| 57 | workspaceDefaults = { |
| 58 | envMode: "local" as "local" | "worktree", |
| 59 | startFromOrigin: false, |
| 60 | }, |
| 61 | ) { |
| 62 | storedDraft = nextStoredDraft; |
| 63 | projectRemoved = false; |
| 64 | targetSettings = { |
| 65 | defaultThreadEnvMode: workspaceDefaults.envMode, |
| 66 | newWorktreesStartFromOrigin: workspaceDefaults.startFromOrigin, |
| 67 | defaultModelSelection: null, |
| 68 | defaultRuntimeMode: "full-access", |
| 69 | }; |
| 70 | router.state.location.href = "/"; |
| 71 | router.navigate.mockClear(); |
| 72 | draftStore.setDraftThreadContext.mockClear(); |
| 73 | draftStore.setLogicalProjectDraftThreadId.mockClear(); |
| 74 | projectFileRead = new Promise<null>((resolve) => { |
| 75 | completeProjectFileRead = resolve; |
| 76 | }); |
| 77 | }, |
| 78 | router, |
| 79 | }; |
| 80 | }); |
| 81 | |
| 82 | vi.mock("@effect/atom-react", () => ({ |
| 83 | useAtomValue: (atom: unknown) => |
| 84 | atom === "primary-settings" |
| 85 | ? { newWorktreesStartFromOrigin: !testState.targetSettings.newWorktreesStartFromOrigin } |
| 86 | : new Map([ |
| 87 | [ |
| 88 | "environment-primary", |
| 89 | { |
| 90 | settings: { |
| 91 | ...testState.targetSettings, |
| 92 | newWorktreesStartFromOrigin: !testState.targetSettings.newWorktreesStartFromOrigin, |
| 93 | }, |
| 94 | }, |
| 95 | ], |
| 96 | ["environment-ssh", { settings: testState.targetSettings }], |
| 97 | ]), |
| 98 | })); |
| 99 | vi.mock("@t3tools/client-runtime/environment", () => ({ |
| 100 | scopedProjectKey: () => "remote-project", |
| 101 | scopeProjectRef: (environmentId: string, projectId: string) => ({ environmentId, projectId }), |
| 102 | scopeThreadRef: (environmentId: string, threadId: string) => ({ environmentId, threadId }), |
| 103 | })); |
| 104 | vi.mock("@t3tools/contracts", () => ({ |
| 105 | DEFAULT_RUNTIME_MODE: "default", |
| 106 | DEFAULT_SERVER_SETTINGS: {}, |
| 107 | })); |
| 108 | vi.mock("@t3tools/shared/projectSettings", () => ({ |
| 109 | // Environment settings pass through; the tests set project fields on the |
| 110 | // project record, which the hook still honors until the server folds them. |
| 111 | // With a file argument the env mode resolves like the real chain. |
| 112 | resolveProjectSettings: ( |
| 113 | settings: Record<string, unknown>, |
| 114 | _projectId: unknown, |
| 115 | _project: unknown, |
| 116 | projectFile?: { defaultThreadEnvMode?: "local" | "worktree" } | null, |
| 117 | ) => ({ |
| 118 | settings: |
| 119 | projectFile === undefined |
| 120 | ? settings |
| 121 | : { |
| 122 | ...settings, |
| 123 | defaultThreadEnvMode: |
| 124 | settings.defaultThreadEnvMode ?? projectFile?.defaultThreadEnvMode ?? "local", |
| 125 | }, |
| 126 | sources: { defaultModelSelection: "environment", defaultThreadEnvMode: "environment" }, |
| 127 | overrides: {}, |
| 128 | }), |
| 129 | // The new-project rule has its own tests in projectSettings.test.ts. |
| 130 | resolveNewThreadEnvMode: (input: { |
| 131 | projectSettings: { settings: { defaultThreadEnvMode: "local" | "worktree" } }; |
| 132 | }) => input.projectSettings.settings.defaultThreadEnvMode, |
| 133 | })); |
| 134 | vi.mock("@tanstack/react-router", () => ({ |
| 135 | useParams: () => null, |
| 136 | useRouter: () => testState.router, |
| 137 | })); |
| 138 | vi.mock("react", () => ({ |
| 139 | useCallback: <T>(callback: T) => callback, |
| 140 | useMemo: <T>(factory: () => T) => factory(), |
| 141 | })); |
| 142 | vi.mock("../components/Sidebar.logic", () => ({ orderItemsByPreferredIds: () => [] })); |
| 143 | vi.mock("../composerDraftStore", () => { |
| 144 | const useComposerDraftStore = Object.assign(() => null, { |
| 145 | getState: () => testState.draftStore, |
| 146 | }); |
| 147 | return { |
| 148 | composerDraftHasUserContent: () => false, |
| 149 | markPromotedDraftThreadByRef: vi.fn(), |
| 150 | useComposerDraftStore, |
| 151 | }; |
| 152 | }); |
| 153 | vi.mock("../lib/chatThreadActions", async (importOriginal) => ({ |
| 154 | ...(await importOriginal<typeof import("../lib/chatThreadActions")>()), |
| 155 | hasExplicitComposerModelSelection: () => false, |
| 156 | resolveNewThreadModelSelectionOverride: () => null, |
| 157 | })); |
| 158 | vi.mock("../lib/t3ProjectFileDefaults", () => ({ |
| 159 | readT3ProjectFile: () => testState.projectFileRead, |
| 160 | })); |
| 161 | vi.mock("../lib/utils", () => ({ |
| 162 | newDraftId: () => "draft-delayed", |
| 163 | newThreadId: () => "thread-delayed", |
| 164 | })); |
| 165 | vi.mock("../logicalProject", () => ({ |
| 166 | deriveLogicalProjectKeyFromSettings: () => "remote-project", |
| 167 | getProjectOrderKey: () => "remote-project", |
| 168 | selectProjectGroupingSettings: () => ({}), |
| 169 | })); |
| 170 | vi.mock("../state/entities", () => { |
| 171 | const project = { |
| 172 | id: "project-remote", |
| 173 | environmentId: "environment-ssh", |
| 174 | workspaceRoot: "/remote/project", |
| 175 | defaultThreadEnvMode: null, |
| 176 | defaultModelSelection: null, |
| 177 | }; |
| 178 | return { |
| 179 | readProject: () => (testState.projectRemoved ? null : project), |
| 180 | readProjects: () => (testState.projectRemoved ? [] : [project]), |
| 181 | readThreadShell: () => null, |
| 182 | readThreadShells: () => [], |
| 183 | useProjects: () => [], |
| 184 | useThread: () => null, |
| 185 | }; |
| 186 | }); |
| 187 | vi.mock("../state/server", () => ({ |
| 188 | environmentServerConfigsAtom: {}, |
| 189 | primaryServerSettingsAtom: "primary-settings", |
| 190 | })); |
| 191 | vi.mock("../threadRoutes", () => ({ resolveThreadRouteTarget: () => null })); |
| 192 | vi.mock("../uiStateStore", () => ({ |
| 193 | legacyProjectCwdPreferenceKey: () => "remote-project", |
| 194 | useUiStateStore: () => [], |
| 195 | })); |
| 196 | vi.mock("./useSettings", () => ({ useClientSettings: () => ({}) })); |
| 197 | |
| 198 | import { useNewThreadHandler } from "./useHandleNewThread"; |
| 199 | |
| 200 | describe.each([ |
| 201 | ["new", null], |
| 202 | [ |
| 203 | "reusable", |
| 204 | { |
| 205 | draftId: "draft-existing", |
| 206 | environmentId: "environment-ssh", |
| 207 | promotedTo: null, |
| 208 | threadId: "thread-existing", |
| 209 | }, |
| 210 | ], |
| 211 | ])("useNewThreadHandler with a %s draft", (_, draft) => { |
| 212 | it.each(["approval-required", "auto-accept-edits", "auto", "full-access"] as const)( |
| 213 | "uses the target environment's %s permissions for new threads", |
| 214 | async (runtimeMode) => { |
| 215 | testState.reset(draft); |
| 216 | testState.targetSettings.defaultRuntimeMode = runtimeMode; |
| 217 | const projectRef = { |
| 218 | environmentId: "environment-ssh", |
| 219 | projectId: "project-remote", |
| 220 | } as never; |
| 221 | const pendingOpen = useNewThreadHandler()(projectRef); |
| 222 | testState.completeProjectFileRead(null); |
| 223 | const opened = await pendingOpen; |
| 224 | |
| 225 | expect(testState.draftStore.setLogicalProjectDraftThreadId).toHaveBeenCalledWith( |
| 226 | "remote-project", |
| 227 | projectRef, |
| 228 | opened!.draftId, |
| 229 | expect.objectContaining({ runtimeMode }), |
| 230 | ); |
| 231 | }, |
| 232 | ); |
| 233 | |
| 234 | it("abandons a delayed draft open when the user navigates elsewhere", async () => { |
| 235 | testState.reset(draft); |
| 236 | const openThread = useNewThreadHandler(); |
| 237 | const pendingOpen = openThread( |
| 238 | { environmentId: "environment-ssh", projectId: "project-remote" } as never, |
| 239 | { replace: true }, |
| 240 | ); |
| 241 | |
| 242 | testState.router.state.location.href = "/usage"; |
| 243 | testState.completeProjectFileRead(null); |
| 244 | await pendingOpen; |
| 245 | |
| 246 | expect(testState.router.state.location.href).toBe("/usage"); |
| 247 | expect(testState.router.navigate).not.toHaveBeenCalled(); |
| 248 | expect(testState.draftStore.setLogicalProjectDraftThreadId).not.toHaveBeenCalled(); |
| 249 | }); |
| 250 | |
| 251 | it("abandons a delayed draft open when the project is removed meanwhile", async () => { |
| 252 | testState.reset(draft); |
| 253 | const pendingOpen = useNewThreadHandler()( |
| 254 | { environmentId: "environment-ssh", projectId: "project-remote" } as never, |
| 255 | { replace: true }, |
| 256 | ); |
| 257 | |
| 258 | testState.removeProject(); |
| 259 | testState.completeProjectFileRead(null); |
| 260 | |
| 261 | await expect(pendingOpen).resolves.toBeNull(); |
| 262 | expect(testState.router.navigate).not.toHaveBeenCalled(); |
| 263 | expect(testState.draftStore.setLogicalProjectDraftThreadId).not.toHaveBeenCalled(); |
| 264 | }); |
| 265 | |
| 266 | it.each([true, false])( |
| 267 | "uses the target environment's start-from-origin default of %s", |
| 268 | async (startFromOrigin) => { |
| 269 | testState.reset(draft, { envMode: "worktree", startFromOrigin }); |
| 270 | const openThread = useNewThreadHandler(); |
| 271 | const projectRef = { |
| 272 | environmentId: "environment-ssh", |
| 273 | projectId: "project-remote", |
| 274 | } as never; |
| 275 | const pendingOpen = openThread(projectRef); |
| 276 | |
| 277 | testState.completeProjectFileRead(null); |
| 278 | const opened = await pendingOpen; |
| 279 | |
| 280 | expect(opened).toEqual({ |
| 281 | draftId: draft?.draftId ?? "draft-delayed", |
| 282 | threadId: draft?.threadId ?? "thread-delayed", |
| 283 | }); |
| 284 | expect(testState.draftStore.setLogicalProjectDraftThreadId).toHaveBeenCalledWith( |
| 285 | "remote-project", |
| 286 | projectRef, |
| 287 | opened!.draftId, |
| 288 | expect.objectContaining({ envMode: "worktree", startFromOrigin }), |
| 289 | ); |
| 290 | if (draft) { |
| 291 | expect(testState.draftStore.setDraftThreadContext).toHaveBeenCalledWith( |
| 292 | draft.draftId, |
| 293 | expect.objectContaining({ envMode: "worktree", startFromOrigin }), |
| 294 | ); |
| 295 | } |
| 296 | }, |
| 297 | ); |
| 298 | |
| 299 | it.each([true, false])( |
| 300 | "preserves an explicit start-from-origin choice of %s", |
| 301 | async (startFromOrigin) => { |
| 302 | testState.reset(draft, { envMode: "worktree", startFromOrigin: !startFromOrigin }); |
| 303 | const openThread = useNewThreadHandler(); |
| 304 | const projectRef = { |
| 305 | environmentId: "environment-ssh", |
| 306 | projectId: "project-remote", |
| 307 | } as never; |
| 308 | |
| 309 | const opened = await openThread(projectRef, { envMode: "worktree", startFromOrigin }); |
| 310 | |
| 311 | expect(testState.draftStore.setLogicalProjectDraftThreadId).toHaveBeenCalledWith( |
| 312 | "remote-project", |
| 313 | projectRef, |
| 314 | opened!.draftId, |
| 315 | expect.objectContaining({ envMode: "worktree", startFromOrigin }), |
| 316 | ); |
| 317 | }, |
| 318 | ); |
| 319 | }); |