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