返回 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 { subscribeToUpdateRefresh } from "../components/UpdateBanner";
8 import { __emitMockUpdater, type AppBindings } from "../lib/bridge";
9 import { classifyUpdateError, UpdaterProvider, useUpdater } from "../lib/useUpdater";
10 import { installDesktopHostStub } from "./desktopHostStub";
11
12 let passed = 0;
13 let failed = 0;
14
15 function ok(value: boolean, label: string) {
16 if (value) {
17 process.stdout.write(` PASS ${label}\n`);
18 passed += 1;
19 } else {
20 process.stdout.write(` FAIL ${label}\n`);
21 failed += 1;
22 }
23 }
24
25 function Consumer({ id, checking = false }: { id: string; checking?: boolean }) {
26 const updater = useUpdater();
27 const busy =
28 updater.status.kind === "checking" ||
29 updater.status.kind === "downloading" ||
30 updater.status.kind === "verifying" ||
31 updater.status.kind === "authorizing" ||
32 updater.status.kind === "installing" ||
33 updater.status.kind === "relaunching";
34 return (
35 <section>
36 <output id={`${id}-status`}>{updater.status.kind}</output>
37 <output id={`${id}-manual`}>
38 {updater.status.kind === "error" ? updater.status.disposition : ""}
39 </output>
40 <output id={`${id}-received`}>
41 {updater.status.kind === "downloading" ? updater.status.received : ""}
42 </output>
43 {checking && (
44 <button
45 id={`${id}-check-update`}
46 type="button"
47 disabled={busy}
48 onClick={() => void updater.check()}
49 >
50 Check
51 </button>
52 )}
53 {/* Always-enabled control so tests can force-invoke check while UI is busy. */}
54 <button id={`${id}-force-check`} type="button" onClick={() => void updater.check()}>
55 ForceCheck
56 </button>
57 <button id={`${id}-refresh`} type="button" onClick={() => void updater.refresh()}>
58 Refresh
59 </button>
60 <button id={`${id}-reset`} type="button" onClick={() => updater.reset()}>Reset</button>
61 <button
62 id={`${id}-abandon`}
63 type="button"
64 disabled={busy}
65 onClick={() => void updater.abandonPending()}
66 >
67 Discard
68 </button>
69 {updater.status.kind === "available" && (
70 <button id={`${id}-apply`} type="button" onClick={() => updater.apply(updater.status.info)}>Apply</button>
71 )}
72 {updater.status.kind === "error" && updater.status.info && (
73 <button
74 id={`${id}-retry`}
75 type="button"
76 disabled={busy}
77 onClick={() => updater.apply(updater.status.info!)}
78 >
79 Retry
80 </button>
81 )}
82 </section>
83 );
84 }
85
86 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
87 pretendToBeVisual: true,
88 url: "http://localhost/",
89 });
90 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
91 globalThis.window = dom.window as unknown as Window & typeof globalThis;
92 globalThis.document = dom.window.document;
93 globalThis.Node = dom.window.Node;
94 globalThis.Element = dom.window.Element;
95 globalThis.HTMLElement = dom.window.HTMLElement;
96 globalThis.Event = dom.window.Event;
97 globalThis.MouseEvent = dom.window.MouseEvent;
98
99 let scheduledRefreshes = 0;
100 const unsubscribeRefresh = subscribeToUpdateRefresh(() => {
101 scheduledRefreshes += 1;
102 }, 60_000);
103 window.dispatchEvent(new Event("focus"));
104 document.dispatchEvent(new Event("visibilitychange"));
105 ok(scheduledRefreshes === 2, "visible focus and visibility changes schedule update refreshes");
106 unsubscribeRefresh();
107 window.dispatchEvent(new Event("focus"));
108 ok(scheduledRefreshes === 2, "update refresh listeners are removed on cleanup");
109
110 const root = createRoot(document.getElementById("root")!);
111 await act(async () => {
112 root.render(
113 <UpdaterProvider>
114 <Consumer id="banner" checking />
115 <Consumer id="settings" checking />
116 </UpdaterProvider>,
117 );
118 });
119
120 ok(document.getElementById("banner-status")?.textContent === "idle", "banner starts idle");
121 ok(document.getElementById("settings-status")?.textContent === "idle", "settings starts idle");
122 ok(classifyUpdateError("prepare update: a pending update already exists") === "recovery", "pending update errors require recovery fallback");
123 ok(classifyUpdateError("prepare update: recover existing handoff backup: operation not permitted") === "recovery", "macOS backup permission errors require recovery fallback");
124 ok(classifyUpdateError("update recovery: the previous update is still completing its startup health check; wait briefly and try again, or discard the previous update") === "recovery", "awaiting-health errors require recovery fallback");
125 ok(classifyUpdateError("update: manual update required") === "manual", "manual-only errors prefer the official download");
126 ok(classifyUpdateError('update: fetch manifest: https://dl.reasonix.io/latest/latest.json: platforms darwin-arm64 asset: unsupported install_layout "electron-v1" (keeping current version)') === "manual", "install layout boundaries prefer the official download");
127 ok(classifyUpdateError("connection reset by peer") === "retryable", "transient errors remain retryable");
128
129 await act(async () => {
130 (document.getElementById("banner-check-update") as HTMLButtonElement).click();
131 await new Promise((resolve) => setTimeout(resolve, 0));
132 });
133
134 ok(document.getElementById("banner-status")?.textContent === "upToDate", "banner receives the check result");
135 ok(document.getElementById("settings-status")?.textContent === "upToDate", "settings receives the same check result");
136
137 const debInfo = {
138 available: true,
139 current: "v1.0.0",
140 latest: "v1.1.0",
141 notes: "",
142 channel: "stable",
143 canSelfUpdate: true,
144 manualOnly: false,
145 installMode: "deb",
146 requiresElevation: true,
147 downloaded: false,
148 downloadUrl: "https://example.invalid/download",
149 assetSize: 42,
150 };
151 const applyAttempts: Array<{
152 requestId: string;
153 version: string;
154 channel: string;
155 resolve: () => void;
156 reject: (err: Error) => void;
157 }> = [];
158 const checkedChannels: string[] = [];
159 const appStubTable = ({
160 main: {
161 App: {
162 async CheckUpdate(channel: string) {
163 checkedChannels.push(channel);
164 return { ...debInfo, channel: "stable" };
165 },
166 ApplyUpdateRequest(channel: string, expectedVersion: string, requestId: string) {
167 return new Promise<void>((resolve, reject) => applyAttempts.push({
168 requestId,
169 version: expectedVersion,
170 channel,
171 resolve,
172 reject,
173 }));
174 },
175 } as AppBindings,
176 },
177 }).main.App;
178 const desktopStub = installDesktopHostStub(appStubTable);
179
180 await act(async () => {
181 (document.getElementById("settings-check-update") as HTMLButtonElement).click();
182 await new Promise((resolve) => setTimeout(resolve, 0));
183 });
184 ok(checkedChannels[0] === "stable", "settings check uses the official release channel");
185 ok(document.getElementById("banner-status")?.textContent === "available", "deb update becomes available");
186 ok(document.getElementById("settings-status")?.textContent === "available", "deb availability is shared");
187
188 await act(async () => {
189 (document.getElementById("banner-apply") as HTMLButtonElement).click();
190 });
191 ok(document.getElementById("banner-status")?.textContent === "authorizing", "deb apply starts authorizing");
192 ok(document.getElementById("settings-status")?.textContent === "authorizing", "authorizing state is shared");
193
194 const checksBeforeBusyRefresh = checkedChannels.length;
195 await act(async () => {
196 (document.getElementById("banner-refresh") as HTMLButtonElement).click();
197 await new Promise((resolve) => setTimeout(resolve, 0));
198 });
199 ok(checkedChannels.length === checksBeforeBusyRefresh, "background refresh cannot supersede an active update");
200
201 await act(async () => {
202 __emitMockUpdater({
203 requestId: applyAttempts[0].requestId,
204 version: applyAttempts[0].version,
205 channel: "stable",
206 phase: "downloading",
207 received: 10,
208 total: 42,
209 });
210 });
211 ok(document.getElementById("banner-status")?.textContent === "downloading", "download phase is shared");
212 ok(document.getElementById("banner-received")?.textContent === "10", "download progress is shared");
213
214 await act(async () => {
215 __emitMockUpdater({
216 requestId: applyAttempts[0].requestId,
217 version: applyAttempts[0].version,
218 channel: "stable",
219 phase: "verifying",
220 received: 42,
221 total: 42,
222 });
223 });
224 ok(document.getElementById("banner-status")?.textContent === "verifying", "verify phase is shared");
225
226 await act(async () => {
227 __emitMockUpdater({
228 requestId: applyAttempts[0].requestId,
229 version: applyAttempts[0].version,
230 channel: "stable",
231 phase: "installing",
232 received: 42,
233 total: 42,
234 });
235 });
236 ok(document.getElementById("banner-status")?.textContent === "installing", "install phase advances");
237
238 await act(async () => {
239 applyAttempts[0]?.reject(new Error("update: manual update required: system update helper is unavailable"));
240 await new Promise((resolve) => setTimeout(resolve, 0));
241 });
242 ok(document.getElementById("banner-status")?.textContent === "error", "manual reclassification leaves busy state");
243 ok(document.getElementById("banner-manual")?.textContent === "manual", "manual reclassification offers download fallback");
244 ok(document.getElementById("settings-status")?.textContent === "error", "manual fallback error is shared");
245 ok(!!document.getElementById("banner-retry"), "error state exposes retry");
246
247 // Retry releases the mutex and can start a new apply immediately.
248 await act(async () => {
249 (document.getElementById("banner-retry") as HTMLButtonElement).click();
250 });
251 ok(document.getElementById("banner-status")?.textContent === "authorizing", "retry re-enters applying");
252 await act(async () => {
253 applyAttempts[1]?.resolve();
254 __emitMockUpdater({
255 requestId: applyAttempts[1].requestId,
256 version: applyAttempts[1].version,
257 channel: "stable",
258 phase: "relaunching",
259 received: 0,
260 total: 0,
261 });
262 await new Promise((resolve) => setTimeout(resolve, 0));
263 });
264 ok(document.getElementById("banner-status")?.textContent === "relaunching", "relaunching phase is shared");
265
266 let resolveFirstCheck!: (value: typeof debInfo) => void;
267 let resolveSecondCheck!: (value: typeof debInfo) => void;
268 let checkCalls = 0;
269 appStubTable.CheckUpdate = () =>
270 new Promise<typeof debInfo>((resolve) => {
271 checkCalls += 1;
272 if (checkCalls === 1) resolveFirstCheck = resolve;
273 else resolveSecondCheck = resolve;
274 });
275
276 // Force-check bypasses disabled UI so we still cover check-vs-check supersession.
277 await act(async () => {
278 (document.getElementById("banner-force-check") as HTMLButtonElement).click();
279 (document.getElementById("settings-force-check") as HTMLButtonElement).click();
280 });
281 await act(async () => {
282 resolveSecondCheck({ ...debInfo, channel: "stable", latest: "v1.2.0" });
283 await new Promise((resolve) => setTimeout(resolve, 0));
284 });
285 ok(document.getElementById("banner-status")?.textContent === "available", "newest official check publishes its result");
286 await act(async () => {
287 resolveFirstCheck({ ...debInfo, available: false, channel: "stable", latest: "v1.0.0" });
288 await new Promise((resolve) => setTimeout(resolve, 0));
289 });
290 ok(document.getElementById("banner-status")?.textContent === "available", "stale official check cannot overwrite the newest result");
291
292 let oldApplyRequestId = "";
293 let oldApplyVersion = "";
294 let resolveOldApply!: () => void;
295 appStubTable.ApplyUpdateRequest = (_channel: string, expectedVersion: string, requestId: string) =>
296 new Promise<void>((resolve) => {
297 oldApplyRequestId = requestId;
298 oldApplyVersion = expectedVersion;
299 resolveOldApply = resolve;
300 });
301 await act(async () => {
302 (document.getElementById("banner-apply") as HTMLButtonElement).click();
303 });
304 ok(document.getElementById("banner-status")?.textContent === "authorizing", "official apply starts");
305 await act(async () => {
306 __emitMockUpdater({
307 requestId: oldApplyRequestId,
308 version: `${oldApplyVersion}-wrong`,
309 channel: "stable",
310 phase: "installing",
311 received: 42,
312 total: 42,
313 });
314 });
315 ok(
316 document.getElementById("banner-status")?.textContent === "authorizing",
317 "same-request wrong-version progress cannot advance the active apply",
318 );
319 await act(async () => {
320 __emitMockUpdater({
321 requestId: oldApplyRequestId,
322 version: oldApplyVersion,
323 channel: "preview",
324 phase: "downloading",
325 received: 41,
326 total: 42,
327 });
328 });
329 ok(document.getElementById("banner-received")?.textContent === "", "retired-channel progress cannot update the active official apply");
330 await act(async () => {
331 (document.getElementById("banner-reset") as HTMLButtonElement).click();
332 __emitMockUpdater({
333 requestId: oldApplyRequestId,
334 version: oldApplyVersion,
335 channel: "stable",
336 phase: "error",
337 received: 0,
338 total: 0,
339 err: "old apply failed",
340 });
341 resolveOldApply();
342 await new Promise((resolve) => setTimeout(resolve, 0));
343 });
344 ok(document.getElementById("banner-status")?.textContent === "idle", "same-channel superseded progress and Promise completion stay ignored");
345
346 appStubTable.CheckUpdate = async () => ({
347 ...debInfo,
348 channel: "stable",
349 latest: "v1.2.0",
350 });
351 appStubTable.ApplyUpdateRequest = async () => {
352 throw new Error("should not reach when version mismatches from progress only");
353 };
354
355 await act(async () => {
356 (document.getElementById("settings-check-update") as HTMLButtonElement).click();
357 await new Promise((resolve) => setTimeout(resolve, 0));
358 });
359 ok(document.getElementById("banner-status")?.textContent === "available", "official update is available again");
360
361 appStubTable.ApplyUpdateRequest = async (_channel: string, _expectedVersion: string, requestId: string) => {
362 // Hang until cancelled by reset so we can prove supersession.
363 return new Promise<void>((_resolve, reject) => {
364 applyAttempts.push({
365 requestId,
366 version: _expectedVersion,
367 channel: _channel,
368 resolve: () => {},
369 reject,
370 });
371 });
372 };
373 await act(async () => {
374 (document.getElementById("banner-apply") as HTMLButtonElement).click();
375 });
376 const staleApply = applyAttempts[applyAttempts.length - 1];
377 ok(document.getElementById("banner-status")?.textContent === "authorizing", "official apply starts");
378 await act(async () => {
379 (document.getElementById("banner-reset") as HTMLButtonElement).click();
380 __emitMockUpdater({
381 requestId: staleApply.requestId,
382 version: staleApply.version,
383 channel: "stable",
384 phase: "error",
385 received: 0,
386 total: 0,
387 err: "old install failed",
388 });
389 staleApply.reject(new Error("old official apply failed"));
390 await new Promise((resolve) => setTimeout(resolve, 0));
391 });
392 ok(document.getElementById("banner-status")?.textContent === "idle", "superseded apply progress and rejection stay ignored");
393
394 appStubTable.CheckUpdate = async () => ({ ...debInfo, channel: "preview" });
395 await act(async () => {
396 (document.getElementById("settings-check-update") as HTMLButtonElement).click();
397 await new Promise((resolve) => setTimeout(resolve, 0));
398 });
399 ok(document.getElementById("banner-status")?.textContent === "error", "wrong-channel check response leaves the checking state");
400
401 appStubTable.CheckUpdate = async () => ({ ...debInfo, channel: "stable", latest: "v1.3.0" });
402 await act(async () => {
403 (document.getElementById("settings-check-update") as HTMLButtonElement).click();
404 await new Promise((resolve) => setTimeout(resolve, 0));
405 });
406 ok(document.getElementById("banner-status")?.textContent === "available", "check can retry after a wrong-channel response");
407
408 // Force a recovery error so discard is meaningful, then prove a deferred abandon
409 // owns the busy UI and cannot be superseded by Check while in flight.
410 await act(async () => {
411 (document.getElementById("banner-reset") as HTMLButtonElement).click();
412 });
413 let resolveAbandon!: () => void;
414 let rejectAbandon!: (err: Error) => void;
415 let abandonCalls = 0;
416 let checkCallsDuringAbandon = 0;
417 appStubTable.AbandonPendingUpdate = () =>
418 new Promise<void>((resolve, reject) => {
419 abandonCalls += 1;
420 resolveAbandon = resolve;
421 rejectAbandon = reject;
422 });
423 appStubTable.CheckUpdate = async () => {
424 checkCallsDuringAbandon += 1;
425 return { ...debInfo, available: false, channel: "stable", latest: "v1.0.0" };
426 };
427 // Seed recovery error without going through apply (info may be absent).
428 await act(async () => {
429 appStubTable.CheckUpdate = async () => {
430 throw new Error("update recovery: the previous update is still completing its startup health check; wait briefly and try again, or discard the previous update");
431 };
432 (document.getElementById("settings-check-update") as HTMLButtonElement).click();
433 await new Promise((resolve) => setTimeout(resolve, 0));
434 });
435 ok(document.getElementById("banner-status")?.textContent === "error", "recovery error surfaces before discard");
436 ok(document.getElementById("banner-manual")?.textContent === "recovery", "awaiting-health is recovery disposition");
437
438 appStubTable.CheckUpdate = async () => {
439 checkCallsDuringAbandon += 1;
440 return { ...debInfo, available: false, channel: "stable", latest: "v1.0.0" };
441 };
442
443 await act(async () => {
444 (document.getElementById("banner-abandon") as HTMLButtonElement).click();
445 });
446 ok(document.getElementById("banner-status")?.textContent === "checking", "discard publishes busy checking status immediately");
447 ok(document.getElementById("settings-status")?.textContent === "checking", "discard busy status is shared");
448 ok((document.getElementById("banner-check-update") as HTMLButtonElement).disabled, "Check is disabled while discard runs");
449 ok((document.getElementById("banner-abandon") as HTMLButtonElement).disabled, "Discard is disabled while already discarding");
450 ok(abandonCalls === 1, "discard starts exactly one native AbandonPendingUpdate");
451
452 // Even if check() is force-invoked (bypassing disabled UI) while abandon owns
453 // the operation, it must not start a new CheckUpdate call or steal the epoch.
454 await act(async () => {
455 (document.getElementById("banner-force-check") as HTMLButtonElement).click();
456 await new Promise((resolve) => setTimeout(resolve, 0));
457 });
458 ok(checkCallsDuringAbandon === 0, "check() does not call the bridge while discard is in flight");
459 ok(document.getElementById("banner-status")?.textContent === "checking", "discard still owns the busy UI after a forced check");
460
461 await act(async () => {
462 resolveAbandon();
463 await new Promise((resolve) => setTimeout(resolve, 0));
464 });
465 ok(document.getElementById("banner-status")?.textContent === "idle", "successful discard returns to idle");
466 ok(checkCallsDuringAbandon === 0, "deferred discard completion is not overwritten by a concurrent check");
467
468 // Failure path still reports an error after the deferred reject settles.
469 await act(async () => {
470 (document.getElementById("banner-abandon") as HTMLButtonElement).click();
471 });
472 ok(document.getElementById("banner-status")?.textContent === "checking", "second discard re-enters busy state");
473 await act(async () => {
474 rejectAbandon(new Error("could not discard the previous update: still locked"));
475 await new Promise((resolve) => setTimeout(resolve, 0));
476 });
477 ok(document.getElementById("banner-status")?.textContent === "error", "failed discard surfaces an error");
478 ok(
479 document.getElementById("banner-manual")?.textContent === "recovery",
480 "discard failure keeps the recovery disposition when the message matches",
481 );
482
483 desktopStub.uninstall();
484
485 process.stdout.write(`\n${passed} passed, ${failed} failed\n`);
486 if (failed > 0) process.exit(1);
487
487 lines Plain Text