-
Notifications
You must be signed in to change notification settings - Fork 3
XS✔ ◾ fix: stop update-ready reminder from re-popping and stacking within a session #972
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
813afca
ab0e2eb
1b3ff9f
49c3204
beec5d2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,21 +1,37 @@ | ||
| import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from "vitest"; | ||
|
|
||
| // Keep Electron, the real autoUpdater, and encrypted storage out of this unit test — we're | ||
| // exercising the token-health gating logic in ReleaseChannelIPCHandlers (#919), not Electron | ||
| // itself. | ||
| // exercising both the token-health gating logic (#919) and the update-ready reminder dialog's | ||
| // anti-stacking guard behavior (#456) in ReleaseChannelIPCHandlers, driven via mocked | ||
| // Electron/autoUpdater listeners rather than the real Electron runtime. | ||
| const showMessageBoxMock = vi.fn().mockResolvedValue({ response: 1 }); | ||
| vi.mock("electron", () => ({ | ||
| app: { | ||
| getName: () => "YakShaver", | ||
| getVersion: () => "1.2.3", | ||
| isPackaged: true, | ||
| }, | ||
| BrowserWindow: { getAllWindows: () => [] }, | ||
| dialog: { showMessageBox: vi.fn().mockResolvedValue({ response: 1 }) }, | ||
| dialog: { showMessageBox: (...args: unknown[]) => showMessageBoxMock(...args) }, | ||
| ipcMain: { handle: vi.fn() }, | ||
| })); | ||
|
|
||
| const checkForUpdatesMock = vi.fn(); | ||
| const setFeedURLMock = vi.fn(); | ||
| const quitAndInstallMock = vi.fn(); | ||
| // Capture registered autoUpdater listeners (keyed by event name) so tests can fire events like | ||
| // "update-downloaded" directly, the same way the real electron-updater instance would (#456). | ||
| const autoUpdaterListeners = new Map<string, Array<(...args: unknown[]) => void>>(); | ||
| const autoUpdaterOnMock = vi.fn((event: string, listener: (...args: unknown[]) => void) => { | ||
| const listeners = autoUpdaterListeners.get(event) ?? []; | ||
| listeners.push(listener); | ||
| autoUpdaterListeners.set(event, listeners); | ||
| }); | ||
| function emitAutoUpdaterEvent(event: string, ...args: unknown[]) { | ||
| for (const listener of autoUpdaterListeners.get(event) ?? []) { | ||
| listener(...args); | ||
| } | ||
| } | ||
| vi.mock("electron-updater", () => ({ | ||
| autoUpdater: { | ||
| autoDownload: false, | ||
|
|
@@ -24,9 +40,10 @@ vi.mock("electron-updater", () => ({ | |
| allowPrerelease: false, | ||
| allowDowngrade: false, | ||
| requestHeaders: {}, | ||
| on: vi.fn(), | ||
| on: (...args: [string, (...a: unknown[]) => void]) => autoUpdaterOnMock(...args), | ||
| setFeedURL: (...args: unknown[]) => setFeedURLMock(...args), | ||
| checkForUpdates: (...args: unknown[]) => checkForUpdatesMock(...args), | ||
| quitAndInstall: (...args: unknown[]) => quitAndInstallMock(...args), | ||
| }, | ||
| })); | ||
|
|
||
|
|
@@ -373,3 +390,118 @@ describe("ReleaseChannelIPCHandlers — PR releases require a healthy GitHub tok | |
| expect(result).toEqual({ available: false, currentVersion: "1.2.3" }); | ||
| }); | ||
| }); | ||
|
|
||
| describe("ReleaseChannelIPCHandlers — update-ready reminder dialog (#456)", () => { | ||
| beforeEach(() => { | ||
| vi.clearAllMocks(); | ||
| autoUpdaterListeners.clear(); | ||
| showMessageBoxMock.mockReset(); | ||
| }); | ||
|
|
||
| it("does not stack a second reminder dialog while the first is still awaiting a response", async () => { | ||
| // The reported bug's second half: "If the reminder dialog is open already, a subsequent | ||
| // update check should not open another reminder dialog on top of it." Simulate the first | ||
| // dialog's promise never resolving (user hasn't answered yet) and fire a second | ||
| // "update-downloaded" event (e.g. the periodic background check landing mid-dialog) — only one | ||
| // dialog must ever be shown. | ||
| let resolveFirstDialog: ((result: { response: number }) => void) | undefined; | ||
| showMessageBoxMock.mockImplementationOnce( | ||
| () => | ||
| new Promise((resolve) => { | ||
| resolveFirstDialog = resolve; | ||
| }), | ||
| ); | ||
|
|
||
| new ReleaseChannelIPCHandlers(); | ||
|
|
||
| emitAutoUpdaterEvent("update-downloaded"); | ||
| emitAutoUpdaterEvent("update-downloaded"); | ||
|
|
||
| expect(showMessageBoxMock).toHaveBeenCalledTimes(1); | ||
|
|
||
| // Resolve the first dialog and wait for the handler's .then/.finally chain to flush before | ||
| // the test ends, matching the sibling test's vi.waitFor pattern rather than firing-and-forgetting. | ||
| resolveFirstDialog?.({ response: 1 }); | ||
| await vi.waitFor(() => expect(showMessageBoxMock).toHaveBeenCalledTimes(1)); | ||
| }); | ||
|
|
||
| it("keeps suppressing duplicate reminders while a long-running dialog remains open", async () => { | ||
| vi.useFakeTimers(); | ||
| try { | ||
| showMessageBoxMock.mockImplementation(() => new Promise(() => {})); // never settles | ||
|
|
||
| new ReleaseChannelIPCHandlers(); | ||
|
|
||
| emitAutoUpdaterEvent("update-downloaded"); | ||
| expect(showMessageBoxMock).toHaveBeenCalledTimes(1); | ||
|
|
||
| // Advance beyond both the removed five-minute watchdog and the ten-minute update interval. | ||
| await vi.advanceTimersByTimeAsync(11 * 60 * 1000); | ||
|
|
||
| // The original native dialog is still visible, so another event must remain suppressed. | ||
| emitAutoUpdaterEvent("update-downloaded"); | ||
| expect(showMessageBoxMock).toHaveBeenCalledTimes(1); | ||
| } finally { | ||
| vi.useRealTimers(); | ||
| } | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [minor] No test covers a third event after timeout, nor the abandoned first dialog's late resolution The "releases the guard after the dialog settles" test only verifies that after the 5-minute timeout, a second Suggested addition: a test that (1) triggers dialog 1, (2) lets it hang past the 5-minute timeout so the guard resets, (3) triggers dialog 2 via a third event, (4) then resolves dialog 1's original promise with e.g. flagged by: code-review + codex-rescue (independent agreement)
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agree — added in 1b3ff9f. New test "still honors the original dialog's real answer after the watchdog timeout releases the guard, without disturbing a newer dialog (#456 blocking fix)": dialog 1 hangs past its watchdog (guard releases) -> a third event opens dialog 2 (still open, unanswered) -> dialog 1's original promise finally resolves ("Later", to keep this isolated from the separate isRestartingToInstall short-circuit). Asserts the late response is honored and that a fourth event is still correctly suppressed as a stack-on-top of dialog 2 — i.e. dialog 1's late .finally() did not incorrectly clobber dialog 2's guard state. |
||
| }); | ||
|
|
||
| it("releases the guard after a dialog error so a later event can retry", async () => { | ||
| const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); | ||
| showMessageBoxMock | ||
| .mockRejectedValueOnce(new Error("dialog failed")) | ||
| .mockImplementationOnce(() => new Promise(() => {})); | ||
|
|
||
| try { | ||
| new ReleaseChannelIPCHandlers(); | ||
|
|
||
| emitAutoUpdaterEvent("update-downloaded"); | ||
| await vi.waitFor(() => expect(errorSpy).toHaveBeenCalledTimes(1)); | ||
|
|
||
| emitAutoUpdaterEvent("update-downloaded"); | ||
| expect(showMessageBoxMock).toHaveBeenCalledTimes(2); | ||
| } finally { | ||
| errorSpy.mockRestore(); | ||
| } | ||
| }); | ||
|
|
||
| it("does not show a reminder again this session after the user clicks Later — the originally reported bug", async () => { | ||
| // The reported bug's first half: clicking "Remind me later" must stop further reminders for | ||
| // the rest of the current session, even when the periodic ~10-minute check fires again. | ||
| showMessageBoxMock.mockResolvedValue({ response: 1 }); // 1 = "Later" | ||
|
|
||
| new ReleaseChannelIPCHandlers(); | ||
|
|
||
| emitAutoUpdaterEvent("update-downloaded"); | ||
| await vi.waitFor(() => expect(showMessageBoxMock).toHaveBeenCalledTimes(1)); | ||
|
|
||
| // Simulate the periodic check finding the same update again later in the session. | ||
| emitAutoUpdaterEvent("update-downloaded"); | ||
| emitAutoUpdaterEvent("update-downloaded"); | ||
|
|
||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [nit] No test directly exercises the guard's release-and-reuse path (dialog resolves → guard clears → a later legitimate
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agree — implemented in ab0e2eb. Added "releases the guard after the dialog settles so a later legitimate event is handled normally", which directly pins the |
||
| expect(showMessageBoxMock).toHaveBeenCalledTimes(1); | ||
| }); | ||
|
|
||
| it("does not show another reminder after the user clicks Restart Now, even before quitAndInstall fires", async () => { | ||
| // Regression coverage (review on #456): isUpdateDialogOpen resets in .finally() synchronously, | ||
| // but the real quit (autoUpdater.quitAndInstall) is deferred via setImmediate. A second | ||
| // "update-downloaded" event landing in that gap must not stack a dialog moments before the app | ||
| // quits — pinned here via the isRestartingToInstall short-circuit. | ||
| showMessageBoxMock.mockResolvedValue({ response: 0 }); // 0 = "Restart Now" | ||
|
|
||
| new ReleaseChannelIPCHandlers(); | ||
|
|
||
| emitAutoUpdaterEvent("update-downloaded"); | ||
| await vi.waitFor(() => expect(showMessageBoxMock).toHaveBeenCalledTimes(1)); | ||
|
|
||
| // A second event landing after the dialog resolved (isUpdateDialogOpen already released) but | ||
| // before the deferred quitAndInstall() fires must still be suppressed. | ||
| emitAutoUpdaterEvent("update-downloaded"); | ||
|
|
||
| expect(showMessageBoxMock).toHaveBeenCalledTimes(1); | ||
|
|
||
| // Let the deferred quitAndInstall() flush before the test ends, so it doesn't fire during a | ||
| // later test. | ||
| await vi.waitFor(() => expect(quitAndInstallMock).toHaveBeenCalledTimes(1)); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -75,6 +75,23 @@ export class ReleaseChannelIPCHandlers { | |
| private updateCheckInterval: NodeJS.Timeout | null = null; | ||
| private readonly UPDATE_CHECK_INTERVAL = 10 * 60 * 1000; // Check every 10 minutes | ||
| private updateDialogDismissedInSession = false; // Track if user dismissed update dialog this session | ||
| // Track whether the "Update Ready" dialog is currently open (#456) - guards against a second | ||
| // autoUpdater "update-downloaded" event (e.g. a periodic re-check landing while the first | ||
| // dialog is still awaiting a response) from stacking a duplicate dialog on top of it. | ||
| private isUpdateDialogOpen = false; | ||
| // Once the user picks "Restart Now" the app is on its way out (review on #456) - short-circuit | ||
| // any further "update-downloaded" events immediately rather than relying solely on the | ||
| // isUpdateDialogOpen guard, which resets in .finally() before the deferred quitAndInstall() call | ||
| // actually fires and would otherwise leave a narrow window where a second event could stack a | ||
| // dialog moments before quit. | ||
| // | ||
| // Never reset back to false (review on #456): quitAndInstall() is fired via a fire-and-forget | ||
| // setImmediate() with no completion signal, so there is no reliable point at which we could learn | ||
| // the quit was blocked/failed and it's safe to resume showing reminders. Once the user has | ||
| // committed to restarting, permanently suppressing further reminders for the rest of this | ||
| // (soon-to-exit) session is the safer failure mode versus risking a reminder popping up while the | ||
| // app believes it is already on its way out. | ||
| private isRestartingToInstall = false; | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [minor] isRestartingToInstall is never reset, so a failed/blocked quit permanently suppresses all future reminders
flagged by: codex-rescue
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Discuss — agree this is a real gap, but implemented as documentation rather than a reset. There's no reliable signal to reset on: quitAndInstall() is fired via a fire-and-forget setImmediate() with no completion/failure callback, so we can't distinguish "quit is slow" from "quit is permanently blocked" without adding a second speculative watchdog/timeout guessing at how long a legitimate quit may take. Since the user has already explicitly committed to restarting, permanently suppressing further reminders for the rest of that (expected-to-exit-soon) session reads as the safer failure mode versus risking a reminder popping up while the app believes it's already quitting. Expanded the inline comment in 1b3ff9f to state this is intentional rather than an oversight. Happy to add a bounded reset watchdog (mirroring the dialog's own) if you'd rather have that — flagging as a possible follow-up rather than blocking this PR on it.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [minor] By design (per the PR's own comment) Separately: the class already has a Non-blocking; consider logging when flagged by: codex-rescue + code-review (independently, related observations)
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Discuss — this is the same point raised and discussed in review round 2 (see the earlier thread on this line), where the team's call was to keep it as a documented, intentional fail-safe rather than add a speculative secondary watchdog, since there's no reliable 'quit failed' signal to reset on. Standing by that decision rather than re-opening it here — happy to add a bounded reset watchdog as a tracked follow-up if a human reviewer wants it, but not implementing it speculatively in this pass. The teardown-hook gap is real but low-risk today (the class is a singleton and
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [minor] isRestartingToInstall is permanently sticky with no recovery path if quitAndInstall silently fails If flagged by: codex-rescue |
||
|
|
||
| constructor() { | ||
| ipcMain.handle(IPC_CHANNELS.RELEASE_CHANNEL_GET, () => this.getChannel()); | ||
|
|
@@ -111,11 +128,27 @@ export class ReleaseChannelIPCHandlers { | |
| }); | ||
|
|
||
| autoUpdater.on("update-downloaded", () => { | ||
| // Skip showing dialog if user already dismissed it this session | ||
| // Skip showing dialog if user already dismissed it this session (#456) - once "Later" is | ||
| // clicked, no further reminder should appear for the rest of the current session. | ||
| if (this.updateDialogDismissedInSession) { | ||
| return; | ||
| } | ||
|
|
||
| // Skip if the app is already on its way out from a prior "Restart Now" (review on #456) - | ||
| // checked before isUpdateDialogOpen since that guard resets in .finally() slightly before | ||
| // the deferred quitAndInstall() call actually fires. | ||
| if (this.isRestartingToInstall) { | ||
| return; | ||
| } | ||
|
|
||
| // Skip if a reminder dialog is already open (#456) - a subsequent "update-downloaded" event | ||
| // (e.g. the periodic background check firing again) must not stack another dialog on top of | ||
| // one the user hasn't responded to yet. | ||
| if (this.isUpdateDialogOpen) { | ||
| return; | ||
| } | ||
| this.isUpdateDialogOpen = true; | ||
|
|
||
| dialog | ||
| .showMessageBox({ | ||
| type: "info", | ||
|
|
@@ -127,6 +160,9 @@ export class ReleaseChannelIPCHandlers { | |
| }) | ||
| .then((result) => { | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [blocking] The new Concrete failure sequence:
This undermines the PR's own stated design intent for handling a late response from an abandoned dialog — the Related: the third new test ("still honors the original dialog's real answer after the watchdog timeout releases the guard, without disturbing a newer dialog (#456 blocking fix)") does not actually catch this — see the separate major finding on that test. flagged by: codex-rescue
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agree — this was a real bug, thanks for catching it (and for verifying it directly against the actual head commit rather than trusting my prior review-round comments). Fixed in 49c3204: the |
||
| if (result.response === 0) { | ||
| // The app is quitting to install - short-circuit any further events immediately, | ||
| // ahead of the isUpdateDialogOpen reset in .finally() below. | ||
| this.isRestartingToInstall = true; | ||
| // Set isQuitting to true so that the before-quit handler allows the app to quit | ||
| setIsQuitting(true); | ||
| // Force immediate quit and install | ||
|
|
@@ -135,12 +171,19 @@ export class ReleaseChannelIPCHandlers { | |
| autoUpdater.quitAndInstall(false, true); | ||
| }); | ||
| } else { | ||
| // User chose "Later" - remember this for the current session | ||
| // User chose "Later" - remember this for the current session so no further reminder | ||
| // is shown, whether from another "update-downloaded" event or the periodic check. | ||
| this.updateDialogDismissedInSession = true; | ||
| } | ||
| }) | ||
| .catch((err) => { | ||
| console.error("Error showing update dialog:", err); | ||
| }) | ||
| .finally(() => { | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [minor] Guard resets before the actual quit fires on "Restart Now" (flagged by both lenses) On the "Restart Now" branch,
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agree — implemented in ab0e2eb. Added an |
||
| // Keep the guard for the full lifetime of the native dialog. This dialog has no reliable | ||
| // cross-platform cancellation configured, so releasing the guard on a timeout would | ||
| // allow another dialog to stack on top while the original is still visible. | ||
| this.isUpdateDialogOpen = false; | ||
| }); | ||
| }); | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nit]
resolveFirstDialog?.({ response: 1 })is called without awaiting the resulting.then/.finallychain on the handler side, so the guard's reset and any pending microtasks aren't guaranteed to flush before the test ends. Low risk (the nextbeforeEachclears mocks), but the second new test's use ofvi.waitFor(...)is the more robust pattern — worth matching here too.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agree — implemented in ab0e2eb. Replaced the fire-and-forget resolve with
await vi.waitFor(...)after resolving the dialog, matching the sibling test's pattern so the guard's reset and any pending microtasks are guaranteed to flush before the test ends.