Skip to content
Source
Sign inSign up
Open History to see the last commit

ProjectCloneTracker.test.ts

372 lines15.1 KB

1import { SourceControlProviderKind } from "@t3tools/contracts";
2import { describe, expect, it } from "@effect/vitest";
3import {
4 OrchestrationDispatchCommandError,
5 ProjectId,
6 projectCloneProgressSummary,
7 type SourceControlCloneProtocol,
8 SourceControlRepositoryError,
9} from "@t3tools/contracts";
10import * as Deferred from "effect/Deferred";
11import * as Effect from "effect/Effect";
12import * as Fiber from "effect/Fiber";
13import * as Layer from "effect/Layer";
14import * as Stream from "effect/Stream";
15import * as TestClock from "effect/testing/TestClock";
16​
17import * as SourceControlRepositoryService from "../sourceControl/SourceControlRepositoryService.ts";
18import * as ProjectCloneTracker from "./ProjectCloneTracker.ts";
19import { ProjectConflictError } from "./ProjectService.ts";
20import { parseGitCloneProgressLine } from "./gitCloneProgress.ts";
21​
22const projectId = ProjectId.make("project-1");
23const startInput = {
24 projectId,
25 title: "t3code",
26 createdAt: "2026-01-01T00:00:00.000Z",
27 remoteUrl: "git@github.com:octocat/t3code.git",
28 destinationPath: "/workspace/t3code",
29};
30​
31function makeHarness(options?: {
32 readonly clone?: SourceControlRepositoryService.SourceControlRepositoryService["Service"]["cloneRepository"];
33}) {
34 const created: Array<{ projectId: ProjectId; workspaceRoot: string }> = [];
35 const cloned: Array<ProjectId> = [];
36 const discarded: Array<string> = [];
37 const hooks: ProjectCloneTracker.ProjectCloneHooks = {
38 createProject: (input) =>
39 Effect.sync(() => {
40 created.push({ projectId: input.projectId, workspaceRoot: input.workspaceRoot });
41 }),
42 onCloned: (input) => Effect.sync(() => void cloned.push(input.projectId)),
43 };
44 const layer = ProjectCloneTracker.layer.pipe(
45 Layer.provide(
46 Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({
47 prepareClone: (input) =>
48 Effect.succeed({
49 destinationPath: input.destinationPath,
50 remoteUrl: input.remoteUrl ?? "",
51 cloneUrl: input.remoteUrl ?? "",
52 repository: null,
53 }),
54 cloneRepository:
55 options?.clone ??
56 ((input) =>
57 Effect.succeed({
58 cwd: input.destinationPath,
59 remoteUrl: input.remoteUrl ?? "",
60 repository: null,
61 })),
62 discardClone: (destination) => Effect.sync(() => void discarded.push(destination)),
63 }),
64 ),
65 );
66 return { layer, hooks, created, cloned, discarded };
68​
69describe("ProjectCloneTracker", () => {
70 it.effect("creates the project first and reports the clone through the stream", () => {
71 const release = Deferred.makeUnsafe<void>();
72 const harness = makeHarness({
73 clone: (input, options) =>
74 Effect.gen(function* () {
75 yield* (
76 options?.onProgress?.({ stage: "receiving", percent: 40, detail: "1 MiB" }) ??
77 Effect.void
78 );
79 yield* Deferred.await(release);
80 return { cwd: input.destinationPath, remoteUrl: input.remoteUrl ?? "", repository: null };
81 }),
82 });
83 return Effect.gen(function* () {
84 const tracker = yield* ProjectCloneTracker.ProjectCloneTracker;
85 const collected = yield* tracker.stream.pipe(
86 Stream.takeUntil((clones) => clones[0]?.phase === "done"),
87 Stream.runCollect,
88 Effect.forkChild,
89 );
90 yield* Effect.yieldNow;
91​
92 const result = yield* tracker.start(startInput, harness.hooks);
93 expect(result.cwd).toBe("/workspace/t3code");
94 expect(harness.created).toEqual([{ projectId, workspaceRoot: "/workspace/t3code" }]);
95​
96 yield* Effect.yieldNow;
97 const running = yield* tracker.get(projectId);
98 expect(running).toMatchObject({ phase: "running", stage: "receiving", percent: 40 });
99​
100 yield* Deferred.succeed(release, undefined);
101 const lists = yield* Fiber.join(collected);
102 const final = lists.at(-1)?.[0];
103 expect(final).toMatchObject({ phase: "done", percent: 100 });
104 expect(harness.cloned).toEqual([projectId]);
105​
106 yield* TestClock.adjust("31 seconds");
107 expect(yield* tracker.get(projectId)).toBeNull();
108 }).pipe(Effect.provide(harness.layer));
109 });
110​
111 it.effect("flags a clone stalled when git goes quiet and clears it when git speaks", () => {
112 const release = Deferred.makeUnsafe<void>();
113 const resumed = Deferred.makeUnsafe<void>();
114 const harness = makeHarness({
115 clone: (input, options) =>
116 Effect.gen(function* () {
117 const report = options?.onProgress ?? (() => Effect.void);
118 yield* report({ stage: "receiving", percent: 24, detail: "168.80 MiB | 20.69 MiB/s" });
119 yield* Deferred.await(resumed);
120 yield* report({ stage: "receiving", percent: 25, detail: "170.00 MiB | 1.00 MiB/s" });
121 yield* Deferred.await(release);
122 return { cwd: input.destinationPath, remoteUrl: input.remoteUrl ?? "", repository: null };
123 }),
124 });
125 return Effect.gen(function* () {
126 const tracker = yield* ProjectCloneTracker.ProjectCloneTracker;
127 yield* tracker.start(startInput, harness.hooks);
128 yield* Effect.yieldNow;
129 expect((yield* tracker.get(projectId))?.stalled).toBe(false);
130​
131 yield* TestClock.adjust("19 seconds");
132 expect((yield* tracker.get(projectId))?.stalled).toBe(false);
133 yield* TestClock.adjust("1 second");
134 const stalled = yield* tracker.get(projectId);
135 expect(stalled).toMatchObject({ stalled: true, percent: 24 });
136 expect(projectCloneProgressSummary(stalled!)).toBe("Receiving objects · 24% · Stalled");
137​
138 yield* Deferred.succeed(resumed, undefined);
139 yield* Effect.yieldNow;
140 expect(yield* tracker.get(projectId)).toMatchObject({ stalled: false, percent: 25 });
141​
142 yield* TestClock.adjust("20 seconds");
143 expect((yield* tracker.get(projectId))?.stalled).toBe(true);
144 yield* Deferred.succeed(release, undefined);
145 yield* Effect.yieldNow;
146 expect(yield* tracker.get(projectId)).toMatchObject({ phase: "done", stalled: false });
147 }).pipe(Effect.provide(harness.layer));
148 });
149​
150 it.effect("keeps a failed clone with git's own explanation and retries it", () => {
151 const protocols: Array<SourceControlCloneProtocol | undefined> = [];
152 const harness = makeHarness({
153 clone: (input) =>
154 Effect.suspend(() => {
155 protocols.push(input.protocol);
156 return protocols.length === 1
157 ? Effect.fail(
158 new SourceControlRepositoryError({
159 operation: "cloneRepository",
160 provider: SourceControlProviderKind.make("unknown"),
161 detail: "fatal: repository not found",
162 }),
163 )
164 : Effect.succeed({
165 cwd: input.destinationPath,
166 remoteUrl: input.remoteUrl ?? "",
167 repository: null,
168 });
169 }),
170 });
171 return Effect.gen(function* () {
172 const tracker = yield* ProjectCloneTracker.ProjectCloneTracker;
173 yield* tracker.start({ ...startInput, protocol: "ssh" }, harness.hooks);
174 yield* Effect.yieldNow;
175 const failed = yield* tracker.get(projectId);
176 expect(failed).toMatchObject({ phase: "failed", error: "fatal: repository not found" });
177​
178 expect(yield* tracker.retry(projectId)).toBe(true);
179 expect(harness.discarded).toEqual(["/workspace/t3code"]);
180 yield* Effect.yieldNow;
181 expect((yield* tracker.get(projectId))?.phase).toBe("done");
182 expect(protocols).toEqual(["ssh", "ssh"]);
183 }).pipe(Effect.provide(harness.layer));
184 });
185​
186 it.effect("cancel interrupts the clone and removes the partial checkout", () => {
187 const harness = makeHarness({ clone: () => Effect.never });
188 return Effect.gen(function* () {
189 const tracker = yield* ProjectCloneTracker.ProjectCloneTracker;
190 yield* tracker.start(startInput, harness.hooks);
191 yield* Effect.yieldNow;
192​
193 expect(yield* tracker.cancel(projectId)).toBe(true);
194 expect((yield* tracker.get(projectId))?.phase).toBe("cancelled");
195 expect(harness.discarded).toEqual(["/workspace/t3code"]);
196 expect(yield* tracker.cancel(projectId)).toBe(false);
197 expect(yield* tracker.retry(projectId)).toBe(true);
198 }).pipe(Effect.provide(harness.layer));
199 });
200​
201 it.effect("a cancel that lands after git finished keeps the checkout", () => {
202 const gate = Deferred.makeUnsafe<void>();
203 const harness = makeHarness();
204 const hooks: ProjectCloneTracker.ProjectCloneHooks = {
205 ...harness.hooks,
206 onCloned: () => Deferred.await(gate),
207 };
208 return Effect.gen(function* () {
209 const tracker = yield* ProjectCloneTracker.ProjectCloneTracker;
210 yield* tracker.start(startInput, hooks);
211 yield* Effect.yieldNow;
212 expect((yield* tracker.get(projectId))?.phase).toBe("done");
213 expect(yield* tracker.cancel(projectId)).toBe(false);
214 expect(harness.discarded).toEqual([]);
215 yield* Deferred.succeed(gate, undefined);
216 }).pipe(Effect.provide(harness.layer));
217 });
218​
219 it.effect("hands git the credential-bearing URL while snapshots carry the redacted one", () => {
220 const cloneUrls: Array<string> = [];
221 const harness = makeHarness({
222 clone: (input) =>
223 Effect.sync(() => {
224 cloneUrls.push(input.remoteUrl ?? "");
225 return { cwd: input.destinationPath, remoteUrl: "", repository: null };
226 }),
227 });
228 const layer = ProjectCloneTracker.layer.pipe(
229 Layer.provide(
230 Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({
231 prepareClone: (input) =>
232 Effect.succeed({
233 destinationPath: input.destinationPath,
234 remoteUrl: "https://github.com/octocat/t3code.git",
235 cloneUrl: "https://user:s3cret@github.com/octocat/t3code.git",
236 repository: null,
237 }),
238 cloneRepository: (input) =>
239 Effect.sync(() => {
240 cloneUrls.push(input.remoteUrl ?? "");
241 return { cwd: input.destinationPath, remoteUrl: "", repository: null };
242 }),
243 discardClone: () => Effect.void,
244 }),
245 ),
246 );
247 return Effect.gen(function* () {
248 const tracker = yield* ProjectCloneTracker.ProjectCloneTracker;
249 const result = yield* tracker.start(startInput, harness.hooks);
250 yield* Effect.yieldNow;
251 expect(result.remoteUrl).toBe("https://github.com/octocat/t3code.git");
252 expect(cloneUrls).toEqual(["https://user:s3cret@github.com/octocat/t3code.git"]);
253 }).pipe(Effect.provide(layer));
254 });
255​
256 it.effect("discard forgets a project's clone when the project is deleted", () => {
257 const harness = makeHarness({ clone: () => Effect.never });
258 return Effect.gen(function* () {
259 const tracker = yield* ProjectCloneTracker.ProjectCloneTracker;
260 yield* tracker.start(startInput, harness.hooks);
261 yield* Effect.yieldNow;
262 yield* tracker.discard(projectId);
263 expect(yield* tracker.get(projectId)).toBeNull();
264 expect(harness.discarded).toEqual(["/workspace/t3code"]);
265 }).pipe(Effect.provide(harness.layer));
266 });
267​
268 it.effect("releases the claim when project creation fails", () => {
269 const harness = makeHarness();
270 const hooks: ProjectCloneTracker.ProjectCloneHooks = {
271 ...harness.hooks,
272 createProject: () =>
273 Effect.fail(new OrchestrationDispatchCommandError({ message: "workspace root exists" })),
274 };
275 return Effect.gen(function* () {
276 const tracker = yield* ProjectCloneTracker.ProjectCloneTracker;
277 const error = yield* Effect.flip(tracker.start(startInput, hooks));
278 expect(error.message).toContain("workspace root exists");
279 expect(yield* tracker.get(projectId)).toBeNull();
280 yield* tracker.start(startInput, harness.hooks);
281 yield* Effect.yieldNow;
282 expect((yield* tracker.get(projectId))?.phase).toBe("done");
283 }).pipe(Effect.provide(harness.layer));
284 });
285​
286 it.effect("clones for the project that already owns the empty destination", () => {
287 const owner = ProjectId.make("project-stranded");
288 const harness = makeHarness();
289 const hooks: ProjectCloneTracker.ProjectCloneHooks = {
290 ...harness.hooks,
291 createProject: (input) =>
292 Effect.fail(
293 new ProjectConflictError({
294 projectId: input.projectId,
295 workspaceRoot: input.workspaceRoot,
296 conflictingProjectId: owner,
297 }),
298 ),
299 };
300 return Effect.gen(function* () {
301 const tracker = yield* ProjectCloneTracker.ProjectCloneTracker;
302 const result = yield* tracker.start(startInput, hooks);
303 expect(result.projectId).toBe(owner);
304 yield* Effect.yieldNow;
305 expect(yield* tracker.get(owner)).toMatchObject({
306 projectId: owner,
307 destinationPath: "/workspace/t3code",
308 phase: "done",
309 });
310 expect(harness.cloned).toEqual([owner]);
311 expect(yield* tracker.get(projectId)).toBeNull();
312 }).pipe(Effect.provide(harness.layer));
313 });
314​
315 it.effect("does not create a project when the clone cannot be prepared", () => {
316 const harness = makeHarness();
317 const layer = ProjectCloneTracker.layer.pipe(
318 Layer.provide(
319 Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({
320 prepareClone: () =>
321 Effect.fail(
322 new SourceControlRepositoryError({
323 operation: "cloneRepository",
324 provider: SourceControlProviderKind.make("unknown"),
325 detail: "Destination path already exists and is not empty.",
326 }),
327 ),
328 }),
329 ),
330 );
331 return Effect.gen(function* () {
332 const tracker = yield* ProjectCloneTracker.ProjectCloneTracker;
333 const error = yield* Effect.flip(tracker.start(startInput, harness.hooks));
334 expect(error.message).toContain("not empty");
335 expect(harness.created).toEqual([]);
336 expect(yield* tracker.get(projectId)).toBeNull();
337 }).pipe(Effect.provide(layer));
338 });
339});
340​
341describe("parseGitCloneProgressLine", () => {
342 it("parses git's transfer counters and ignores other output", () => {
343 expect(
344 parseGitCloneProgressLine("Receiving objects: 45% (4500/10000), 12.30 MiB | 5.00 MiB/s"),
345 ).toEqual({ stage: "receiving", percent: 45, detail: "12.30 MiB | 5.00 MiB/s" });
346 expect(
347 parseGitCloneProgressLine("Receiving objects: 100% (5/5), 40.00 MiB | 1.40 MiB/s, done."),
348 ).toEqual({ stage: "receiving", percent: 100, detail: "40.00 MiB | 1.40 MiB/s" });
349 expect(parseGitCloneProgressLine("Resolving deltas: 100% (700/700), done.")).toEqual({
350 stage: "resolving",
351 percent: 100,
352 detail: null,
353 });
354 expect(parseGitCloneProgressLine("remote: Compressing objects: 12% (3/25)")).toEqual({
355 stage: "counting",
356 percent: 12,
357 detail: null,
358 });
359 expect(parseGitCloneProgressLine("Updating files: 78% (2104/2700)")).toEqual({
360 stage: "checkout",
361 percent: 78,
362 detail: null,
363 });
364 expect(parseGitCloneProgressLine("remote: Enumerating objects: 10, done.")).toEqual({
365 stage: "counting",
366 percent: null,
367 detail: null,
368 });
369 expect(parseGitCloneProgressLine("Cloning into 't3code'...")).toBeNull();
370 expect(parseGitCloneProgressLine("fatal: repository not found")).toBeNull();
371 });
372});