返回 DeepSeek-Reasonix
project-tree-organization-races.test.tsx
根目录 / desktop / frontend / src / __tests__ / project-tree-organization-races.test.tsx
1 // Run: tsx src/__tests__/project-tree-organization-races.test.tsx
2
3 import { JSDOM } from "jsdom";
4 import React, { StrictMode } from "react";
5 import { act } from "react";
6 import { createRoot, type Root } from "react-dom/client";
7 import { useProjectTreeOrganization } from "../components/ProjectTreeOrganization";
8 import type { ProjectNode, ProjectTreeOrganizationBindings, SessionGroup } from "../lib/types";
9 import type { SessionOrganizationSnapshot } from "../generated/desktopContract.generated";
10 import { ToastProvider } from "../lib/toast";
11 import { LocaleProvider } from "../lib/i18n";
12
13 let passed = 0;
14 let failed = 0;
15
16 function ok(value: boolean, label: string) {
17 process.stdout.write(` ${value ? "PASS" : "FAIL"} ${label}\n`);
18 if (value) passed += 1; else failed += 1;
19 }
20
21 function deferred<T>() {
22 let resolve!: (value: T) => void;
23 const promise = new Promise<T>((done) => { resolve = done; });
24 return { promise, resolve };
25 }
26
27 function installDom() {
28 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
29 pretendToBeVisual: true,
30 url: "http://localhost/",
31 });
32 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
33 globalThis.window = dom.window as unknown as Window & typeof globalThis;
34 globalThis.document = dom.window.document;
35 globalThis.Node = dom.window.Node;
36 globalThis.Element = dom.window.Element;
37 globalThis.HTMLElement = dom.window.HTMLElement;
38 globalThis.Event = dom.window.Event;
39 globalThis.MouseEvent = dom.window.MouseEvent;
40 return dom;
41 }
42
43 const folder: ProjectNode = {
44 key: "project-/repo",
45 kind: "project",
46 label: "Repo",
47 root: "/repo",
48 children: [],
49 };
50
51 function Harness({ bindings, revision = 0 }: { bindings: ProjectTreeOrganizationBindings; revision?: number }) {
52 const organization = useProjectTreeOrganization({
53 tree: [folder],
54 refresh: async () => {},
55 organizationRevision: revision,
56 bindings,
57 });
58 const groups = organization.groupsFor(folder);
59 return <>
60 <output id="groups">{JSON.stringify(groups)}</output>
61 <button id="create" onClick={() => organization.createGroup(folder, "Local")}>create</button>
62 </>;
63 }
64
65 async function flush() {
66 await new Promise((resolve) => setTimeout(resolve, 0));
67 }
68
69 async function waitFor(label: string, predicate: () => boolean) {
70 for (let attempt = 0; attempt < 30; attempt += 1) {
71 await act(flush);
72 if (predicate()) return;
73 }
74 throw new Error(`timed out waiting for ${label}`);
75 }
76
77 async function mount(bindings: ProjectTreeOrganizationBindings) {
78 const dom = installDom();
79 const root = createRoot(document.getElementById("root")!);
80 let revision = 0;
81 const render = async (nextRevision = revision) => {
82 revision = nextRevision;
83 await act(async () => {
84 root.render(<StrictMode><LocaleProvider><ToastProvider><Harness bindings={bindings} revision={revision} /></ToastProvider></LocaleProvider></StrictMode>);
85 await flush();
86 });
87 };
88 await render();
89 return { dom, root, render };
90 }
91
92 async function cleanup(dom: JSDOM, root: Root) {
93 await act(async () => root.unmount());
94 dom.window.close();
95 }
96
97 function legacyBindings(list: () => Promise<SessionGroup[]>): ProjectTreeOrganizationBindings {
98 return {
99 ReorderTopics: async () => {},
100 ListProjectGroups: async () => list(),
101 SaveSessionGroups: async () => {},
102 };
103 }
104 function snapshot(groups: SessionGroup[], revision = 1, applied = true): SessionOrganizationSnapshot {
105 return { groups: structuredClone(groups), revision, applied, order: [], manualOrderEnabled: false };
106 }
107 const rejectLegacyWrite = async () => { throw new Error("new UI must never invoke an unversioned or topic group writer"); };
108 function organizationBindings(read: () => Promise<SessionOrganizationSnapshot>, write: NonNullable<ProjectTreeOrganizationBindings["UpdateSessionOrganization"]>): ProjectTreeOrganizationBindings {
109 return { ReorderTopics: rejectLegacyWrite, ListProjectGroups: async () => { throw new Error("new API must own reads"); }, SaveSessionGroups: rejectLegacyWrite,
110 GetSessionOrganization: read, UpdateSessionOrganization: write };
111 }
112
113 console.log("\nproject tree organization races");
114
115 {
116 const initial = deferred<SessionOrganizationSnapshot>();
117 const { dom, root } = await mount(organizationBindings(() => initial.promise, async () => { throw new Error("read only scenario"); }));
118 await act(async () => {
119 initial.resolve(snapshot([{ id: "existing", title: "Existing", sessionKeys: [] }]));
120 await flush();
121 });
122 await waitFor("StrictMode group load", () => document.getElementById("groups")?.textContent?.includes("Existing") === true);
123 ok(true, "StrictMode effect replay does not discard the deferred group load");
124 await cleanup(dom, root);
125 }
126
127 {
128 const initial = deferred<SessionOrganizationSnapshot>();
129 const write = deferred<void>();
130 let reads = 0;
131 const { dom, root } = await mount(organizationBindings(() => ++reads === 1 ? initial.promise : Promise.resolve(snapshot([])), async (_workspace, expected, mutation) => {
132 await write.promise;
133 return snapshot([{ id: mutation.groupId!, title: mutation.title!, sessionKeys: [] }], expected + 1);
134 }));
135 await act(async () => {
136 (document.getElementById("create") as HTMLButtonElement).click();
137 await flush();
138 });
139 initial.resolve(snapshot([{ id: "stale", title: "Stale", sessionKeys: [] }]));
140 await act(flush);
141 const text = document.getElementById("groups")?.textContent ?? "";
142 ok(text.includes("Local") && !text.includes("Stale"), "a stale initial read cannot overwrite an optimistic mutation");
143 await act(async () => { write.resolve(); await flush(); });
144 await cleanup(dom, root);
145 }
146
147 {
148 let state: SessionGroup[] = [];
149 let revision = 0;
150 let conflictInjected = false;
151 const writes: number[] = [];
152 const bindings = organizationBindings(async () => snapshot(state, revision), async (_workspace, expected, mutation) => {
153 writes.push(expected);
154 if (!conflictInjected) {
155 conflictInjected = true;
156 state = [{ id: "remote", title: "Remote", sessionKeys: [] }];
157 revision += 1;
158 }
159 if (expected !== revision) return snapshot(state, revision, false);
160 state.push({ id: mutation.groupId!, title: mutation.title!, sessionKeys: [] });
161 revision += 1;
162 return snapshot(state, revision);
163 });
164 const { dom, root } = await mount(bindings);
165 await act(async () => {
166 (document.getElementById("create") as HTMLButtonElement).click();
167 await flush();
168 });
169 await waitFor("CAS rebase", () => state.length === 2);
170 await waitFor("CAS UI reconciliation", () => {
171 const text = document.getElementById("groups")?.textContent ?? "";
172 return text.includes("Remote") && text.includes("Local");
173 });
174 ok(state.some((group) => group.title === "Remote") && state.some((group) => group.title === "Local"),
175 "CAS conflict rebases and displays the local mutation without losing the remote group");
176 ok(JSON.stringify(writes) === "[0,1]", "conflict replays one semantic mutation against the returned revision");
177 await cleanup(dom, root);
178 }
179
180 {
181 let state: SessionGroup[] = [{ id: "one", title: "One", sessionKeys: ["ref\x00local\x00archived"] }];
182 let revision = 1;
183 const bindings = organizationBindings(async () => snapshot(state, revision), async () => snapshot(state, revision, false));
184 const { dom, root, render } = await mount(bindings);
185 await waitFor("initial archive membership", () => document.getElementById("groups")?.textContent?.includes("archived") === true);
186 state = [{ id: "one", title: "One", sessionKeys: [] }];
187 revision += 1;
188 await render(1);
189 await waitFor("metadata invalidation", () => !document.getElementById("groups")?.textContent?.includes("archived"));
190 ok(true, "metadata revision invalidates loaded groups after archive cleanup");
191 await cleanup(dom, root);
192 }
193
194 {
195 let legacyWrites = 0;
196 const bindings = legacyBindings(async () => [{ id: "existing", title: "Authoritative", sessionKeys: [] }]);
197 bindings.SaveSessionGroups = async () => { legacyWrites++; };
198 const { dom, root } = await mount(bindings);
199 await waitFor("legacy read", () => document.getElementById("groups")?.textContent?.includes("Authoritative") === true);
200 await act(async () => { (document.getElementById("create") as HTMLButtonElement).click(); await flush(); });
201 await waitFor("unsupported write restores authority", () => !document.getElementById("groups")?.textContent?.includes("Local"));
202 ok(legacyWrites === 0 && document.getElementById("groups")?.textContent?.includes("Authoritative") === true,
203 "missing versioned API never falls back to old writes and restores authoritative groups");
204 ok(document.querySelector(".toast--error")?.textContent?.includes("Upgrade the desktop service") === true,
205 "unsupported writer produces a visible upgrade error");
206 await cleanup(dom, root);
207 }
208
209 process.stdout.write(`\nproject-tree-organization-races: ${passed} passed, ${failed} failed\n`);
210 if (failed > 0) process.exit(1);
211
211 lines Plain Text