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 { isValidElement, type ReactNode } from "react";
10import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
11
12const state = vi.hoisted(() => ({
13 canOperate: false,
14 canWriteSourceControl: true,
15 hookValues: [] as unknown[],
16 hookIndex: 0,
17 tracks: 0,
18 resets: 0,
19 manager: { operation: null as string | null, error: null, isRunning: false },
20 interrupted: false,
21 pendingResponse: undefined as Promise<void> | undefined,
22 openChanges: [] as boolean[],
23 checkouts: [] as string[],
24 setupScripts: 0,
25 prepared: [] as { branch: string; worktreePath: string | null }[],
26}));
27
28vi.mock("react", async (importOriginal) => ({
29 ...(await importOriginal<typeof import("react")>()),
30 useCallback: (callback: unknown) => callback,
31 useMemo: (factory: () => unknown) => factory(),
32 useState: (initial: unknown) => {
33 const index = state.hookIndex++;
34 if (index === state.hookValues.length) state.hookValues.push(initial);
35 return [state.hookValues[index], (value: unknown) => (state.hookValues[index] = value)];
36 },
37 useRef: (current: unknown) => ({ current }),
38 useEffect: () => {},
39}));
40vi.mock("@tanstack/react-pacer", () => ({
41 useDebouncedValue: (value: unknown) => [value, { state: { isPending: false } }],
42}));
43vi.mock("~/state/session", () => ({
44 useEnvironmentScope: (_environmentId: unknown, scope: string) =>
45 scope === AuthOrchestrationOperateScope
46 ? state.canOperate
47 : scope === AuthSourceControlWriteScope && state.canWriteSourceControl,
48 readEnvironmentScope: (_environmentId: unknown, scope: string) =>
49 scope === AuthOrchestrationOperateScope
50 ? state.canOperate
51 : scope === AuthSourceControlWriteScope && state.canWriteSourceControl,
52}));
53vi.mock("~/lib/sourceControlActions", async () => {
54 const { usePreparePullRequestThreadAction } = await vi.importActual<
55 typeof import("~/state/sourceControlActions")
56 >("~/state/sourceControlActions");
57 return {
58 readCachedPullRequestResolution: () => null,
59 usePullRequestResolution: () => ({
60 data: { pullRequest: { number: 123, title: "Pull request", state: "open" } },
61 }),
62 usePreparePullRequestThreadAction,
63 };
64});
65vi.mock("@effect/atom-react", () => ({ useAtomValue: () => state.manager }));
66vi.mock("~/rpc/atomRegistry", () => ({ appAtomRegistry: {} }));
67vi.mock("~/state/use-atom-command", () => ({
68 useAtomCommand: (command: unknown) => command,
69}));
70vi.mock("~/state/sourceControl", () => ({ sourceControlEnvironment: {} }));
71vi.mock("~/state/git", () => ({
72 gitEnvironment: {
73 preparePullRequestThread: async ({
74 input: { mode, threadId },
75 }: {
76 input: { mode: string; threadId?: string };
77 }) => {
78 state.checkouts.push(mode);
79 if (mode === "worktree" && threadId) state.setupScripts += 1;
80 await state.pendingResponse;
81 if (state.interrupted) return AsyncResult.failure(Cause.interrupt());
82 return AsyncResult.success({
83 branch: "feature/pr",
84 worktreePath: mode === "worktree" ? "/worktree" : null,
85 });
86 },
87 },
88}));
89vi.mock("~/lib/utils", () => ({ cn: () => "" }));
90vi.mock("~/state/query", () => ({ useEnvironmentQuery: () => ({ data: null }) }));
91vi.mock("~/state/vcs", () => ({
92 vcsEnvironment: { status: () => null },
93 vcsActionManager: {
94 stateAtom: () => "vcs-state",
95 track: (_registry: unknown, _target: unknown, _input: unknown, execute: () => unknown) => {
96 state.tracks += 1;
97 return execute();
98 },
99 resetError: () => state.resets++,
100 },
101}));
102vi.mock("./ui/button", () => ({ Button: "Button" }));
103vi.mock("./ui/input", () => ({ Input: "Input" }));
104vi.mock("./ui/spinner", () => ({ Spinner: "Spinner" }));
105vi.mock("./ui/dialog", () => ({
106 Dialog: "Dialog",
107 DialogDescription: "DialogDescription",
108 DialogFooter: "DialogFooter",
109 DialogHeader: "DialogHeader",
110 DialogPanel: "DialogPanel",
111 DialogPopup: "DialogPopup",
112 DialogTitle: "DialogTitle",
113}));
114
115import { PullRequestThreadDialog } from "./PullRequestThreadDialog";
116
117function findAction(node: ReactNode, label: string): (() => unknown) | null {
118 if (Array.isArray(node)) {
119 for (const child of node) {
120 const action = findAction(child, label);
121 if (action) return action;
122 }
123 return null;
124 }
125 if (!isValidElement<{ children?: ReactNode; onClick?: () => unknown }>(node)) return null;
126 if (node.props.children === label) return node.props.onClick ?? null;
127 return findAction(node.props.children, label);
128}
129
130function renderDialog() {
131 state.hookIndex = 0;
132 return PullRequestThreadDialog({
133 open: true,
134 environmentId: EnvironmentId.make("environment"),
135 threadId: ThreadId.make("thread"),
136 cwd: "/repo",
137 initialReference: "123",
138 onOpenChange: (open) => state.openChanges.push(open),
139 onPrepared: (input) => {
140 state.prepared.push(input);
141 },
142 });
143}
144
145function prepareAction(label: "Local" | "Worktree") {
146 const action = findAction(renderDialog(), label);
147 if (!action) throw new Error(`${label} action missing`);
148 return action;
149}
150
151function visibleText(node: ReactNode): string {
152 if (Array.isArray(node)) return node.map(visibleText).join(" ");
153 if (typeof node === "string" || typeof node === "number") return String(node);
154 return isValidElement<{ children?: ReactNode }>(node) ? visibleText(node.props.children) : "";
155}
156
157function deferred<T>() {
158 let resolve!: (value: T) => void;
159 const promise = new Promise<T>((complete) => {
160 resolve = complete;
161 });
162 return { promise, resolve };
163}
164
165describe("pull request worktree permissions", () => {
166 beforeEach(() => {
167 state.canOperate = false;
168 state.canWriteSourceControl = true;
169 state.hookValues = [];
170 state.hookIndex = 0;
171 state.tracks = 0;
172 state.resets = 0;
173 state.manager = { operation: null, error: null, isRunning: false };
174 state.interrupted = false;
175 state.pendingResponse = undefined;
176 state.openChanges = [];
177 state.checkouts = [];
178 state.setupScripts = 0;
179 state.prepared = [];
180 });
181
182 it("does not prepare a worktree or run setup with only source-control permission", async () => {
183 await prepareAction("Worktree")();
184
185 expect(state.checkouts).toEqual([]);
186 expect(state.setupScripts).toBe(0);
187 expect(state.prepared).toEqual([]);
188 });
189
190 it("prepares the worktree and thread when task permission is also granted", async () => {
191 state.canOperate = true;
192 await prepareAction("Worktree")();
193
194 expect(state.checkouts).toEqual(["worktree"]);
195 expect(state.setupScripts).toBe(1);
196 expect(state.prepared).toEqual([{ branch: "feature/pr", worktreePath: "/worktree" }]);
197 });
198
199 it("rechecks task permission before invoking a retained worktree action", async () => {
200 state.canOperate = true;
201 const prepare = prepareAction("Worktree");
202 state.canOperate = false;
203 await prepare();
204
205 expect(state.checkouts).toEqual([]);
206 expect(state.setupScripts).toBe(0);
207 });
208
209 it("keeps local checkout available without task permission", async () => {
210 await prepareAction("Local")();
211
212 expect(state.checkouts).toEqual(["local"]);
213 expect(state.setupScripts).toBe(0);
214 expect(state.prepared).toEqual([{ branch: "feature/pr", worktreePath: null }]);
215 });
216
217 it.each(["Local", "Worktree"] as const)(
218 "shows the returned scope denial for a retained %s callback without starting a checkout",
219 async (label) => {
220 state.canOperate = true;
221 state.manager = { operation: "pull", error: null, isRunning: true };
222 const prepare = prepareAction(label);
223 state.canWriteSourceControl = false;
224
225 await prepare();
226
227 expect(visibleText(renderDialog())).toContain(
228 "This connection cannot change source control.",
229 );
230 expect(state.checkouts).toEqual([]);
231 expect(state.setupScripts).toBe(0);
232 expect(state.prepared).toEqual([]);
233 expect(state.openChanges).toEqual([]);
234 expect(state.tracks).toBe(0);
235 expect(state.manager).toEqual({ operation: "pull", error: null, isRunning: true });
236 },
237 );
238
239 it("clears a returned error when a newly authorized retry starts, then completes normally", async () => {
240 const prepare = prepareAction("Local");
241 state.canWriteSourceControl = false;
242 await prepare();
243 expect(visibleText(renderDialog())).toContain("This connection cannot change source control.");
244
245 state.canWriteSourceControl = true;
246 const response = deferred<void>();
247 state.pendingResponse = response.promise;
248 const completion = prepareAction("Local")();
249
250 expect(visibleText(renderDialog())).not.toContain(
251 "This connection cannot change source control.",
252 );
253 expect(state.checkouts).toEqual(["local"]);
254 expect(state.prepared).toEqual([]);
255 response.resolve(undefined);
256 await completion;
257
258 expect(state.prepared).toEqual([{ branch: "feature/pr", worktreePath: null }]);
259 expect(state.openChanges).toEqual([false]);
260 });
261
262 it("keeps interrupted preparations quiet and preserves error reset behavior", async () => {
263 state.interrupted = true;
264 const before = visibleText(renderDialog());
265
266 await prepareAction("Local")();
267
268 expect(visibleText(renderDialog())).toBe(before);
269 expect(state.resets).toBe(1);
270 expect(state.prepared).toEqual([]);
271 expect(state.openChanges).toEqual([]);
272 });
273});