| 1 | // Run: tsx src/__tests__/workspace-context-menu.test.tsx |
| 2 | |
| 3 | import { JSDOM } from "jsdom"; |
| 4 | import React from "react"; |
| 5 | import { act } from "react"; |
| 6 | import { createRoot } from "react-dom/client"; |
| 7 | import { WorkspacePanel } from "../components/WorkspacePanel"; |
| 8 | import type { AppBindings } from "../lib/bridge"; |
| 9 | import { LocaleProvider } from "../lib/i18n"; |
| 10 | import { resetWorkspaceTreeMemoryForTests } from "../lib/workspaceTreeMemory"; |
| 11 | import { installDesktopHostStub } from "./desktopHostStub"; |
| 12 | |
| 13 | let passed = 0; |
| 14 | let failed = 0; |
| 15 | |
| 16 | function ok(value: boolean, label: string) { |
| 17 | if (value) { |
| 18 | process.stdout.write(` PASS ${label}\n`); |
| 19 | passed += 1; |
| 20 | } else { |
| 21 | process.stdout.write(` FAIL ${label}\n`); |
| 22 | failed += 1; |
| 23 | } |
| 24 | } |
| 25 | |
| 26 | function flushTimers(): Promise<void> { |
| 27 | return new Promise((resolve) => setTimeout(resolve, 0)); |
| 28 | } |
| 29 | |
| 30 | async function waitFor(label: string, predicate: () => boolean) { |
| 31 | for (let attempt = 0; attempt < 20; attempt += 1) { |
| 32 | await act(async () => { |
| 33 | await flushTimers(); |
| 34 | }); |
| 35 | if (predicate()) return; |
| 36 | } |
| 37 | throw new Error(`timed out waiting for ${label}`); |
| 38 | } |
| 39 | |
| 40 | class TestResizeObserver { |
| 41 | observe() {} |
| 42 | unobserve() {} |
| 43 | disconnect() {} |
| 44 | } |
| 45 | |
| 46 | function installDom() { |
| 47 | const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", { |
| 48 | pretendToBeVisual: true, |
| 49 | url: "http://localhost/", |
| 50 | }); |
| 51 | (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; |
| 52 | globalThis.window = dom.window as unknown as Window & typeof globalThis; |
| 53 | globalThis.document = dom.window.document; |
| 54 | Object.defineProperty(dom.window.navigator, "language", { configurable: true, value: "en-US" }); |
| 55 | Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator }); |
| 56 | globalThis.Node = dom.window.Node; |
| 57 | globalThis.Element = dom.window.Element; |
| 58 | globalThis.HTMLElement = dom.window.HTMLElement; |
| 59 | globalThis.Event = dom.window.Event; |
| 60 | globalThis.CustomEvent = dom.window.CustomEvent; |
| 61 | globalThis.KeyboardEvent = dom.window.KeyboardEvent; |
| 62 | globalThis.MouseEvent = dom.window.MouseEvent; |
| 63 | globalThis.PointerEvent = dom.window.MouseEvent as unknown as typeof PointerEvent; |
| 64 | globalThis.MutationObserver = dom.window.MutationObserver; |
| 65 | globalThis.ResizeObserver = TestResizeObserver; |
| 66 | dom.window.ResizeObserver = TestResizeObserver; |
| 67 | globalThis.localStorage = dom.window.localStorage; |
| 68 | globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window); |
| 69 | globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window); |
| 70 | Object.defineProperty(dom.window.HTMLElement.prototype, "scrollIntoView", { configurable: true, value: () => {} }); |
| 71 | Object.defineProperty(dom.window.HTMLElement.prototype, "offsetWidth", { configurable: true, get: () => 320 }); |
| 72 | Object.defineProperty(dom.window.HTMLElement.prototype, "offsetHeight", { |
| 73 | configurable: true, |
| 74 | get: function offsetHeight(this: HTMLElement) { |
| 75 | return this.classList.contains("workspace-tree") ? 300 : this.dataset.index ? 24 : 0; |
| 76 | }, |
| 77 | }); |
| 78 | Object.defineProperty(dom.window.HTMLElement.prototype, "getBoundingClientRect", { |
| 79 | configurable: true, |
| 80 | value: function getBoundingClientRect(this: HTMLElement) { |
| 81 | const width = 320; |
| 82 | const height = this.classList.contains("workspace-tree") ? 300 : this.dataset.index ? 24 : 0; |
| 83 | return { x: 0, y: 0, top: 0, left: 0, right: width, bottom: height, width, height, toJSON: () => ({}) } as DOMRect; |
| 84 | }, |
| 85 | }); |
| 86 | return dom; |
| 87 | } |
| 88 | |
| 89 | function menuLabels(): string[] { |
| 90 | return Array.from(document.querySelectorAll<HTMLButtonElement>(".workspace-tree-menu button")).map( |
| 91 | (button) => button.textContent?.trim() ?? "", |
| 92 | ); |
| 93 | } |
| 94 | |
| 95 | async function openRowMenu(path: string) { |
| 96 | const row = document.querySelector<HTMLButtonElement>(`[data-workspace-path="${path}"]`); |
| 97 | if (!row) throw new Error(`missing workspace row ${path}`); |
| 98 | await act(async () => { |
| 99 | row.dispatchEvent(new window.MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 30, clientY: 30 })); |
| 100 | await flushTimers(); |
| 101 | }); |
| 102 | } |
| 103 | |
| 104 | console.log("\nworkspace file context menu"); |
| 105 | |
| 106 | resetWorkspaceTreeMemoryForTests(); |
| 107 | const dom = installDom(); |
| 108 | const openCalls: Array<{ tabId: string; path: string }> = []; |
| 109 | installDesktopHostStub(({ |
| 110 | main: { |
| 111 | App: { |
| 112 | ListDirForTab: async (_tabId, dir) => dir === "" |
| 113 | ? [ |
| 114 | { name: "docs", isDir: true }, |
| 115 | { name: "README.md", isDir: false }, |
| 116 | ] |
| 117 | : [], |
| 118 | SearchFileRefsForTab: async () => [], |
| 119 | WorkspaceGitHistory: async () => [], |
| 120 | WorkspaceChanges: async () => ({ files: [], gitAvailable: true }), |
| 121 | WorkspaceChangeDetail: async () => ({}), |
| 122 | ReadFileForTab: async (_tabId, path) => ({ path, body: "", size: 0, truncated: false, binary: false }), |
| 123 | ResolveWorkspacePathForTab: async (_tabId, path) => `/repo/${path}`, |
| 124 | RevealWorkspacePathForTab: async () => {}, |
| 125 | GetPinnedFilesForTab: async () => [], |
| 126 | OpenWorkspacePathForTab: async (tabId, path) => { |
| 127 | openCalls.push({ tabId, path }); |
| 128 | }, |
| 129 | } as Partial<AppBindings> as AppBindings, |
| 130 | }, |
| 131 | }).main.App); |
| 132 | |
| 133 | const rootElement = document.getElementById("root"); |
| 134 | if (!rootElement) throw new Error("missing root"); |
| 135 | const root = createRoot(rootElement); |
| 136 | await act(async () => { |
| 137 | root.render( |
| 138 | <LocaleProvider> |
| 139 | <WorkspacePanel |
| 140 | open |
| 141 | tabId="workspace-tab" |
| 142 | cwd="/repo" |
| 143 | maximized={false} |
| 144 | initialViewMode="files" |
| 145 | onClose={() => {}} |
| 146 | onToggleMaximized={() => {}} |
| 147 | onOpenInTerminal={() => {}} |
| 148 | /> |
| 149 | </LocaleProvider>, |
| 150 | ); |
| 151 | await flushTimers(); |
| 152 | }); |
| 153 | |
| 154 | await waitFor("workspace rows", () => document.querySelector('[data-workspace-path="README.md"]') != null); |
| 155 | await openRowMenu("README.md"); |
| 156 | |
| 157 | const fileLabels = [ |
| 158 | "Open with default app", |
| 159 | "Show in file manager", |
| 160 | "Open in integrated terminal", |
| 161 | "Copy relative path", |
| 162 | "Copy absolute path", |
| 163 | "Add file reference", |
| 164 | "Add file contents", |
| 165 | "Pin to Session Context", |
| 166 | ]; |
| 167 | ok(JSON.stringify(menuLabels()) === JSON.stringify(fileLabels), "file menu keeps the default-open action first and preserves command order"); |
| 168 | ok(document.querySelectorAll(".workspace-tree-menu [role=separator]").length === 1, "file menu separates path commands from chat commands"); |
| 169 | |
| 170 | const defaultOpen = Array.from(document.querySelectorAll<HTMLButtonElement>(".workspace-tree-menu button")).find( |
| 171 | (button) => button.textContent?.trim() === "Open with default app", |
| 172 | ); |
| 173 | await act(async () => { |
| 174 | defaultOpen?.click(); |
| 175 | await flushTimers(); |
| 176 | }); |
| 177 | ok( |
| 178 | JSON.stringify(openCalls) === JSON.stringify([{ tabId: "workspace-tab", path: "README.md" }]), |
| 179 | "default-open routes the selected relative file path through the active workspace tab", |
| 180 | ); |
| 181 | ok(document.querySelector(".workspace-tree-menu") == null, "default-open closes the file menu"); |
| 182 | |
| 183 | await openRowMenu("docs/"); |
| 184 | ok(!menuLabels().includes("Open with default app"), "folder menu does not offer default-open"); |
| 185 | ok( |
| 186 | JSON.stringify(menuLabels()) === JSON.stringify([ |
| 187 | "Show in file manager", |
| 188 | "Open in integrated terminal", |
| 189 | "Copy relative path", |
| 190 | "Copy absolute path", |
| 191 | "Add folder reference", |
| 192 | ]), |
| 193 | "folder menu preserves its existing command order", |
| 194 | ); |
| 195 | ok(document.querySelectorAll(".workspace-tree-menu [role=separator]").length === 1, "folder menu keeps one chat-command separator"); |
| 196 | |
| 197 | await act(async () => { |
| 198 | root.unmount(); |
| 199 | }); |
| 200 | dom.window.close(); |
| 201 | |
| 202 | console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`); |
| 203 | if (failed > 0) process.exit(1); |
| 204 |