返回 DeepSeek-Reasonix
pinned-files.test.tsx
根目录 / desktop / frontend / src / __tests__ / pinned-files.test.tsx
1 // Run: tsx src/__tests__/pinned-files.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 { PinnedFilesShelf } from "../components/PinnedFilesShelf";
8 import { WorkspaceTreeMenu } from "../components/WorkspaceTreeMenu";
9 import { LocaleProvider } from "../lib/i18n";
10 import { ToastProvider } from "../lib/toast";
11 import type { AppBindings } from "../lib/bridge";
12 import type { PinnedFileInfo } from "../lib/pinnedContextBridge";
13 import { installDesktopHostStub } from "./desktopHostStub";
14
15 let passed = 0;
16 let failed = 0;
17
18 function ok(value: boolean, label: string) {
19 if (value) {
20 process.stdout.write(` PASS ${label}\n`);
21 passed += 1;
22 } else {
23 process.stdout.write(` FAIL ${label}\n`);
24 failed += 1;
25 }
26 }
27
28 function flushTimers(): Promise<void> {
29 return new Promise((resolve) => setTimeout(resolve, 10));
30 }
31
32 function installDom() {
33 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
34 pretendToBeVisual: true,
35 url: "http://localhost/",
36 });
37 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
38 globalThis.window = dom.window as unknown as Window & typeof globalThis;
39 globalThis.document = dom.window.document;
40 Object.defineProperty(dom.window.navigator, "language", { configurable: true, value: "en-US" });
41 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
42 globalThis.Node = dom.window.Node;
43 globalThis.Element = dom.window.Element;
44 globalThis.HTMLElement = dom.window.HTMLElement;
45 globalThis.Event = dom.window.Event;
46 globalThis.CustomEvent = dom.window.CustomEvent;
47 globalThis.KeyboardEvent = dom.window.KeyboardEvent;
48 globalThis.MouseEvent = dom.window.MouseEvent;
49 globalThis.localStorage = dom.window.localStorage;
50 return dom;
51 }
52
53 async function run() {
54 installDom();
55 const rootElement = document.getElementById("root");
56 if (!rootElement) throw new Error("root missing");
57 const root = createRoot(rootElement);
58
59 let unpinnedPath = "";
60 let openedPath = "";
61 let pinnedViaMenu = "";
62 let shouldFailPin = false;
63
64 installDesktopHostStub(({
65 main: {
66 App: {
67 UnpinFileForTab: async (_tabId: string, path: string) => {
68 unpinnedPath = path;
69 },
70 OpenWorkspacePathForTab: async (_tabId: string, path: string) => {
71 openedPath = path;
72 },
73 GetPinnedFilesForTab: async (_tabId: string) => {
74 return [{ path: "already_pinned.md", sizeBytes: 500, tokenEstimate: 125 }];
75 },
76 PinFileForTab: async (_tabId: string, path: string) => {
77 if (shouldFailPin) {
78 throw new Error("file size exceeds maximum pinned file limit");
79 }
80 pinnedViaMenu = path;
81 return { path, sizeBytes: 100, tokenEstimate: 25 };
82 },
83 ResolveWorkspacePathForTab: async (_tabId: string, path: string) => {
84 return `/abs/${path}`;
85 },
86 RevealWorkspacePathForTab: async () => {},
87 },
88 },
89 }).main.App);
90
91 // Test 1: PinnedFilesShelf empty rendering
92 await act(async () => {
93 root.render(
94 <LocaleProvider initialLocale="en">
95 <ToastProvider>
96 <PinnedFilesShelf tabId="tab-1" pinnedFiles={[]} />
97 </ToastProvider>
98 </LocaleProvider>,
99 );
100 await flushTimers();
101 });
102 ok(document.querySelector(".pinned-files-shelf") === null, "PinnedFilesShelf renders null when empty");
103
104 // Test 2: PinnedFilesShelf with files
105 const sampleFiles: PinnedFileInfo[] = [
106 { path: "docs/api.md", sizeBytes: 1200, tokenEstimate: 300 },
107 { path: "schema.sql", sizeBytes: 400, tokenEstimate: 100 },
108 ];
109
110 await act(async () => {
111 root.render(
112 <LocaleProvider initialLocale="en">
113 <ToastProvider>
114 <PinnedFilesShelf tabId="tab-1" pinnedFiles={sampleFiles} />
115 </ToastProvider>
116 </LocaleProvider>,
117 );
118 await flushTimers();
119 });
120
121 const shelf = document.querySelector(".pinned-files-shelf");
122 ok(shelf !== null, "PinnedFilesShelf renders container when files are present");
123 const chips = document.querySelectorAll(".pinned-files-shelf .group");
124 ok(chips.length === 2, "PinnedFilesShelf renders correct number of chips");
125 ok(chips[0]?.textContent?.includes("api.md") === true, "First chip renders filename");
126
127 // Test unpin click
128 const unpinBtn = chips[0]?.querySelector("button");
129 if (unpinBtn) {
130 await act(async () => {
131 unpinBtn.dispatchEvent(new window.MouseEvent("click", { bubbles: true, cancelable: true }));
132 await flushTimers();
133 });
134 ok(unpinnedPath === "docs/api.md", "Clicking unpin button calls app.UnpinFileForTab with correct path");
135 }
136
137 // Test open file click
138 if (chips[1]) {
139 await act(async () => {
140 (chips[1] as HTMLElement).dispatchEvent(new window.MouseEvent("click", { bubbles: true, cancelable: true }));
141 await flushTimers();
142 });
143 ok(openedPath === "schema.sql", "Clicking chip opens file via app.OpenWorkspacePathForTab");
144 }
145
146 // A file that grew beyond the backend budget remains pinned and surfaces
147 // its omission reason directly on the chip.
148 const grownError = "file size exceeds the 65536-byte limit";
149 await act(async () => {
150 root.render(
151 <LocaleProvider initialLocale="en">
152 <ToastProvider>
153 <PinnedFilesShelf
154 tabId="tab-1"
155 pinnedFiles={[{ path: "grown.log", sizeBytes: 70000, tokenEstimate: 17500, error: grownError }]}
156 />
157 </ToastProvider>
158 </LocaleProvider>,
159 );
160 await flushTimers();
161 });
162 const errorChip = document.querySelector<HTMLElement>(".pinned-files-shelf .group");
163 ok(errorChip?.title === grownError, "Pinned file chip exposes the backend read error");
164 ok(errorChip?.querySelector(`[aria-label="${grownError}"]`) !== null, "Pinned file chip renders a warning icon");
165
166 // Test 3: WorkspaceTreeMenu Pin action for unpinned file
167 await act(async () => {
168 root.render(
169 <LocaleProvider initialLocale="en">
170 <ToastProvider>
171 <WorkspaceTreeMenu
172 target={{ x: 10, y: 10, path: "unpinned_file.ts", isDir: false }}
173 workspaceTabId="tab-1"
174 isScopeCurrent={() => true}
175 onClose={() => {}}
176 onAddReference={() => {}}
177 onAddFile={() => {}}
178 />
179 </ToastProvider>
180 </LocaleProvider>,
181 );
182 await flushTimers();
183 });
184
185 const menuButtons = Array.from(document.querySelectorAll<HTMLButtonElement>(".workspace-tree-menu button"));
186 const pinBtn = menuButtons.find((btn) => btn.textContent?.includes("Pin to Session Context"));
187 ok(pinBtn !== undefined, "WorkspaceTreeMenu shows Pin to Session Context for unpinned file");
188
189 if (pinBtn) {
190 await act(async () => {
191 pinBtn.dispatchEvent(new window.MouseEvent("click", { bubbles: true, cancelable: true }));
192 await flushTimers();
193 });
194 ok(pinnedViaMenu === "unpinned_file.ts", "Clicking Pin calls app.PinFileForTab with target path");
195 }
196
197 // Test 4: WorkspaceTreeMenu Pin error surfaces Toast
198 shouldFailPin = true;
199 await act(async () => {
200 root.render(
201 <LocaleProvider initialLocale="en">
202 <ToastProvider>
203 <WorkspaceTreeMenu
204 target={{ x: 10, y: 10, path: "too_large.dat", isDir: false }}
205 workspaceTabId="tab-1"
206 isScopeCurrent={() => true}
207 onClose={() => {}}
208 onAddReference={() => {}}
209 onAddFile={() => {}}
210 />
211 </ToastProvider>
212 </LocaleProvider>,
213 );
214 await flushTimers();
215 });
216
217 const menuButtonsError = Array.from(document.querySelectorAll<HTMLButtonElement>(".workspace-tree-menu button"));
218 const pinBtnError = menuButtonsError.find((btn) => btn.textContent?.includes("Pin to Session Context"));
219 if (pinBtnError) {
220 await act(async () => {
221 pinBtnError.dispatchEvent(new window.MouseEvent("click", { bubbles: true, cancelable: true }));
222 await flushTimers();
223 });
224 const toast = document.querySelector(".toast--error");
225 ok(toast !== null, "Pinning error displays error toast");
226 ok(toast?.textContent?.includes("exceeds maximum") === true, "Toast includes error message");
227 }
228 shouldFailPin = false;
229
230 // Test 5: WorkspaceTreeMenu Unpin action for already pinned file
231 await act(async () => {
232 root.render(
233 <LocaleProvider initialLocale="en">
234 <ToastProvider>
235 <WorkspaceTreeMenu
236 target={{ x: 10, y: 10, path: "already_pinned.md", isDir: false }}
237 workspaceTabId="tab-1"
238 isScopeCurrent={() => true}
239 onClose={() => {}}
240 onAddReference={() => {}}
241 onAddFile={() => {}}
242 />
243 </ToastProvider>
244 </LocaleProvider>,
245 );
246 });
247 await act(async () => {
248 await flushTimers();
249 });
250
251 const menuButtons2 = Array.from(document.querySelectorAll<HTMLButtonElement>(".workspace-tree-menu button"));
252 const unpinBtn2 = menuButtons2.find((btn) => btn.textContent?.includes("Unpin from Context"));
253 ok(unpinBtn2 !== undefined, "WorkspaceTreeMenu shows Unpin from Context for already pinned file");
254
255 await act(async () => {
256 root.unmount();
257 await flushTimers();
258 });
259
260 if (failed > 0) {
261 process.exit(1);
262 }
263 }
264
265 void run();
266
266 lines Plain Text