返回 DeepSeek-Reasonix
open-topic-coalescing.test.tsx
根目录 / desktop / frontend / src / __tests__ / open-topic-coalescing.test.tsx
1 // Run: tsx src/__tests__/open-topic-coalescing.test.tsx
2
3 import { JSDOM } from "jsdom";
4 import React, { act, useCallback, useRef } from "react";
5 import { createRoot } from "react-dom/client";
6 import { enqueueNavigationRequest, enqueueOpenTopicRequest, type PendingOpenTopicRequest } from "../lib/openTopicCoalescing";
7
8 let passed = 0;
9 let failed = 0;
10
11 function ok(value: boolean, label: string) {
12 if (value) {
13 process.stdout.write(` PASS ${label}\n`);
14 passed += 1;
15 } else {
16 process.stdout.write(` FAIL ${label}\n`);
17 failed += 1;
18 }
19 }
20
21 function eq(actual: unknown, expected: unknown, label: string) {
22 ok(actual === expected, `${label}${actual === expected ? "" : `: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`}`);
23 }
24
25 function flushPromises(): Promise<void> {
26 return new Promise((resolve) => setTimeout(resolve, 0));
27 }
28
29 function deferred<T>() {
30 let resolve!: (value: T) => void;
31 let reject!: (reason?: unknown) => void;
32 const promise = new Promise<T>((res, rej) => {
33 resolve = res;
34 reject = rej;
35 });
36 return { promise, resolve, reject };
37 }
38
39 console.log("\nopen topic coalescing");
40
41 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
42 pretendToBeVisual: true,
43 url: "http://localhost/",
44 });
45 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
46 globalThis.window = dom.window as unknown as Window & typeof globalThis;
47 globalThis.document = dom.window.document;
48 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
49 globalThis.Node = dom.window.Node;
50 globalThis.HTMLElement = dom.window.HTMLElement;
51 globalThis.Event = dom.window.Event;
52 globalThis.CustomEvent = dom.window.CustomEvent;
53 globalThis.KeyboardEvent = dom.window.KeyboardEvent;
54 globalThis.MouseEvent = dom.window.MouseEvent;
55 globalThis.localStorage = dom.window.localStorage;
56 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
57 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
58
59 const gates = new Map<string, ReturnType<typeof deferred<void>>>();
60 const calls: string[] = [];
61 const updates: string[] = [];
62 const toasts: string[] = [];
63 let refreshes = 0;
64 let openTopic!: (topicId: string) => Promise<void>;
65
66 function gateFor(topicId: string) {
67 let gate = gates.get(topicId);
68 if (!gate) {
69 gate = deferred<void>();
70 gates.set(topicId, gate);
71 }
72 return gate;
73 }
74
75 function resetRecords() {
76 calls.length = 0;
77 updates.length = 0;
78 toasts.length = 0;
79 refreshes = 0;
80 }
81
82 function Harness() {
83 const seqRef = useRef(0);
84 const runningRef = useRef(false);
85 const pendingRef = useRef<PendingOpenTopicRequest | null>(null);
86 const run = useCallback(async (request: PendingOpenTopicRequest) => {
87 calls.push(request.topicId);
88 try {
89 await gateFor(request.topicId).promise;
90 if (request.topicId.includes("fail")) throw new Error(request.topicId);
91 if (request.seq !== seqRef.current) return;
92 updates.push(request.topicId);
93 refreshes += 1;
94 } catch {
95 if (request.seq !== seqRef.current) return;
96 toasts.push(request.topicId);
97 refreshes += 1;
98 }
99 }, []);
100 openTopic = (topicId: string) => enqueueOpenTopicRequest(
101 { seqRef, runningRef, pendingRef },
102 { scope: "global", workspaceRoot: "", topicId },
103 run,
104 );
105 return null;
106 }
107
108 const rootEl = document.getElementById("root");
109 if (!rootEl) throw new Error("missing root");
110 const root = createRoot(rootEl);
111
112 await act(async () => {
113 root.render(<Harness />);
114 await flushPromises();
115 });
116
117 let bResolved = false;
118 let cResolved = false;
119 let dResolved = false;
120 const pA = openTopic("A");
121 await act(async () => {
122 await flushPromises();
123 });
124 const pB = openTopic("B").then(() => { bResolved = true; });
125 const pC = openTopic("C").then(() => { cResolved = true; });
126 const pD = openTopic("D").then(() => { dResolved = true; });
127 await act(async () => {
128 await flushPromises();
129 });
130
131 eq(calls.join(","), "A", "only the running request starts while newer requests coalesce");
132 ok(bResolved && cResolved, "superseded pending requests resolve immediately");
133 eq(dResolved, false, "latest pending request waits until it runs");
134
135 await act(async () => {
136 gateFor("A").resolve();
137 await pA;
138 await flushPromises();
139 });
140 eq(calls.join(","), "A,D", "after the running request finishes, only the latest pending request runs");
141 eq(updates.join(","), "", "stale running request does not update UI");
142
143 await act(async () => {
144 gateFor("D").resolve();
145 await pD;
146 await flushPromises();
147 });
148 eq(updates.join(","), "D", "latest coalesced request updates UI");
149 eq(refreshes, 1, "only the latest successful request refreshes metadata");
150
151 await pB;
152 await pC;
153
154 resetRecords();
155 let oldFailRejected = false;
156 let newFailRejected = false;
157 const pOldFail = openTopic("old-fail").catch(() => { oldFailRejected = true; });
158 await act(async () => {
159 await flushPromises();
160 });
161 const pNewFail = openTopic("new-fail").catch(() => { newFailRejected = true; });
162 await act(async () => {
163 await flushPromises();
164 gateFor("old-fail").resolve();
165 await pOldFail;
166 await flushPromises();
167 });
168
169 eq(calls.join(","), "old-fail,new-fail", "latest pending request starts after an older failing request");
170 eq(toasts.join(","), "", "stale failure does not show a toast");
171 eq(oldFailRejected, false, "stale failure promise does not reject");
172
173 await act(async () => {
174 gateFor("new-fail").resolve();
175 await pNewFail;
176 await flushPromises();
177 });
178 eq(toasts.join(","), "new-fail", "latest failure shows a toast");
179 eq(newFailRejected, false, "latest failure promise does not reject");
180 eq(refreshes, 1, "latest failure refreshes metadata once");
181
182 await act(async () => {
183 root.unmount();
184 });
185 dom.window.close();
186
187 // Tab-bar switches (App.handleTabChange) route through the same scheduler so
188 // rapidly clicking between two running sessions can't run switchTab()
189 // concurrently — concurrent switches race the backend SetActiveTab ordering and
190 // land events on the wrong session (#5352). This guards serialization (no two
191 // run()s overlap) + last-click-wins.
192 {
193 const refs = { seqRef: { current: 0 }, runningRef: { current: false }, pendingRef: { current: null as any } };
194 let active = 0;
195 let maxConcurrent = 0;
196 const ran: string[] = [];
197 const gates = new Map<string, ReturnType<typeof deferred<void>>>();
198 const gate = (id: string) => {
199 if (!gates.has(id)) gates.set(id, deferred<void>());
200 return gates.get(id)!;
201 };
202 const switchTab = (req: { tabId: string }) =>
203 enqueueNavigationRequest(refs, { tabId: req.tabId }, async (r) => {
204 active += 1;
205 maxConcurrent = Math.max(maxConcurrent, active);
206 await gate(r.tabId).promise;
207 ran.push(r.tabId);
208 active -= 1;
209 });
210
211 const pA = switchTab({ tabId: "A" }); // starts running
212 const pB = switchTab({ tabId: "B" }); // coalesced away (superseded while A runs)
213 const pC = switchTab({ tabId: "C" }); // latest pending
214 gate("A").resolve();
215 await pA;
216 await flushPromises();
217 gate("C").resolve();
218 await pC;
219 await pB;
220 await flushPromises();
221
222 eq(ran.join(","), "A,C", "tab switches serialize: only the running + latest run, middle coalesces");
223 eq(maxConcurrent, 1, "no two tab switches run concurrently (no backend SetActiveTab race)");
224 }
225
226 console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
227 if (failed > 0) process.exit(1);
228
228 lines Plain Text