diff --git a/ui/src/app/stale-chunk-reload.ts b/ui/src/app/stale-chunk-reload.ts index b0645db53434..d44c3bc4d8a8 100644 --- a/ui/src/app/stale-chunk-reload.ts +++ b/ui/src/app/stale-chunk-reload.ts @@ -50,7 +50,10 @@ export function isStaleChunkImportError(error: unknown): boolean { } function reloadControlUiDocument(): void { - window.location.reload(); + const url = new URL(window.location.href); + // The pre-app mount recovery strips this one-shot cache buster before bootstrap. + url.searchParams.set("openclaw_mount_recovery", String(Date.now())); + window.location.replace(url.href); } function sessionStorageOrNull(): Pick | null { diff --git a/ui/src/e2e/locale-offline-retry.e2e.test.ts b/ui/src/e2e/locale-offline-retry.e2e.test.ts index fe0ad4913b61..f6bc6160ce37 100644 --- a/ui/src/e2e/locale-offline-retry.e2e.test.ts +++ b/ui/src/e2e/locale-offline-retry.e2e.test.ts @@ -100,14 +100,14 @@ suite.define(() => { await route.abort("internetdisconnected"); }; await page.route(frenchLocaleModule, abortFrenchLocale); - let navigationCount = 0; + let documentRequestCount = 0; try { const response = await page.goto(`${suite.server.baseUrl}settings/appearance`); expect(response?.status()).toBe(200); - page.on("framenavigated", (frame) => { - if (frame === page.mainFrame()) { - navigationCount += 1; + page.on("request", (request) => { + if (request.resourceType() === "document") { + documentRequestCount += 1; } }); await page.locator(".settings-row__title", { hasText: "Language" }).waitFor(); @@ -128,7 +128,7 @@ suite.define(() => { await page.locator(".settings-row__title", { hasText: "Language" }).waitFor(); await page.locator(".settings-page").waitFor(); expect(await documentMarker(page)).toBe("same-document"); - expect(navigationCount).toBe(0); + expect(documentRequestCount).toBe(0); await page.unroute(frenchLocaleModule, abortFrenchLocale); await reconnect(page, gateway); @@ -137,14 +137,14 @@ suite.define(() => { .locator(".settings-row__title", { hasText: "Langue" }) .waitFor({ timeout: 10_000 }); expect(await documentMarker(page)).toBeUndefined(); - expect(navigationCount).toBe(1); + expect(documentRequestCount).toBe(1); expect( await page.evaluate(() => sessionStorage.getItem("openclaw.controlUi.staleChunkReloadBuildId"), ), ).toBe("e2e"); await page.waitForTimeout(500); - expect(navigationCount).toBe(1); + expect(documentRequestCount).toBe(1); expect(new URL(page.url()).pathname).toBe("/settings/appearance"); } finally { await page.unroute(frenchLocaleModule, abortFrenchLocale); diff --git a/ui/src/e2e/login-gate.e2e.test.ts b/ui/src/e2e/login-gate.e2e.test.ts index b66fdd33e7c5..7db43024e487 100644 --- a/ui/src/e2e/login-gate.e2e.test.ts +++ b/ui/src/e2e/login-gate.e2e.test.ts @@ -59,6 +59,62 @@ async function closeContext(context: BrowserContext): Promise { } suite.define(() => { + it("cache-busts stale-build recovery on a first dashboard navigation", async () => { + const context = await suite.browser.newContext({ + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const documentRequests: Array<{ fresh: boolean; pathname: string }> = []; + const appOrigin = new URL(suite.server.baseUrl).origin; + await page.route(`${appOrigin}/**`, async (route) => { + const request = route.request(); + if (request.resourceType() === "document") { + const url = new URL(request.url()); + documentRequests.push({ + fresh: url.searchParams.has("openclaw_mount_recovery"), + pathname: url.pathname, + }); + } + await route.continue(); + }); + const gateway = await installMockGateway(page, { + deferredMethods: ["connect"], + sessionKey: "agent:example-agent:example-session", + }); + const mismatch = { + code: "UNAVAILABLE", + message: "Control UI updated; reload this page to continue", + details: { + code: ConnectErrorDetailCodes.CONTROL_UI_BUILD_MISMATCH, + gatewayBuildId: "replacement-build", + reloadRequired: true, + }, + retryable: false, + }; + const target = new URL("dashboard/example-agent/example-session", suite.server.baseUrl); + + try { + await page.goto(target.href); + await gateway.waitForRequest("connect"); + await gateway.rejectDeferred("connect", mismatch); + + await expect.poll(() => documentRequests.length).toBe(2); + await gateway.waitForRequest("connect"); + expect(documentRequests).toEqual([ + { fresh: false, pathname: target.pathname }, + { fresh: true, pathname: target.pathname }, + ]); + await gateway.resolveDeferred("connect"); + + await page.locator("openclaw-app-shell").waitFor(); + expect(await page.locator("openclaw-login-gate").count()).toBe(0); + await expect.poll(() => page.url()).toBe(target.href); + } finally { + await closeContext(context); + } + }); + it("reloads once for a build rejection, then keeps visible recovery guidance", async () => { const context = await suite.browser.newContext({ viewport: { height: 900, width: 1280 } }); const page = await context.newPage(); diff --git a/ui/src/e2e/plugin-bundled-view-recovery.e2e.test.ts b/ui/src/e2e/plugin-bundled-view-recovery.e2e.test.ts index fd47f507d730..0c71ff5f1514 100644 --- a/ui/src/e2e/plugin-bundled-view-recovery.e2e.test.ts +++ b/ui/src/e2e/plugin-bundled-view-recovery.e2e.test.ts @@ -100,10 +100,10 @@ suite.define(() => { path: path.join(artifactDir, "failure.png"), }); - let navigationCount = 0; - page.on("framenavigated", (frame) => { - if (frame === page.mainFrame()) { - navigationCount += 1; + let documentRequestCount = 0; + page.on("request", (request) => { + if (request.resourceType() === "document") { + documentRequestCount += 1; } }); markDocumentReachable(); @@ -112,13 +112,13 @@ suite.define(() => { await alert.waitFor(); await page.waitForTimeout(500); expect(await alert.count()).toBe(1); - expect(navigationCount).toBe(1); + expect(documentRequestCount).toBe(1); await alert.getByRole("button", { name: "Reload" }).click(); await page.locator(".logbook").waitFor(); expect(await alert.count()).toBe(0); expect(assetRequests).toBeGreaterThan(2); - expect(navigationCount).toBe(2); + expect(documentRequestCount).toBe(2); await gateway.waitForRequest("logbook.status"); await page.screenshot({ fullPage: true,