返回 DeepSeek-Reasonix
project-tree-loading.test.tsx
根目录 / desktop / frontend / src / __tests__ / project-tree-loading.test.tsx
1 // Run: node --import ./scripts/svg-stub-register.mjs --import tsx src/__tests__/project-tree-loading.test.tsx
2 import assert from "node:assert/strict";
3 import { mock } from "node:test";
4 import { JSDOM } from "jsdom";
5 import React, { act } from "react";
6 import type { Root } from "react-dom/client";
7 import type { ReasonixDesktopHost } from "../lib/desktopHost";
8 import type { ProjectNode, ProjectTopicPage, ProjectTopicPageRequest, SessionGroup } from "../lib/types";
9
10 const dom = new JSDOM('<html><body><div id="root"></div></body></html>', { url: "http://localhost/", pretendToBeVisual: true });
11 Object.assign(globalThis, {
12 window: dom.window, document: dom.window.document, Element: dom.window.Element,
13 HTMLElement: dom.window.HTMLElement, Node: dom.window.Node, Event: dom.window.Event,
14 MouseEvent: dom.window.MouseEvent, localStorage: dom.window.localStorage,
15 requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(() => callback(Date.now()), 0),
16 cancelAnimationFrame: (id: ReturnType<typeof setTimeout>) => clearTimeout(id),
17 IS_REACT_ACT_ENVIRONMENT: true,
18 });
19 Object.defineProperty(globalThis, "navigator", { value: dom.window.navigator, configurable: true });
20 window.matchMedia = (() => ({ matches: false, addEventListener() {}, removeEventListener() {} })) as unknown as typeof window.matchMedia;
21
22 // Import the event system after installing the DOM, so real React input events
23 // exercise the component's search handler as they do in the renderer.
24 const { createRoot } = await import("react-dom/client");
25 const { ProjectTree } = await import("../components/ProjectTree");
26 const { LocaleProvider } = await import("../lib/i18n");
27 const { ToastProvider } = await import("../lib/toast");
28 const { resetProjectTreeRuntimeWindowLimits } = await import("../lib/projectTreeWindow");
29
30 const roots = ["/review-a", "/review-b"];
31 const projects: ProjectNode[] = roots.map((root, i) => ({ key: `project-${i}`, kind: "project", label: i ? "B" : "A", root, children: [] }));
32 let visibleProjects = projects;
33 const releasedSnapshots: string[] = [];
34 const topic = (id: string, root = roots[0]): ProjectNode => ({ key: id, topicId: id, kind: "topic", label: id, root, children: [] });
35 const listeners = new Map<string, Set<(...args: unknown[]) => void>>();
36 let revision = 1;
37 let mountID = 0;
38 let rows: Record<string, ProjectNode[]> = {};
39 let groups: SessionGroup[] = [];
40 let calls: ProjectTopicPageRequest[] = [];
41 const removedWorkspaces: string[] = [];
42 const renamedSessions: string[] = [];
43 let intercept: ((req: ProjectTopicPageRequest) => Promise<ProjectTopicPage> | undefined) | undefined;
44 let snapshotFailure: Error | null = null;
45 const catalog = () => ({ state: "ready", revision, indexed: 2, total: 2, repairPending: 0 });
46
47 function page(req: ProjectTopicPageRequest): ProjectTopicPage {
48 const query = req.query?.toLowerCase() ?? "";
49 const selected = req.groupId ? [] : (rows[req.workspaceRoot ?? ""] ?? []).filter(row => row.label.toLowerCase().includes(query));
50 const start = Number(req.cursor || 0), limit = req.limit ?? 5;
51 const items = selected.slice(start, start + limit);
52 return { revision, items, complete: true, nextCursor: start + items.length < selected.length ? String(start + items.length) : undefined };
53 }
54 const bindings = {
55 ArchiveSessionTarget: async ({ sessionPath }: { sessionPath: string }) => {
56 rows = Object.fromEntries(Object.entries(rows).map(([key, items]) => [key, items.filter(row => row.sessionPath !== sessionPath)]));
57 revision++;
58 return { committed: true, lifecycleGeneration: 1, operationId: `archive-${sessionPath}` };
59 },
60 GetProjectTreeSnapshot: async () => {
61 if (snapshotFailure) throw snapshotFailure;
62 return { revision, projects: visibleProjects, catalog: catalog() };
63 },
64 ReleaseReadSnapshot: async (id: string) => { releasedSnapshots.push(id); },
65 ListProjectTopics: async (req: ProjectTopicPageRequest) => { calls.push(req); return intercept?.(req) ?? page(req); },
66 GetSessionCatalogStatus: async () => catalog(),
67 GetSessionOrganization: async () => ({ groups, revision, order: [], manualOrderEnabled: false }),
68 GetProjectTreeRuntimeSnapshot: async () => ({ revision: 0, topics: [] }),
69 IsolatedWorktreeAvailability: async () => ({ available: true, reason: "" }),
70 RemoveWorkspace: async (path: string) => { removedWorkspaces.push(path); },
71 RenameSessionTarget: async (_target: unknown, title: string) => { renamedSessions.push(title); },
72 Platform: async () => "darwin",
73 RemoteConnectionStatuses: async () => [],
74 };
75 window.reasonixDesktop = {
76 kind: "electron", contract: { commands: Object.keys(bindings) }, platform: { os: "darwin" }, native: {},
77 invoke: (method: string, args: unknown[]) => (bindings as unknown as Record<string, (...args: unknown[]) => Promise<unknown>>)[method](...args),
78 on: (name: string, cb: (...args: unknown[]) => void) => {
79 const set = listeners.get(name) ?? new Set(); set.add(cb); listeners.set(name, set);
80 return () => set.delete(cb);
81 },
82 } as unknown as ReasonixDesktopHost;
83
84 const container = document.getElementById("root")!;
85 let root: Root;
86 const flush = async () => { await act(async () => { await new Promise<void>(resolve => setImmediate(resolve)); }); };
87 const advance = async (ms = 200) => { await act(async () => mock.timers.tick(ms)); await flush(); };
88 const labels = () => [...container.querySelectorAll(".project-tree__topic-label")].map(el => el.textContent);
89 const count = (workspaceRoot = roots[0]) => calls.filter(req => req.workspaceRoot === workspaceRoot).length;
90 async function click(selector: string, scope: ParentNode = container) {
91 const target = scope.querySelector<HTMLElement>(selector);
92 assert.ok(target, `missing control: ${selector}`);
93 await act(async () => target.click()); await flush();
94 }
95 async function folder(label = "A") {
96 const target = [...container.querySelectorAll<HTMLElement>(".project-tree__folder--project .project-tree__folder-main")]
97 .find(el => el.textContent?.trim() === label);
98 assert.ok(target, `missing project ${label}`);
99 await act(async () => target.click()); await flush();
100 }
101 async function event(stale = false, reason = "changed") {
102 revision++;
103 await act(async () => {
104 for (const callback of listeners.get("project-tree:changed-v2") ?? []) callback({ revision: stale ? 0 : revision, roots: [roots[0]], reason });
105 });
106 await advance(500);
107 }
108 async function search(value: string) {
109 const input = container.querySelector<HTMLInputElement>(".project-tree__search input")!;
110 assert.ok(input, "search input exists");
111 await act(async () => {
112 Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, "value")!.set!.call(input, value);
113 input.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
114 });
115 await flush();
116 }
117 async function mount(withGroups = false, withSessions = false, onTopicsChanged?: () => Promise<void>) {
118 mountID++;
119 revision = 1; calls = []; intercept = undefined; snapshotFailure = null; visibleProjects = projects; releasedSnapshots.length = 0; removedWorkspaces.length = 0;
120 rows = Object.fromEntries(roots.map((path, i) => [path, Array.from({ length: 12 }, (_, n) => topic(`${i ? "B" : "A"}-${n}`, path))]));
121 if (withSessions) rows = Object.fromEntries(Object.entries(rows).map(([path, items]) => [path, items.map(row => ({ ...row, kind: "session", sessionPath: `/sessions/${mountID}/${row.key}` }))]));
122 groups = withGroups ? [{ id: "feature", title: "Feature", topicIds: [] }] : [];
123 resetProjectTreeRuntimeWindowLimits(); localStorage.clear();
124 root = createRoot(container);
125 await act(async () => root.render(<LocaleProvider><ToastProvider><ProjectTree activeScope="project" activeWorkspaceRoot={roots[0]} onOpenTopic={() => {}} onAddProject={async () => {}} onTopicsChanged={onTopicsChanged} /></ToastProvider></LocaleProvider>));
126 await flush(); await advance();
127 }
128 async function unmount() { await act(async () => root.unmount()); }
129 function deferred<T>() {
130 let resolve!: (value: T) => void;
131 let reject!: (error: unknown) => void;
132 const promise = new Promise<T>((done, fail) => { resolve = done; reject = fail; });
133 return { promise, resolve, reject };
134 }
135
136 mock.timers.enable({ apis: ["setTimeout", "Date"] });
137 try {
138 await mount(false, true);
139 await act(async () => container.querySelector(".project-tree__topic-main")!.dispatchEvent(new dom.window.MouseEvent("contextmenu", { bubbles: true, clientX: 30, clientY: 30 })));
140 await flush();
141 const renameSession = [...document.querySelectorAll<HTMLButtonElement>('[role="menuitem"]')].find(button => button.textContent?.includes("Rename session"));
142 assert.ok(renameSession, "session context menu offers Rename session");
143 await act(async () => renameSession.click()); await flush();
144 const renameInput = container.querySelector<HTMLInputElement>(".project-tree__topic-input");
145 assert.ok(renameInput, "session rename opens the inline input");
146 const composingEnter = new dom.window.KeyboardEvent("keydown", { key: "Enter", isComposing: true, bubbles: true });
147 const safariEnter = new dom.window.KeyboardEvent("keydown", { key: "Enter", bubbles: true });
148 Object.defineProperty(safariEnter, "keyCode", { value: 229 });
149 await act(async () => { renameInput.dispatchEvent(composingEnter); renameInput.dispatchEvent(safariEnter); });
150 assert.equal(container.querySelector(".project-tree__topic-input"), renameInput, "IME Enter keeps the session rename editor open");
151 assert.deepEqual(renamedSessions, [], "IME Enter does not persist a partial session name");
152 await act(async () => renameInput.dispatchEvent(new dom.window.KeyboardEvent("keydown", { key: "Enter", bubbles: true })));
153 await flush();
154 assert.equal(container.querySelector(".project-tree__topic-input"), null, "ordinary Enter finishes session rename");
155 assert.equal(renamedSessions.length, 1, "ordinary Enter persists the session name once");
156 await unmount();
157 console.log(" PASS session rename ignores IME Enter and accepts ordinary Enter");
158
159 await mount(true);
160 const coldPage = deferred<ProjectTopicPage>();
161 intercept = req => req.workspaceRoot === roots[1] ? coldPage.promise.then(() => page(req)) : undefined;
162 await folder("B");
163 assert.ok(container.querySelector(".project-tree__skeleton, .project-tree__topic-window-status"), "first expansion still indicates a pending initial page");
164 await act(async () => coldPage.resolve(page(calls.at(-1)!))); await flush();
165 intercept = undefined;
166 await click('[aria-label="Show more in A"]');
167 assert.equal(labels().filter(label => label?.startsWith("A-")).length, 10);
168 for (let turn = 0; turn < 3; turn++) {
169 const refreshPage = deferred<ProjectTopicPage>();
170 intercept = req => req.workspaceRoot === roots[0] ? refreshPage.promise.then(() => page(req)) : undefined;
171 const residentRows = [...container.querySelectorAll(".project-tree__topic-main")];
172 const more = container.querySelector<HTMLButtonElement>('[aria-label="Show more in A"]')!;
173 await event();
174 assert.equal(container.querySelectorAll(".project-tree__topic-window-status, .project-tree__skeleton").length, 0,
175 "turn refreshes keep both the populated list and its empty group quiet");
176 assert.equal(container.querySelector('[aria-label="Show more in A"]'), more, "background refresh retains the footer control instead of swapping it for loading text");
177 assert.equal(more.disabled, true, "the retained control cannot mix cursors while refreshing");
178 assert.deepEqual([...container.querySelectorAll(".project-tree__topic-main")], residentRows, "refresh start retains existing row elements");
179 assert.equal(calls.filter(req => req.workspaceRoot === roots[0] && !req.groupId).at(-1)?.limit, 10, "refresh retains the expanded window");
180 rows[roots[0]] = rows[roots[0]].map((row, i) => i === 0 ? { ...row, label: `A-updated-${turn}` } : row);
181 await act(async () => refreshPage.resolve(page({ workspaceRoot: roots[0], limit: 10 }))); await flush();
182 assert.ok(labels().includes(`A-updated-${turn}`), "background updates still reach the rendered row");
183 assert.deepEqual([...container.querySelectorAll(".project-tree__topic-main")], residentRows, "settlement reuses stable row elements");
184 assert.equal(labels().filter(label => label?.startsWith("A-")).length, 10);
185 assert.equal(more.disabled, false);
186 }
187 const appendPage = deferred<ProjectTopicPage>();
188 intercept = req => req.workspaceRoot === roots[0] ? appendPage.promise : undefined;
189 await click('[aria-label="Show more in A"]');
190 assert.equal(container.querySelectorAll(".project-tree__topic-window-status").length, 1, "an explicit next page still shows loading");
191 await act(async () => appendPage.resolve(page(calls.at(-1)!))); await flush();
192 assert.equal(labels().filter(label => label?.startsWith("A-")).length, 12);
193 const failedRefresh = deferred<ProjectTopicPage>();
194 intercept = req => req.workspaceRoot === roots[0] && !req.groupId ? failedRefresh.promise : undefined;
195 await event();
196 await act(async () => failedRefresh.reject(new Error("catalog temporarily unavailable"))); await flush();
197 assert.ok(container.querySelector('[aria-label="Retry loading A"]'), "silent reads still expose failures and retry");
198 assert.equal(labels().filter(label => label?.startsWith("A-")).length, 12, "refresh failure retains the resident page");
199 intercept = undefined;
200 await click('[aria-label="Retry loading A"]');
201 await unmount();
202 console.log(" PASS background refresh is silent, preserves expanded rows, and keeps foreground/error feedback");
203
204 for (const remaining of [0, 1]) {
205 await mount();
206 rows[roots[0]] = rows[roots[0]].slice(0, remaining);
207 await event();
208 const exhaustedRefresh = deferred<ProjectTopicPage>();
209 intercept = () => exhaustedRefresh.promise;
210 await event();
211 assert.equal(labels().length, remaining, "an exhausted list retains its resident rows during refresh");
212 assert.equal(container.querySelectorAll(".project-tree__skeleton, .project-tree__topic-window-status").length, 0,
213 "a settled exhausted folder refreshes without a loading placeholder");
214 const exhaustedPage = page(calls.at(-1)!);
215 assert.equal(exhaustedPage.nextCursor, undefined);
216 await act(async () => exhaustedRefresh.resolve(exhaustedPage)); await flush();
217 const settledCalls = count();
218 await advance(360_000);
219 assert.equal(count(), settledCalls, "an exhausted list does not keep requesting nonexistent pages");
220 assert.equal(labels().length, remaining);
221 assert.equal(container.querySelectorAll(".project-tree__skeleton, .project-tree__topic-window-status").length, 0);
222 assert.equal(container.querySelector('[aria-label="Show more in A"]'), null);
223 await unmount();
224 }
225 console.log(" PASS empty and single-row exhausted pages refresh quietly and stop loading");
226
227 await mount();
228 const stamped = (row: ProjectNode, at: number): ProjectNode => ({ ...row, createdAt: at, lastActivityAt: at });
229 rows[roots[0]] = rows[roots[0]].map((row, n) => stamped(row, 1_000 - n));
230 await event();
231 const residentWindow = labels().filter(label => label?.startsWith("A-"));
232 assert.deepEqual(residentWindow, ["A-0", "A-1", "A-2", "A-3", "A-4"], "a busy folder starts from a complete five-row window");
233 rows[roots[0]] = [stamped(topic("A-fresh"), 2_000), ...rows[roots[0]]];
234 intercept = req => req.workspaceRoot === roots[0] ? Promise.resolve({ ...page(req), complete: false }) : undefined;
235 await event();
236 assert.deepEqual(labels().filter(label => label?.startsWith("A-")), ["A-fresh", ...residentWindow.slice(0, 4)],
237 "a conversation discovered while the catalog is incomplete is accepted into the window");
238 intercept = undefined;
239 await unmount();
240 console.log(" PASS an incomplete refresh keeps a new conversation visible in a busy folder");
241
242 await mount();
243 rows[roots[0]] = Array.from({ length: 70 }, (_, i) => topic(`Search-${i}`));
244 await search("Search-"); await advance();
245 const searchRefresh = deferred<ProjectTopicPage>();
246 intercept = req => req.query ? searchRefresh.promise : undefined;
247 await event();
248 const searchMore = container.querySelector<HTMLButtonElement>('[aria-label="Load more results · A"]')!;
249 assert.ok(searchMore);
250 assert.equal(searchMore.textContent, "Load more results", "search refresh keeps the pagination label stable");
251 assert.equal(searchMore.disabled, true);
252 await act(async () => searchRefresh.resolve(page(calls.at(-1)!))); await flush();
253 const searchAppend = deferred<ProjectTopicPage>();
254 intercept = () => searchAppend.promise;
255 await click('[aria-label="Load more results · A"]');
256 assert.equal(searchMore.textContent, "Loading…", "explicit search pagination has visible loading feedback");
257 await act(async () => searchAppend.resolve(page(calls.at(-1)!))); await flush();
258 assert.equal(labels().length, 70);
259 await unmount();
260 console.log(" PASS search results distinguish background refresh from explicit pagination");
261
262 const archiveCases = [12, 2, 1].flatMap(size =>
263 (["old-first", "fresh-first", "old-error"] as const).map(completion => ({ size, completion })));
264 for (const { size, completion } of archiveCases) {
265 await mount(false, true);
266 rows[roots[0]] = rows[roots[0]].slice(0, size);
267 await event();
268 await folder("B");
269 const oldRequest = deferred<ProjectTopicPage>(), freshRequest = deferred<ProjectTopicPage>();
270 let requestIndex = 0;
271 intercept = req => req.workspaceRoot === roots[0]
272 ? (++requestIndex === 1 ? oldRequest.promise : freshRequest.promise) : undefined;
273 await event();
274 const oldPage = { ...page(calls.at(-1)!), snapshotId: `pre-archive-${completion}` };
275 await act(async () => container.querySelector('.project-tree__topic-main')!.dispatchEvent(new dom.window.MouseEvent("contextmenu", { bubbles: true, clientX: 30, clientY: 30 })));
276 await flush();
277 await click('[role="menuitem"].context-menu__item--danger', document);
278 await click('[role="menuitem"].context-menu__item--danger', document);
279 assert.equal(labels().filter(label => label?.startsWith("A-")).length, Math.min(size, 5) - 1, "the committed archive removes one visible row immediately");
280 await advance(500);
281 if (completion !== "fresh-first") {
282 await act(async () => completion === "old-error" ? oldRequest.reject(new Error("obsolete read failed")) : oldRequest.resolve(oldPage));
283 await flush();
284 }
285 const freshPage = { ...page({ workspaceRoot: roots[0], limit: 5 }), snapshotId: `post-archive-${completion}` };
286 await act(async () => freshRequest.resolve(freshPage));
287 await flush(); await advance(500);
288 assert.equal(container.querySelectorAll(".project-tree__topic-window-status").length, 0,
289 "archiving during an in-flight page must finish loading after the replacement page settles");
290 assert.deepEqual(labels().filter(label => label?.startsWith("A-")), rows[roots[0]].slice(0, 5).map(row => row.label), "replacement reads replenish the window or settle at the remaining row count");
291 if (completion === "fresh-first") {
292 await act(async () => oldRequest.resolve(oldPage)); await flush();
293 assert.ok(!labels().includes("A-0"), "a late pre-archive response cannot resurrect the archived session");
294 }
295 assert.equal(count(roots[1]), 1, "archiving A leaves the sibling project's cache intact");
296 if (completion !== "old-error") assert.ok(releasedSnapshots.includes(oldPage.snapshotId), "the discarded read releases its snapshot");
297 intercept = req => req.workspaceRoot === roots[0] ? Promise.resolve({ ...page(req), snapshotId: freshPage.snapshotId }) : undefined;
298 if (size > 5) {
299 await click('[aria-label="Show more in A"]');
300 assert.equal(labels().filter(label => label?.startsWith("A-")).length, 10, "pagination remains usable after archive");
301 } else {
302 assert.equal(container.querySelector('[aria-label="Show more in A"]'), null, "an exhausted archive result has no next-page control");
303 const settledCalls = count();
304 await advance(360_000);
305 assert.equal(count(), settledCalls, "archiving to one or zero rows does not leave an automatic loading loop");
306 assert.equal(container.querySelectorAll(".project-tree__skeleton, .project-tree__topic-window-status").length, 0);
307 }
308 await unmount();
309 }
310 console.log(" PASS session archive retires pending pages for either completion order and obsolete errors");
311
312 await mount();
313 assert.equal(count(), 1, "cold first page loads once");
314 await folder("B");
315 assert.equal(count(roots[1]), 1);
316 await folder(); await folder(); await advance();
317 assert.equal(count(), 1, "unchanged project reopen reuses its cache");
318 assert.equal(count(roots[1]), 1, "sibling expand/collapse never reloads B");
319 await click('[aria-label="Collapse all"]');
320 await click('[aria-label="Restore previous groups"]'); await advance();
321 assert.equal(calls.length, 2, "unchanged global restore reuses both caches");
322
323 await folder();
324 rows[roots[0]] = [topic("A-new"), ...rows[roots[0]].filter(row => row.topicId !== "A-0")];
325 await event();
326 assert.equal(count(), 1, "collapsed invalidation does not eagerly fetch");
327 await folder(); await advance();
328 assert.equal(count(), 2, "reopening an invalidated project fetches once");
329 assert.ok(labels().includes("A-new"));
330 assert.ok(!labels().includes("A-0"), "externally archived row is removed");
331 assert.equal(count(roots[1]), 1, "A's invalidation leaves B's cache valid");
332
333 await click('[aria-label="Collapse all"]');
334 rows[roots[0]] = [topic("A-newer"), ...rows[roots[0]].filter(row => row.topicId !== "A-new")];
335 await event();
336 assert.equal(count(), 2);
337 await click('[aria-label="Restore previous groups"]'); await advance();
338 assert.equal(count(), 3, "global restore reloads only invalidated A");
339 assert.equal(count(roots[1]), 1);
340 assert.ok(labels().includes("A-newer")); assert.ok(!labels().includes("A-new"));
341 await folder();
342 rows[roots[0]] = [topic("A-reconciled"), ...rows[roots[0]]];
343 await event(true);
344 await folder(); await advance();
345 assert.ok(labels().includes("A-reconciled"), "out-of-order events still invalidate cached lists while reconciling shells");
346 assert.equal(count(), 4); assert.equal(count(roots[1]), 1);
347 await unmount();
348 console.log(" PASS project/global reopen caches, deferred invalidation and sibling isolation");
349
350 await mount();
351 rows[roots[0]] = [topic("Fork one"), ...rows[roots[0]]];
352 await event(false, "membership");
353 assert.ok(labels().includes("Fork one"), "the first fork enters the visible topic page");
354 rows[roots[0]] = [topic("Fork two"), ...rows[roots[0]]];
355 await event(false, "membership");
356 assert.ok(labels().includes("Fork one") && labels().includes("Fork two"), "a second fork from the original appears without restarting");
357 await unmount();
358 console.log(" PASS repeated fork membership events refresh the visible topic page");
359
360 await mount(true);
361 assert.deepEqual(calls.map(req => req.groupId || "ungrouped").sort(), ["feature", "ungrouped"], "group and parent initialization share one request per list");
362 await advance();
363 assert.equal(calls.length, 2, "no delayed duplicate first page");
364 await folder(); await folder(); await advance();
365 assert.equal(calls.length, 2, "reopening a grouped project reuses both lists");
366 await unmount();
367 console.log(" PASS grouped cold start requests each list exactly once");
368
369 await mount();
370 const oldRequest = deferred<ProjectTopicPage>(), freshRequest = deferred<ProjectTopicPage>();
371 let requestIndex = 0;
372 intercept = () => (++requestIndex === 1 ? oldRequest.promise : freshRequest.promise);
373 await event();
374 const oldPage = page(calls.at(-1)!);
375 rows[roots[0]] = [topic("A-fresh"), ...rows[roots[0]]];
376 await event();
377 assert.equal(count(), 2, "background events queue behind an active logical read");
378 await act(async () => oldRequest.resolve(oldPage)); await flush();
379 await advance(500);
380 assert.equal(count(), 3, "queued refresh starts after the old read completes");
381 const freshPage = page(calls.at(-1)!);
382 await act(async () => freshRequest.resolve(freshPage)); await flush();
383 assert.ok(labels().includes("A-fresh"));
384 assert.equal(count(), 3);
385 await unmount();
386 console.log(" PASS background events do not starve pending reads and coalesce into one follow-up");
387
388 await mount();
389 await folder();
390 await search("A-");
391 assert.equal(count(), 1, "typing is debounced");
392 // An event is allowed to populate a visible search before debounce expires.
393 rows[roots[0]] = [topic("A-search-new"), ...rows[roots[0]]];
394 await event();
395 assert.equal(count(), 2, "search visibility refreshes even a manually collapsed project");
396 await advance();
397 assert.equal(count(), 2, "debounced search reuses the event's initialized page");
398 assert.ok(labels().includes("A-search-new"));
399 const oldSearch = deferred<ProjectTopicPage>();
400 intercept = req => req.query === "pending" ? oldSearch.promise : undefined;
401 await search("pending"); await advance();
402 await search("");
403 await folder();
404 await act(async () => oldSearch.resolve({ revision, items: [topic("stale-search")], complete: true })); await flush();
405 assert.ok(labels().includes("A-search-new")); assert.ok(!labels().includes("stale-search"));
406 assert.equal(container.querySelectorAll(".project-tree__topic-window-status").length, 0, "abandoned query cannot strand the normal list in loading");
407 await unmount();
408 console.log(" PASS search visibility, debounce deduplication and abandoned requests");
409
410 await mount();
411 const initialSort = calls.at(-1)?.sortMode ?? "created";
412 const nextSort = initialSort === "created" ? "updated" : "created";
413 const oldSort = deferred<ProjectTopicPage>();
414 intercept = req => req.sortMode === initialSort ? oldSort.promise : undefined;
415 await event();
416 const oldSortPage = page(calls.at(-1)!);
417 await click('.project-tree__header-menu-wrap button[aria-haspopup="menu"]');
418 const nextSortLabel = nextSort === "created" ? "Created time" : "Updated time";
419 const nextSortButton = [...document.querySelectorAll<HTMLButtonElement>('[role="menuitem"]')].find(button => button.textContent?.includes(nextSortLabel));
420 assert.ok(nextSortButton);
421 await act(async () => nextSortButton.click()); await flush();
422 assert.equal(calls.at(-1)?.sortMode, nextSort, "sorting starts a new request while the old order is pending");
423 assert.equal(calls.at(-1)?.cursor, "", "new sort discards the old order's pagination cursor");
424 assert.equal(count(), 3);
425 await act(async () => oldSort.resolve(oldSortPage)); await flush();
426 assert.equal(container.querySelectorAll(".project-tree__topic-window-status").length, 0);
427 assert.equal(count(), 3);
428 await unmount();
429 console.log(" PASS sort changes retire pending requests and their cursors");
430
431 await mount();
432 const removedRequest = deferred<ProjectTopicPage>();
433 intercept = req => req.workspaceRoot === roots[0] ? removedRequest.promise : undefined;
434 await event();
435 visibleProjects = [projects[1]]; revision++;
436 await act(async () => root.render(<LocaleProvider><ProjectTree activeScope="project" activeWorkspaceRoot={roots[1]} refreshSignal={1} onOpenTopic={() => {}} onAddProject={async () => {}} /></LocaleProvider>));
437 await flush();
438 assert.ok(!container.textContent?.includes("A-0"));
439 visibleProjects = projects; revision++;
440 intercept = undefined;
441 rows[roots[0]] = [topic("A-returned")];
442 await act(async () => root.render(<LocaleProvider><ProjectTree activeScope="project" activeWorkspaceRoot={roots[0]} refreshSignal={2} onOpenTopic={() => {}} onAddProject={async () => {}} /></LocaleProvider>));
443 await flush(); await advance(500);
444 assert.ok(labels().includes("A-returned"));
445 await event();
446 await act(async () => removedRequest.resolve({ revision: revision + 1, items: [topic("A-obsolete")], snapshotId: "removed-project-read", complete: true }));
447 await flush();
448 assert.ok(labels().includes("A-returned"));
449 assert.ok(!labels().includes("A-obsolete"), "removed/re-added project cannot reuse an old request generation");
450 assert.ok(releasedSnapshots.includes("removed-project-read"));
451 await unmount();
452 console.log(" PASS project removal/re-addition fences late responses and releases obsolete snapshots");
453
454 let projectSyncs = 0;
455 await mount(false, false, async () => { projectSyncs++; });
456 const firstProject = container.querySelector<HTMLElement>(".project-tree__folder--project");
457 assert.ok(firstProject, "project folder exists");
458 await act(async () => firstProject.dispatchEvent(new dom.window.MouseEvent("contextmenu", { bubbles: true, clientX: 30, clientY: 30 })));
459 await flush();
460 const removeItem = () => [...document.querySelectorAll<HTMLButtonElement>('[role="menuitem"]')]
461 .find(button => ["Remove", "Confirm remove"].includes(button.textContent?.trim() ?? ""));
462 assert.ok(removeItem(), "remove project command exists");
463 await act(async () => removeItem()!.click()); await flush();
464 assert.ok(removeItem(), "remove project command asks for confirmation");
465 await act(async () => removeItem()!.click()); await flush();
466 assert.deepEqual(removedWorkspaces, [roots[0]], "the selected workspace is removed");
467 assert.equal(projectSyncs, 1, "workspace removal synchronizes tabs and the active session");
468 await unmount();
469 console.log(" PASS project removal synchronizes the application session surface");
470
471 await mount();
472 snapshotFailure = new Error("workspace snapshot failed");
473 const addMenus = container.querySelectorAll<HTMLButtonElement>('.project-tree__header-menu-wrap button[aria-haspopup="menu"]');
474 assert.ok(addMenus.length > 0, "add project menu exists");
475 await act(async () => addMenus[addMenus.length - 1].click()); await flush();
476 const addFolder = document.querySelectorAll<HTMLButtonElement>('[role="menuitem"]')[1];
477 assert.ok(addFolder, "add local folder command exists");
478 await act(async () => addFolder.click()); await flush();
479 assert.ok(document.querySelector(".toast--error")?.textContent?.includes("workspace snapshot failed"), "add project reports snapshot refresh failure");
480 await unmount();
481 console.log(" PASS add project reports shell refresh failures");
482 } finally {
483 mock.timers.reset();
484 dom.window.close();
485 }
486
486 lines Plain Text