| 1 | import assert from "node:assert/strict"; |
| 2 | import { JSDOM } from "jsdom"; |
| 3 | import React, { act, useEffect } from "react"; |
| 4 | import { createRoot } from "react-dom/client"; |
| 5 | import { UpdateBanner } from "../components/UpdateBanner"; |
| 6 | import { LocaleProvider } from "../lib/i18n"; |
| 7 | import { |
| 8 | __resetUpdaterCheckScheduleForTests, |
| 9 | UPDATE_CHECK_INTERVAL_MS, |
| 10 | UPDATE_CHECK_STORAGE_KEY, |
| 11 | UpdaterProvider, |
| 12 | useUpdater, |
| 13 | } from "../lib/useUpdater"; |
| 14 | import { installDesktopHostStub } from "./desktopHostStub"; |
| 15 | |
| 16 | const dom = new JSDOM("<!doctype html><div id='root'></div>", { url: "http://localhost", pretendToBeVisual: true }); |
| 17 | Object.assign(globalThis, { |
| 18 | window: dom.window, |
| 19 | document: dom.window.document, |
| 20 | Node: dom.window.Node, |
| 21 | Element: dom.window.Element, |
| 22 | HTMLElement: dom.window.HTMLElement, |
| 23 | Event: dom.window.Event, |
| 24 | MouseEvent: dom.window.MouseEvent, |
| 25 | IS_REACT_ACT_ENVIRONMENT: true, |
| 26 | }); |
| 27 | |
| 28 | let now = 1_800_000_000_000; |
| 29 | const originalDateNow = Date.now; |
| 30 | Date.now = () => now; |
| 31 | |
| 32 | let checkCalls = 0; |
| 33 | const desktopStub = installDesktopHostStub({ |
| 34 | CheckUpdate: async () => { |
| 35 | checkCalls += 1; |
| 36 | return { |
| 37 | available: false, |
| 38 | current: "v1.2.3", |
| 39 | latest: "v1.2.3", |
| 40 | notes: "", |
| 41 | channel: "stable", |
| 42 | canSelfUpdate: true, |
| 43 | manualOnly: false, |
| 44 | installMode: "portable", |
| 45 | requiresElevation: false, |
| 46 | downloaded: false, |
| 47 | downloadUrl: "https://example.invalid/download", |
| 48 | assetSize: 0, |
| 49 | }; |
| 50 | }, |
| 51 | }); |
| 52 | |
| 53 | function ManualCheck() { |
| 54 | const updater = useUpdater(); |
| 55 | return <button id="manual-check" onClick={() => void updater.check()}>Check</button>; |
| 56 | } |
| 57 | |
| 58 | function AutomaticCheck() { |
| 59 | const updater = useUpdater(); |
| 60 | useEffect(() => { void updater.refresh(); }, [updater.refresh]); |
| 61 | return null; |
| 62 | } |
| 63 | |
| 64 | async function flush() { |
| 65 | await new Promise((resolve) => setTimeout(resolve, 0)); |
| 66 | } |
| 67 | |
| 68 | const root = createRoot(document.getElementById("root")!); |
| 69 | try { |
| 70 | __resetUpdaterCheckScheduleForTests(); |
| 71 | await act(async () => { |
| 72 | root.render(<LocaleProvider><UpdaterProvider><UpdateBanner enabled /><ManualCheck /></UpdaterProvider></LocaleProvider>); |
| 73 | await flush(); |
| 74 | }); |
| 75 | assert.equal(checkCalls, 1, "first enabled mount performs one automatic check"); |
| 76 | |
| 77 | now += UPDATE_CHECK_INTERVAL_MS - 1; |
| 78 | await act(async () => { |
| 79 | window.dispatchEvent(new Event("focus")); |
| 80 | document.dispatchEvent(new Event("visibilitychange")); |
| 81 | await flush(); |
| 82 | }); |
| 83 | assert.equal(checkCalls, 1, "focus and visibility events stay throttled before six hours"); |
| 84 | |
| 85 | now += 1; |
| 86 | await act(async () => { |
| 87 | window.dispatchEvent(new Event("focus")); |
| 88 | document.dispatchEvent(new Event("visibilitychange")); |
| 89 | await flush(); |
| 90 | }); |
| 91 | assert.equal(checkCalls, 2, "simultaneous due events produce one check after six hours"); |
| 92 | |
| 93 | await act(async () => root.render(<LocaleProvider><UpdaterProvider><AutomaticCheck /><ManualCheck /></UpdaterProvider></LocaleProvider>)); |
| 94 | await act(async () => { await flush(); }); |
| 95 | assert.equal(checkCalls, 2, "stored attempt survives provider remounts"); |
| 96 | |
| 97 | await act(async () => { |
| 98 | (document.getElementById("manual-check") as HTMLButtonElement).click(); |
| 99 | await flush(); |
| 100 | }); |
| 101 | assert.equal(checkCalls, 3, "manual checks bypass the automatic throttle"); |
| 102 | assert.equal(window.localStorage.getItem(UPDATE_CHECK_STORAGE_KEY), String(now), "manual check resets the automatic schedule"); |
| 103 | |
| 104 | now += UPDATE_CHECK_INTERVAL_MS - 1; |
| 105 | await act(async () => { |
| 106 | window.dispatchEvent(new Event("focus")); |
| 107 | await flush(); |
| 108 | }); |
| 109 | assert.equal(checkCalls, 3, "manual check suppresses automatic checks for the next six hours"); |
| 110 | |
| 111 | await act(async () => root.render(<LocaleProvider><UpdaterProvider><UpdateBanner enabled={false} /></UpdaterProvider></LocaleProvider>)); |
| 112 | now += UPDATE_CHECK_INTERVAL_MS; |
| 113 | await act(async () => { |
| 114 | window.dispatchEvent(new Event("focus")); |
| 115 | document.dispatchEvent(new Event("visibilitychange")); |
| 116 | await flush(); |
| 117 | }); |
| 118 | assert.equal(checkCalls, 3, "disabled automatic checks install no startup, focus, or visibility work"); |
| 119 | |
| 120 | __resetUpdaterCheckScheduleForTests(); |
| 121 | window.localStorage.setItem(UPDATE_CHECK_STORAGE_KEY, String(now + UPDATE_CHECK_INTERVAL_MS)); |
| 122 | await act(async () => root.render(<LocaleProvider><UpdaterProvider><AutomaticCheck /></UpdaterProvider></LocaleProvider>)); |
| 123 | await act(async () => { await flush(); }); |
| 124 | assert.equal(checkCalls, 4, "a future timestamp is discarded and rebuilt by an immediate check"); |
| 125 | |
| 126 | const localStorageDescriptor = Object.getOwnPropertyDescriptor(window, "localStorage"); |
| 127 | Object.defineProperty(window, "localStorage", { |
| 128 | configurable: true, |
| 129 | get() { throw new Error("storage denied"); }, |
| 130 | }); |
| 131 | __resetUpdaterCheckScheduleForTests(); |
| 132 | await act(async () => root.render(<LocaleProvider><UpdaterProvider><UpdateBanner enabled /></UpdaterProvider></LocaleProvider>)); |
| 133 | await act(async () => { await flush(); }); |
| 134 | assert.equal(checkCalls, 5, "storage denial still allows the first process-local check"); |
| 135 | await act(async () => { |
| 136 | window.dispatchEvent(new Event("focus")); |
| 137 | document.dispatchEvent(new Event("visibilitychange")); |
| 138 | await flush(); |
| 139 | }); |
| 140 | assert.equal(checkCalls, 5, "process-local fallback throttles duplicate checks when storage is unavailable"); |
| 141 | if (localStorageDescriptor) Object.defineProperty(window, "localStorage", localStorageDescriptor); |
| 142 | |
| 143 | console.log("PASS updater six-hour scheduling, persistence, manual bypass, disable, and storage recovery"); |
| 144 | } finally { |
| 145 | await act(async () => root.unmount()); |
| 146 | desktopStub.uninstall(); |
| 147 | Date.now = originalDateNow; |
| 148 | dom.window.close(); |
| 149 | } |
| 150 |