返回 DeepSeek-Reasonix
updater-shared-state.test.tsx
根目录 / desktop / frontend / src / __tests__ / updater-shared-state.test.tsx
1 // Run: tsx src/__tests__/updater-shared-state.test.tsx
2
3 import { JSDOM } from "jsdom";
4 import React from "react";
5 import { act } from "react";
6 import { createRoot } from "react-dom/client";
7 import { __emitMockUpdater, type AppBindings } from "../lib/bridge";
8 import { classifyUpdateError, UpdaterProvider, useUpdater } from "../lib/useUpdater";
9
10 let passed = 0;
11 let failed = 0;
12
13 function ok(value: boolean, label: string) {
14 if (value) {
15 process.stdout.write(` PASS ${label}\n`);
16 passed += 1;
17 } else {
18 process.stdout.write(` FAIL ${label}\n`);
19 failed += 1;
20 }
21 }
22
23 function Consumer({ id, checking = false }: { id: string; checking?: boolean }) {
24 const updater = useUpdater();
25 return (
26 <section>
27 <output id={`${id}-status`}>{updater.status.kind}</output>
28 <output id={`${id}-manual`}>
29 {updater.status.kind === "error" ? updater.status.disposition : ""}
30 </output>
31 <output id={`${id}-received`}>
32 {updater.status.kind === "downloading" ? updater.status.received : ""}
33 </output>
34 {checking && <button id={`${id}-check-update`} type="button" onClick={() => void updater.check()}>Check</button>}
35 <button id={`${id}-reset`} type="button" onClick={() => updater.reset()}>Reset</button>
36 {updater.status.kind === "available" && (
37 <button id={`${id}-apply`} type="button" onClick={() => updater.apply(updater.status.info)}>Apply</button>
38 )}
39 {updater.status.kind === "error" && updater.status.info && (
40 <button id={`${id}-retry`} type="button" onClick={() => updater.apply(updater.status.info!)}>Retry</button>
41 )}
42 </section>
43 );
44 }
45
46 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
47 pretendToBeVisual: true,
48 url: "http://localhost/",
49 });
50 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
51 globalThis.window = dom.window as unknown as Window & typeof globalThis;
52 globalThis.document = dom.window.document;
53 globalThis.Node = dom.window.Node;
54 globalThis.Element = dom.window.Element;
55 globalThis.HTMLElement = dom.window.HTMLElement;
56 globalThis.Event = dom.window.Event;
57 globalThis.MouseEvent = dom.window.MouseEvent;
58
59 const root = createRoot(document.getElementById("root")!);
60 await act(async () => {
61 root.render(
62 <UpdaterProvider>
63 <Consumer id="banner" checking />
64 <Consumer id="settings" checking />
65 </UpdaterProvider>,
66 );
67 });
68
69 ok(document.getElementById("banner-status")?.textContent === "idle", "banner starts idle");
70 ok(document.getElementById("settings-status")?.textContent === "idle", "settings starts idle");
71 ok(classifyUpdateError("prepare update: a pending update already exists") === "recovery", "pending update errors require recovery fallback");
72 ok(classifyUpdateError("prepare update: recover existing handoff backup: operation not permitted") === "recovery", "macOS backup permission errors require recovery fallback");
73 ok(classifyUpdateError("update: manual update required") === "manual", "manual-only errors prefer the official download");
74 ok(classifyUpdateError("connection reset by peer") === "retryable", "transient errors remain retryable");
75
76 await act(async () => {
77 (document.getElementById("banner-check-update") as HTMLButtonElement).click();
78 await new Promise((resolve) => setTimeout(resolve, 0));
79 });
80
81 ok(document.getElementById("banner-status")?.textContent === "upToDate", "banner receives the check result");
82 ok(document.getElementById("settings-status")?.textContent === "upToDate", "settings receives the same check result");
83
84 const debInfo = {
85 available: true,
86 current: "v1.0.0",
87 latest: "v1.1.0",
88 notes: "",
89 channel: "stable",
90 canSelfUpdate: true,
91 manualOnly: false,
92 installMode: "deb",
93 requiresElevation: true,
94 downloaded: false,
95 downloadUrl: "https://example.invalid/download",
96 assetSize: 42,
97 };
98 const applyAttempts: Array<{
99 requestId: string;
100 version: string;
101 channel: string;
102 resolve: () => void;
103 reject: (err: Error) => void;
104 }> = [];
105 const checkedChannels: string[] = [];
106 window.go = {
107 main: {
108 App: {
109 async CheckUpdate(channel: string) {
110 checkedChannels.push(channel);
111 return { ...debInfo, channel: "stable" };
112 },
113 ApplyUpdateRequest(channel: string, expectedVersion: string, requestId: string) {
114 return new Promise<void>((resolve, reject) => applyAttempts.push({
115 requestId,
116 version: expectedVersion,
117 channel,
118 resolve,
119 reject,
120 }));
121 },
122 } as AppBindings,
123 },
124 };
125
126 await act(async () => {
127 (document.getElementById("settings-check-update") as HTMLButtonElement).click();
128 await new Promise((resolve) => setTimeout(resolve, 0));
129 });
130 ok(checkedChannels[0] === "stable", "settings check uses the official release channel");
131 ok(document.getElementById("banner-status")?.textContent === "available", "deb update becomes available");
132 ok(document.getElementById("settings-status")?.textContent === "available", "deb availability is shared");
133
134 await act(async () => {
135 (document.getElementById("banner-apply") as HTMLButtonElement).click();
136 });
137 ok(document.getElementById("banner-status")?.textContent === "authorizing", "deb apply starts authorizing");
138 ok(document.getElementById("settings-status")?.textContent === "authorizing", "authorizing state is shared");
139
140 await act(async () => {
141 __emitMockUpdater({
142 requestId: applyAttempts[0].requestId,
143 version: applyAttempts[0].version,
144 channel: "stable",
145 phase: "downloading",
146 received: 10,
147 total: 42,
148 });
149 });
150 ok(document.getElementById("banner-status")?.textContent === "downloading", "download phase is shared");
151 ok(document.getElementById("banner-received")?.textContent === "10", "download progress is shared");
152
153 await act(async () => {
154 __emitMockUpdater({
155 requestId: applyAttempts[0].requestId,
156 version: applyAttempts[0].version,
157 channel: "stable",
158 phase: "verifying",
159 received: 42,
160 total: 42,
161 });
162 });
163 ok(document.getElementById("banner-status")?.textContent === "verifying", "verify phase is shared");
164
165 await act(async () => {
166 __emitMockUpdater({
167 requestId: applyAttempts[0].requestId,
168 version: applyAttempts[0].version,
169 channel: "stable",
170 phase: "installing",
171 received: 42,
172 total: 42,
173 });
174 });
175 ok(document.getElementById("banner-status")?.textContent === "installing", "install phase advances");
176
177 await act(async () => {
178 applyAttempts[0]?.reject(new Error("update: manual update required: system update helper is unavailable"));
179 await new Promise((resolve) => setTimeout(resolve, 0));
180 });
181 ok(document.getElementById("banner-status")?.textContent === "error", "manual reclassification leaves busy state");
182 ok(document.getElementById("banner-manual")?.textContent === "manual", "manual reclassification offers download fallback");
183 ok(document.getElementById("settings-status")?.textContent === "error", "manual fallback error is shared");
184 ok(!!document.getElementById("banner-retry"), "error state exposes retry");
185
186 // Retry releases the mutex and can start a new apply immediately.
187 await act(async () => {
188 (document.getElementById("banner-retry") as HTMLButtonElement).click();
189 });
190 ok(document.getElementById("banner-status")?.textContent === "authorizing", "retry re-enters applying");
191 await act(async () => {
192 applyAttempts[1]?.resolve();
193 __emitMockUpdater({
194 requestId: applyAttempts[1].requestId,
195 version: applyAttempts[1].version,
196 channel: "stable",
197 phase: "relaunching",
198 received: 0,
199 total: 0,
200 });
201 await new Promise((resolve) => setTimeout(resolve, 0));
202 });
203 ok(document.getElementById("banner-status")?.textContent === "relaunching", "relaunching phase is shared");
204
205 let resolveFirstCheck!: (value: typeof debInfo) => void;
206 let resolveSecondCheck!: (value: typeof debInfo) => void;
207 let checkCalls = 0;
208 window.go.main.App.CheckUpdate = () =>
209 new Promise<typeof debInfo>((resolve) => {
210 checkCalls += 1;
211 if (checkCalls === 1) resolveFirstCheck = resolve;
212 else resolveSecondCheck = resolve;
213 });
214
215 await act(async () => {
216 (document.getElementById("banner-check-update") as HTMLButtonElement).click();
217 (document.getElementById("settings-check-update") as HTMLButtonElement).click();
218 });
219 await act(async () => {
220 resolveSecondCheck({ ...debInfo, channel: "stable", latest: "v1.2.0" });
221 await new Promise((resolve) => setTimeout(resolve, 0));
222 });
223 ok(document.getElementById("banner-status")?.textContent === "available", "newest official check publishes its result");
224 await act(async () => {
225 resolveFirstCheck({ ...debInfo, available: false, channel: "stable", latest: "v1.0.0" });
226 await new Promise((resolve) => setTimeout(resolve, 0));
227 });
228 ok(document.getElementById("banner-status")?.textContent === "available", "stale official check cannot overwrite the newest result");
229
230 let oldApplyRequestId = "";
231 let oldApplyVersion = "";
232 let resolveOldApply!: () => void;
233 window.go.main.App.ApplyUpdateRequest = (_channel: string, expectedVersion: string, requestId: string) =>
234 new Promise<void>((resolve) => {
235 oldApplyRequestId = requestId;
236 oldApplyVersion = expectedVersion;
237 resolveOldApply = resolve;
238 });
239 await act(async () => {
240 (document.getElementById("banner-apply") as HTMLButtonElement).click();
241 });
242 ok(document.getElementById("banner-status")?.textContent === "authorizing", "official apply starts");
243 await act(async () => {
244 __emitMockUpdater({
245 requestId: oldApplyRequestId,
246 version: `${oldApplyVersion}-wrong`,
247 channel: "stable",
248 phase: "installing",
249 received: 42,
250 total: 42,
251 });
252 });
253 ok(
254 document.getElementById("banner-status")?.textContent === "authorizing",
255 "same-request wrong-version progress cannot advance the active apply",
256 );
257 await act(async () => {
258 __emitMockUpdater({
259 requestId: oldApplyRequestId,
260 version: oldApplyVersion,
261 channel: "preview",
262 phase: "downloading",
263 received: 41,
264 total: 42,
265 });
266 });
267 ok(document.getElementById("banner-received")?.textContent === "", "retired-channel progress cannot update the active official apply");
268 await act(async () => {
269 (document.getElementById("banner-reset") as HTMLButtonElement).click();
270 __emitMockUpdater({
271 requestId: oldApplyRequestId,
272 version: oldApplyVersion,
273 channel: "stable",
274 phase: "error",
275 received: 0,
276 total: 0,
277 err: "old apply failed",
278 });
279 resolveOldApply();
280 await new Promise((resolve) => setTimeout(resolve, 0));
281 });
282 ok(document.getElementById("banner-status")?.textContent === "idle", "same-channel superseded progress and Promise completion stay ignored");
283
284 window.go.main.App.CheckUpdate = async () => ({
285 ...debInfo,
286 channel: "stable",
287 latest: "v1.2.0",
288 });
289 window.go.main.App.ApplyUpdateRequest = async () => {
290 throw new Error("should not reach when version mismatches from progress only");
291 };
292
293 await act(async () => {
294 (document.getElementById("settings-check-update") as HTMLButtonElement).click();
295 await new Promise((resolve) => setTimeout(resolve, 0));
296 });
297 ok(document.getElementById("banner-status")?.textContent === "available", "official update is available again");
298
299 window.go.main.App.ApplyUpdateRequest = async (_channel: string, _expectedVersion: string, requestId: string) => {
300 // Hang until cancelled by reset so we can prove supersession.
301 return new Promise<void>((_resolve, reject) => {
302 applyAttempts.push({
303 requestId,
304 version: _expectedVersion,
305 channel: _channel,
306 resolve: () => {},
307 reject,
308 });
309 });
310 };
311 await act(async () => {
312 (document.getElementById("banner-apply") as HTMLButtonElement).click();
313 });
314 const staleApply = applyAttempts[applyAttempts.length - 1];
315 ok(document.getElementById("banner-status")?.textContent === "authorizing", "official apply starts");
316 await act(async () => {
317 (document.getElementById("banner-reset") as HTMLButtonElement).click();
318 __emitMockUpdater({
319 requestId: staleApply.requestId,
320 version: staleApply.version,
321 channel: "stable",
322 phase: "error",
323 received: 0,
324 total: 0,
325 err: "old install failed",
326 });
327 staleApply.reject(new Error("old official apply failed"));
328 await new Promise((resolve) => setTimeout(resolve, 0));
329 });
330 ok(document.getElementById("banner-status")?.textContent === "idle", "superseded apply progress and rejection stay ignored");
331
332 window.go.main.App.CheckUpdate = async () => ({ ...debInfo, channel: "preview" });
333 await act(async () => {
334 (document.getElementById("settings-check-update") as HTMLButtonElement).click();
335 await new Promise((resolve) => setTimeout(resolve, 0));
336 });
337 ok(document.getElementById("banner-status")?.textContent === "error", "wrong-channel check response leaves the checking state");
338
339 window.go.main.App.CheckUpdate = async () => ({ ...debInfo, channel: "stable", latest: "v1.3.0" });
340 await act(async () => {
341 (document.getElementById("settings-check-update") as HTMLButtonElement).click();
342 await new Promise((resolve) => setTimeout(resolve, 0));
343 });
344 ok(document.getElementById("banner-status")?.textContent === "available", "check can retry after a wrong-channel response");
345
346 delete window.go;
347
348 process.stdout.write(`\n${passed} passed, ${failed} failed\n`);
349 if (failed > 0) process.exit(1);
350
350 lines Plain Text