| 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 { resetProjectTreeRuntimeWindowLimits } = await import("../lib/projectTreeWindow"); |
| 28 | |
| 29 | const roots = ["/review-a", "/review-b"]; |
| 30 | const projects: ProjectNode[] = roots.map((root, i) => ({ key: `project-${i}`, kind: "project", label: i ? "B" : "A", root, children: [] })); |
| 31 | const topic = (id: string, root = roots[0]): ProjectNode => ({ key: id, topicId: id, kind: "topic", label: id, root, children: [] }); |
| 32 | const listeners = new Map<string, Set<(...args: unknown[]) => void>>(); |
| 33 | let revision = 1; |
| 34 | let rows: Record<string, ProjectNode[]> = {}; |
| 35 | let groups: SessionGroup[] = []; |
| 36 | let calls: ProjectTopicPageRequest[] = []; |
| 37 | let intercept: ((req: ProjectTopicPageRequest) => Promise<ProjectTopicPage> | undefined) | undefined; |
| 38 | const catalog = () => ({ state: "ready", revision, indexed: 2, total: 2, repairPending: 0 }); |
| 39 | |
| 40 | function page(req: ProjectTopicPageRequest): ProjectTopicPage { |
| 41 | const query = req.query?.toLowerCase() ?? ""; |
| 42 | const selected = req.groupId ? [] : (rows[req.workspaceRoot ?? ""] ?? []).filter(row => row.label.toLowerCase().includes(query)); |
| 43 | const start = Number(req.cursor || 0), limit = req.limit ?? 5; |
| 44 | const items = selected.slice(start, start + limit); |
| 45 | return { revision, items, complete: true, nextCursor: start + items.length < selected.length ? String(start + items.length) : undefined }; |
| 46 | } |
| 47 | const bindings = { |
| 48 | GetProjectTreeSnapshot: async () => ({ revision, projects, catalog: catalog() }), |
| 49 | ListProjectTopics: async (req: ProjectTopicPageRequest) => { calls.push(req); return intercept?.(req) ?? page(req); }, |
| 50 | GetSessionCatalogStatus: async () => catalog(), |
| 51 | GetSessionOrganization: async () => ({ groups, revision, order: [], manualOrderEnabled: false }), |
| 52 | GetProjectTreeRuntimeSnapshot: async () => ({ revision: 0, topics: [] }), |
| 53 | Platform: async () => "darwin", |
| 54 | RemoteConnectionStatuses: async () => [], |
| 55 | }; |
| 56 | window.reasonixDesktop = { |
| 57 | kind: "electron", contract: { commands: Object.keys(bindings) }, platform: { os: "darwin" }, native: {}, |
| 58 | invoke: (method: string, args: unknown[]) => (bindings as unknown as Record<string, (...args: unknown[]) => Promise<unknown>>)[method](...args), |
| 59 | on: (name: string, cb: (...args: unknown[]) => void) => { |
| 60 | const set = listeners.get(name) ?? new Set(); set.add(cb); listeners.set(name, set); |
| 61 | return () => set.delete(cb); |
| 62 | }, |
| 63 | } as unknown as ReasonixDesktopHost; |
| 64 | |
| 65 | const container = document.getElementById("root")!; |
| 66 | let root: Root; |
| 67 | const flush = async () => { await act(async () => { await new Promise<void>(resolve => setImmediate(resolve)); }); }; |
| 68 | const advance = async (ms = 200) => { await act(async () => mock.timers.tick(ms)); await flush(); }; |
| 69 | const labels = () => [...container.querySelectorAll(".project-tree__topic-label")].map(el => el.textContent); |
| 70 | const count = (workspaceRoot = roots[0]) => calls.filter(req => req.workspaceRoot === workspaceRoot).length; |
| 71 | async function click(selector: string, scope: ParentNode = container) { |
| 72 | const target = scope.querySelector<HTMLElement>(selector); |
| 73 | assert.ok(target, `missing control: ${selector}`); |
| 74 | await act(async () => target.click()); await flush(); |
| 75 | } |
| 76 | async function folder(label = "A") { |
| 77 | const target = [...container.querySelectorAll<HTMLElement>(".project-tree__folder--project .project-tree__folder-main")] |
| 78 | .find(el => el.textContent?.trim() === label); |
| 79 | assert.ok(target, `missing project ${label}`); |
| 80 | await act(async () => target.click()); await flush(); |
| 81 | } |
| 82 | async function event(stale = false) { |
| 83 | revision++; |
| 84 | await act(async () => { |
| 85 | for (const callback of listeners.get("project-tree:changed-v2") ?? []) callback({ revision: stale ? 0 : revision, roots: [roots[0]], reason: "changed" }); |
| 86 | }); |
| 87 | await flush(); |
| 88 | } |
| 89 | async function search(value: string) { |
| 90 | const input = container.querySelector<HTMLInputElement>(".project-tree__search input")!; |
| 91 | assert.ok(input, "search input exists"); |
| 92 | await act(async () => { |
| 93 | Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, "value")!.set!.call(input, value); |
| 94 | input.dispatchEvent(new dom.window.Event("input", { bubbles: true })); |
| 95 | }); |
| 96 | await flush(); |
| 97 | } |
| 98 | async function mount(withGroups = false) { |
| 99 | revision = 1; calls = []; intercept = undefined; |
| 100 | rows = Object.fromEntries(roots.map((path, i) => [path, Array.from({ length: 12 }, (_, n) => topic(`${i ? "B" : "A"}-${n}`, path))])); |
| 101 | groups = withGroups ? [{ id: "feature", title: "Feature", topicIds: [] }] : []; |
| 102 | resetProjectTreeRuntimeWindowLimits(); localStorage.clear(); |
| 103 | root = createRoot(container); |
| 104 | await act(async () => root.render(<LocaleProvider><ProjectTree activeScope="project" activeWorkspaceRoot={roots[0]} onOpenTopic={() => {}} onAddProject={async () => {}} /></LocaleProvider>)); |
| 105 | await flush(); await advance(); |
| 106 | } |
| 107 | async function unmount() { await act(async () => root.unmount()); } |
| 108 | function deferred<T>() { |
| 109 | let resolve!: (value: T) => void; |
| 110 | const promise = new Promise<T>(done => { resolve = done; }); |
| 111 | return { promise, resolve }; |
| 112 | } |
| 113 | |
| 114 | mock.timers.enable({ apis: ["setTimeout"] }); |
| 115 | try { |
| 116 | await mount(); |
| 117 | assert.equal(count(), 1, "cold first page loads once"); |
| 118 | await folder("B"); |
| 119 | assert.equal(count(roots[1]), 1); |
| 120 | await folder(); await folder(); await advance(); |
| 121 | assert.equal(count(), 1, "unchanged project reopen reuses its cache"); |
| 122 | assert.equal(count(roots[1]), 1, "sibling expand/collapse never reloads B"); |
| 123 | await click('[aria-label="Collapse all"]'); |
| 124 | await click('[aria-label="Restore previous groups"]'); await advance(); |
| 125 | assert.equal(calls.length, 2, "unchanged global restore reuses both caches"); |
| 126 | |
| 127 | await folder(); |
| 128 | rows[roots[0]] = [topic("A-new"), ...rows[roots[0]].filter(row => row.topicId !== "A-0")]; |
| 129 | await event(); |
| 130 | assert.equal(count(), 1, "collapsed invalidation does not eagerly fetch"); |
| 131 | await folder(); await advance(); |
| 132 | assert.equal(count(), 2, "reopening an invalidated project fetches once"); |
| 133 | assert.ok(labels().includes("A-new")); |
| 134 | assert.ok(!labels().includes("A-0"), "externally archived row is removed"); |
| 135 | assert.equal(count(roots[1]), 1, "A's invalidation leaves B's cache valid"); |
| 136 | |
| 137 | await click('[aria-label="Collapse all"]'); |
| 138 | rows[roots[0]] = [topic("A-newer"), ...rows[roots[0]].filter(row => row.topicId !== "A-new")]; |
| 139 | await event(); |
| 140 | assert.equal(count(), 2); |
| 141 | await click('[aria-label="Restore previous groups"]'); await advance(); |
| 142 | assert.equal(count(), 3, "global restore reloads only invalidated A"); |
| 143 | assert.equal(count(roots[1]), 1); |
| 144 | assert.ok(labels().includes("A-newer")); assert.ok(!labels().includes("A-new")); |
| 145 | await folder(); |
| 146 | rows[roots[0]] = [topic("A-reconciled"), ...rows[roots[0]]]; |
| 147 | await event(true); |
| 148 | await folder(); await advance(); |
| 149 | assert.ok(labels().includes("A-reconciled"), "out-of-order events still invalidate cached lists while reconciling shells"); |
| 150 | assert.equal(count(), 4); assert.equal(count(roots[1]), 1); |
| 151 | await unmount(); |
| 152 | console.log(" PASS project/global reopen caches, deferred invalidation and sibling isolation"); |
| 153 | |
| 154 | await mount(true); |
| 155 | assert.deepEqual(calls.map(req => req.groupId || "ungrouped").sort(), ["feature", "ungrouped"], "group and parent initialization share one request per list"); |
| 156 | await advance(); |
| 157 | assert.equal(calls.length, 2, "no delayed duplicate first page"); |
| 158 | await folder(); await folder(); await advance(); |
| 159 | assert.equal(calls.length, 2, "reopening a grouped project reuses both lists"); |
| 160 | await unmount(); |
| 161 | console.log(" PASS grouped cold start requests each list exactly once"); |
| 162 | |
| 163 | for (const oldFinishesFirst of [true, false]) { |
| 164 | await mount(); |
| 165 | const oldRequest = deferred<ProjectTopicPage>(), freshRequest = deferred<ProjectTopicPage>(); |
| 166 | let requestIndex = 0; |
| 167 | intercept = () => (++requestIndex === 1 ? oldRequest.promise : freshRequest.promise); |
| 168 | await event(); |
| 169 | const oldPage = page(calls.at(-1)!); |
| 170 | rows[roots[0]] = [topic("A-fresh"), ...rows[roots[0]]]; |
| 171 | await event(); |
| 172 | const freshPage = page(calls.at(-1)!); |
| 173 | assert.equal(count(), 3, "invalidation starts a new generation despite an older pending request"); |
| 174 | if (oldFinishesFirst) { |
| 175 | await act(async () => oldRequest.resolve(oldPage)); await flush(); |
| 176 | assert.ok(container.querySelector(".project-tree__topic-window-status"), "old completion cannot clear the newer loading state"); |
| 177 | await act(async () => freshRequest.resolve(freshPage)); |
| 178 | } else { |
| 179 | await act(async () => freshRequest.resolve(freshPage)); await flush(); |
| 180 | await act(async () => oldRequest.resolve(oldPage)); |
| 181 | } |
| 182 | await flush(); |
| 183 | assert.ok(labels().includes("A-fresh"), "late stale response cannot overwrite fresh data"); |
| 184 | assert.equal(count(), 3); |
| 185 | await unmount(); |
| 186 | } |
| 187 | console.log(" PASS old/new request completion order preserves the current generation"); |
| 188 | |
| 189 | await mount(); |
| 190 | await folder(); |
| 191 | await search("A-"); |
| 192 | assert.equal(count(), 1, "typing is debounced"); |
| 193 | // An event is allowed to populate a visible search before debounce expires. |
| 194 | rows[roots[0]] = [topic("A-search-new"), ...rows[roots[0]]]; |
| 195 | await event(); |
| 196 | assert.equal(count(), 2, "search visibility refreshes even a manually collapsed project"); |
| 197 | await advance(); |
| 198 | assert.equal(count(), 2, "debounced search reuses the event's initialized page"); |
| 199 | assert.ok(labels().includes("A-search-new")); |
| 200 | const oldSearch = deferred<ProjectTopicPage>(); |
| 201 | intercept = req => req.query === "pending" ? oldSearch.promise : undefined; |
| 202 | await search("pending"); await advance(); |
| 203 | await search(""); |
| 204 | await folder(); |
| 205 | await act(async () => oldSearch.resolve({ revision, items: [topic("stale-search")], complete: true })); await flush(); |
| 206 | assert.ok(labels().includes("A-search-new")); assert.ok(!labels().includes("stale-search")); |
| 207 | assert.equal(container.querySelectorAll(".project-tree__topic-window-status").length, 0, "abandoned query cannot strand the normal list in loading"); |
| 208 | await unmount(); |
| 209 | console.log(" PASS search visibility, debounce deduplication and abandoned requests"); |
| 210 | |
| 211 | await mount(); |
| 212 | const initialSort = calls.at(-1)?.sortMode ?? "created"; |
| 213 | const nextSort = initialSort === "created" ? "updated" : "created"; |
| 214 | const oldSort = deferred<ProjectTopicPage>(); |
| 215 | intercept = req => req.sortMode === initialSort ? oldSort.promise : undefined; |
| 216 | await event(); |
| 217 | const oldSortPage = page(calls.at(-1)!); |
| 218 | await click('.project-tree__header-menu-wrap button[aria-haspopup="menu"]'); |
| 219 | const nextSortLabel = nextSort === "created" ? "Created time" : "Updated time"; |
| 220 | const nextSortButton = [...document.querySelectorAll<HTMLButtonElement>('[role="menuitem"]')].find(button => button.textContent?.includes(nextSortLabel)); |
| 221 | assert.ok(nextSortButton); |
| 222 | await act(async () => nextSortButton.click()); await flush(); |
| 223 | assert.equal(calls.at(-1)?.sortMode, nextSort, "sorting starts a new request while the old order is pending"); |
| 224 | assert.equal(calls.at(-1)?.cursor, "", "new sort discards the old order's pagination cursor"); |
| 225 | assert.equal(count(), 3); |
| 226 | await act(async () => oldSort.resolve(oldSortPage)); await flush(); |
| 227 | assert.equal(container.querySelectorAll(".project-tree__topic-window-status").length, 0); |
| 228 | assert.equal(count(), 3); |
| 229 | await unmount(); |
| 230 | console.log(" PASS sort changes retire pending requests and their cursors"); |
| 231 | } finally { |
| 232 | mock.timers.reset(); |
| 233 | dom.window.close(); |
| 234 | } |
| 235 |