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("~/state/sourceControlActions", async () => {
54 const { usePreparePullRequestThreadAction } = await vi.importActual<
55 typeof import("~/state/sourceControlActions")
56 >("~/state/sourceControlActions");
57 return {
58 readCachedPullRequestResolution: () => null,
59 usePullRequestResolutionState: () => ({
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 isOnPullRequestHead: true,
86 });
87 },
88 },
89}));
90vi.mock("~/lib/utils", () => ({ cn: () => "" }));
91vi.mock("~/state/query", () => ({ useEnvironmentQuery: () => ({ data: null }) }));
92vi.mock("~/state/vcs", () => ({
93 vcsEnvironment: { status: () => null },
94 vcsActionManager: {
95 stateAtom: () => "vcs-state",
96 track: (_registry: unknown, _target: unknown, _input: unknown, execute: () => unknown) => {
97 state.tracks += 1;
98 return execute();
99 },
100 resetError: () => state.resets++,
101 },
102}));
103vi.mock("./ui/button", () => ({ Button: "Button" }));
104vi.mock("./ui/input", () => ({ Input: "Input" }));
105vi.mock("./ui/spinner", () => ({ Spinner: "Spinner" }));
106vi.mock("./ui/toast", () => ({ toastManager: { add: vi.fn() } }));
107vi.mock("./ui/dialog", () => ({
108 Dialog: "Dialog",
109 DialogDescription: "DialogDescription",
110 DialogFooter: "DialogFooter",
111 DialogHeader: "DialogHeader",
112 DialogPanel: "DialogPanel",
113 DialogPopup: "DialogPopup",
114 DialogTitle: "DialogTitle",
115}));
116
117import { PullRequestThreadDialog } from "./PullRequestThreadDialog";
118
119function findAction(node: ReactNode, label: string): (() => unknown) | null {
120 if (Array.isArray(node)) {
121 for (const child of node) {
122 const action = findAction(child, label);
123 if (action) return action;
124 }
125 return null;
126 }
127 if (!isValidElement<{ children?: ReactNode; onClick?: () => unknown }>(node)) return null;
128 if (node.props.children === label) return node.props.onClick ?? null;
129 return findAction(node.props.children, label);
130}
131
132function renderDialog() {
133 state.hookIndex = 0;
134 return PullRequestThreadDialog({
135 open: true,
136 environmentId: EnvironmentId.make("environment"),
137 threadId: ThreadId.make("thread"),
138 cwd: "/repo",
139 initialReference: "123",
140 onOpenChange: (open) => state.openChanges.push(open),
141 onPrepared: (input) => {
142 state.prepared.push(input);
143 },
144 });
145}
146
147function prepareAction(label: "Local" | "Worktree") {
148 const action = findAction(renderDialog(), label);
149 if (!action) throw new Error(`${label} action missing`);
150 return action;
151}
152
153describe("pull request worktree permissions", () => {
154 beforeEach(() => {
155 state.canOperate = false;
156 state.canWriteSourceControl = true;
157 state.hookValues = [];
158 state.hookIndex = 0;
159 state.tracks = 0;
160 state.resets = 0;
161 state.manager = { operation: null, error: null, isRunning: false };
162 state.interrupted = false;
163 state.pendingResponse = undefined;
164 state.openChanges = [];
165 state.checkouts = [];
166 state.setupScripts = 0;
167 state.prepared = [];
168 });
169
170 it("rechecks task permission before invoking a retained worktree action", async () => {
171 state.canOperate = true;
172 const prepare = prepareAction("Worktree");
173 state.canOperate = false;
174 await prepare();
175
176 expect(state.checkouts).toEqual([]);
177 expect(state.setupScripts).toBe(0);
178 });
179});