返回 DeepSeek-Reasonix
workspace-context-menu.test.tsx
根目录 / desktop / frontend / src / __tests__ / workspace-context-menu.test.tsx
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
204 lines Plain Text