| 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 |