返回 DeepSeek-Reasonix
workspace-panel-test-harness.tsx
根目录 / desktop / frontend / src / __tests__ / workspace-panel-test-harness.tsx
1 import { JSDOM } from "jsdom";
2 import { registerHooks } from "node:module";
3 import React, { act } from "react";
4 import { createRoot } from "react-dom/client";
5 import { WorkspacePanel } from "../components/WorkspacePanel";
6 import type { AppBindings } from "../lib/bridge";
7 import { LocaleProvider } from "../lib/i18n";
8 import type { GitCommitView, WireCompletionSummary, WorkspaceChangeDetailView, WorkspaceChangesView } from "../lib/types";
9 import { resetWorkspaceTreeMemoryForTests } from "../lib/workspaceTreeMemory";
10 import { setFileNavigationOwner } from "../lib/fileNavigationCommands";
11 import { useActivityBarStore } from "../store/activityBar";
12 import { installDesktopHostStub } from "./desktopHostStub";
13
14 registerHooks({
15 resolve(specifier, context, nextResolve) {
16 if (specifier.endsWith(".css")) {
17 return nextResolve("./asset-stub-for-tests.ts", { ...context, parentURL: import.meta.url });
18 }
19 return nextResolve(specifier, context);
20 },
21 });
22
23 class TestResizeObserver {
24 observe() {}
25 unobserve() {}
26 disconnect() {}
27 }
28
29 export function flushPromises(): Promise<void> {
30 return new Promise((resolve) => setTimeout(resolve, 0));
31 }
32
33 // Text input events dispatched in this jsdom do not reach React's onChange, so
34 // a suite that must drive a controlled field calls the mounted element's own
35 // `onChange` through its `__reactProps$` entry instead (see
36 // `ask-card-layout.test.ts` and `remote-file-navigation-races.test.tsx`).
37
38 export async function waitFor(label: string, predicate: () => boolean) {
39 for (let attempt = 0; attempt < 20; attempt += 1) {
40 await act(async () => {
41 await flushPromises();
42 });
43 if (predicate()) return;
44 }
45 throw new Error(`timed out waiting for ${label}`);
46 }
47
48 function installDom() {
49 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
50 pretendToBeVisual: true,
51 url: "http://localhost/",
52 });
53 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
54 globalThis.window = dom.window as unknown as Window & typeof globalThis;
55 globalThis.document = dom.window.document;
56 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
57 globalThis.Node = dom.window.Node;
58 globalThis.Element = dom.window.Element;
59 globalThis.HTMLElement = dom.window.HTMLElement;
60 globalThis.Event = dom.window.Event;
61 globalThis.CustomEvent = dom.window.CustomEvent;
62 globalThis.KeyboardEvent = dom.window.KeyboardEvent;
63 globalThis.MouseEvent = dom.window.MouseEvent;
64 globalThis.PointerEvent = dom.window.MouseEvent as unknown as typeof PointerEvent;
65 globalThis.MutationObserver = dom.window.MutationObserver;
66 globalThis.ResizeObserver = TestResizeObserver;
67 dom.window.ResizeObserver = TestResizeObserver;
68 globalThis.localStorage = dom.window.localStorage;
69 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
70 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
71 (dom.window.HTMLElement.prototype as unknown as { attachEvent: () => void }).attachEvent = () => {};
72 (dom.window.HTMLElement.prototype as unknown as { detachEvent: () => void }).detachEvent = () => {};
73 Object.defineProperty(dom.window.HTMLElement.prototype, "scrollIntoView", { configurable: true, value: () => {} });
74 Object.defineProperty(dom.window.HTMLElement.prototype, "offsetWidth", {
75 configurable: true,
76 get: () => 320,
77 });
78 Object.defineProperty(dom.window.HTMLElement.prototype, "offsetHeight", {
79 configurable: true,
80 get: function offsetHeight(this: HTMLElement) {
81 return this.classList.contains("workspace-tree") ? 300 : this.dataset.index ? 24 : 0;
82 },
83 });
84 Object.defineProperty(dom.window.HTMLElement.prototype, "getBoundingClientRect", {
85 configurable: true,
86 value: function getBoundingClientRect(this: HTMLElement) {
87 const width = 320;
88 const height = this.classList.contains("workspace-tree") ? 300 : this.dataset.index ? 24 : 0;
89 return {
90 x: 0,
91 y: 0,
92 top: 0,
93 left: 0,
94 right: width,
95 bottom: height,
96 width,
97 height,
98 toJSON: () => ({}),
99 } as DOMRect;
100 },
101 });
102 return dom;
103 }
104
105 export async function renderWorkspace(
106 changes: WorkspaceChangesView,
107 options: { history?: GitCommitView[]; detail?: WorkspaceChangeDetailView; completionSummary?: WireCompletionSummary } = {},
108 ) {
109 resetWorkspaceTreeMemoryForTests();
110 setFileNavigationOwner(null);
111 const dom = installDom();
112 await act(async () => {
113 useActivityBarStore.setState({ workspaceRoot: "/repo", tabs: [], activeTabId: null });
114 });
115 const dockTabId = useActivityBarStore.getState().openEntry("file", "Files");
116 const desktopStub = installDesktopHostStub(({
117 main: {
118 App: {
119 ListDirForTab: async () => [],
120 WorkspaceGitHistory: async () => options.history ?? [],
121 WorkspaceChanges: async () => changes,
122 WorkspaceChangeDetail: async () => options.detail ?? {},
123 ResolveWorkspacePathForTab: async (_tabID, path) => path.startsWith("/") ? path : `/repo/${path}`,
124 ReadFileForTab: async (_tabID, path) => ({ path, body: "", size: 0, truncated: false, binary: false }),
125 } as Partial<AppBindings> as AppBindings,
126 },
127 }).main.App);
128 const rootEl = document.getElementById("root");
129 if (!rootEl) throw new Error("missing root");
130 const root = createRoot(rootEl);
131 await act(async () => {
132 root.render(
133 <LocaleProvider>
134 <WorkspacePanel
135 open
136 tabId="tab-a"
137 dockTabId={dockTabId}
138 cwd="/repo"
139 maximized={false}
140 initialViewMode="changed"
141 completionSummary={options.completionSummary}
142 onClose={() => {}}
143 onToggleMaximized={() => {}}
144 />
145 </LocaleProvider>,
146 );
147 await flushPromises();
148 });
149 await waitFor("workspace changes", () => Boolean(document.querySelector(".workspace-preview__body")));
150 return { dom, root, dockTabId };
151 }
152
153 export async function renderFilesWorkspace(methods: Partial<AppBindings>, props: Partial<Parameters<typeof WorkspacePanel>[0]> = {}) {
154 resetWorkspaceTreeMemoryForTests();
155 // Each mount gets its own navigation instance, and the file dock tab a
156 // command targets is the one this panel renders — exactly as the region does.
157 setFileNavigationOwner(null);
158 const dom = installDom();
159 await act(async () => {
160 useActivityBarStore.setState({ workspaceRoot: "/repo", tabs: [], activeTabId: null });
161 });
162 const dockTabId = useActivityBarStore.getState().openEntry("file", "Files");
163 const desktopStub = installDesktopHostStub(({
164 main: {
165 App: {
166 ListDirForTab: async () => [],
167 SearchFileRefsForTab: async () => [],
168 WorkspaceGitHistory: async () => [],
169 WorkspaceChanges: async () => ({ files: [], gitAvailable: true }),
170 WorkspaceChangeDetail: async () => ({}),
171 ResolveWorkspacePathForTab: async (_tabID, path) => path.startsWith("/") ? path : `/repo/${path}`,
172 ResolvePresentedPathForTab: async (_tabID, _toolCallID, path) => path.startsWith("/") ? path : `/repo/${path}`,
173 ReadFileForTab: async (_tabID, path) => ({ path, body: "", size: 0, truncated: false, binary: false }),
174 ...methods,
175 } as Partial<AppBindings> as AppBindings,
176 },
177 }).main.App);
178 const rootEl = document.getElementById("root");
179 if (!rootEl) throw new Error("missing root");
180 const root = createRoot(rootEl);
181 let currentProps: Parameters<typeof WorkspacePanel>[0] = {
182 open: true,
183 tabId: "tab-a",
184 cwd: "/repo",
185 maximized: false,
186 initialViewMode: "files",
187 onClose: () => {},
188 onToggleMaximized: () => {},
189 dockTabId,
190 ...props,
191 };
192 const rerender = async (nextProps: Partial<Parameters<typeof WorkspacePanel>[0]> = {}) => {
193 currentProps = { ...currentProps, ...nextProps };
194 await act(async () => {
195 root.render(
196 <LocaleProvider>
197 <WorkspacePanel {...currentProps} />
198 </LocaleProvider>,
199 );
200 await flushPromises();
201 });
202 };
203 await rerender();
204 return { dom, root, rerender, dockTabId };
205 }
206
206 lines Plain Text