返回 DeepSeek-Reasonix
workspace-launcher-races.test.tsx
根目录 / desktop / frontend / src / __tests__ / workspace-launcher-races.test.tsx
1 import assert from "node:assert/strict";
2 import React, { act, StrictMode, useRef } from "react";
3 import { createRoot } from "react-dom/client";
4 import { JSDOM } from "jsdom";
5 import { useBranchSwitcher } from "../lib/useBranchSwitcher";
6 import { useWorkspaceDiffStats } from "../lib/useWorkspaceDiffStats";
7 import { createSerialWorkspacePoll } from "../lib/serialWorkspacePoll";
8 import { installDesktopHostStub } from "./desktopHostStub";
9 import type { WorkspaceChangesView } from "../lib/types";
10 import { DockLauncher } from "../components/DockLauncher";
11 import { LocaleProvider } from "../lib/i18n";
12
13 function deferred<T>() {
14 let resolve!: (value: T) => void;
15 let reject!: (error: Error) => void;
16 const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
17 return { promise, resolve, reject };
18 }
19 const flush = async () => { for (let i = 0; i < 8; i++) await Promise.resolve(); };
20
21 // Completion-driven polling with a clock controlled by the test.
22 const timers = new Map<number, () => void>();
23 let nextTimer = 0;
24 const poll = createSerialWorkspacePoll({
25 schedule(callback, delay) { assert.equal(delay, 5000); timers.set(++nextTimer, callback); return nextTimer; },
26 cancel(handle) { timers.delete(handle as number); },
27 });
28 const pending: ReturnType<typeof deferred<void>>[] = [];
29 const job = () => { const request = deferred<void>(); pending.push(request); return request.promise; };
30 poll.setJob(job);
31 await flush();
32 assert.equal(pending.length, 1);
33 assert.equal(timers.size, 0, "slow request must not schedule overlapping polls");
34 poll.refresh(); poll.refresh(); poll.refresh();
35 pending[0].resolve(); await flush();
36 assert.equal(pending.length, 2, "manual refreshes coalesce");
37 pending[1].resolve(); await flush();
38 assert.equal(timers.size, 1, "wait five seconds after completion");
39 const tick = [...timers.values()][0]; timers.clear(); tick(); await flush();
40 assert.equal(pending.length, 3);
41 poll.setJob(null); pending[2].resolve(); await flush();
42 assert.equal(timers.size, 0);
43 poll.setJob(job); poll.setJob(null); await flush();
44 assert.equal(pending.length, 3, "hide before dispatch cancels queued work");
45
46 const dom = new JSDOM("<div id='root'></div>", { url: "http://localhost/", pretendToBeVisual: true });
47 Object.assign(globalThis, {
48 window: dom.window, document: dom.window.document, Node: dom.window.Node,
49 localStorage: dom.window.localStorage, IS_REACT_ACT_ENVIRONMENT: true,
50 });
51 let foreground = true;
52 Object.defineProperty(document, "visibilityState", { get: () => foreground ? "visible" : "hidden" });
53 const lists: { tab: string; root: string; request: ReturnType<typeof deferred<string[]>> }[] = [];
54 const switches: { tab: string; root: string; branch: string; request: ReturnType<typeof deferred<void>> }[] = [];
55 const stats: { tab: string; root: string; request: ReturnType<typeof deferred<WorkspaceChangesView>> }[] = [];
56 const stub = installDesktopHostStub({
57 GitBranchesForTab(tab: string, root: string) {
58 const request = deferred<string[]>(); lists.push({ tab, root, request }); return request.promise;
59 },
60 GitCheckoutForTab(tab: string, root: string, branch: string) {
61 const request = deferred<void>(); switches.push({ tab, root, branch, request }); return request.promise;
62 },
63 GitCreateBranchForTab(tab: string, root: string, branch: string) {
64 const request = deferred<void>(); switches.push({ tab, root, branch, request }); return request.promise;
65 },
66 WorkspaceGitStatsForTab(tab: string, root: string) {
67 const request = deferred<WorkspaceChangesView>(); stats.push({ tab, root, request }); return request.promise;
68 },
69 });
70 let branch!: ReturnType<typeof useBranchSwitcher>;
71 let diff!: ReturnType<typeof useWorkspaceDiffStats>;
72 let changes = 0;
73 function Harness({ scope, visible }: { scope: string; visible: boolean }) {
74 const rootRef = useRef<HTMLDivElement>(null);
75 diff = useWorkspaceDiffStats("reused-tab", scope, "/" + scope, visible);
76 branch = useBranchSwitcher({
77 tabId: "reused-tab", scopeKey: scope, workspaceRoot: "/" + scope, gitBranch: "main", rootRef,
78 onBranchChanged: () => { changes++; diff.reloadDiffStats(); },
79 });
80 return <div ref={rootRef} />;
81 }
82 const root = createRoot(document.getElementById("root")!);
83 const render = async (scope: string, visible = true) => act(async () => {
84 root.render(<StrictMode><Harness scope={scope} visible={visible} /></StrictMode>);
85 await flush();
86 });
87 await render("A");
88 await act(async () => { branch.toggleBranchMenu(); branch.setBranchQuery("old"); await flush(); });
89 assert.equal(lists[0].root, "/A");
90 await act(async () => { void branch.checkoutBranch("shared"); await flush(); });
91 assert.equal(switches[0].root, "/A");
92 await render("B");
93 assert.equal(branch.branchQuery, "");
94 assert.deepEqual(branch.branches, []);
95 assert.equal(branch.branchSwitchErr, "");
96 await act(async () => { branch.toggleBranchMenu(); await flush(); });
97 assert.equal(lists[1].root, "/B", "same tab and same branch still carry project identity");
98 await act(async () => {
99 lists[1].request.resolve(["main", "shared", "B-only"]);
100 lists[0].request.resolve(["A-only"]);
101 switches[0].request.resolve();
102 await flush();
103 });
104 assert.deepEqual(branch.branches, ["main", "shared", "B-only"]);
105 assert.equal(branch.activeBranch, "main", "late A checkout cannot change B's branch");
106 assert.equal(changes, 0, "late A checkout cannot refresh B");
107 assert.equal(stats.length, 1, "B waits for the in-flight A statistics request");
108 await act(async () => { stats[0].request.resolve({ files: [], gitAvailable: true, added: 99 }); await flush(); });
109 assert.equal(diff.diffStats, null, "A statistics must not render in B");
110 assert.equal(stats.length, 2);
111 assert.equal(stats[1].root, "/B");
112 await act(async () => { stats[1].request.resolve({ files: [], gitAvailable: true, added: 2, incomplete: true }); await flush(); });
113 assert.equal(diff.diffStats?.added, 2);
114 assert.equal(diff.diffStats?.incomplete, true);
115 await act(async () => { void branch.checkoutBranch("shared"); void branch.checkoutBranch("shared"); await flush(); });
116 assert.equal(switches.length, 2, "duplicate checkout clicks coalesce synchronously");
117 await act(async () => { switches[1].request.resolve(); await flush(); });
118 assert.equal(branch.activeBranch, "shared");
119 assert.equal(changes, 1);
120 assert.equal(lists.length, 3, "successful checkout refreshes its own branch list");
121 assert.equal(stats.length, 3, "successful checkout refreshes its own totals");
122 await act(async () => {
123 lists[2].request.resolve(["main", "shared"]);
124 stats[2].request.reject(new Error("timeout"));
125 await flush();
126 });
127 assert.equal(diff.diffStats?.added, 2, "failed count retains last estimate");
128 assert.equal(diff.diffStats?.incomplete, true);
129 await act(async () => { void branch.createBranch("new"); await flush(); });
130 await render("C");
131 await act(async () => { switches[2].request.reject(new Error("A stale failure")); await flush(); });
132 assert.equal(branch.branchSwitchErr, "");
133 await act(async () => {
134 foreground = false; document.dispatchEvent(new dom.window.Event("visibilitychange")); await flush();
135 });
136 const countBeforeHide = stats.length;
137 await act(async () => {
138 stats.at(-1)!.request.resolve({ files: [], gitAvailable: true, added: 100 });
139 diff.reloadDiffStats(); await flush();
140 });
141 assert.equal(stats.length, countBeforeHide, "hidden page stops manual and periodic dispatch");
142 assert.equal(diff.diffStats, null, "hidden request result is invalidated");
143 await act(async () => { foreground = true; document.dispatchEvent(new dom.window.Event("visibilitychange")); await flush(); });
144 assert.equal(stats.length, countBeforeHide + 1);
145 const beforeRemount = stats.length;
146 await act(async () => { root.render(null); await flush(); });
147 await render("C");
148 assert.equal(stats.length, beforeRemount, "remount waits for the old scope's running RPC");
149 await act(async () => { stats[beforeRemount - 1].request.resolve({ files: [], gitAvailable: true, added: 999 }); await flush(); });
150 assert.equal(stats.length, beforeRemount + 1, "remount scans again only after the old request completes");
151 assert.equal(diff.diffStats, null, "remount must discard the pre-hide result");
152 await render("C", false);
153 await act(async () => { stats.at(-1)!.request.resolve({ files: [], gitAvailable: true }); await flush(); });
154 await act(async () => {
155 root.render(<LocaleProvider><DockLauncher tabId="badge" scopeKey="badge" workspaceRoot="/badge" visible overlay gitBranch="main" onSelect={() => {}} /></LocaleProvider>);
156 await flush();
157 });
158 await act(async () => { stats.at(-1)!.request.resolve({ files: [], gitAvailable: true, added: 0, removed: 0, incomplete: true }); await flush(); });
159 const badge = document.querySelector(".dock-launcher__entry-stats");
160 assert(badge, "an incomplete zero must not disappear as an exact clean result");
161 assert(badge.textContent.includes("~"), "incomplete totals visibly show an estimate");
162 assert(badge.getAttribute("title")?.includes("incomplete"), "estimate has an accessible explanation");
163 await act(async () => root.unmount());
164 stub.uninstall(); dom.window.close();
165 console.log("PASS scoped branch races, stale checkout/create, serial polling, visibility and incomplete totals");
166
166 lines Plain Text