1import { EnvironmentId, ProjectId } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import type { EnvironmentProject } from "./models.ts";
5import { chooseLoadBalancedEnvironment } from "../load-balancing.ts";
6import {
7 buildProjectGroups,
8 deriveLogicalProjectKeyFromSettings,
9 derivePhysicalProjectKey,
10 resolveScratchProjectScopeKey,
11 sortProjectsByGroupOrder,
12 type ProjectGroupingSettings,
13} from "./projectGrouping.ts";
14
15const environmentId = EnvironmentId.make("environment");
16
17describe("load balancing shared project machines", () => {
18 const now = 100_000;
19 const resources = {
20 sampledAt: now,
21 cpuUtilization: 0.2,
22 cpuCount: 8,
23 availableMemoryBytes: 8_000,
24 totalMemoryBytes: 16_000,
25 };
26
27 it("compares three machines using free capacity and preference", () => {
28 const candidates = [
29 { environmentId: "busy", resources: { ...resources, cpuUtilization: 0.9 }, weight: 1 },
30 { environmentId: "idle", resources, weight: 1 },
31 { environmentId: "preferred", resources: { ...resources, cpuCount: 4 }, weight: 3 },
32 ];
33 expect(chooseLoadBalancedEnvironment(candidates, now)).toBe("preferred");
34 expect(chooseLoadBalancedEnvironment(candidates.slice(0, 2), now)).toBe("idle");
35 });
36
37 it("rejects stale, unknown, excluded and saturated machines", () => {
38 expect(
39 chooseLoadBalancedEnvironment(
40 [
41 {
42 environmentId: "stale",
43 resources: { ...resources, sampledAt: now - 15_001 },
44 weight: 1,
45 },
46 { environmentId: "unknown", resources: null, weight: 1 },
47 {
48 environmentId: "no-cpu-sample",
49 resources: { ...resources, cpuUtilization: null },
50 weight: 1,
51 },
52 { environmentId: "excluded", resources, weight: 0 },
53 {
54 environmentId: "cpu-full",
55 resources: { ...resources, cpuUtilization: 0.95 },
56 weight: 1,
57 },
58 {
59 environmentId: "memory-full",
60 resources: { ...resources, availableMemoryBytes: 100 },
61 weight: 1,
62 },
63 ],
64 now,
65 ),
66 ).toBeNull();
67 });
68
69 it("uses client receipt time when host clocks differ", () => {
70 const candidate = {
71 environmentId: "different-clock",
72 resources: { ...resources, sampledAt: now + 60_000 },
73 receivedAt: now,
74 weight: 1,
75 };
76 expect(chooseLoadBalancedEnvironment([candidate], now)).toBe("different-clock");
77 expect(chooseLoadBalancedEnvironment([candidate], now + 15_001)).toBeNull();
78 });
79});
80const repositoryIdentity = {
81 canonicalKey: "github.com/t3tools/t3code",
82 locator: {
83 source: "git-remote" as const,
84 remoteName: "upstream",
85 remoteUrl: "https://github.com/t3tools/t3code.git",
86 },
87 provider: "github",
88 owner: "t3tools",
89 name: "t3code",
90 displayName: "T3 Code",
91};
92
93function makeProject(
94 id: string,
95 workspaceRoot: string,
96 overrides: Partial<EnvironmentProject> = {},
97): EnvironmentProject {
98 return {
99 environmentId,
100 id: ProjectId.make(id),
101 title: id,
102 workspaceRoot,
103 repositoryIdentity,
104 defaultModelSelection: null,
105 scripts: [],
106 createdAt: "2026-07-01T00:00:00.000Z",
107 updatedAt: "2026-07-01T00:00:00.000Z",
108 ...overrides,
109 };
110}
111
112function settings(
113 mode: ProjectGroupingSettings["sidebarProjectGroupingMode"],
114 overrides: ProjectGroupingSettings["sidebarProjectGroupingOverrides"] = {},
115): ProjectGroupingSettings {
116 return {
117 sidebarProjectGroupingMode: mode,
118 sidebarProjectGroupingOverrides: overrides,
119 };
120}
121
122describe("buildProjectGroups", () => {
123 it.each(["repository", "repository_path", "separate"] as const)(
124 "keeps scratch project ownership separate by default in %s mode",
125 (mode) => {
126 const projects = [
127 makeProject("scratch-local", "/local/scratch", {
128 isScratch: true,
129 repositoryIdentity: null,
130 }),
131 makeProject("scratch-remote", "/remote/scratch", {
132 environmentId: EnvironmentId.make("remote"),
133 isScratch: true,
134 repositoryIdentity: null,
135 }),
136 ];
137 const groups = buildProjectGroups({ projects, settings: settings(mode) });
138 expect(groups.map((group) => group.key)).toEqual(projects.map(derivePhysicalProjectKey));
139 expect(groups.map((group) => group.memberProjectRefs)).toEqual(
140 projects.map((project) => [
141 { environmentId: project.environmentId, projectId: project.id },
142 ]),
143 );
144 expect(
145 buildProjectGroups({ projects, settings: settings(mode), groupScratchProjects: true }),
146 ).toHaveLength(1);
147 },
148 );
149
150 it.each(["repository", "repository_path", "separate"] as const)(
151 "migrates a pre-config %s scratch selection and retains it after the original host is removed",
152 (mode) => {
153 const local = makeProject("scratch-local", "/local/repo/scratch", {
154 repositoryIdentity: { ...repositoryIdentity, rootPath: "/local/repo" },
155 });
156 const remote = makeProject("scratch-remote", "/remote/scratch", {
157 environmentId: EnvironmentId.make("remote"),
158 repositoryIdentity: null,
159 isScratch: true,
160 });
161 const groupingSettings = settings(mode);
162 const selectedBeforeConfig = deriveLogicalProjectKeyFromSettings(local, groupingSettings);
163 const loaded = [{ ...local, isScratch: true as const }, remote];
164 const migrated = resolveScratchProjectScopeKey(
165 selectedBeforeConfig,
166 loaded,
167 groupingSettings,
168 );
169 const scratchKey = buildProjectGroups({
170 projects: loaded,
171 settings: groupingSettings,
172 groupScratchProjects: true,
173 })[0]!.key;
174 expect(migrated).toBe(scratchKey);
175 expect(resolveScratchProjectScopeKey(migrated, [remote], groupingSettings)).toBe(scratchKey);
176 expect(
177 buildProjectGroups({
178 projects: [remote],
179 settings: groupingSettings,
180 groupScratchProjects: true,
181 })[0]?.memberProjectRefs,
182 ).toEqual([{ environmentId: remote.environmentId, projectId: remote.id }]);
183 },
184 );
185
186 it("retains a repository filter that still belongs to an ordinary project", () => {
187 const scratch = makeProject("scratch", "/scratch", { isScratch: true });
188 const ordinary = makeProject("ordinary", "/ordinary");
189 const key = deriveLogicalProjectKeyFromSettings(ordinary, settings("repository"));
190 expect(resolveScratchProjectScopeKey(key, [scratch, ordinary])).toBe(key);
191 });
192
193 it.each(["repository", "repository_path", "separate"] as const)(
194 "coalesces scratch homes in %s mode without merging ordinary same-title projects",
195 (mode) => {
196 const remoteEnvironmentId = EnvironmentId.make("remote");
197 const projects = [
198 makeProject("scratch-local", "/local/scratch", {
199 title: "No project",
200 isScratch: true,
201 repositoryIdentity: null,
202 }),
203 makeProject("scratch-remote", "/remote/scratch", {
204 title: "No project",
205 environmentId: remoteEnvironmentId,
206 isScratch: true,
207 }),
208 makeProject("scratch-duplicate", "/remote/scratch/", {
209 title: "No project",
210 environmentId: remoteEnvironmentId,
211 isScratch: true,
212 repositoryIdentity: null,
213 updatedAt: "2026-07-02T00:00:00.000Z",
214 }),
215 makeProject("ordinary", "/ordinary", { title: "No project" }),
216 makeProject("reserved-collision", "/other", {
217 title: "No project",
218 repositoryIdentity: { ...repositoryIdentity, canonicalKey: "\0no-project" },
219 }),
220 ];
221 const groups = buildProjectGroups({
222 projects,
223 groupScratchProjects: true,
224 settings: settings(mode, { [derivePhysicalProjectKey(projects[0]!)]: "separate" }),
225 preferredEnvironmentId: remoteEnvironmentId,
226 });
227 expect(groups).toHaveLength(3);
228 const scratch = groups.find((group) => group.representative.isScratch)!;
229 expect(scratch.label).toBe("No project");
230 expect(scratch.representative.id).toBe(projects[2]?.id);
231 expect(scratch.members).toHaveLength(2);
232 expect(scratch.memberProjectRefs).toEqual(
233 projects.slice(0, 3).map((project) => ({
234 environmentId: project.environmentId,
235 projectId: project.id,
236 })),
237 );
238 expect(resolveScratchProjectScopeKey(derivePhysicalProjectKey(projects[1]!), projects)).toBe(
239 scratch.key,
240 );
241 expect(resolveScratchProjectScopeKey(derivePhysicalProjectKey(projects[3]!), projects)).toBe(
242 derivePhysicalProjectKey(projects[3]!),
243 );
244 expect(groups.filter((group) => !group.representative.isScratch)).toHaveLength(2);
245 },
246 );
247
248 it("preserves every physical clone as a selectable member in repository modes", () => {
249 const projects = [
250 makeProject("t3code", "/work/t3code"),
251 makeProject("t3code-2", "/work/t3code-2"),
252 makeProject("t3code-3", "/work/t3code-3"),
253 ];
254
255 for (const mode of ["repository", "repository_path"] as const) {
256 const groups = buildProjectGroups({ projects, settings: settings(mode) });
257 expect(groups).toHaveLength(1);
258 expect(groups[0]?.members.map((member) => member.project.id)).toEqual([
259 "t3code",
260 "t3code-2",
261 "t3code-3",
262 ]);
263 expect(groups[0]?.memberProjectRefs).toHaveLength(3);
264 }
265 });
266
267 it("uses a shared custom title as the repository group's label", () => {
268 const projects = [
269 makeProject("first", "/work/t3code", { title: "Custom project" }),
270 makeProject("second", "/work/t3code-2", { title: "Custom project" }),
271 ];
272
273 expect(buildProjectGroups({ projects, settings: settings("repository") })[0]?.label).toBe(
274 "Custom project",
275 );
276 });
277
278 it("keeps the repository label when shared titles match its repository name", () => {
279 const projects = [
280 makeProject("first", "/work/t3code", { title: "t3code" }),
281 makeProject("second", "/work/t3code-2", { title: "t3code" }),
282 ];
283
284 expect(buildProjectGroups({ projects, settings: settings("repository") })[0]?.label).toBe(
285 "T3 Code",
286 );
287 });
288
289 it("keeps a fork apart from its upstream checkout and labels it by its own remote", () => {
290 const fork = makeProject("fork", "/work/t3code-fork", {
291 repositoryIdentity: {
292 ...repositoryIdentity,
293 origin: {
294 canonicalKey: "github.com/julius/t3code-fork",
295 displayName: "julius/t3code-fork",
296 },
297 },
298 });
299 const forkWorktree = makeProject("fork-2", "/work/t3code-fork-2", {
300 repositoryIdentity: fork.repositoryIdentity,
301 });
302 const projects = [makeProject("t3code", "/work/t3code"), fork, forkWorktree];
303
304 const groups = buildProjectGroups({ projects, settings: settings("repository") });
305 expect(groups.map((group) => group.key)).toEqual([
306 "github.com/t3tools/t3code",
307 "github.com/julius/t3code-fork",
308 ]);
309 expect(groups[1]?.members.map((member) => member.project.id)).toEqual(["fork", "fork-2"]);
310 expect(groups[1]?.label).toBe("julius/t3code-fork");
311 });
312
313 it("labels a fork by its canonical key when its origin has no display name", () => {
314 const identity = {
315 ...repositoryIdentity,
316 origin: { canonicalKey: "internal-host" },
317 };
318 const projects = [
319 makeProject("fork", "/work/fork", { repositoryIdentity: identity }),
320 makeProject("fork-2", "/work/fork-2", { repositoryIdentity: identity }),
321 ];
322
323 const groups = buildProjectGroups({ projects, settings: settings("repository") });
324 expect(groups).toHaveLength(1);
325 expect(groups[0]?.label).toBe("internal-host");
326 });
327
328 it("keeps physical clones in separate groups when requested", () => {
329 const projects = [
330 makeProject("t3code", "/work/t3code"),
331 makeProject("t3code-2", "/work/t3code-2"),
332 makeProject("t3code-3", "/work/t3code-3"),
333 ];
334
335 const groups = buildProjectGroups({ projects, settings: settings("separate") });
336 expect(groups).toHaveLength(3);
337 expect(groups.flatMap((group) => group.members)).toHaveLength(3);
338 expect(groups.map((group) => group.label)).toEqual(["t3code", "t3code-2", "t3code-3"]);
339 });
340
341 it("applies a physical-project override without dropping its siblings", () => {
342 const first = makeProject("t3code", "/work/t3code");
343 const second = makeProject("t3code-2", "/work/t3code-2");
344 const third = makeProject("t3code-3", "/work/t3code-3");
345 const groups = buildProjectGroups({
346 projects: [first, second, third],
347 settings: settings("repository", {
348 [derivePhysicalProjectKey(second)]: "separate",
349 }),
350 });
351
352 expect(groups).toHaveLength(2);
353 expect(groups.flatMap((group) => group.members.map((member) => member.project.id))).toEqual([
354 "t3code",
355 "t3code-3",
356 "t3code-2",
357 ]);
358 });
359
360 it("dedupes stale registrations at one physical path using the freshest project", () => {
361 const stale = makeProject("stale", "/work/t3code", {
362 repositoryIdentity: null,
363 updatedAt: "2026-07-01T00:00:00.000Z",
364 });
365 const fresh = makeProject("fresh", "/work/t3code/", {
366 updatedAt: "2026-07-02T00:00:00.000Z",
367 });
368
369 const groups = buildProjectGroups({
370 projects: [stale, fresh],
371 settings: settings("repository"),
372 });
373 expect(groups).toHaveLength(1);
374 expect(groups[0]?.members).toHaveLength(1);
375 expect(groups[0]?.representative.id).toBe("fresh");
376 expect(groups[0]?.memberProjectRefs).toHaveLength(2);
377 });
378
379 it("uses repository identity from a duplicate registration when the winner lacks it", () => {
380 const identified = makeProject("identified", "/work/t3code", {
381 updatedAt: "2026-07-01T00:00:00.000Z",
382 });
383 const freshUnidentified = makeProject("fresh", "/work/t3code/", {
384 repositoryIdentity: null,
385 updatedAt: "2026-07-02T00:00:00.000Z",
386 });
387 const sibling = makeProject("sibling", "/work/t3code-2");
388
389 const groups = buildProjectGroups({
390 projects: [identified, freshUnidentified, sibling],
391 settings: settings("repository"),
392 });
393 expect(groups).toHaveLength(1);
394 expect(groups[0]?.members.map((member) => member.project.id)).toEqual(["fresh", "sibling"]);
395 });
396
397 it("uses the freshest winner's repository identity when stale duplicates disagree", () => {
398 const staleIdentity = {
399 ...repositoryIdentity,
400 canonicalKey: "github.com/t3tools/old-repository",
401 name: "old-repository",
402 displayName: "Old Repository",
403 };
404 const stale = makeProject("stale", "/work/t3code", {
405 repositoryIdentity: staleIdentity,
406 updatedAt: "2026-07-01T00:00:00.000Z",
407 });
408 const fresh = makeProject("fresh", "/work/t3code/", {
409 updatedAt: "2026-07-02T00:00:00.000Z",
410 });
411 const sibling = makeProject("sibling", "/work/t3code-2");
412
413 const groups = buildProjectGroups({
414 projects: [stale, fresh, sibling],
415 settings: settings("repository"),
416 });
417 expect(groups).toHaveLength(1);
418 expect(groups[0]?.members.map((member) => member.project.id)).toEqual(["fresh", "sibling"]);
419 });
420
421 it("uses the freshest identity-bearing duplicate when the winner lacks identity", () => {
422 const staleIdentity = {
423 ...repositoryIdentity,
424 canonicalKey: "github.com/t3tools/old-repository",
425 name: "old-repository",
426 displayName: "Old Repository",
427 };
428 const staleIdentified = makeProject("stale-identified", "/work/t3code", {
429 repositoryIdentity: staleIdentity,
430 updatedAt: "2026-07-01T00:00:00.000Z",
431 });
432 const freshIdentified = makeProject("fresh-identified", "/work/t3code/", {
433 updatedAt: "2026-07-02T00:00:00.000Z",
434 });
435 const winner = makeProject("winner", "/work/t3code", {
436 repositoryIdentity: null,
437 updatedAt: "2026-07-03T00:00:00.000Z",
438 });
439 const sibling = makeProject("sibling", "/work/t3code-2");
440
441 const groups = buildProjectGroups({
442 projects: [staleIdentified, freshIdentified, winner, sibling],
443 settings: settings("repository"),
444 });
445 expect(groups).toHaveLength(1);
446 expect(groups[0]?.members.map((member) => member.project.id)).toEqual(["winner", "sibling"]);
447 });
448});
449
450describe("sortProjectsByGroupOrder", () => {
451 const remote = EnvironmentId.make("remote");
452 const ref = (projectId: string, env = environmentId) => ({
453 environmentId: env,
454 projectId: ProjectId.make(projectId),
455 });
456 const project = (id: string, env: string = environmentId) => ({ id, environmentId: env });
457
458 it("follows the group order, keeping ungrouped projects last in their own order", () => {
459 const sorted = sortProjectsByGroupOrder(
460 [
461 project("alpha"),
462 project("zeta"),
463 project("orphan-b"),
464 project("beta"),
465 project("orphan-a"),
466 ],
467 [{ memberProjectRefs: [ref("zeta")] }, { memberProjectRefs: [ref("beta"), ref("alpha")] }],
468 );
469 expect(sorted.map((entry) => entry.id)).toEqual([
470 "zeta",
471 "alpha",
472 "beta",
473 "orphan-b",
474 "orphan-a",
475 ]);
476 });
477
478 it("tells the same project id on two environments apart", () => {
479 const sorted = sortProjectsByGroupOrder(
480 [project("shared"), project("shared", remote)],
481 [{ memberProjectRefs: [ref("shared", remote)] }, { memberProjectRefs: [ref("shared")] }],
482 );
483 expect(sorted.map((entry) => entry.environmentId)).toEqual([remote, environmentId]);
484 });
485});