From f8aa44dcdab515191a3a69215d2f2faf2b8e57e3 Mon Sep 17 00:00:00 2001 From: Shakker Date: Wed, 29 Jul 2026 22:08:41 +0100 Subject: [PATCH] fix: preserve uncertain update outcomes --- ui/src/app/overlays-update-race.test.ts | 46 ++++++++++++++++++++++--- ui/src/app/overlays.ts | 13 +++---- 2 files changed, 49 insertions(+), 10 deletions(-) diff --git a/ui/src/app/overlays-update-race.test.ts b/ui/src/app/overlays-update-race.test.ts index 960ac8082079..dc208b6fed57 100644 --- a/ui/src/app/overlays-update-race.test.ts +++ b/ui/src/app/overlays-update-race.test.ts @@ -127,6 +127,18 @@ describe("application update reconciliation races", () => { const overlays = createApplicationOverlays(harness.gateway); try { + harness.update({ + hello: { + server: { version: "1.0.0" }, + snapshot: { + updateAvailable: { + currentVersion: "1.0.0", + latestVersion: "2.0.0", + channel: "stable", + }, + }, + } as ApplicationGatewaySnapshot["hello"], + }); const running = overlays.runUpdate(); await flushMicrotasks(); harness.update({ phase: "stopped" }); @@ -154,9 +166,16 @@ describe("application update reconciliation races", () => { it("releases ambiguous reconciliation when update status access is revoked", async () => { installUpdateTranslations(); const updateRun = deferred(); - const request = vi.fn((method) => - method === "update.run" ? updateRun.promise : Promise.resolve([]), - ); + const updateStatus = deferred(); + const request = vi.fn((method) => { + if (method === "update.run") { + return updateRun.promise; + } + if (method === "update.status") { + return updateStatus.promise; + } + return Promise.resolve([]); + }); const harness = createGatewayHarness(client(request)); harness.update({ hello: { @@ -170,6 +189,11 @@ describe("application update reconciliation races", () => { await flushMicrotasks(); expect(overlays.snapshot.updateReconciliationPending).toBe(true); + harness.update({ phase: "stopped" }); + harness.update({ phase: "connected" }); + await flushMicrotasks(); + expect(request).toHaveBeenCalledWith("update.status", {}); + harness.update({ hello: { auth: { role: "operator", scopes: ["operator.read"] }, @@ -183,10 +207,24 @@ describe("application update reconciliation races", () => { text: UNKNOWN_OUTCOME_TEXT, }); + updateStatus.resolve({ + sentinel: { + kind: "update", + status: "error", + stats: { reason: "stale-result" }, + }, + }); + await flushMicrotasks(); + expect(overlays.snapshot.updateStatusBanner).toEqual({ + tone: "danger", + text: UNKNOWN_OUTCOME_TEXT, + }); + expect(request.mock.calls.filter(([method]) => method === "update.status")).toHaveLength(1); + updateRun.resolve({}); await running; - expect(request).not.toHaveBeenCalledWith("update.status", {}); } finally { + updateStatus.resolve({}); updateRun.resolve({}); overlays.dispose(); } diff --git a/ui/src/app/overlays.ts b/ui/src/app/overlays.ts index 0b8c07f0a885..d672650b4b8f 100644 --- a/ui/src/app/overlays.ts +++ b/ui/src/app/overlays.ts @@ -342,12 +342,12 @@ export function createApplicationOverlays( const currentVersion = gateway.snapshot.hello?.server?.version?.trim() || null; pendingUpdate = null; publishUpdateBanner( - expectedVersion && currentVersion !== expectedVersion - ? resolveUpdateVerificationBanner({ expectedVersion, actualVersion: currentVersion }) - : reconciliation.kind === "handoff" - ? resolvePendingUpdateHandoffTimeoutBanner() - : reconciliation.kind === "ambiguous" - ? unknownUpdateBanner() + reconciliation.kind === "ambiguous" + ? unknownUpdateBanner() + : expectedVersion && currentVersion !== expectedVersion + ? resolveUpdateVerificationBanner({ expectedVersion, actualVersion: currentVersion }) + : reconciliation.kind === "handoff" + ? resolvePendingUpdateHandoffTimeoutBanner() : null, ); }; @@ -378,6 +378,7 @@ export function createApplicationOverlays( pairingPendingCount.invalidate({ clear: true }); if (accessTransition.adminRevoked) { updateRunGeneration += 1; + cancelUpdateVerification(); const updateStatusBanner = pendingUpdate ? unknownUpdateBanner() : snapshot.updateStatusBanner;