1// @vitest-environment jsdom
2import { AuthSourceControlWriteScope, EnvironmentId, ThreadId } from "@t3tools/contracts";
3import { AsyncResult } from "effect/reactivity";
4import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
5
6const state = vi.hoisted(() => ({
7 actions: [] as {
8 scope: { environmentId: string | null; cwd: string | null };
9 input: { threadId?: string; action: string };
10 }[],
11 toasts: [] as { type?: string; description?: string; actionProps?: { onClick: () => void } }[],
12 scopes: new Set<string>(),
13 primaryScopes: new Set<string>(),
14 isRepo: true,
15}));
16
17vi.mock("@effect/atom-react", () => ({
18 useAtomValue: (atom: unknown) => (atom === "vcs-state" ? { isRunning: false } : null),
19}));
20vi.mock("~/state/entities", () => ({
21 useThreadProjection: () => null,
22 useThreadShell: (ref: { threadId: string }) => ({ branch: "main", id: ref.threadId }),
23}));
24vi.mock("~/state/session", () => ({
25 useEnvironmentScope: (environmentId: unknown, scope: string) =>
26 (environmentId === "environment" ? state.scopes : state.primaryScopes).has(scope),
27 readEnvironmentScope: (environmentId: unknown, scope: string) =>
28 (environmentId === "environment" ? state.scopes : state.primaryScopes).has(scope),
29}));
30vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: (command: unknown) => command }));
31vi.mock("~/state/server", () => ({ serverEnvironment: { configValueAtom: () => null } }));
32vi.mock("~/state/sourceControl", () => ({ sourceControlEnvironment: { discovery: () => null } }));
33vi.mock("~/state/vcs", () => ({
34 vcsEnvironment: { status: () => null },
35 vcsActionManager: { stateAtom: () => "vcs-state" },
36}));
37vi.mock("~/state/threads", () => ({
38 threadEnvironment: { updateMetadata: async () => AsyncResult.success(undefined) },
39}));
40vi.mock("~/state/query", () => ({
41 useEnvironmentQuery: () => ({
42 data: {
43 sourceControlProviders: [],
44 isRepo: state.isRepo,
45 refName: "main",
46 isDefaultRef: false,
47 hasPrimaryRemote: true,
48 hasWorkingTreeChanges: true,
49 workingTree: {
50 files: [{ path: "file.ts", status: "modified", insertions: 1, deletions: 0 }],
51 },
52 },
53 error: null,
54 }),
55}));
56vi.mock("@tanstack/react-router", () => ({ useNavigate: () => () => {} }));
57vi.mock("~/composerDraftStore", () => ({
58 useComposerDraftStore: (
59 select: (store: {
60 getDraftSession: () => null;
61 getDraftThreadByRef: () => null;
62 setDraftThreadContext: () => void;
63 }) => unknown,
64 ) =>
65 select({
66 getDraftSession: () => null,
67 getDraftThreadByRef: () => null,
68 setDraftThreadContext: () => {},
69 }),
70}));
71vi.mock("~/lib/sourceControlActions", () => ({
72 useSourceControlActionRunning: () => false,
73 useVcsInitAction: () => ({}),
74 useVcsPullAction: () => ({}),
75 useSourceControlPublishRepositoryAction: () => ({}),
76 useGitStackedAction: (scope: { environmentId: string | null; cwd: string | null }) => ({
77 run: async (input: { featureBranch?: boolean; threadId?: string; action: string }) => {
78 state.actions.push({ scope, input });
79 return {
80 _tag: "Success",
81 value: {
82 branch: { status: "skipped_not_requested" },
83 toast: {
84 title: "Committed",
85 description: "Committed",
86 cta: {
87 kind: "run_action",
88 label: "Push & create PR",
89 action: { kind: "commit_push_pr" },
90 },
91 },
92 },
93 };
94 },
95 }),
96}));
97vi.mock("~/lib/utils", () => ({ cn: () => "", randomUUID: () => "action" }));
98vi.mock("~/editorPreferences", () => ({ useOpenInPreferredEditor: () => () => {} }));
99vi.mock("~/browser/useOpenLink", () => ({ useOpenLink: () => () => {} }));
100vi.mock("~/lib/openPullRequestLink", () => ({ useOpenPrLink: () => () => {} }));
101vi.mock("~/components/ui/toast", () => ({
102 stackedThreadToast: (input: unknown) => input,
103 toastManager: {
104 add: (input: {
105 type?: string;
106 description?: string;
107 actionProps?: { onClick: () => void };
108 }) => {
109 state.toasts.push(input);
110 return "toast";
111 },
112 update: () => {},
113 close: () => {},
114 },
115}));
116vi.mock("~/components/ui/dialog", () => ({
117 Dialog: "Dialog",
118 DialogDescription: "DialogDescription",
119 DialogFooter: "DialogFooter",
120 DialogHeader: "DialogHeader",
121 DialogPanel: "DialogPanel",
122 DialogPopup: "DialogPopup",
123 DialogTitle: "DialogTitle",
124}));
125vi.mock("~/components/ui/group", () => ({ Group: "Group", GroupSeparator: "GroupSeparator" }));
126vi.mock("~/components/ui/menu", () => ({
127 Menu: "Menu",
128 MenuItem: "MenuItem",
129 MenuItemLabel: "MenuItemLabel",
130 MenuPopup: "MenuPopup",
131 MenuTrigger: "MenuTrigger",
132}));
133vi.mock("~/components/ui/popover", () => ({
134 Popover: "Popover",
135 PopoverPopup: "PopoverPopup",
136 PopoverTrigger: "PopoverTrigger",
137}));
138vi.mock("~/components/ui/tooltip", () => ({
139 Tooltip: "Tooltip",
140 TooltipPopup: "TooltipPopup",
141 TooltipTrigger: "TooltipTrigger",
142}));
143vi.mock("~/components/ui/button", () => ({ Button: "button" }));
144vi.mock("~/components/ui/checkbox", () => ({ Checkbox: "Checkbox" }));
145vi.mock("~/components/ui/input", () => ({ Input: "Input" }));
146vi.mock("~/components/ui/radio-group", () => ({ RadioGroup: "RadioGroup" }));
147vi.mock("~/components/ui/scroll-area", () => ({ ScrollArea: "ScrollArea" }));
148vi.mock("~/components/ui/spinner", () => ({ Spinner: "Spinner" }));
149vi.mock("~/components/ui/textarea", () => ({ Textarea: "Textarea" }));
150vi.mock("~/components/ui/toggle", () => ({ toggleVariants: () => "" }));
151vi.mock("./AnimatedHeight", () => ({ AnimatedHeight: "AnimatedHeight" }));
152
153import GitActionsControl from "./GitActionsControl";
154
155import { createElement, memo } from "react";
156import { act, create } from "react-test-renderer";
157
158vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
159
160const renderers: ReturnType<typeof create>[] = [];
161// ThreadDetailsPanel keeps this component mounted behind React.memo when threads change.
162const MemoGitActionsControl = memo(GitActionsControl);
163const props = (thread: string, cwd: string) => ({
164 gitCwd: cwd,
165 activeThreadRef: {
166 environmentId: EnvironmentId.make("environment"),
167 threadId: ThreadId.make(thread),
168 },
169});
170
171async function renderActions(thread: string, cwd: string) {
172 let renderer!: ReturnType<typeof create>;
173 await act(async () => {
174 renderer = create(createElement(MemoGitActionsControl, props(thread, cwd)));
175 });
176 renderers.push(renderer);
177 return renderer;
178}
179
180async function clickQuickAction(renderer: ReturnType<typeof create>) {
181 const button = renderer.root
182 .findAllByType("button")
183 .find((button) => button.props.onClick && !button.props.disabled);
184 expect(button).toBeDefined();
185 await act(async () => {
186 button!.props.onClick({});
187 });
188}
189
190beforeEach(() => {
191 state.scopes = new Set([AuthSourceControlWriteScope]);
192 state.primaryScopes = new Set([AuthSourceControlWriteScope]);
193 state.actions = [];
194 state.toasts = [];
195 state.isRepo = true;
196});
197
198afterEach(async () => {
199 await act(async () => {
200 for (const renderer of renderers.splice(0)) renderer.unmount();
201 });
202});
203
204describe("Git action targets across thread navigation", () => {
205 it("keeps Changes reachable in a JJ-only panel without rendering Git actions", async () => {
206 state.isRepo = false;
207 let renderer!: ReturnType<typeof create>;
208 await act(async () => {
209 renderer = create(
210 createElement(MemoGitActionsControl, {
211 ...props("jj-thread", "/jj-workspace"),
212 displayMode: "panel",
213 onOpenChanges: () => {},
214 }),
215 );
216 });
217 renderers.push(renderer);
218 expect(renderer.root.findAllByType("button")).toHaveLength(1);
219 expect(JSON.stringify(renderer.toJSON())).toContain("Changes");
220 expect(JSON.stringify(renderer.toJSON())).not.toContain("Commit");
221 expect(state.actions).toEqual([]);
222
223 await act(async () => {
224 renderer.update(
225 createElement(MemoGitActionsControl, {
226 ...props("plain-thread", "/plain-folder"),
227 displayMode: "panel",
228 }),
229 );
230 });
231 expect(renderer.toJSON()).toBeNull();
232 });
233
234 it("runs a retained PR toast in the thread that produced it", async () => {
235 const renderer = await renderActions("first", "/first");
236 await clickQuickAction(renderer);
237 const toast = state.toasts.find((toast) => toast.actionProps)!;
238 expect(toast).toBeDefined();
239 await act(async () => {
240 renderer.update(createElement(MemoGitActionsControl, props("second", "/second")));
241 });
242 await act(async () => {
243 toast.actionProps!.onClick();
244 });
245 expect(state.actions.at(-1)).toMatchObject({
246 scope: { cwd: "/first" },
247 input: { threadId: "first", action: "commit_push_pr" },
248 });
249 });
250
251 it("runs the memoized panel button in the current thread after navigation", async () => {
252 const renderer = await renderActions("first", "/first");
253 for (const [thread, cwd] of [
254 ["first", "/first"],
255 ["second", "/second"],
256 ["first", "/first"],
257 ] as const) {
258 await act(async () => {
259 renderer.update(createElement(MemoGitActionsControl, props(thread, cwd)));
260 });
261 await clickQuickAction(renderer);
262 expect(state.actions.at(-1)).toMatchObject({ scope: { cwd }, input: { threadId: thread } });
263 }
264 });
265});