返回 DeepSeek-Reasonix
project-tree-shell-ordering.test.tsx
根目录 / desktop / frontend / src / __tests__ / project-tree-shell-ordering.test.tsx
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
172 lines Plain Text