Skip to content
Source
Sign inSign up

WorktreeSetupTracker.test.ts

143 lines5.3 KB

1import { describe, expect, it } from "@effect/vitest";
2import { ThreadId } from "@t3tools/contracts";
3import * as Deferred from "effect/Deferred";
4import * as Effect from "effect/Effect";
5import * as Exit from "effect/Exit";
6import * as Fiber from "effect/Fiber";
7import * as Stream from "effect/Stream";
8​
9import * as WorktreeSetupTracker from "./WorktreeSetupTracker.ts";
10​
11const threadId = ThreadId.make("thread-1");
12​
13describe("WorktreeSetupTracker", () => {
14 it.effect("records stage transitions, checkout progress, and the final phase", () =>
15 Effect.gen(function* () {
16 const tracker = yield* WorktreeSetupTracker.make;
17 yield* tracker.begin({
18 threadId,
19 branch: "feature",
20 baseRef: "main",
21 stages: ["checkout", "fetch", "agent"],
22 fiber: null,
23 });
24​
25 const initial = yield* tracker.get(threadId);
26 expect(initial?.stages.map((stage) => stage.id)).toEqual(["fetch", "checkout", "agent"]);
27 expect(initial?.phase).toBe("running");
28​
29 yield* tracker.stageStatus(threadId, "fetch", "running");
30 yield* tracker.stageStatus(threadId, "fetch", "done", "origin/main at abc1234");
31 yield* tracker.stageStatus(threadId, "checkout", "running");
32 yield* tracker.stage(threadId, "checkout", { percent: 42, detail: "42 / 100 files" });
33 yield* tracker.finish(threadId, "failed", "boom");
34​
35 const final = yield* tracker.get(threadId);
36 expect(final?.phase).toBe("failed");
37 expect(final?.error).toBe("boom");
38 const [fetch, checkout, agent] = final?.stages ?? [];
39 expect(fetch).toMatchObject({ status: "done", detail: "origin/main at abc1234" });
40 expect(fetch?.startedAt).not.toBeNull();
41 expect(fetch?.endedAt).not.toBeNull();
42 expect(checkout).toMatchObject({ status: "failed", percent: 42 });
43 expect(agent?.status).toBe("pending");
44 expect(final?.sequence).toBeGreaterThan(initial?.sequence ?? 0);
45 }),
46 );
47​
48 it.effect("stream emits the current snapshot first and then only newer ones", () =>
49 Effect.gen(function* () {
50 const tracker = yield* WorktreeSetupTracker.make;
51 yield* tracker.begin({
52 threadId,
53 branch: null,
54 baseRef: null,
55 stages: ["agent"],
56 fiber: null,
57 });
58​
59 const collected = yield* tracker.stream(threadId).pipe(
60 Stream.takeUntil((snapshot) => snapshot?.sequence === 2),
61 Stream.runCollect,
62 Effect.forkChild,
63 );
64 yield* Effect.yieldNow;
65 yield* tracker.stageStatus(threadId, "agent", "running");
66 yield* tracker.appendTail(threadId, "agent", "line 1");
67​
68 const snapshots = yield* Fiber.join(collected);
69 const sequences = snapshots.map((snapshot) => snapshot?.sequence ?? -1);
70 expect(sequences.at(-1)).toBe(2);
71 expect(sequences).toEqual([...sequences].toSorted((a, b) => a - b));
72 expect(snapshots.at(-1)?.stages[0]?.tail).toEqual(["line 1"]);
73 }),
74 );
75​
76 it.effect("a new setup on the same thread keeps sequences increasing", () =>
77 Effect.gen(function* () {
78 const tracker = yield* WorktreeSetupTracker.make;
79 yield* tracker.begin({
80 threadId,
81 branch: "first",
82 baseRef: null,
83 stages: ["agent"],
84 fiber: null,
85 });
86 yield* tracker.finish(threadId, "failed", "boom");
87 const failedSequence = (yield* tracker.get(threadId))?.sequence ?? -1;
88​
89 const collected = yield* tracker.stream(threadId).pipe(
90 Stream.takeUntil((snapshot) => snapshot?.branch === "second"),
91 Stream.runCollect,
92 Effect.forkChild,
93 );
94 yield* Effect.yieldNow;
95 yield* tracker.begin({
96 threadId,
97 branch: "second",
98 baseRef: null,
99 stages: ["agent"],
100 fiber: null,
101 });
102​
103 const snapshots = yield* Fiber.join(collected);
104 const last = snapshots.at(-1);
105 expect(last?.phase).toBe("running");
106 expect(last?.sequence).toBeGreaterThan(failedSequence);
107 }),
108 );
109​
110 it.effect("cancel interrupts the bootstrap fiber and reports whether one was running", () =>
111 Effect.gen(function* () {
112 const tracker = yield* WorktreeSetupTracker.make;
113 const started = yield* Deferred.make<void>();
114 const fiber = yield* Deferred.succeed(started, undefined).pipe(
115 Effect.andThen(Effect.never),
116 Effect.forkChild,
117 );
118 yield* Deferred.await(started);
119 yield* tracker.begin({ threadId, branch: null, baseRef: null, stages: ["agent"], fiber });
120​
121 expect(yield* tracker.cancel(threadId)).toBe(true);
122 const exit = yield* Fiber.await(fiber);
123 expect(Exit.hasInterrupts(exit)).toBe(true);
124​
125 yield* tracker.finish(threadId, "cancelled");
126 expect(yield* tracker.cancel(threadId)).toBe(false);
127 expect(yield* tracker.cancel(ThreadId.make("unknown"))).toBe(false);
128 }),
129 );
130​
131 it.effect("markUncancellable makes a later cancel a no-op while the setup keeps running", () =>
132 Effect.gen(function* () {
133 const tracker = yield* WorktreeSetupTracker.make;
134 const fiber = yield* Effect.forkChild(Effect.never);
135 yield* tracker.begin({ threadId, branch: null, baseRef: null, stages: ["agent"], fiber });
136​
137 yield* tracker.markUncancellable(threadId);
138 expect(yield* tracker.cancel(threadId)).toBe(false);
139 expect((yield* tracker.get(threadId))?.phase).toBe("running");
140 yield* Fiber.interrupt(fiber);
141 }),
142 );
143});