diff --git a/ui/e2e/maintenance.spec.ts b/ui/e2e/maintenance.spec.ts index 4d913dd..110b8d4 100644 --- a/ui/e2e/maintenance.spec.ts +++ b/ui/e2e/maintenance.spec.ts @@ -20,3 +20,19 @@ test('maintenance overlay covers the app and lifts on recovery', async ({ page } await page.evaluate(() => (window as unknown as { __maint: { off(): void } }).__maint.off()); await expect(page.getByRole('alertdialog')).toHaveCount(0); }); + +test('recovery reloads the SPA to pick up the fresh client', async ({ page }) => { + await page.goto('/'); + await page.evaluate(() => (window as unknown as { __maint: { on(): void } }).__maint.on()); + await expect(page.getByRole('alertdialog')).toBeVisible(); + + // Real recovery reloads the page (the deploy may have shipped an incompatible client, and the + // running bundle is the old one). Wait for the forced navigation, then confirm the app + // re-bootstrapped: the overlay is gone (the store reset by the reload) and the login is back. + await Promise.all([ + page.waitForEvent('load'), + page.evaluate(() => (window as unknown as { __maint: { recover(): void } }).__maint.recover()), + ]); + await expect(page.getByRole('alertdialog')).toHaveCount(0); + await expect(page.getByRole('button', { name: /guest/i })).toBeVisible(); +}); diff --git a/ui/src/lib/gateway.ts b/ui/src/lib/gateway.ts index 7cbe567..a8bef98 100644 --- a/ui/src/lib/gateway.ts +++ b/ui/src/lib/gateway.ts @@ -6,7 +6,7 @@ import type { GatewayClient } from './client'; import { MockGateway } from './mock/client'; import { createTransport } from './transport'; import { reportOffline, reportOnline } from './connection.svelte'; -import { clearMaintenance, reportMaintenance } from './maintenance.svelte'; +import { clearMaintenance, maintenanceRecovered, reportMaintenance } from './maintenance.svelte'; const isMock = import.meta.env.MODE === 'mock'; @@ -23,9 +23,12 @@ if (isMock && typeof window !== 'undefined') { }; // The mock never produces a real 503, so the e2e drives the maintenance overlay directly // (the store's poll is inert in mock — no probe is registered — so `off` clears it). - (window as unknown as { __maint?: { on(retryAfterMs?: number): void; off(): void } }).__maint = { + ( + window as unknown as { __maint?: { on(retryAfterMs?: number): void; off(): void; recover(): void } } + ).__maint = { on: (retryAfterMs = 15000) => reportMaintenance(retryAfterMs), off: clearMaintenance, + recover: maintenanceRecovered, }; // Drive the auto-match opponent join deterministically from the e2e (the mock otherwise // attaches a robot on a timer). diff --git a/ui/src/lib/maintenance.svelte.ts b/ui/src/lib/maintenance.svelte.ts index 65133e8..2745b9d 100644 --- a/ui/src/lib/maintenance.svelte.ts +++ b/ui/src/lib/maintenance.svelte.ts @@ -41,7 +41,8 @@ export function reportMaintenance(retryAfterMs: number): void { if (probe) schedulePoll(); } -/** clearMaintenance lowers the overlay and stops the poll (a successful read, or reset). */ +/** clearMaintenance lowers the overlay and stops the poll without reloading (a reset, or the + * e2e hook). Prod recovery goes through maintenanceRecovered instead. */ export function clearMaintenance(): void { active = false; if (pollTimer) { @@ -50,6 +51,21 @@ export function clearMaintenance(): void { } } +/** maintenanceRecovered runs when the edge answers again after a window we were showing. + * The deploy that ended the window may have shipped a client-incompatible change (a wire / + * schema bump), and the running bundle is the OLD one — so reload to pick up the fresh + * client instead of merely hiding the overlay. A no-op unless the overlay was up; the cover + * stays over the brief reload flash. In prod recovery is the only way the overlay clears. */ +export function maintenanceRecovered(): void { + if (!active) return; + active = false; + if (pollTimer) { + clearTimeout(pollTimer); + pollTimer = null; + } + if (typeof location !== 'undefined') location.reload(); +} + /** retryNow forces an immediate re-check (the overlay's button). It never closes the * overlay itself — only a successful probe does. A no-op while a probe is in flight. */ export function retryNow(): void { @@ -73,7 +89,7 @@ function runProbe(): void { probe().then( () => { probing = false; - clearMaintenance(); + maintenanceRecovered(); }, () => { probing = false; diff --git a/ui/src/lib/transport.ts b/ui/src/lib/transport.ts index 2230466..dd89956 100644 --- a/ui/src/lib/transport.ts +++ b/ui/src/lib/transport.ts @@ -12,7 +12,7 @@ import { GatewayError, type GatewayClient } from './client'; import * as codec from './codec'; import { browserOffset } from './profileValidation'; import { registerProbe, reportOffline, reportOnline } from './connection.svelte'; -import { clearMaintenance, registerMaintenanceProbe, reportMaintenance } from './maintenance.svelte'; +import { maintenanceRecovered, registerMaintenanceProbe, reportMaintenance } from './maintenance.svelte'; import { maintenanceRetryMs } from './maintenance'; import { backoffMs, isConnectionCode, retryable, toGatewayError } from './retry'; @@ -67,7 +67,9 @@ export function createTransport(baseUrl: string): GatewayClient { throw err; } reportOnline(); - clearMaintenance(); + // A read got through: if the maintenance overlay was up, the deploy window has ended — + // reload to pick up the (possibly incompatible) fresh client (maintenance.svelte.ts). + maintenanceRecovered(); if (res.resultCode && res.resultCode !== 'ok') throw new GatewayError(res.resultCode); return res.payload; }