返回 DeepSeek-Reasonix
workspace-tree-scroll-restore.test.tsx
根目录 / desktop / frontend / src / __tests__ / workspace-tree-scroll-restore.test.tsx
1 // Run: tsx src/__tests__/workspace-tree-scroll-restore.test.tsx
2 // zk-ge CLAIM.TREE.008: 切换 dock 标签(组件 unmount/remount)后文件树滚动位置恢复
3
4 import { JSDOM } from "jsdom";
5 import React from "react";
6 import { act } from "react";
7 import { createRoot } from "react-dom/client";
8 import type { Root } from "react-dom/client";
9 import { WorkspacePanel } from "../components/WorkspacePanel";
10 import type { AppBindings } from "../lib/bridge";
11 import { LocaleProvider } from "../lib/i18n";
12 import { resetWorkspaceTreeMemoryForTests } from "../lib/workspaceTreeMemory";
13 import { installDesktopHostStub } from "./desktopHostStub";
14 import { workspaceViewMemoryKey, rememberWorkspaceTreeScroll, reloadWorkspaceViewMemoryForTests } from "../lib/workspaceViewMemory";
15
16 let passed = 0;
17 let failed = 0;
18
19 function ok(value: boolean, label: string) {
20 if (value) {
21 process.stdout.write(` PASS ${label}\n`);
22 passed += 1;
23 } else {
24 process.stdout.write(` FAIL ${label}\n`);
25 failed += 1;
26 }
27 }
28
29 function flushTimers(): Promise<void> {
30 return new Promise((resolve) => setTimeout(resolve, 0));
31 }
32
33 async function waitFor(label: string, predicate: () => boolean) {
34 for (let attempt = 0; attempt < 30; attempt += 1) {
35 await act(async () => {
36 await flushTimers();
37 });
38 if (predicate()) return;
39 }
40 throw new Error(`timed out waiting for ${label}`);
41 }
42
43 class TestResizeObserver {
44 observe() {}
45 unobserve() {}
46 disconnect() {}
47 }
48
49 function installDom() {
50 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
51 pretendToBeVisual: true,
52 url: "http://localhost/",
53 });
54 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
55 globalThis.window = dom.window as unknown as Window & typeof globalThis;
56 globalThis.document = dom.window.document;
57 Object.defineProperty(dom.window.navigator, "language", { configurable: true, value: "en-US" });
58 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
59 globalThis.Node = dom.window.Node;
60 globalThis.Element = dom.window.Element;
61 globalThis.HTMLElement = dom.window.HTMLElement;
62 globalThis.Event = dom.window.Event;
63 globalThis.CustomEvent = dom.window.CustomEvent;
64 globalThis.KeyboardEvent = dom.window.KeyboardEvent;
65 globalThis.MouseEvent = dom.window.MouseEvent;
66 globalThis.PointerEvent = dom.window.MouseEvent as unknown as typeof PointerEvent;
67 globalThis.MutationObserver = dom.window.MutationObserver;
68 globalThis.ResizeObserver = TestResizeObserver;
69 dom.window.ResizeObserver = TestResizeObserver;
70 globalThis.localStorage = dom.window.localStorage;
71 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
72 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
73 Object.defineProperty(dom.window.HTMLElement.prototype, "scrollIntoView", { configurable: true, value: () => {} });
74 Object.defineProperty(dom.window.HTMLElement.prototype, "offsetWidth", { configurable: true, get: () => 320 });
75 Object.defineProperty(dom.window.HTMLElement.prototype, "clientHeight", {
76 configurable: true,
77 get: function clientHeight(this: HTMLElement) {
78 return this.classList.contains("workspace-tree") ? 300 : 0;
79 },
80 });
81 Object.defineProperty(dom.window.HTMLElement.prototype, "scrollHeight", {
82 configurable: true,
83 get: function scrollHeight(this: HTMLElement) {
84 return this.classList.contains("workspace-tree") ? 40 * 24 : 0;
85 },
86 });
87 Object.defineProperty(dom.window.HTMLElement.prototype, "offsetHeight", {
88 configurable: true,
89 get: function offsetHeight(this: HTMLElement) {
90 return this.classList.contains("workspace-tree") ? 300 : this.dataset.index ? 24 : 0;
91 },
92 });
93 Object.defineProperty(dom.window.HTMLElement.prototype, "getBoundingClientRect", {
94 configurable: true,
95 value: function getBoundingClientRect(this: HTMLElement) {
96 const width = 320;
97 const height = this.classList.contains("workspace-tree") ? 300 : this.dataset.index ? 24 : 0;
98 return { x: 0, y: 0, top: 0, left: 0, right: width, bottom: height, width, height, toJSON: () => ({}) } as DOMRect;
99 },
100 });
101 Object.defineProperty(dom.window.HTMLElement.prototype, "scrollTo", {
102 configurable: true,
103 value: function scrollTo(this: HTMLElement, options?: ScrollToOptions | number) {
104 const requested = typeof options === "number" ? options : options?.top ?? 0;
105 const maximum = Math.max(0, this.scrollHeight - this.clientHeight);
106 this.scrollTop = Math.max(0, Math.min(maximum, requested));
107 },
108 });
109 return dom;
110 }
111
112 const MEMORY_KEY = "scroll-test\u0000/repo";
113
114 function renderPanel(root: Root, memoryKey = MEMORY_KEY) {
115 return act(async () => {
116 root.render(
117 <LocaleProvider>
118 <WorkspacePanel
119 key={memoryKey}
120 open
121 tabId="scroll-tab"
122 cwd="/repo"
123 workspaceMemoryKey={memoryKey}
124 maximized={false}
125 initialViewMode="files"
126 onClose={() => {}}
127 onToggleMaximized={() => {}}
128 onOpenInTerminal={() => {}}
129 />
130 </LocaleProvider>,
131 );
132 await flushTimers();
133 });
134 }
135
136 console.log("\nworkspace tree scroll restore (CLAIM.TREE.008)");
137
138 resetWorkspaceTreeMemoryForTests();
139 const dom = installDom();
140 dom.window.localStorage.clear();
141 const RESTORED_SCROLL_TOP = 200;
142 dom.window.localStorage.setItem("reasonix.workspaceState.v2", JSON.stringify({
143 version: 2,
144 projects: [{
145 key: MEMORY_KEY,
146 state: {
147 openDirs: [""],
148 scrollTop: RESTORED_SCROLL_TOP,
149 updatedAt: Date.now(),
150 },
151 }],
152 }));
153
154 installDesktopHostStub(({
155 main: {
156 App: {
157 ListDirForTab: async (_tabId, dir) => {
158 if (dir === "") {
159 return Array.from({ length: 40 }, (_, i) => ({ name: `file-${i}.ts`, isDir: false }));
160 }
161 return [];
162 },
163 SearchFileRefsForTab: async () => [],
164 WorkspaceGitHistory: async () => [],
165 WorkspaceChanges: async () => ({ files: [], gitAvailable: true }),
166 WorkspaceChangeDetail: async () => ({}),
167 ReadFileForTab: async (_tabId, path) => ({ path, body: "", size: 0, truncated: false, binary: false }),
168 ResolveWorkspacePathForTab: async (_tabId, path) => `/repo/${path}`,
169 RevealWorkspacePathForTab: async () => {},
170 OpenWorkspacePathForTab: async () => {},
171 } as Partial<AppBindings> as AppBindings,
172 },
173 }).main.App);
174
175 const rootElement = document.getElementById("root");
176 if (!rootElement) throw new Error("missing root");
177 const root = createRoot(rootElement);
178
179 // Phase 1: mount from a persisted deep offset and verify the virtualizer writes
180 // that offset into a genuinely scrollable DOM fixture.
181 await renderPanel(root);
182 await waitFor("workspace rows render", () => document.querySelectorAll(".workspace-tree__row").length > 0);
183
184 const treeEl = document.querySelector<HTMLElement>(".workspace-tree");
185 if (!treeEl) throw new Error("missing workspace-tree element");
186 ok(treeEl.scrollHeight > treeEl.clientHeight, "fixture has a real scrollable extent");
187 await waitFor("persisted tree offset restore", () => treeEl.scrollTop === RESTORED_SCROLL_TOP);
188 ok(treeEl.scrollTop === RESTORED_SCROLL_TOP, "initial mount restores the persisted DOM scroll offset");
189
190 // Scroll position should have been persisted to the upstream per-project
191 // workspace state envelope (reasonix.workspaceState.v2).
192 function persistedScrollTop(): number | null {
193 const raw = dom.window.localStorage.getItem("reasonix.workspaceState.v2");
194 if (!raw) return null;
195 try {
196 const parsed = JSON.parse(raw) as { projects?: Array<{ key: string; state: { scrollTop?: number } }> };
197 const project = parsed.projects?.find((entry) => entry.key === MEMORY_KEY);
198 return typeof project?.state?.scrollTop === "number" ? project.state.scrollTop : null;
199 } catch {
200 return null;
201 }
202 }
203
204 ok(persistedScrollTop() === RESTORED_SCROLL_TOP, "restoration preserves the persisted localStorage offset");
205
206 // Phase 2: unmount (simulates switching from 文件 to 概览 dock tab)
207 await act(async () => {
208 root.render(<></>);
209 await flushTimers();
210 });
211
212 // Phase 3: remount (simulates switching back to 文件)
213 await renderPanel(root);
214 await waitFor("workspace rows re-render", () => document.querySelectorAll(".workspace-tree__row").length > 0);
215
216 const treeEl2 = document.querySelector<HTMLElement>(".workspace-tree");
217 if (!treeEl2) throw new Error("missing workspace-tree element after remount");
218
219 // The persisted value must survive the remount — verify the workspace memory
220 // still holds it (this isolates persistence loss from virtualizer restore).
221 ok(persistedScrollTop() === RESTORED_SCROLL_TOP, "persisted scroll offset survives remount in localStorage");
222
223 // Remount must re-render the tree rows (otherwise the restore effect's
224 // treeRows.length guard never passes).
225 const rowsAfterRemount = document.querySelectorAll(".workspace-tree__row").length;
226 ok(rowsAfterRemount > 0, `tree rows re-render after remount (got ${rowsAfterRemount})`);
227
228 await waitFor("remounted tree DOM restore", () => treeEl2.scrollTop === RESTORED_SCROLL_TOP);
229 ok(treeEl2.scrollTop === RESTORED_SCROLL_TOP, "dock-tab remount restores the actual DOM scroll offset");
230
231 // Phase 4: switch to the "changed" view via the tab button, then back to
232 // "files". The pending scroll restore must be reset on viewMode change — the
233 // saved offset belongs to the previous mode's tree and re-applying it to a
234 // shorter list (files -> changed) would scroll past the end and leave a blank
235 // band at the top. We assert the panel still renders rows after the round
236 // trip; the reset itself is what makes that render coherent.
237 const tabs = document.querySelectorAll(".workspace-files__tab");
238 ok(tabs.length >= 2, "files and changed view tabs render in the fixture");
239 if (tabs.length >= 2) {
240 await act(async () => {
241 (tabs[1] as HTMLElement).click();
242 await flushTimers();
243 });
244 await act(async () => {
245 (tabs[0] as HTMLElement).click();
246 await flushTimers();
247 });
248 await waitFor("files rows re-render after view-mode round trip", () => document.querySelectorAll(".workspace-tree__row").length > 0);
249 ok(document.querySelectorAll(".workspace-tree__row").length > 0, "tree rows re-render after files<->changed view switch");
250 }
251
252 const firstView = workspaceViewMemoryKey(MEMORY_KEY, "view-a", true);
253 const secondView = workspaceViewMemoryKey(MEMORY_KEY, "view-b");
254 rememberWorkspaceTreeScroll(firstView, 160);
255 rememberWorkspaceTreeScroll(secondView, 420);
256 for (const [key, offset] of [[firstView, 160], [secondView, 420], [firstView, 160]] as const) {
257 await renderPanel(root, key);
258 await waitFor("independent view DOM offset", () => document.querySelector<HTMLElement>(".workspace-tree")?.scrollTop === offset);
259 ok(document.querySelector<HTMLElement>(".workspace-tree")?.scrollTop === offset, `view restores its own DOM offset ${offset}`);
260 }
261 await act(async () => { root.render(null); await flushTimers(); });
262 reloadWorkspaceViewMemoryForTests();
263 await renderPanel(root, secondView);
264 await waitFor("view offset after storage reload", () => document.querySelector<HTMLElement>(".workspace-tree")?.scrollTop === 420);
265 ok(document.querySelector<HTMLElement>(".workspace-tree")?.scrollTop === 420, "view offset survives storage reload");
266 await act(async () => root.unmount());
267 dom.window.close();
268 console.log(`\nworkspace tree scroll restore: ${passed} passed, ${failed} failed`);
269 if (failed > 0) process.exit(1);
270
270 lines Plain Text