Skip to content
Source
Sign inSign up

projectGrouping.test.ts

437 lines16.0 KB

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