| 1 | // Run: node --import ./scripts/svg-stub-register.mjs --import tsx src/__tests__/project-tree-shell-ordering.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 { ReasonixDesktopHost } from "../lib/desktopHost"; |
| 7 | import type { ProjectNode, ProjectTreeSnapshot } from "../lib/types"; |
| 8 | |
| 9 | const dom = new JSDOM('<html><body><div id="root"></div></body></html>', { url: "http://localhost/", pretendToBeVisual: true }); |
| 10 | Object.assign(globalThis, { |
| 11 | window: dom.window, document: dom.window.document, Element: dom.window.Element, |
| 12 | HTMLElement: dom.window.HTMLElement, Node: dom.window.Node, Event: dom.window.Event, |
| 13 | MouseEvent: dom.window.MouseEvent, localStorage: dom.window.localStorage, |
| 14 | requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(() => callback(Date.now()), 0), |
| 15 | cancelAnimationFrame: (id: ReturnType<typeof setTimeout>) => clearTimeout(id), |
| 16 | IS_REACT_ACT_ENVIRONMENT: true, |
| 17 | }); |
| 18 | Object.defineProperty(globalThis, "navigator", { value: dom.window.navigator, configurable: true }); |
| 19 | window.matchMedia = (() => ({ matches: false, addEventListener() {}, removeEventListener() {} })) as unknown as typeof window.matchMedia; |
| 20 | |
| 21 | const { createRoot } = await import("react-dom/client"); |
| 22 | const { ProjectTree } = await import("../components/ProjectTree"); |
| 23 | const { LocaleProvider } = await import("../lib/i18n"); |
| 24 | const { ToastProvider } = await import("../lib/toast"); |
| 25 | |
| 26 | function deferred<T>() { |
| 27 | let resolve!: (value: T) => void; |
| 28 | let reject!: (reason: Error) => void; |
| 29 | const promise = new Promise<T>((done, fail) => { resolve = done; reject = fail; }); |
| 30 | return { promise, resolve, reject }; |
| 31 | } |
| 32 | |
| 33 | const pending: ReturnType<typeof deferred<ProjectTreeSnapshot>>[] = []; |
| 34 | const catalog = { state: "ready" as const, revision: 7, indexed: 0, total: 0, repairPending: 0 }; |
| 35 | const bindings = { |
| 36 | GetProjectTreeSnapshot: () => { |
| 37 | const request = deferred<ProjectTreeSnapshot>(); |
| 38 | pending.push(request); |
| 39 | return request.promise; |
| 40 | }, |
| 41 | ListProjectTopics: async () => ({ revision: 7, items: [], complete: true }), |
| 42 | GetSessionCatalogStatus: async () => catalog, |
| 43 | GetSessionOrganization: async () => ({ groups: [], revision: 7, order: [], manualOrderEnabled: false }), |
| 44 | GetProjectTreeRuntimeSnapshot: async () => ({ revision: 0, topics: [] }), |
| 45 | IsolatedWorktreeAvailability: async () => ({ available: true, reason: "" }), |
| 46 | Platform: async () => "darwin", |
| 47 | RemoteConnectionStatuses: async () => [], |
| 48 | }; |
| 49 | window.reasonixDesktop = { |
| 50 | kind: "electron", contract: { commands: Object.keys(bindings) }, platform: { os: "darwin" }, native: {}, |
| 51 | invoke: (method: string, args: unknown[]) => (bindings as unknown as Record<string, (...args: unknown[]) => Promise<unknown>>)[method](...args), |
| 52 | on: () => () => {}, |
| 53 | } as unknown as ReasonixDesktopHost; |
| 54 | |
| 55 | const project = (label: string): ProjectNode => ({ key: `project-${label}`, kind: "project", label, root: `/ordering-${label}`, children: [] }); |
| 56 | const a = project("A"), b = project("B"); |
| 57 | const snapshot = (projects: ProjectNode[]): ProjectTreeSnapshot => ({ revision: 7, projects, catalog }); |
| 58 | const container = document.getElementById("root")!; |
| 59 | const flush = async () => { await act(async () => { await new Promise<void>(resolve => setImmediate(resolve)); }); }; |
| 60 | const labels = () => [...container.querySelectorAll(".project-tree__folder--project .project-tree__folder-main")].map(el => el.textContent?.trim()); |
| 61 | const noop = () => {}; |
| 62 | const addProject = async () => {}; |
| 63 | |
| 64 | mock.timers.enable({ apis: ["setTimeout", "Date"] }); |
| 65 | try { |
| 66 | for (const scenario of [ |
| 67 | { name: "addition", before: [a], after: [a, b] }, |
| 68 | { name: "removal", before: [a, b], after: [a] }, |
| 69 | ]) { |
| 70 | pending.length = 0; |
| 71 | localStorage.clear(); |
| 72 | const root = createRoot(container); |
| 73 | const render = async (refreshSignal: number) => { |
| 74 | await act(async () => root.render(<LocaleProvider><ToastProvider><ProjectTree |
| 75 | onOpenTopic={noop} onAddProject={addProject} refreshSignal={refreshSignal} |
| 76 | /></ToastProvider></LocaleProvider>)); |
| 77 | await flush(); |
| 78 | }; |
| 79 | try { |
| 80 | await render(0); |
| 81 | assert.equal(pending.length, 1, "mount starts one shell read"); |
| 82 | assert.match(container.querySelector(".project-tree__empty-state")?.textContent ?? "", /读取|讀取|Reading/, |
| 83 | "an unanswered shell read must say it is loading"); |
| 84 | assert.doesNotMatch(container.textContent ?? "", /No projects yet|还没有项目|還沒有專案/, |
| 85 | "pending shell read must not claim there are no projects"); |
| 86 | if (scenario.name === "addition") { |
| 87 | assert.equal(container.querySelector(".project-tree__empty-state .project-tree__skeleton"), null, |
| 88 | "fast shell reads must not flash a skeleton"); |
| 89 | await act(async () => mock.timers.tick(250)); |
| 90 | assert.ok(container.querySelector(".project-tree__empty-state .project-tree__skeleton"), |
| 91 | "a delayed shell read shows a skeleton"); |
| 92 | } |
| 93 | await act(async () => pending[0].resolve(snapshot(scenario.before))); |
| 94 | await flush(); |
| 95 | assert.deepEqual(labels(), scenario.before.map(item => item.label)); |
| 96 | assert.equal(container.querySelector(".project-tree__empty-state .project-tree__skeleton"), null); |
| 97 | |
| 98 | // A read started before the mutation retains the old shell contents. |
| 99 | // Its scalar revision may equal the post-mutation read: this fixture |
| 100 | // exercises client ordering, not proof of a particular backend cause. |
| 101 | await render(1); |
| 102 | assert.equal(pending.length, 2); |
| 103 | await render(2); |
| 104 | assert.equal(pending.length, 3); |
| 105 | await act(async () => pending[2].resolve(snapshot(scenario.after))); |
| 106 | await flush(); |
| 107 | assert.deepEqual(labels(), scenario.after.map(item => item.label), "latest state is initially painted"); |
| 108 | |
| 109 | await act(async () => pending[1].resolve(snapshot(scenario.before))); |
| 110 | await flush(); |
| 111 | assert.deepEqual(labels(), scenario.after.map(item => item.label), |
| 112 | `a delayed pre-${scenario.name} shell must not reverse the latest visible project list`); |
| 113 | |
| 114 | await render(3); |
| 115 | await act(async () => pending[3].resolve({ ...snapshot(scenario.after), workspaceGeneration: 5 })); |
| 116 | await flush(); |
| 117 | await render(4); |
| 118 | await act(async () => pending[4].resolve({ ...snapshot(scenario.before), workspaceGeneration: 4, revision: 1000 })); |
| 119 | await flush(); |
| 120 | assert.deepEqual(labels(), scenario.after.map(item => item.label), "a newer catalog cannot make older membership authoritative"); |
| 121 | await render(5); |
| 122 | await act(async () => pending[5].resolve({ ...snapshot(scenario.before), workspaceGeneration: 6, revision: 1 })); |
| 123 | await flush(); |
| 124 | assert.deepEqual(labels(), scenario.before.map(item => item.label), "new membership survives catalog revision reset"); |
| 125 | console.log(` PASS delayed shell response cannot reverse project ${scenario.name}`); |
| 126 | } finally { |
| 127 | await act(async () => root.unmount()); |
| 128 | } |
| 129 | } |
| 130 | |
| 131 | pending.length = 0; |
| 132 | localStorage.clear(); |
| 133 | const emptyRoot = createRoot(container); |
| 134 | try { |
| 135 | await act(async () => emptyRoot.render(<LocaleProvider><ToastProvider><ProjectTree |
| 136 | onOpenTopic={noop} onAddProject={addProject} |
| 137 | /></ToastProvider></LocaleProvider>)); |
| 138 | await flush(); |
| 139 | assert.doesNotMatch(container.textContent ?? "", /No projects yet|还没有项目|還沒有專案/); |
| 140 | await act(async () => pending[0].resolve(snapshot([]))); |
| 141 | await flush(); |
| 142 | assert.match(container.textContent ?? "", /No projects yet|还没有项目|還沒有專案/, |
| 143 | "an empty answer restores the true empty state"); |
| 144 | } finally { |
| 145 | await act(async () => emptyRoot.unmount()); |
| 146 | } |
| 147 | |
| 148 | pending.length = 0; |
| 149 | localStorage.clear(); |
| 150 | const failedRoot = createRoot(container); |
| 151 | try { |
| 152 | await act(async () => failedRoot.render(<LocaleProvider><ToastProvider><ProjectTree |
| 153 | onOpenTopic={noop} onAddProject={addProject} |
| 154 | /></ToastProvider></LocaleProvider>)); |
| 155 | await flush(); |
| 156 | assert.match(container.querySelector(".project-tree__empty-state")?.textContent ?? "", /读取|讀取|Reading/); |
| 157 | await act(async () => pending[0].reject(new Error("snapshot unavailable"))); |
| 158 | await flush(); |
| 159 | assert.doesNotMatch(container.textContent ?? "", /读取项目|讀取專案|Reading projects/, |
| 160 | "a failed first shell read must settle the loading state"); |
| 161 | assert.match(container.textContent ?? "", /Add new project|添加新项目|新增專案/, |
| 162 | "the empty-state project action must remain available after a failed read"); |
| 163 | assert.match(container.textContent ?? "", /Remote connection|远程连接|遠端連線/, |
| 164 | "the empty-state remote action must remain available after a failed read"); |
| 165 | } finally { |
| 166 | await act(async () => failedRoot.unmount()); |
| 167 | } |
| 168 | } finally { |
| 169 | mock.timers.reset(); |
| 170 | dom.window.close(); |
| 171 | } |
| 172 |