返回 DeepSeek-Reasonix
project-tree-archive-race.test.ts
根目录 / desktop / frontend / src / __tests__ / project-tree-archive-race.test.ts
1 import assert from "node:assert/strict";
2 import {
3 enqueueProjectTreeArchive,
4 projectTreeSessionArchiveTargetKey,
5 projectTreeTopicArchiveTargetKey,
6 projectTreeTrashingTopics,
7 runProjectTreeArchiveJob,
8 } from "../lib/projectTreeArchive";
9 import {
10 invalidateProjectTreeTopicLoads,
11 projectTreeFolderKeyForSession,
12 projectTreeWithoutTopics,
13 } from "../lib/projectTreeTopic";
14 import type { ProjectNode } from "../lib/types";
15 import { readFileSync } from "node:fs";
16 import { dirname, join } from "node:path";
17 import { fileURLToPath } from "node:url";
18
19 function deferred<T>() {
20 let resolve!: (value: T) => void;
21 let reject!: (error: unknown) => void;
22 const promise = new Promise<T>((resolvePromise, rejectPromise) => {
23 resolve = resolvePromise;
24 reject = rejectPromise;
25 });
26 return { promise, resolve, reject };
27 }
28
29 async function testLatePreArchivePageCannotReinsertTopic() {
30 const sequences: Record<string, number> = { project: 1 };
31 const capturedSequence = sequences.project;
32 const latePage = deferred<ProjectNode[]>();
33 const applied = latePage.promise.then((items) =>
34 sequences.project === capturedSequence ? items : [],
35 );
36
37 invalidateProjectTreeTopicLoads(sequences, ["project"]);
38 latePage.resolve([{ key: "topic-a", kind: "topic", label: "A", topicId: "topic-a" }]);
39 assert.deepEqual(await applied, []);
40 }
41
42 async function testPendingTombstonesFilterEveryIncomingPage() {
43 const incoming: ProjectNode[] = [
44 { key: "topic-a", kind: "topic", label: "A", topicId: "topic-a" },
45 { key: "topic-b", kind: "topic", label: "B", topicId: "topic-b" },
46 { key: "topic-c", kind: "topic", label: "C", topicId: "topic-c" },
47 ];
48 assert.deepEqual(
49 projectTreeWithoutTopics(incoming, new Set(["topic-a", "topic-b"])).map((node) => node.topicId),
50 ["topic-c"],
51 );
52 }
53
54 async function testPostCommitRestoreCanWinWhilePendingIndicatorFinishes() {
55 const pending = projectTreeTrashingTopics(new Set(), "topic-a", true);
56 const tombstones = projectTreeTrashingTopics(new Set(), "topic-a", true);
57 const sequences: Record<string, number> = { project: 2 };
58
59 // Starting the post-commit canonical load fences every pre-commit response,
60 // then releases only the tombstone. The visible pending state may remain
61 // until that load finishes without hiding a legitimate later restore.
62 invalidateProjectTreeTopicLoads(sequences, ["project"]);
63 const releasedTombstones = projectTreeTrashingTopics(tombstones, "topic-a", false);
64 const restored = projectTreeWithoutTopics(
65 [{ key: "topic-a", kind: "topic", label: "Restored A", topicId: "topic-a" }],
66 releasedTombstones,
67 );
68 assert.equal(pending.has("topic-a"), true);
69 assert.equal(restored[0]?.topicId, "topic-a");
70 }
71
72 async function testConcurrentArchivesReachBackendSerially() {
73 const firstGate = deferred<void>();
74 const secondGate = deferred<void>();
75 const firstStarted = deferred<void>();
76 const secondStarted = deferred<void>();
77 const calls: string[] = [];
78 let tail = Promise.resolve();
79
80 const first = enqueueProjectTreeArchive(tail, async () => {
81 calls.push("a:start");
82 firstStarted.resolve();
83 await firstGate.promise;
84 calls.push("a:end");
85 });
86 tail = first;
87 const second = enqueueProjectTreeArchive(tail, async () => {
88 calls.push("b:start");
89 secondStarted.resolve();
90 await secondGate.promise;
91 calls.push("b:end");
92 });
93 tail = second;
94
95 await firstStarted.promise;
96 assert.deepEqual(calls, ["a:start"]);
97 firstGate.resolve();
98 await first;
99 await secondStarted.promise;
100 assert.deepEqual(calls, ["a:start", "a:end", "b:start"]);
101 secondGate.resolve();
102 await tail;
103 assert.deepEqual(calls, ["a:start", "a:end", "b:start", "b:end"]);
104 }
105
106 async function testPendingEndsOnlyAfterCanonicalReload() {
107 const reloadGate = deferred<void>();
108 let pending = true;
109 const job = runProjectTreeArchiveJob({
110 archive: async () => {},
111 commit: () => {},
112 reload: () => reloadGate.promise,
113 finishPending: () => { pending = false; },
114 recover: async () => {},
115 });
116
117 await Promise.resolve();
118 assert.equal(pending, true);
119 reloadGate.resolve();
120 assert.equal(await job, true);
121 assert.equal(pending, false);
122 }
123
124 async function testFailedArchiveRestoresVisibilityBeforeRecoveryReload() {
125 const backendGate = deferred<void>();
126 let pending = true;
127 let tombstones = new Set<string>();
128 let recoveryObservedPending: boolean | null = null;
129 const job = runProjectTreeArchiveJob({
130 archive: () => backendGate.promise,
131 commit: () => { tombstones = projectTreeTrashingTopics(tombstones, "topic-a", true); },
132 reload: async () => {},
133 finishPending: () => { pending = false; },
134 recover: async () => { recoveryObservedPending = pending; },
135 });
136
137 await Promise.resolve();
138 assert.equal(pending, true);
139 assert.equal(tombstones.has("topic-a"), false, "pending backend work must not hide the topic");
140 assert.equal(projectTreeWithoutTopics(
141 [{ key: "topic-a", kind: "topic", label: "A", topicId: "topic-a" }],
142 tombstones,
143 ).length, 1);
144 backendGate.reject(new Error("busy"));
145 assert.equal(await job, false);
146 assert.equal(tombstones.has("topic-a"), false, "rejected archives never commit a tombstone");
147 assert.equal(recoveryObservedPending, false);
148 }
149
150 function testArchiveTargetsDoNotCollideAcrossRows() {
151 assert.notEqual(
152 projectTreeTopicArchiveTargetKey("project", "/a", "shared"),
153 projectTreeTopicArchiveTargetKey("project", "/b", "shared"),
154 "same-id topics in different projects keep independent confirmations",
155 );
156 assert.notEqual(
157 projectTreeSessionArchiveTargetKey("/a/one.jsonl"),
158 projectTreeSessionArchiveTargetKey("/a/two.jsonl"),
159 "sessions in one topic keep independent confirmations",
160 );
161 }
162
163 function testSessionArchiveReloadsItsOwningFolder() {
164 const tree: ProjectNode[] = [{
165 key: "project-a",
166 kind: "project",
167 label: "Project A",
168 children: [{
169 key: "topic-a",
170 kind: "topic",
171 label: "Topic A",
172 children: [{
173 key: "session-a",
174 kind: "session",
175 label: "Session A",
176 sessionPath: " /sessions/a.jsonl ",
177 }],
178 }],
179 }];
180 assert.equal(projectTreeFolderKeyForSession(tree, "/sessions/a.jsonl"), "project-a");
181 assert.equal(projectTreeFolderKeyForSession(tree, "/sessions/missing.jsonl"), "");
182 }
183
184 function testProjectTreeWiresEveryRaceGuard() {
185 const source = readFileSync(
186 join(dirname(fileURLToPath(import.meta.url)), "../components/ProjectTree.tsx"),
187 "utf8",
188 );
189 const archiveSource = readFileSync(
190 join(dirname(fileURLToPath(import.meta.url)), "../lib/projectTreeArchive.ts"),
191 "utf8",
192 );
193 const sessionMenuSource = readFileSync(
194 join(dirname(fileURLToPath(import.meta.url)), "../components/ProjectTreeSessionArchiveMenu.tsx"),
195 "utf8",
196 );
197 assert.match(archiveSource, /await archive\(\)[\s\S]*commit\(\)[\s\S]*await reload\(\)/);
198 assert.match(archiveSource, /commitArchiveTombstone\(topicId\)[\s\S]*invalidateProjectTreeTopicLoads[\s\S]*optimisticallyRemoveTopic\(topicId\)/);
199 assert.match(source, /projectTreeWithoutTopics\(asArray\(page\.items\), currentArchiveTombstones\(\)\)/);
200 assert.match(archiveSource, /return previous\.catch\(\(\) => undefined\)\.then\(work\)/);
201 assert.match(archiveSource, /pendingLoads = targets\.map[\s\S]*onReloadStarted[\s\S]*await Promise\.all\(pendingLoads\)/);
202 assert.match(archiveSource, /onReloadStarted: \(\) => releaseArchiveTombstone\(topicId\)/);
203 assert.match(archiveSource, /finishPending: \(\) => endTrashingTopic\(topicId\)/);
204 assert.match(source, /const topicMenuOpen = menuNodeKey === key/);
205 assert.match(source, /onContextMenu=\{openTopicMenu\}/);
206 assert.match(source, /node\.sessionPath \?\? ""/);
207 assert.match(sessionMenuSource, /disabled: !sessionPath \|\| blocked \|\| busy/);
208 assert.match(source, /sessionPath=\{sessionPath\} blocked=\{archiveBlocked \|\| topicTrashing\}/);
209 assert.match(source, /void trashSession\(node\)/);
210 assert.match(archiveSource, /projectTreeFolderKeyForSession\(treeRef\.current, sessionPath\)[\s\S]*sessionLifecycleFences.archive\(target, receipt\)[\s\S]*optimisticallyRemoveSession\(target\)[\s\S]*refreshRef\.current\(reloadOptions\)/);
211 }
212
213 await testLatePreArchivePageCannotReinsertTopic();
214 await testPendingTombstonesFilterEveryIncomingPage();
215 await testPostCommitRestoreCanWinWhilePendingIndicatorFinishes();
216 await testConcurrentArchivesReachBackendSerially();
217 await testPendingEndsOnlyAfterCanonicalReload();
218 await testFailedArchiveRestoresVisibilityBeforeRecoveryReload();
219 testArchiveTargetsDoNotCollideAcrossRows();
220 testSessionArchiveReloadsItsOwningFolder();
221 testProjectTreeWiresEveryRaceGuard();
222 console.log("project tree archive race: 9 passed");
223
223 lines TYPESCRIPT