1import { EnvironmentId, ProjectId, type AgentSessionProjectCandidate } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import {
5 groupOnboardingProjects,
6 partitionOnboardingProjects,
7 onboardingProjectKey,
8 resolveOnboardingLandingProject,
9 resolveOnboardingProjectId,
10 splitOnboardingProjectPath,
11} from "./projectImport.logic";
12
13const now = Date.parse("2026-08-22T12:00:00.000Z");
14
15function 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
31const github = (repository: string) => ({
32 remoteKey: `github.com/${repository.toLowerCase()}`,
33 repository,
34});
35
36describe("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
86describe("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
150describe("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
279describe("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
334describe("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
354describe("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});