diff --git a/ui/src/app/overlays.test.ts b/ui/src/app/overlays.test.ts index e653b119792c..b5ba79f81fc0 100644 --- a/ui/src/app/overlays.test.ts +++ b/ui/src/app/overlays.test.ts @@ -143,3 +143,25 @@ describe("application approval overlays", () => { overlays.dispose(); }); }); + +describe("application update overlays", () => { + it("surfaces a coalesced restart while reconnect verification remains active", async () => { + const request = vi.fn().mockResolvedValue({ + ok: true, + restart: { coalesced: true }, + result: { status: "ok", after: { version: "2.0.0" } }, + }); + const harness = createGatewayHarness(client(request)); + const overlays = createApplicationOverlays(harness.gateway); + + await overlays.runUpdate(); + + expect(request).toHaveBeenCalledWith("update.run", {}); + expect(overlays.snapshot.updateStatusBanner).toEqual({ + tone: "info", + text: "Update installed. A gateway restart is already in progress; status will refresh after it reconnects.", + }); + expect(overlays.snapshot.updateRunning).toBe(false); + overlays.dispose(); + }); +}); diff --git a/ui/src/app/overlays.ts b/ui/src/app/overlays.ts index 9b7f9314fbbd..95d794b11fa0 100644 --- a/ui/src/app/overlays.ts +++ b/ui/src/app/overlays.ts @@ -191,6 +191,7 @@ type UpdateRunResponse = { after?: { version?: string | null } | null; }; handoff?: { status?: string }; + restart?: { coalesced?: boolean } | null; }; export function createApplicationOverlays(gateway: ApplicationGateway): ApplicationOverlays { @@ -521,6 +522,15 @@ export function createApplicationOverlays(gateway: ApplicationGateway): Applicat if (response.ok === true && status === "ok") { pendingUpdateExpectedVersion = expectedVersion; pendingUpdateHandoff = false; + if (response.restart?.coalesced === true) { + snapshot = { + ...snapshot, + updateStatusBanner: { + tone: "info", + text: "Update installed. A gateway restart is already in progress; status will refresh after it reconnects.", + }, + }; + } return; } pendingUpdateExpectedVersion = null; diff --git a/ui/src/e2e/update-coalesced.e2e.test.ts b/ui/src/e2e/update-coalesced.e2e.test.ts new file mode 100644 index 000000000000..1feea5d99559 --- /dev/null +++ b/ui/src/e2e/update-coalesced.e2e.test.ts @@ -0,0 +1,81 @@ +import path from "node:path"; +import { chromium, type Browser } from "playwright"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + canRunPlaywrightChromium, + installMockGateway, + resolvePlaywrightChromiumExecutablePath, + startControlUiE2eServer, + type ControlUiE2eServer, +} from "../test-helpers/control-ui-e2e.ts"; + +const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath()); +const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath); +const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1"; +const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip; + +let browser: Browser; +let server: ControlUiE2eServer; + +describeControlUiE2e("Control UI coalesced update E2E", () => { + beforeAll(async () => { + if (!chromiumAvailable) { + throw new Error(`Playwright Chromium is unavailable at ${chromiumExecutablePath}`); + } + server = await startControlUiE2eServer(); + browser = await chromium.launch({ executablePath: chromiumExecutablePath }); + }); + + afterAll(async () => { + await browser?.close(); + await server?.close(); + }); + + it("shows coalesced restart feedback after the Update click", async () => { + const artifactDir = path.resolve(".artifacts/control-ui-e2e/update-coalesced"); + const context = await browser.newContext({ + locale: "en-US", + recordVideo: { dir: artifactDir, size: { height: 720, width: 1280 } }, + serviceWorkers: "block", + viewport: { height: 720, width: 1280 }, + }); + const page = await context.newPage(); + const pageErrors: string[] = []; + page.on("pageerror", (error) => pageErrors.push(String(error))); + const gateway = await installMockGateway(page, { + methodResponses: { + "update.run": { + ok: true, + restart: { coalesced: true }, + result: { after: { version: "2.0.0" }, status: "ok" }, + }, + }, + }); + + try { + expect((await page.goto(`${server.baseUrl}chat`))?.status()).toBe(200); + await gateway.emitGatewayEvent("update.available", { + updateAvailable: { + channel: "stable", + currentVersion: "1.0.0", + latestVersion: "2.0.0", + }, + }); + + await page.getByRole("button", { name: "Update now" }).click(); + await page + .getByText( + "Update installed. A gateway restart is already in progress; status will refresh after it reconnects.", + { exact: true }, + ) + .waitFor(); + + expect(await gateway.getRequests("update.run")).toHaveLength(1); + expect(await page.getByRole("button", { name: "Update now" }).isEnabled()).toBe(true); + expect(pageErrors).toEqual([]); + await page.screenshot({ path: path.join(artifactDir, "coalesced-restart-banner.png") }); + } finally { + await context.close(); + } + }); +});