1import {
2 AuthOrchestrationOperateScope,
3 EnvironmentId,
4 ProjectId,
5 ThreadId,
6 type AuthEnvironmentScope,
7 type EditorId,
8 type ThreadLinkedPullRequest,
9} from "@t3tools/contracts";
10import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings";
11import { AsyncResult } from "effect/reactivity";
12import { cloneElement, type ReactElement, type ReactNode } from "react";
13import { act, create, type ReactTestRenderer } from "react-test-renderer";
14import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
15
16const state = vi.hoisted(() => ({
17 allowed: true,
18 listeners: new Set<() => void>(),
19 openEditor: vi.fn(),
20 updateMetadata: vi.fn(),
21 search: vi.fn(),
22 choose: vi.fn(),
23 openFile: vi.fn(),
24 openExternal: vi.fn(),
25 copy: vi.fn(),
26 toast: vi.fn(),
27 linkedPullRequest: null as ThreadLinkedPullRequest | null,
28}));
29
30vi.mock("@effect/atom-react", () => ({ useAtomValue: () => serverConfig }));
31vi.mock("../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) }));
32vi.mock("../hooks/useFileMetadata", () => ({ useFileMetadata: () => undefined }));
33vi.mock("../hooks/useSettings", () => ({
34 getClientSettings: () => DEFAULT_CLIENT_SETTINGS,
35 useClientSettings: (select?: (value: typeof DEFAULT_CLIENT_SETTINGS) => unknown) =>
36 select ? select(DEFAULT_CLIENT_SETTINGS) : DEFAULT_CLIENT_SETTINGS,
37}));
38vi.mock("./ui/tooltip", () => ({
39 Tooltip: ({ children }: { children: ReactNode }) => children,
40 TooltipTrigger: ({ render, children }: { render: ReactElement; children?: ReactNode }) =>
41 children === undefined ? render : cloneElement(render, undefined, children),
42 TooltipPopup: () => null,
43}));
44vi.mock("./chat/PierreEntryIcon", () => ({ PierreEntryIcon: () => null }));
45vi.mock("./ui/toast", () => ({
46 toastManager: { add: state.toast },
47 stackedThreadToast: (value: unknown) => value,
48}));
49vi.mock("../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => state.search }));
50vi.mock("../state/use-atom-command", () => ({
51 useAtomCommand: (command: string) =>
52 command === "updateMetadata" ? state.updateMetadata : state.openEditor,
53}));
54vi.mock("../state/session", async () => {
55 const { useSyncExternalStore } = await import("react");
56 const readEnvironmentScope = (id: EnvironmentId | null, scope: AuthEnvironmentScope) =>
57 id !== null &&
58 (scope !== AuthOrchestrationOperateScope || id !== threadRef.environmentId || state.allowed);
59 return {
60 // Asset atoms read these at module scope; the markdown tests only exercise
61 // the task grant.
62 environmentSession: {
63 sessionStateAtom: () => ({}),
64 preparedConnectionValueAtom: () => ({}),
65 },
66 readEnvironmentScope,
67 useEnvironmentScope: (id: EnvironmentId | null, scope: AuthEnvironmentScope) =>
68 useSyncExternalStore(
69 (listener) => {
70 state.listeners.add(listener);
71 return () => state.listeners.delete(listener);
72 },
73 () => readEnvironmentScope(id, scope),
74 ),
75 usePreparedConnection: () => ({ _tag: "None" }),
76 };
77});
78vi.mock("../state/server", () => ({
79 serverEnvironment: { configValueAtom: () => "config" },
80}));
81vi.mock("../state/threads", () => ({
82 threadEnvironment: { updateMetadata: "updateMetadata" },
83}));
84vi.mock("../state/entities", () => ({
85 readEnvironmentSupportsServerBrowser: () => false,
86 useServerConfigs: () => new Map([[threadRef.environmentId, serverConfig]]),
87 readThreadShell: () => ({ linkedPullRequest: state.linkedPullRequest }),
88 useProjects: () => [{ id: linkedPullRequest.projectId, environmentId: threadRef.environmentId }],
89}));
90vi.mock("../rightPanelStore", () => ({
91 useRightPanelStore: { getState: () => ({ openFile: state.openFile }) },
92}));
93vi.mock("../remoteOpen", () => ({
94 useRemoteOpenResolution: () => ({ state: { mode: "local-exec" }, isResolved: true }),
95}));
96vi.mock("../hooks/useLocalStorage", () => ({
97 useLocalStorage: () => ["vscode", vi.fn()],
98 getLocalStorageItem: () => "vscode",
99 setLocalStorageItem: vi.fn(),
100}));
101vi.mock("../hooks/useCopyToClipboard", () => ({
102 writeTextToClipboard: state.copy,
103 useCopyToClipboard: () => ({ copyToClipboard: state.copy, isCopied: false }),
104}));
105vi.mock("../localApi", () => ({
106 readLocalApi: () => ({
107 contextMenu: { show: state.choose },
108 shell: { openExternal: state.openExternal },
109 }),
110}));
111vi.mock("~/lib/openPullRequestLink", () => ({
112 resolvePullRequestPreviewTarget: () => null,
113 findProjectForChangeRequest: (projects: readonly { id: ProjectId }[]) => projects[0],
114 matchesLinkedPullRequestUrl: (candidate: ThreadLinkedPullRequest, href: string) =>
115 candidate.url === href,
116 parseChangeRequestUrl: (href: string) =>
117 href === linkedPullRequest.url
118 ? {
119 host: "github.com",
120 repository: linkedPullRequest.repository,
121 number: linkedPullRequest.number,
122 }
123 : null,
124 useOpenChangeRequestLink: () => vi.fn(),
125}));
126
127import ChatMarkdown from "./ChatMarkdown";
128
129const threadRef = {
130 environmentId: EnvironmentId.make("selected"),
131 threadId: ThreadId.make("thread"),
132};
133const linkedPullRequest: ThreadLinkedPullRequest = {
134 projectId: ProjectId.make("project"),
135 repository: "example/repo",
136 number: 42,
137 url: "https://github.com/example/repo/pull/42",
138};
139const serverConfig = {
140 availableEditors: ["vscode", "file-manager"] as readonly EditorId[],
141 shellRevealInFileManager: true,
142 shellRevealInFileManagerKind: "xdg-open",
143 environment: { platform: { os: "linux" }, capabilities: { threadPullRequestLinking: true } },
144};
145let renderer: ReactTestRenderer | undefined;
146
147beforeEach(() => {
148 state.allowed = true;
149 state.listeners.clear();
150 state.linkedPullRequest = null;
151 state.openEditor.mockReset().mockResolvedValue(AsyncResult.success(undefined));
152 state.updateMetadata.mockReset().mockResolvedValue(AsyncResult.success(undefined));
153 state.search.mockReset().mockResolvedValue(AsyncResult.success({ entries: [] }));
154 state.choose.mockReset().mockResolvedValue(null);
155 state.openFile.mockReset();
156 state.openExternal.mockReset().mockResolvedValue(undefined);
157 state.copy.mockReset().mockResolvedValue(undefined);
158 state.toast.mockReset();
159 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
160 vi.stubGlobal("window", {});
161 vi.stubGlobal("navigator", { clipboard: { writeText: state.copy } });
162});
163
164afterEach(async () => {
165 await act(async () => renderer?.unmount());
166 renderer = undefined;
167 vi.unstubAllGlobals();
168});
169
170async function renderMarkdown(text: string) {
171 await act(async () => {
172 renderer = create(<ChatMarkdown cwd="/work" threadRef={threadRef} text={text} />);
173 });
174}
175
176function menuEvent() {
177 return { preventDefault: vi.fn(), stopPropagation: vi.fn(), clientX: 10, clientY: 10 };
178}
179
180function anchor() {
181 return renderer!.root.findByType("a");
182}
183
184async function openContextMenu() {
185 await act(async () => anchor().props.onContextMenu(menuEvent()));
186}
187
188function offeredActions() {
189 return state.choose.mock.lastCall![0].map((item: { id: string }) => item.id);
190}
191
192it("removes host actions after revocation while keeping file preview and copying", async () => {
193 await renderMarkdown("[Readme](docs/readme.md)");
194 await openContextMenu();
195 expect(offeredActions()).toEqual(["open", "reveal", "copy-relative", "copy-full"]);
196 await act(async () => {
197 state.allowed = false;
198 for (const listener of state.listeners) listener();
199 });
200 state.choose.mockResolvedValue("copy-full");
201 await openContextMenu();
202 expect(offeredActions()).toEqual(["copy-relative", "copy-full"]);
203 expect(state.copy).toHaveBeenCalledWith("/work/docs/readme.md");
204 await act(async () => anchor().props.onClick(menuEvent()));
205 expect(state.openFile).toHaveBeenCalledWith(threadRef, "docs/readme.md", undefined);
206 expect(state.openEditor).not.toHaveBeenCalled();
207});
208
209it("does not launch an editor after revocation during a native context menu", async () => {
210 let choose: (action: string) => void = () => {
211 throw new Error("Menu not opened");
212 };
213 state.choose.mockImplementationOnce(
214 () =>
215 new Promise<string>((resolve) => {
216 choose = resolve;
217 }),
218 );
219 await renderMarkdown("[Readme](docs/readme.md)");
220 await openContextMenu();
221 expect(offeredActions()).toContain("open");
222 await act(async () => {
223 state.allowed = false;
224 choose("open");
225 });
226 expect(state.openEditor).not.toHaveBeenCalled();
227});
228
229it("does not reveal a file after revocation during its workspace lookup", async () => {
230 let finishLookup: () => void = () => {
231 throw new Error("Lookup not started");
232 };
233 state.search.mockImplementationOnce(
234 () =>
235 new Promise((resolve) => {
236 finishLookup = () => resolve(AsyncResult.success({ entries: [] }));
237 }),
238 );
239 state.choose.mockResolvedValue("reveal");
240 await renderMarkdown("[Readme](readme.md)");
241 await openContextMenu();
242 expect(state.search).toHaveBeenCalledOnce();
243 await act(async () => {
244 state.allowed = false;
245 finishLookup();
246 });
247 expect(state.openEditor).not.toHaveBeenCalled();
248});
249
250it.each([false, true])(
251 "keeps PR links usable without offering metadata edits (linked=%s)",
252 async (linked) => {
253 state.allowed = false;
254 state.linkedPullRequest = linked ? linkedPullRequest : null;
255 state.choose.mockResolvedValue("open-external");
256 await renderMarkdown(`[Review](${linkedPullRequest.url})`);
257 await openContextMenu();
258 expect(offeredActions()).toEqual(["open-external", "copy-link"]);
259 expect(state.openExternal).toHaveBeenCalledWith(linkedPullRequest.url);
260 expect(state.updateMetadata).not.toHaveBeenCalled();
261 },
262);
263
264it.each(["link-to-thread", "unlink-from-thread"])(
265 "rechecks %s after a native context menu returns",
266 async (action) => {
267 state.linkedPullRequest = action === "unlink-from-thread" ? linkedPullRequest : null;
268 let choose: (action: string) => void = () => {
269 throw new Error("Menu not opened");
270 };
271 state.choose.mockImplementationOnce(
272 () =>
273 new Promise<string>((resolve) => {
274 choose = resolve;
275 }),
276 );
277 await renderMarkdown(`[Review](${linkedPullRequest.url})`);
278 await openContextMenu();
279 expect(offeredActions()).toContain(action);
280 await act(async () => {
281 state.allowed = false;
282 choose(action);
283 });
284 expect(state.updateMetadata).not.toHaveBeenCalled();
285
286 await act(async () => {
287 state.allowed = true;
288 for (const listener of state.listeners) listener();
289 });
290 state.choose.mockResolvedValue(action);
291 await openContextMenu();
292 expect(state.updateMetadata).toHaveBeenCalledExactlyOnceWith({
293 environmentId: threadRef.environmentId,
294 input: {
295 threadId: threadRef.threadId,
296 linkedPullRequest: action === "link-to-thread" ? linkedPullRequest : null,
297 },
298 });
299 },
300);