| 1 | import { EnvironmentId, ProjectId, type AgentSessionProjectCandidate } from "@t3tools/contracts"; |
| 2 | import { describe, expect, it } from "vite-plus/test"; |
| 3 | |
| 4 | import { |
| 5 | groupOnboardingProjects, |
| 6 | partitionOnboardingProjects, |
| 7 | onboardingProjectKey, |
| 8 | resolveOnboardingLandingProject, |
| 9 | resolveOnboardingProjectId, |
| 10 | splitOnboardingProjectPath, |
| 11 | } from "./projectImport.logic"; |
| 12 | |
| 13 | const now = Date.parse("2026-08-22T12:00:00.000Z"); |
| 14 | |
| 15 | function candidate( |
| 16 | path: string, |
| 17 | overrides: Partial<AgentSessionProjectCandidate> = {}, |
| 18 | ): AgentSessionProjectCandidate { |
| 19 | return { |
| 20 | title: path.split("/").at(-1) ?? path, |
| 21 | path, |
| 22 | sources: ["codex"], |
| 23 | threadCount: 3, |
| 24 | lastActiveAt: "2026-08-20T12:00:00.000Z", |
| 25 | alreadyImported: false, |
| 26 | git: { remoteKey: null, repository: null }, |
| 27 | ...overrides, |
| 28 | }; |
| 29 | } |
| 30 | |
| 31 | const github = (repository: string) => ({ |
| 32 | remoteKey: `github.com/${repository.toLowerCase()}`, |
| 33 | repository, |
| 34 | }); |
| 35 | |
| 36 | describe("partitionOnboardingProjects", () => { |
| 37 | it("keeps existing projects available for thread history import", () => { |
| 38 | const imported = candidate("/projects/current", { alreadyImported: true }); |
| 39 | const available = candidate("/projects/other"); |
| 40 | |
| 41 | expect(partitionOnboardingProjects([imported, available], now)).toEqual({ |
| 42 | available: [imported, available], |
| 43 | recent: [imported, available], |
| 44 | }); |
| 45 | }); |
| 46 | |
| 47 | it("keeps projects older than 30 days out of the default selection", () => { |
| 48 | const recent = candidate("/projects/recent"); |
| 49 | const older = candidate("/projects/older", { |
| 50 | lastActiveAt: "2026-07-01T12:00:00.000Z", |
| 51 | }); |
| 52 | |
| 53 | expect(partitionOnboardingProjects([recent, older], now)).toEqual({ |
| 54 | available: [recent, older], |
| 55 | recent: [recent], |
| 56 | }); |
| 57 | }); |
| 58 | |
| 59 | it("keeps future activity out of the default selection", () => { |
| 60 | const recent = candidate("/projects/recent"); |
| 61 | const future = candidate("/projects/future", { |
| 62 | lastActiveAt: "2026-08-23T12:00:00.000Z", |
| 63 | }); |
| 64 | |
| 65 | expect(partitionOnboardingProjects([recent, future], now)).toEqual({ |
| 66 | available: [recent, future], |
| 67 | recent: [recent], |
| 68 | }); |
| 69 | }); |
| 70 | |
| 71 | it("keeps non-git folders and thin histories out of the default selection", () => { |
| 72 | const repo = candidate("/projects/repo"); |
| 73 | const folder = candidate("/projects/folder", { git: null }); |
| 74 | const thin = candidate("/projects/thin", { threadCount: 2 }); |
| 75 | |
| 76 | expect(partitionOnboardingProjects([repo, folder, thin], now).recent).toEqual([repo]); |
| 77 | }); |
| 78 | |
| 79 | it("selects candidates from servers that do not report git identity", () => { |
| 80 | const { git: _git, ...legacy } = candidate("/projects/legacy"); |
| 81 | |
| 82 | expect(partitionOnboardingProjects([legacy], now).recent).toEqual([legacy]); |
| 83 | }); |
| 84 | }); |
| 85 | |
| 86 | describe("groupOnboardingProjects", () => { |
| 87 | it("groups clones by origin, keeps local repos separate, and folds non-git folders away", () => { |
| 88 | const main = candidate("/code/t3code", { |
| 89 | git: github("pingdotgg/t3code"), |
| 90 | threadCount: 79, |
| 91 | lastActiveAt: "2026-08-21T12:00:00.000Z", |
| 92 | }); |
| 93 | const clone = candidate("/code/clones/t3code-2", { |
| 94 | git: github("pingdotgg/t3code"), |
| 95 | threadCount: 13, |
| 96 | lastActiveAt: "2026-08-10T12:00:00.000Z", |
| 97 | }); |
| 98 | const older = candidate("/code/fleet", { |
| 99 | git: github("t3dotgg/fleet"), |
| 100 | threadCount: 295, |
| 101 | lastActiveAt: "2026-08-22T00:00:00.000Z", |
| 102 | }); |
| 103 | const local = candidate("/code/scratch-repo", { title: "scratch-repo" }); |
| 104 | const folder = candidate("/tmp/notes", { git: null }); |
| 105 | |
| 106 | const grouped = groupOnboardingProjects([main, clone, older, local, folder]); |
| 107 | |
| 108 | expect(grouped.other).toEqual([folder]); |
| 109 | expect( |
| 110 | grouped.repositories.map((group) => ({ |
| 111 | label: group.label, |
| 112 | paths: group.candidates.map((item) => item.path), |
| 113 | threadCount: group.threadCount, |
| 114 | lastActiveAt: group.lastActiveAt, |
| 115 | })), |
| 116 | ).toEqual([ |
| 117 | { |
| 118 | label: "t3dotgg/fleet", |
| 119 | paths: ["/code/fleet"], |
| 120 | threadCount: 295, |
| 121 | lastActiveAt: "2026-08-22T00:00:00.000Z", |
| 122 | }, |
| 123 | { |
| 124 | label: "pingdotgg/t3code", |
| 125 | paths: ["/code/t3code", "/code/clones/t3code-2"], |
| 126 | threadCount: 92, |
| 127 | lastActiveAt: "2026-08-21T12:00:00.000Z", |
| 128 | }, |
| 129 | { |
| 130 | label: "scratch-repo", |
| 131 | paths: ["/code/scratch-repo"], |
| 132 | threadCount: 3, |
| 133 | lastActiveAt: "2026-08-20T12:00:00.000Z", |
| 134 | }, |
| 135 | ]); |
| 136 | }); |
| 137 | |
| 138 | it("lists candidates without git identity as standalone repositories", () => { |
| 139 | const { git: _git, ...legacy } = candidate("/code/legacy", { title: "legacy" }); |
| 140 | |
| 141 | const grouped = groupOnboardingProjects([legacy]); |
| 142 | |
| 143 | expect(grouped.other).toEqual([]); |
| 144 | expect(grouped.repositories.map((group) => [group.label, group.repository])).toEqual([ |
| 145 | ["legacy", null], |
| 146 | ]); |
| 147 | }); |
| 148 | }); |
| 149 | |
| 150 | describe("resolveOnboardingProjectId", () => { |
| 151 | const localEnvironmentId = EnvironmentId.make("local"); |
| 152 | const remoteEnvironmentId = EnvironmentId.make("remote"); |
| 153 | const localProjectId = ProjectId.make("local-project"); |
| 154 | |
| 155 | it("uses the scanned project ID before the project reaches the client", () => { |
| 156 | expect( |
| 157 | resolveOnboardingProjectId( |
| 158 | [], |
| 159 | localEnvironmentId, |
| 160 | candidate("/projects/repo", { projectId: localProjectId }), |
| 161 | ), |
| 162 | ).toBe(localProjectId); |
| 163 | }); |
| 164 | |
| 165 | it("uses the scanned project ID when the client still has an older project at that root", () => { |
| 166 | expect( |
| 167 | resolveOnboardingProjectId( |
| 168 | [ |
| 169 | { |
| 170 | id: ProjectId.make("stale-project"), |
| 171 | environmentId: localEnvironmentId, |
| 172 | workspaceRoot: "/projects/repo", |
| 173 | }, |
| 174 | ], |
| 175 | localEnvironmentId, |
| 176 | candidate("/projects/repo", { projectId: localProjectId }), |
| 177 | ), |
| 178 | ).toBe(localProjectId); |
| 179 | }); |
| 180 | |
| 181 | it("returns null to create a project when neither the scan nor the client has a project ID", () => { |
| 182 | expect( |
| 183 | resolveOnboardingProjectId([], localEnvironmentId, candidate("/projects/new")), |
| 184 | ).toBeNull(); |
| 185 | }); |
| 186 | |
| 187 | it("finds an existing project by normalized root in the target environment", () => { |
| 188 | expect( |
| 189 | resolveOnboardingProjectId( |
| 190 | [ |
| 191 | { |
| 192 | id: ProjectId.make("remote-project"), |
| 193 | environmentId: remoteEnvironmentId, |
| 194 | workspaceRoot: "C:\\Work\\Repo", |
| 195 | }, |
| 196 | { |
| 197 | id: localProjectId, |
| 198 | environmentId: localEnvironmentId, |
| 199 | workspaceRoot: "C:\\Work\\Repo\\", |
| 200 | }, |
| 201 | ], |
| 202 | localEnvironmentId, |
| 203 | candidate("c:/work/repo"), |
| 204 | ), |
| 205 | ).toBe(localProjectId); |
| 206 | }); |
| 207 | |
| 208 | it("does not reuse a project from another environment", () => { |
| 209 | expect( |
| 210 | resolveOnboardingProjectId( |
| 211 | [ |
| 212 | { |
| 213 | id: ProjectId.make("remote-project"), |
| 214 | environmentId: remoteEnvironmentId, |
| 215 | workspaceRoot: "/projects/repo", |
| 216 | }, |
| 217 | ], |
| 218 | localEnvironmentId, |
| 219 | candidate("/projects/repo"), |
| 220 | ), |
| 221 | ).toBeNull(); |
| 222 | }); |
| 223 | |
| 224 | it("finds an alias after the scanner returns its persisted project root", () => { |
| 225 | expect( |
| 226 | resolveOnboardingProjectId( |
| 227 | [ |
| 228 | { |
| 229 | id: localProjectId, |
| 230 | environmentId: localEnvironmentId, |
| 231 | workspaceRoot: "/real/projects/repo", |
| 232 | }, |
| 233 | ], |
| 234 | localEnvironmentId, |
| 235 | candidate("/real/projects/repo"), |
| 236 | ), |
| 237 | ).toBe(localProjectId); |
| 238 | }); |
| 239 | |
| 240 | it("finds the current root owner when the scan has no project ID", () => { |
| 241 | const recreatedProjectId = ProjectId.make("recreated-project"); |
| 242 | expect( |
| 243 | resolveOnboardingProjectId( |
| 244 | [ |
| 245 | { |
| 246 | id: localProjectId, |
| 247 | environmentId: localEnvironmentId, |
| 248 | workspaceRoot: "/projects/other", |
| 249 | }, |
| 250 | { |
| 251 | id: recreatedProjectId, |
| 252 | environmentId: localEnvironmentId, |
| 253 | workspaceRoot: "/projects/repo", |
| 254 | }, |
| 255 | ], |
| 256 | localEnvironmentId, |
| 257 | candidate("/projects/repo"), |
| 258 | ), |
| 259 | ).toBe(recreatedProjectId); |
| 260 | }); |
| 261 | |
| 262 | it("does not reuse a moved project when the scan has no project ID", () => { |
| 263 | expect( |
| 264 | resolveOnboardingProjectId( |
| 265 | [ |
| 266 | { |
| 267 | id: localProjectId, |
| 268 | environmentId: localEnvironmentId, |
| 269 | workspaceRoot: "/projects/moved", |
| 270 | }, |
| 271 | ], |
| 272 | localEnvironmentId, |
| 273 | candidate("/projects/repo"), |
| 274 | ), |
| 275 | ).toBeNull(); |
| 276 | }); |
| 277 | }); |
| 278 | |
| 279 | describe("resolveOnboardingLandingProject", () => { |
| 280 | it("skips a failed first project for a later project with imported history", () => { |
| 281 | expect( |
| 282 | resolveOnboardingLandingProject( |
| 283 | ["/projects/failed", "/projects/imported"], |
| 284 | new Map([["/projects/imported", "imported"]]), |
| 285 | new Map([["/projects/imported", "imported"]]), |
| 286 | ), |
| 287 | ).toBe("imported"); |
| 288 | }); |
| 289 | |
| 290 | it("prefers a partial first import that added history", () => { |
| 291 | expect( |
| 292 | resolveOnboardingLandingProject( |
| 293 | ["/projects/partial", "/projects/complete"], |
| 294 | new Map([["/projects/partial", "partial"]]), |
| 295 | new Map([["/projects/complete", "complete"]]), |
| 296 | ), |
| 297 | ).toBe("partial"); |
| 298 | }); |
| 299 | |
| 300 | it("uses a completed zero-history project when no import added history", () => { |
| 301 | expect( |
| 302 | resolveOnboardingLandingProject( |
| 303 | ["/projects/empty", "/projects/failed"], |
| 304 | new Map(), |
| 305 | new Map([["/projects/empty", "empty"]]), |
| 306 | ), |
| 307 | ).toBe("empty"); |
| 308 | }); |
| 309 | |
| 310 | it("keeps an earlier successful import available on retry", () => { |
| 311 | expect( |
| 312 | resolveOnboardingLandingProject( |
| 313 | ["/projects/imported", "/projects/retry"], |
| 314 | new Map([["/projects/imported", "imported"]]), |
| 315 | new Map([["/projects/imported", "imported"]]), |
| 316 | ), |
| 317 | ).toBe("imported"); |
| 318 | }); |
| 319 | |
| 320 | it("ignores cached successes outside the current retry selection", () => { |
| 321 | expect( |
| 322 | resolveOnboardingLandingProject( |
| 323 | ["/projects/current"], |
| 324 | new Map([["/projects/previous", "previous"]]), |
| 325 | new Map([ |
| 326 | ["/projects/previous", "previous"], |
| 327 | ["/projects/current", "current"], |
| 328 | ]), |
| 329 | ), |
| 330 | ).toBe("current"); |
| 331 | }); |
| 332 | }); |
| 333 | |
| 334 | describe("projects on multiple computers", () => { |
| 335 | it("keeps identical paths independent when selecting a landing project after partial imports", () => { |
| 336 | const first = onboardingProjectKey(EnvironmentId.make("first"), "/code/app"); |
| 337 | const second = onboardingProjectKey(EnvironmentId.make("second"), "/code/app"); |
| 338 | const completed = new Map([[first, "first-project"]]); |
| 339 | expect(completed.has(second)).toBe(false); |
| 340 | expect(resolveOnboardingLandingProject([second], completed, completed)).toBeUndefined(); |
| 341 | completed.set(second, "second-project"); |
| 342 | expect(resolveOnboardingLandingProject([second, first], completed, completed)).toBe( |
| 343 | "second-project", |
| 344 | ); |
| 345 | }); |
| 346 | |
| 347 | it("preserves computer identity when choosing recent projects", () => { |
| 348 | const first = { ...candidate("/code/app"), environmentId: "first" }; |
| 349 | const second = { ...candidate("/code/app"), environmentId: "second" }; |
| 350 | expect(partitionOnboardingProjects([first, second], now).recent).toEqual([first, second]); |
| 351 | }); |
| 352 | }); |
| 353 | |
| 354 | describe("splitOnboardingProjectPath", () => { |
| 355 | it("keeps sibling checkouts that share a prefix distinguishable by name", () => { |
| 356 | const left = splitOnboardingProjectPath("/Users/me/Projects/clients/acme/t3code"); |
| 357 | const right = splitOnboardingProjectPath("/Users/me/Projects/clients/acme/t3code-worktree"); |
| 358 | expect(left).toEqual({ name: "t3code", parent: "/Users/me/Projects/clients/acme/" }); |
| 359 | expect(right).toEqual({ name: "t3code-worktree", parent: "/Users/me/Projects/clients/acme/" }); |
| 360 | expect(left.name).not.toBe(right.name); |
| 361 | }); |
| 362 | |
| 363 | it("splits Windows paths and ignores a trailing separator", () => { |
| 364 | expect(splitOnboardingProjectPath("C:\\Users\\me\\t3code\\")).toEqual({ |
| 365 | name: "t3code", |
| 366 | parent: "C:\\Users\\me\\", |
| 367 | }); |
| 368 | }); |
| 369 | |
| 370 | it("keeps a root or bare name whole", () => { |
| 371 | expect(splitOnboardingProjectPath("/")).toEqual({ name: "/", parent: "" }); |
| 372 | expect(splitOnboardingProjectPath("/t3code")).toEqual({ name: "t3code", parent: "/" }); |
| 373 | expect(splitOnboardingProjectPath("t3code")).toEqual({ name: "t3code", parent: "" }); |
| 374 | expect(splitOnboardingProjectPath("t3code/")).toEqual({ name: "t3code", parent: "" }); |
| 375 | }); |
| 376 | |
| 377 | it.each(["C:\\", "C:/", "\\\\server\\share", "\\\\server\\share\\", "//server/share/"])( |
| 378 | "keeps the filesystem root %s whole", |
| 379 | (path) => { |
| 380 | expect(splitOnboardingProjectPath(path)).toEqual({ name: path, parent: "" }); |
| 381 | }, |
| 382 | ); |
| 383 | |
| 384 | it("splits a folder below a network share", () => { |
| 385 | expect(splitOnboardingProjectPath("\\\\server\\share\\project\\")).toEqual({ |
| 386 | name: "project", |
| 387 | parent: "\\\\server\\share\\", |
| 388 | }); |
| 389 | }); |
| 390 | }); |