| 1 | import { |
| 2 | AuthOrchestrationOperateScope, |
| 3 | EnvironmentId, |
| 4 | ProjectId, |
| 5 | ThreadId, |
| 6 | type AuthEnvironmentScope, |
| 7 | type EditorId, |
| 8 | type ThreadLinkedPullRequest, |
| 9 | } from "@t3tools/contracts"; |
| 10 | import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; |
| 11 | import { AsyncResult } from "effect/reactivity"; |
| 12 | import { cloneElement, type ReactElement, type ReactNode } from "react"; |
| 13 | import { act, create, type ReactTestRenderer } from "react-test-renderer"; |
| 14 | import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; |
| 15 | |
| 16 | const 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 | |
| 30 | vi.mock("@effect/atom-react", () => ({ useAtomValue: () => serverConfig })); |
| 31 | vi.mock("../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) })); |
| 32 | vi.mock("../hooks/useFileMetadata", () => ({ useFileMetadata: () => undefined })); |
| 33 | vi.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 | })); |
| 38 | vi.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 | })); |
| 44 | vi.mock("./chat/PierreEntryIcon", () => ({ PierreEntryIcon: () => null })); |
| 45 | vi.mock("./ui/toast", () => ({ |
| 46 | toastManager: { add: state.toast }, |
| 47 | stackedThreadToast: (value: unknown) => value, |
| 48 | })); |
| 49 | vi.mock("../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => state.search })); |
| 50 | vi.mock("../state/use-atom-command", () => ({ |
| 51 | useAtomCommand: (command: string) => |
| 52 | command === "updateMetadata" ? state.updateMetadata : state.openEditor, |
| 53 | })); |
| 54 | vi.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 | }); |
| 78 | vi.mock("../state/server", () => ({ |
| 79 | serverEnvironment: { configValueAtom: () => "config" }, |
| 80 | })); |
| 81 | vi.mock("../state/threads", () => ({ |
| 82 | threadEnvironment: { updateMetadata: "updateMetadata" }, |
| 83 | })); |
| 84 | vi.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 | })); |
| 90 | vi.mock("../rightPanelStore", () => ({ |
| 91 | useRightPanelStore: { getState: () => ({ openFile: state.openFile }) }, |
| 92 | })); |
| 93 | vi.mock("../remoteOpen", () => ({ |
| 94 | useRemoteOpenResolution: () => ({ state: { mode: "local-exec" }, isResolved: true }), |
| 95 | })); |
| 96 | vi.mock("../hooks/useLocalStorage", () => ({ |
| 97 | useLocalStorage: () => ["vscode", vi.fn()], |
| 98 | getLocalStorageItem: () => "vscode", |
| 99 | setLocalStorageItem: vi.fn(), |
| 100 | })); |
| 101 | vi.mock("../hooks/useCopyToClipboard", () => ({ |
| 102 | writeTextToClipboard: state.copy, |
| 103 | useCopyToClipboard: () => ({ copyToClipboard: state.copy, isCopied: false }), |
| 104 | })); |
| 105 | vi.mock("../localApi", () => ({ |
| 106 | readLocalApi: () => ({ |
| 107 | contextMenu: { show: state.choose }, |
| 108 | shell: { openExternal: state.openExternal }, |
| 109 | }), |
| 110 | })); |
| 111 | vi.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 | |
| 127 | import ChatMarkdown from "./ChatMarkdown"; |
| 128 | |
| 129 | const threadRef = { |
| 130 | environmentId: EnvironmentId.make("selected"), |
| 131 | threadId: ThreadId.make("thread"), |
| 132 | }; |
| 133 | const linkedPullRequest: ThreadLinkedPullRequest = { |
| 134 | projectId: ProjectId.make("project"), |
| 135 | repository: "example/repo", |
| 136 | number: 42, |
| 137 | url: "https://github.com/example/repo/pull/42", |
| 138 | }; |
| 139 | const 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 | }; |
| 145 | let renderer: ReactTestRenderer | undefined; |
| 146 | |
| 147 | beforeEach(() => { |
| 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 | |
| 164 | afterEach(async () => { |
| 165 | await act(async () => renderer?.unmount()); |
| 166 | renderer = undefined; |
| 167 | vi.unstubAllGlobals(); |
| 168 | }); |
| 169 | |
| 170 | async function renderMarkdown(text: string) { |
| 171 | await act(async () => { |
| 172 | renderer = create(<ChatMarkdown cwd="/work" threadRef={threadRef} text={text} />); |
| 173 | }); |
| 174 | } |
| 175 | |
| 176 | function menuEvent() { |
| 177 | return { preventDefault: vi.fn(), stopPropagation: vi.fn(), clientX: 10, clientY: 10 }; |
| 178 | } |
| 179 | |
| 180 | function anchor() { |
| 181 | return renderer!.root.findByType("a"); |
| 182 | } |
| 183 | |
| 184 | async function openContextMenu() { |
| 185 | await act(async () => anchor().props.onContextMenu(menuEvent())); |
| 186 | } |
| 187 | |
| 188 | function offeredActions() { |
| 189 | return state.choose.mock.lastCall![0].map((item: { id: string }) => item.id); |
| 190 | } |
| 191 | |
| 192 | it("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 | |
| 209 | it("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 | |
| 229 | it("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 | |
| 250 | it.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 | |
| 264 | it.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 | ); |