1import {
2 AuthOrchestrationOperateScope,
3 AuthSourceControlWriteScope,
4 EnvironmentId,
5 ThreadId,
6} from "@t3tools/contracts";
7import * as Cause from "effect/Cause";
8import { AsyncResult } from "effect/reactivity";
9import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
10
11const state = vi.hoisted(() => ({
12 scopes: new Set<string>(),
13 primaryScopes: new Set<string>(),
14 shell: { branch: "main" } as { branch: string } | null,
15 detail: null as { branch: string } | null,
16 draft: null as { branch: string; worktreePath: null; envMode: "local" } | null,
17 branch: "main",
18 commits: 0,
19 metadataRequests: [] as { environmentId: string; branch: string }[],
20 afterGitAction: undefined as (() => void) | undefined,
21 run: null as ((input: { action: "commit"; featureBranch?: boolean }) => Promise<void>) | null,
22}));
23
24vi.mock("react", async (importOriginal) => ({
25 ...(await importOriginal<typeof import("react")>()),
26 useCallback: (callback: typeof state.run) => {
27 if (callback?.constructor.name === "AsyncFunction") state.run = callback;
28 return callback;
29 },
30 useMemo: (factory: () => unknown) => factory(),
31 useState: (initial: unknown) => [typeof initial === "function" ? initial() : initial, () => {}],
32 useRef: (current: unknown) => ({ current }),
33 useEffect: () => {},
34}));
35vi.mock("@effect/atom-react", () => ({
36 useAtomValue: (atom: unknown) => (atom === "vcs-state" ? { isRunning: false } : null),
37}));
38vi.mock("~/state/entities", () => ({
39 useThreadProjection: (ref: unknown) =>
40 ref === null || state.detail === null ? null : { projection: { thread: state.detail } },
41 useThreadShell: () => state.shell,
42}));
43vi.mock("~/state/session", () => ({
44 useEnvironmentScope: (environmentId: unknown, scope: string) =>
45 (environmentId === "environment" ? state.scopes : state.primaryScopes).has(scope),
46 readEnvironmentScope: (environmentId: unknown, scope: string) =>
47 (environmentId === "environment" ? state.scopes : state.primaryScopes).has(scope),
48}));
49vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: (command: unknown) => command }));
50vi.mock("~/state/server", () => ({ serverEnvironment: { configValueAtom: () => null } }));
51vi.mock("~/state/sourceControl", () => ({ sourceControlEnvironment: {} }));
52vi.mock("~/state/vcs", () => ({
53 vcsEnvironment: { status: () => null },
54 vcsActionManager: { stateAtom: () => "vcs-state" },
55}));
56vi.mock("~/state/threads", () => ({
57 threadEnvironment: {
58 updateMetadata: async ({
59 environmentId,
60 input,
61 }: {
62 environmentId: string;
63 input: { branch: string };
64 }) => {
65 state.metadataRequests.push({ environmentId, branch: input.branch });
66 if (!state.scopes.has(AuthOrchestrationOperateScope))
67 return AsyncResult.failure(Cause.fail(new Error("Task denied")));
68 if (state.shell) state.shell.branch = input.branch;
69 if (state.detail) state.detail.branch = input.branch;
70 return AsyncResult.success(undefined);
71 },
72 },
73}));
74vi.mock("~/state/query", () => ({
75 useEnvironmentQuery: () => ({
76 data: {
77 isRepo: true,
78 refName: "main",
79 isDefaultRef: false,
80 hasPrimaryRemote: true,
81 hasWorkingTreeChanges: true,
82 workingTree: {
83 files: [{ path: "file.ts", status: "modified", insertions: 1, deletions: 0 }],
84 },
85 },
86 error: null,
87 }),
88}));
89vi.mock("~/composerDraftStore", () => ({
90 useComposerDraftStore: (
91 select: (store: {
92 getDraftSession: () => typeof state.draft;
93 getDraftThreadByRef: () => typeof state.draft;
94 setDraftThreadContext: (_target: unknown, input: { branch: string }) => void;
95 }) => unknown,
96 ) =>
97 select({
98 getDraftSession: () => state.draft,
99 getDraftThreadByRef: () => state.draft,
100 setDraftThreadContext: (_target, input) => {
101 if (state.draft) state.draft.branch = input.branch;
102 },
103 }),
104}));
105vi.mock("~/lib/sourceControlActions", () => ({
106 useSourceControlActionRunning: () => false,
107 useVcsInitAction: () => ({}),
108 useVcsPullAction: () => ({}),
109 useSourceControlPublishRepositoryAction: () => ({}),
110 useGitStackedAction: () => ({
111 run: async ({ featureBranch }: { featureBranch?: boolean }) => {
112 state.commits += 1;
113 if (featureBranch) state.branch = "feature";
114 state.afterGitAction?.();
115 return {
116 _tag: "Success",
117 value: {
118 branch: featureBranch
119 ? { status: "created", name: "feature" }
120 : { status: "skipped_not_requested" },
121 toast: { title: "Committed", description: "Committed", cta: { kind: "none" } },
122 },
123 };
124 },
125 }),
126}));
127vi.mock("~/lib/utils", () => ({ cn: () => "", randomUUID: () => "action" }));
128vi.mock("~/editorPreferences", () => ({ useOpenInPreferredEditor: () => () => {} }));
129vi.mock("~/browser/useOpenLink", () => ({ useOpenLink: () => () => {} }));
130vi.mock("~/lib/openPullRequestLink", () => ({ useOpenPrLink: () => () => {} }));
131vi.mock("~/components/ui/toast", () => ({
132 stackedThreadToast: (input: unknown) => input,
133 toastManager: { add: () => "toast", update: () => {}, close: () => {} },
134}));
135vi.mock("~/components/ui/dialog", () => ({
136 Dialog: "Dialog",
137 DialogDescription: "DialogDescription",
138 DialogFooter: "DialogFooter",
139 DialogHeader: "DialogHeader",
140 DialogPanel: "DialogPanel",
141 DialogPopup: "DialogPopup",
142 DialogTitle: "DialogTitle",
143}));
144vi.mock("~/components/ui/group", () => ({ Group: "Group", GroupSeparator: "GroupSeparator" }));
145vi.mock("~/components/ui/menu", () => ({
146 Menu: "Menu",
147 MenuItem: "MenuItem",
148 MenuItemLabel: "MenuItemLabel",
149 MenuPopup: "MenuPopup",
150 MenuTrigger: "MenuTrigger",
151}));
152vi.mock("~/components/ui/popover", () => ({
153 Popover: "Popover",
154 PopoverPopup: "PopoverPopup",
155 PopoverTrigger: "PopoverTrigger",
156}));
157vi.mock("~/components/ui/tooltip", () => ({
158 Tooltip: "Tooltip",
159 TooltipPopup: "TooltipPopup",
160 TooltipTrigger: "TooltipTrigger",
161}));
162vi.mock("~/components/ui/button", () => ({ Button: "Button" }));
163vi.mock("~/components/ui/checkbox", () => ({ Checkbox: "Checkbox" }));
164vi.mock("~/components/ui/input", () => ({ Input: "Input" }));
165vi.mock("~/components/ui/radio-group", () => ({ RadioGroup: "RadioGroup" }));
166vi.mock("~/components/ui/scroll-area", () => ({ ScrollArea: "ScrollArea" }));
167vi.mock("~/components/ui/spinner", () => ({ Spinner: "Spinner" }));
168vi.mock("~/components/ui/textarea", () => ({ Textarea: "Textarea" }));
169vi.mock("~/components/ui/toggle", () => ({ toggleVariants: () => "" }));
170vi.mock("./AnimatedHeight", () => ({ AnimatedHeight: "AnimatedHeight" }));
171
172import GitActionsControl from "./GitActionsControl";
173
174function renderActions() {
175 GitActionsControl({
176 gitCwd: "/repo",
177 activeThreadRef: {
178 environmentId: EnvironmentId.make("environment"),
179 threadId: ThreadId.make("thread"),
180 },
181 });
182 if (!state.run) throw new Error("Git action missing");
183 return state.run;
184}
185
186describe("Git actions while thread details load", () => {
187 beforeEach(() => {
188 state.scopes = new Set([AuthSourceControlWriteScope]);
189 state.primaryScopes = new Set([AuthSourceControlWriteScope, AuthOrchestrationOperateScope]);
190 state.shell = { branch: "main" };
191 state.detail = null;
192 state.draft = null;
193 state.branch = "main";
194 state.commits = 0;
195 state.metadataRequests = [];
196 state.afterGitAction = undefined;
197 state.run = null;
198 });
199
200 it("does not create a feature branch for a server thread without task permission", async () => {
201 await renderActions()({ action: "commit", featureBranch: true });
202
203 expect(state.branch).toBe("main");
204 expect(state.commits).toBe(0);
205 expect(state.shell?.branch).toBe("main");
206 });
207
208 it("commits and synchronizes the server thread before details finish loading", async () => {
209 state.primaryScopes.clear();
210 state.scopes.add(AuthOrchestrationOperateScope);
211 await renderActions()({ action: "commit", featureBranch: true });
212
213 expect(state.branch).toBe("feature");
214 expect(state.commits).toBe(1);
215 expect(state.shell?.branch).toBe("feature");
216 expect(state.metadataRequests).toEqual([{ environmentId: "environment", branch: "feature" }]);
217 });
218
219 it("synchronizes archived thread detail after its shell disappears", async () => {
220 state.scopes.add(AuthOrchestrationOperateScope);
221 state.detail = { branch: "main" };
222 renderActions();
223 state.shell = null;
224
225 await renderActions()({ action: "commit", featureBranch: true });
226
227 expect(state.commits).toBe(1);
228 expect(state.branch).toBe("feature");
229 expect(state.detail.branch).toBe("feature");
230 expect(state.metadataRequests).toEqual([{ environmentId: "environment", branch: "feature" }]);
231 });
232
233 it("requires task permission before changing an archived thread's branch", async () => {
234 state.shell = null;
235 state.detail = { branch: "main" };
236
237 await renderActions()({ action: "commit", featureBranch: true });
238
239 expect(state.commits).toBe(0);
240 expect(state.detail.branch).toBe("main");
241 expect(state.metadataRequests).toEqual([]);
242 });
243
244 it("uses the current shell branch when cached detail names the new branch", async () => {
245 state.scopes.add(AuthOrchestrationOperateScope);
246 state.detail = { branch: "feature" };
247
248 await renderActions()({ action: "commit", featureBranch: true });
249
250 expect(state.commits).toBe(1);
251 expect(state.shell?.branch).toBe("feature");
252 expect(state.metadataRequests).toEqual([{ environmentId: "environment", branch: "feature" }]);
253 });
254
255 it("synchronizes a retained callback after the thread grant is gained", async () => {
256 const run = renderActions();
257 state.scopes.add(AuthOrchestrationOperateScope);
258 await run({ action: "commit", featureBranch: true });
259 expect(state.commits).toBe(1);
260 expect(state.shell?.branch).toBe("feature");
261 expect(state.metadataRequests).toHaveLength(1);
262 });
263
264 it.each([
265 [AuthOrchestrationOperateScope, false],
266 [AuthSourceControlWriteScope, true],
267 ] as const)(
268 "uses the fresh task grant after Git finishes, revoking %s",
269 async (revokedScope, syncsThread) => {
270 state.scopes.add(AuthOrchestrationOperateScope);
271 state.afterGitAction = () => state.scopes.delete(revokedScope);
272 await renderActions()({ action: "commit", featureBranch: true });
273 expect(state.commits).toBe(1);
274 expect(state.branch).toBe("feature");
275 expect(state.shell?.branch).toBe(syncsThread ? "feature" : "main");
276 expect(state.metadataRequests).toHaveLength(syncsThread ? 1 : 0);
277 },
278 );
279
280 it("keeps ordinary commits available while details load", async () => {
281 await renderActions()({ action: "commit" });
282
283 expect(state.commits).toBe(1);
284 expect(state.branch).toBe("main");
285 });
286
287 it("keeps feature-branch commits available for a local draft", async () => {
288 state.shell = null;
289 state.draft = { branch: "main", worktreePath: null, envMode: "local" };
290 await renderActions()({ action: "commit", featureBranch: true });
291
292 expect(state.commits).toBe(1);
293 expect(state.draft.branch).toBe("feature");
294 expect(state.metadataRequests).toEqual([]);
295 });
296});