feat(ui): reload the SPA on maintenance recovery
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m6s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m47s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m6s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m47s
The deploy that ends a maintenance window may ship a client-incompatible change (wire/schema bump), and the in-session bundle is the old one. On recovery, reload to pick up the fresh client instead of just hiding the overlay. Ordering is safe: the edge maintenance flag (deploy/prod-deploy.sh) spans the WHOLE roll and clears only at script exit — after the gateway (which serves the embedded SPA) has rolled — so the edge 503s everything until the entire deploy is live; recovery therefore always serves the new SPA. A window.__maint.recover() hook + e2e cover the reload.
This commit is contained in:
@@ -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 page.evaluate(() => (window as unknown as { __maint: { off(): void } }).__maint.off());
|
||||||
await expect(page.getByRole('alertdialog')).toHaveCount(0);
|
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();
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type { GatewayClient } from './client';
|
|||||||
import { MockGateway } from './mock/client';
|
import { MockGateway } from './mock/client';
|
||||||
import { createTransport } from './transport';
|
import { createTransport } from './transport';
|
||||||
import { reportOffline, reportOnline } from './connection.svelte';
|
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';
|
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 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).
|
// (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),
|
on: (retryAfterMs = 15000) => reportMaintenance(retryAfterMs),
|
||||||
off: clearMaintenance,
|
off: clearMaintenance,
|
||||||
|
recover: maintenanceRecovered,
|
||||||
};
|
};
|
||||||
// Drive the auto-match opponent join deterministically from the e2e (the mock otherwise
|
// Drive the auto-match opponent join deterministically from the e2e (the mock otherwise
|
||||||
// attaches a robot on a timer).
|
// attaches a robot on a timer).
|
||||||
|
|||||||
@@ -41,7 +41,8 @@ export function reportMaintenance(retryAfterMs: number): void {
|
|||||||
if (probe) schedulePoll();
|
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 {
|
export function clearMaintenance(): void {
|
||||||
active = false;
|
active = false;
|
||||||
if (pollTimer) {
|
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
|
/** 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. */
|
* overlay itself — only a successful probe does. A no-op while a probe is in flight. */
|
||||||
export function retryNow(): void {
|
export function retryNow(): void {
|
||||||
@@ -73,7 +89,7 @@ function runProbe(): void {
|
|||||||
probe().then(
|
probe().then(
|
||||||
() => {
|
() => {
|
||||||
probing = false;
|
probing = false;
|
||||||
clearMaintenance();
|
maintenanceRecovered();
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
probing = false;
|
probing = false;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { GatewayError, type GatewayClient } from './client';
|
|||||||
import * as codec from './codec';
|
import * as codec from './codec';
|
||||||
import { browserOffset } from './profileValidation';
|
import { browserOffset } from './profileValidation';
|
||||||
import { registerProbe, reportOffline, reportOnline } from './connection.svelte';
|
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 { maintenanceRetryMs } from './maintenance';
|
||||||
import { backoffMs, isConnectionCode, retryable, toGatewayError } from './retry';
|
import { backoffMs, isConnectionCode, retryable, toGatewayError } from './retry';
|
||||||
|
|
||||||
@@ -67,7 +67,9 @@ export function createTransport(baseUrl: string): GatewayClient {
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
reportOnline();
|
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);
|
if (res.resultCode && res.resultCode !== 'ok') throw new GatewayError(res.resultCode);
|
||||||
return res.payload;
|
return res.payload;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user