1import { AsyncResult, AtomRegistry } from "effect/reactivity";
2import * as Cause from "effect/Cause";
3import { describe, expect, it, vi } from "vite-plus/test";
4
5import { appAtomRegistry } from "../../state/atom-registry";
6import {
7 type BranchCheckoutRequest,
8 type BranchCheckoutResult,
9 branchCheckoutRequestKey,
10 makeNewTaskBranchCheckouts,
11} from "./new-task-branch-checkout";
12
13function request(flowKey: string, branch: string): BranchCheckoutRequest {
14 return {
15 flowKey,
16 routeKey: flowKey,
17 environmentId: null,
18 workspaceRoot: "/repo",
19 branch,
20 worktreePath: null,
21 };
22}
23
24function setup() {
25 const runs: Array<{
26 readonly request: BranchCheckoutRequest;
27 readonly settle: (result: BranchCheckoutResult) => void;
28 }> = [];
29 const holds: Array<Promise<unknown>> = [];
30 const onFailure = vi.fn();
31 const checkouts = makeNewTaskBranchCheckouts({
32 run: (input) =>
33 new Promise((resolve) => {
34 runs.push({ request: input, settle: resolve });
35 }),
36 holdNavigation: (operation) => holds.push(operation),
37 onFailure,
38 });
39 return { checkouts, runs, holds, onFailure };
40}
41
42const success = (name: string): BranchCheckoutResult =>
43 AsyncResult.success({ name, current: true, isDefault: false, worktreePath: null });
44const failure: BranchCheckoutResult = AsyncResult.failure(Cause.fail(new Error("dirty tree")));
45
46describe("new task branch checkouts", () => {
47 it("runs once per request even when its atom node is removed and rebuilt", async () => {
48 const { checkouts, runs } = setup();
49 const key = branchCheckoutRequestKey(request("flow-a", "feature"));
50
51 // A render read with no subscriber, then the node is dropped before the
52 // subscription commits, then the subscription builds a fresh node.
53 const tasks: Array<() => void> = [];
54 const registry = AtomRegistry.make({
55 scheduleTask: (task) => {
56 tasks.push(task);
57 return () => undefined;
58 },
59 });
60 const flushRemovals = () => tasks.splice(0).forEach((task) => task());
61
62 registry.get(checkouts.atom(key));
63 await vi.waitFor(() => expect(runs).toHaveLength(1));
64 flushRemovals();
65 expect(registry.getNodes().size).toBe(0);
66 const unmount = registry.mount(checkouts.atom(key));
67 runs[0]!.settle(success("feature"));
68 await vi.waitFor(() =>
69 expect(AsyncResult.isSuccess(registry.get(checkouts.atom(key)))).toBe(true),
70 );
71 unmount();
72
73 expect(runs).toHaveLength(1);
74 });
75
76 it("serializes checkouts across routes", async () => {
77 const { checkouts, runs } = setup();
78 const unmountA = appAtomRegistry.mount(
79 checkouts.atom(branchCheckoutRequestKey(request("flow-a", "a"))),
80 );
81 const unmountB = appAtomRegistry.mount(
82 checkouts.atom(branchCheckoutRequestKey(request("flow-b", "b"))),
83 );
84 await vi.waitFor(() => expect(runs).toHaveLength(1));
85 expect(runs[0]!.request.branch).toBe("a");
86
87 runs[0]!.settle(success("a"));
88 await vi.waitFor(() => expect(runs).toHaveLength(2));
89 expect(runs[1]!.request.branch).toBe("b");
90 runs[1]!.settle(success("b"));
91 unmountA();
92 unmountB();
93 });
94
95 it("handles a failure only after the route is released, and never once it left", async () => {
96 const { checkouts, runs, holds, onFailure } = setup();
97 const keyA = branchCheckoutRequestKey(request("flow-a", "a"));
98 const keyB = branchCheckoutRequestKey(request("flow-b", "b"));
99 const unmountA = appAtomRegistry.mount(checkouts.atom(keyA));
100 const unmountB = appAtomRegistry.mount(checkouts.atom(keyB));
101
102 await vi.waitFor(() => expect(runs).toHaveLength(1));
103 runs[0]!.settle(failure);
104 await vi.waitFor(() => expect(runs).toHaveLength(2));
105 expect(onFailure).not.toHaveBeenCalled();
106 checkouts.release(keyA);
107 await vi.waitFor(() => expect(onFailure).toHaveBeenCalledTimes(1));
108 expect(onFailure.mock.calls[0]![0]).toMatchObject({ flowKey: "flow-a" });
109
110 checkouts.prune(new Set(["flow-a"]));
111 runs[1]!.settle(failure);
112 checkouts.release(keyB);
113 // The hold settles only after the failure continuation has run.
114 await holds[1];
115 expect(onFailure).toHaveBeenCalledTimes(1);
116 unmountA();
117 unmountB();
118 });
119
120 it("stops holding navigation when its route leaves the stack", async () => {
121 const { checkouts, runs, holds } = setup();
122 const unmount = appAtomRegistry.mount(
123 checkouts.atom(branchCheckoutRequestKey(request("flow-a", "a"))),
124 );
125 await vi.waitFor(() => expect(runs).toHaveLength(1));
126 runs[0]!.settle(success("a"));
127
128 checkouts.prune(new Set());
129 await expect(holds[0]).resolves.toBeDefined();
130 unmount();
131 });
132});