mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
fix(ui): recover from stale Control UI bundles (#99111)
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
co-authored by
Peter Steinberger
parent
a0e591c863
commit
f36f3f30ea
+79
-6
@@ -209,14 +209,13 @@
|
||||
<div class="mount-fallback__panel" tabindex="-1">
|
||||
<p class="mount-fallback__eyebrow">OpenClaw Control UI</p>
|
||||
<h1 id="openclaw-mount-fallback-title">Control UI did not start</h1>
|
||||
<p>
|
||||
The browser loaded the static page, but the app bundle did not register the
|
||||
<code>openclaw-app</code> web component. A browser extension or early content script may
|
||||
be blocking module execution.
|
||||
<p id="openclaw-mount-fallback-summary">
|
||||
The browser loaded the static page, but the app bundle did not start. The gateway may be
|
||||
restarting, or this page may reference assets from a different OpenClaw version.
|
||||
</p>
|
||||
<ul>
|
||||
<li>Try again in a clean browser profile or private window.</li>
|
||||
<li>Disable extensions that run on all pages, then reload this dashboard.</li>
|
||||
<li>OpenClaw will retry the current app bundle automatically.</li>
|
||||
<li>If this persists, reload or try a clean browser profile.</li>
|
||||
<li>
|
||||
See
|
||||
<a
|
||||
@@ -249,11 +248,26 @@
|
||||
if (!app || !fallback) return;
|
||||
|
||||
var panel = fallback.querySelector(".mount-fallback__panel");
|
||||
var summary = document.getElementById("openclaw-mount-fallback-summary");
|
||||
var retry = document.getElementById("openclaw-mount-retry");
|
||||
var wait = document.getElementById("openclaw-mount-wait");
|
||||
var rawDelay = Number(fallback.getAttribute("data-openclaw-mount-timeout-ms"));
|
||||
var delay = Number.isFinite(rawDelay) && rawDelay > 0 ? rawDelay : 12000;
|
||||
var maxRecoveryAttempts = 6;
|
||||
var timer;
|
||||
var recoveryTimer;
|
||||
var recoveryAttempt = 0;
|
||||
var recoveryInFlight = false;
|
||||
var recoveryNavigation = false;
|
||||
|
||||
try {
|
||||
var initialUrl = new URL(window.location.href);
|
||||
recoveryNavigation = initialUrl.searchParams.has("openclaw_mount_recovery");
|
||||
if (recoveryNavigation) {
|
||||
initialUrl.searchParams.delete("openclaw_mount_recovery");
|
||||
window.history.replaceState(null, "", initialUrl.href);
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
function appMounted() {
|
||||
try {
|
||||
@@ -273,8 +287,64 @@
|
||||
document.body.classList.remove("openclaw-mount-fallback-active");
|
||||
}
|
||||
|
||||
function setSummary(text) {
|
||||
if (summary) summary.textContent = text;
|
||||
}
|
||||
|
||||
function scheduleRecovery() {
|
||||
window.clearTimeout(recoveryTimer);
|
||||
if (appMounted() || recoveryAttempt >= maxRecoveryAttempts) return;
|
||||
var retryDelay = Math.min(delay, 1000 * Math.pow(2, recoveryAttempt));
|
||||
recoveryTimer = window.setTimeout(retryCurrentDocument, retryDelay);
|
||||
}
|
||||
|
||||
function finishRecoveryAttempt() {
|
||||
recoveryInFlight = false;
|
||||
if (appMounted()) return;
|
||||
if (recoveryAttempt >= maxRecoveryAttempts) {
|
||||
setSummary(
|
||||
"The gateway is still unavailable. Try again, then check the troubleshooting guide if the problem persists.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
setSummary(
|
||||
"The gateway is not reachable yet. OpenClaw will keep retrying while it restarts.",
|
||||
);
|
||||
scheduleRecovery();
|
||||
}
|
||||
|
||||
function retryCurrentDocument() {
|
||||
if (
|
||||
appMounted() ||
|
||||
recoveryInFlight ||
|
||||
recoveryAttempt >= maxRecoveryAttempts ||
|
||||
typeof window.fetch !== "function"
|
||||
)
|
||||
return;
|
||||
var documentUrl = new URL(window.location.href);
|
||||
if (recoveryNavigation) {
|
||||
setSummary(
|
||||
"A fresh page still could not start the Control UI. Try again, then check the troubleshooting guide if the problem persists.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
recoveryInFlight = true;
|
||||
recoveryAttempt += 1;
|
||||
documentUrl.searchParams.set("openclaw_mount_recovery", String(Date.now()));
|
||||
window
|
||||
.fetch(documentUrl.href, { cache: "no-store", credentials: "same-origin" })
|
||||
.then(function (response) {
|
||||
if (!response.ok) throw new Error("gateway unavailable");
|
||||
window.location.replace(documentUrl.href);
|
||||
})
|
||||
.catch(function () {
|
||||
finishRecoveryAttempt();
|
||||
});
|
||||
}
|
||||
|
||||
function showFallback() {
|
||||
if (appMounted()) return;
|
||||
retryCurrentDocument();
|
||||
fallback.hidden = false;
|
||||
document.body.classList.add("openclaw-mount-fallback-active");
|
||||
if (panel && typeof panel.focus === "function") {
|
||||
@@ -297,6 +367,7 @@
|
||||
window.customElements.whenDefined(tagName).then(
|
||||
function () {
|
||||
window.clearTimeout(timer);
|
||||
window.clearTimeout(recoveryTimer);
|
||||
hideFallback();
|
||||
},
|
||||
function () {},
|
||||
@@ -311,6 +382,8 @@
|
||||
if (wait) {
|
||||
wait.addEventListener("click", function () {
|
||||
hideFallback();
|
||||
recoveryAttempt = 0;
|
||||
retryCurrentDocument();
|
||||
armFallbackTimer();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Control UI tests cover mount fallback behavior.
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const indexHtmlPath = path.resolve(
|
||||
process.cwd(),
|
||||
@@ -115,4 +115,38 @@ describe("Control UI mount fallback", () => {
|
||||
expect(fallback.hidden).toBe(true);
|
||||
expect([...frameWindow.document.body.classList]).toEqual([]);
|
||||
});
|
||||
|
||||
it("probes a cache-busted current document when the original bundle did not start", async () => {
|
||||
const frameWindow = createIsolatedWindow();
|
||||
const html = await readIndexHtmlWithDelay(1);
|
||||
const fetch = vi.fn().mockResolvedValue({ ok: false });
|
||||
Object.defineProperty(frameWindow, "fetch", { configurable: true, value: fetch });
|
||||
installFallbackShell(frameWindow, html);
|
||||
|
||||
await vi.waitFor(() => expect(fetch).toHaveBeenCalled());
|
||||
|
||||
expect(fetch).toHaveBeenNthCalledWith(1, expect.stringContaining("openclaw_mount_recovery="), {
|
||||
cache: "no-store",
|
||||
credentials: "same-origin",
|
||||
});
|
||||
});
|
||||
|
||||
it("bounds automatic recovery attempts while the gateway is unavailable", async () => {
|
||||
const frameWindow = createIsolatedWindow();
|
||||
const fetch = vi.fn().mockRejectedValue(new Error("gateway unavailable"));
|
||||
Object.defineProperty(frameWindow, "fetch", { configurable: true, value: fetch });
|
||||
installFallbackShell(frameWindow, await readIndexHtmlWithDelay(1));
|
||||
|
||||
await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(6));
|
||||
await waitForWindowTimeout(frameWindow, 10);
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(6);
|
||||
expect(
|
||||
requireElementById(
|
||||
frameWindow,
|
||||
"openclaw-mount-fallback-summary",
|
||||
frameWindow.HTMLParagraphElement,
|
||||
).textContent,
|
||||
).toContain("gateway is still unavailable");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
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 mount recovery 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("reloads a fresh document after the initial app module is unavailable", async () => {
|
||||
const artifactDir = path.resolve(".artifacts/control-ui-e2e/mount-recovery");
|
||||
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 baseUrl = new URL(server.baseUrl);
|
||||
let documentRequests = 0;
|
||||
let failedModuleRequests = 0;
|
||||
await page.route(`${baseUrl.origin}/**`, async (route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
if (request.resourceType() === "document") {
|
||||
documentRequests += 1;
|
||||
const response = await route.fetch();
|
||||
const body = (await response.text()).replace(
|
||||
'data-openclaw-mount-timeout-ms="12000"',
|
||||
'data-openclaw-mount-timeout-ms="50"',
|
||||
);
|
||||
await route.fulfill({ response, body });
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/src/main.ts" && failedModuleRequests === 0) {
|
||||
failedModuleRequests += 1;
|
||||
await route.fulfill({ body: "gateway restarting", status: 503 });
|
||||
return;
|
||||
}
|
||||
await route.continue();
|
||||
});
|
||||
await installMockGateway(page);
|
||||
|
||||
try {
|
||||
expect(
|
||||
(await page.goto(`${server.baseUrl}chat`, { waitUntil: "domcontentloaded" }))?.status(),
|
||||
).toBe(200);
|
||||
await page.locator("openclaw-app-shell").waitFor();
|
||||
await page.locator(".agent-chat__welcome").waitFor();
|
||||
|
||||
expect(documentRequests).toBe(2);
|
||||
expect(failedModuleRequests).toBe(1);
|
||||
await expect.poll(() => page.url()).not.toContain("openclaw_mount_recovery");
|
||||
await page.screenshot({ path: path.join(artifactDir, "recovered-control-ui.png") });
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user