mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-18 05:15:50 +00:00
fix(ui): show recovery when Control UI does not render (#124861)
* fix(ui): retire bootstrap fallback after render * test(ui): avoid returning from Promise executor
This commit is contained in:
parent
358c06ec95
commit
eadde4a9d0
6 changed files with 235 additions and 36 deletions
|
|
@ -754,7 +754,7 @@ Then point the UI at your Gateway WS URL (e.g. `ws://127.0.0.1:18789`).
|
|||
|
||||
## Blank Control UI page
|
||||
|
||||
If the browser loads a blank dashboard and DevTools shows no useful error, an extension or early content script may have prevented the JavaScript module app from evaluating. The static page includes a plain HTML recovery panel that appears when `<openclaw-app>` is not registered after startup.
|
||||
If the browser loads a blank dashboard and DevTools shows no useful error, an extension or early content script may have prevented the JavaScript module app from evaluating. The static page includes a plain HTML recovery panel that appears when `<openclaw-app>` does not complete its first render after startup.
|
||||
|
||||
Use the panel's **Try again** action after changing the browser environment, or reload manually after these checks:
|
||||
|
||||
|
|
|
|||
|
|
@ -313,8 +313,7 @@
|
|||
</section>
|
||||
<script>
|
||||
(function () {
|
||||
var tagName = "openclaw-app";
|
||||
var app = document.querySelector(tagName);
|
||||
var app = document.querySelector("openclaw-app");
|
||||
var fallback = document.getElementById("openclaw-mount-fallback");
|
||||
if (!app || !fallback) return;
|
||||
|
||||
|
|
@ -330,6 +329,7 @@
|
|||
var recoveryAttempt = 0;
|
||||
var recoveryInFlight = false;
|
||||
var recoveryNavigation = false;
|
||||
var appStarted = false;
|
||||
|
||||
try {
|
||||
var initialUrl = new URL(window.location.href);
|
||||
|
|
@ -340,19 +340,6 @@
|
|||
}
|
||||
} catch (e) {}
|
||||
|
||||
function appMounted() {
|
||||
try {
|
||||
return Boolean(
|
||||
app.isConnected &&
|
||||
window.customElements &&
|
||||
typeof window.customElements.get === "function" &&
|
||||
window.customElements.get(tagName),
|
||||
);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function hideFallback() {
|
||||
fallback.hidden = true;
|
||||
document.body.classList.remove("openclaw-mount-fallback-active");
|
||||
|
|
@ -364,14 +351,14 @@
|
|||
|
||||
function scheduleRecovery() {
|
||||
window.clearTimeout(recoveryTimer);
|
||||
if (appMounted() || recoveryAttempt >= maxRecoveryAttempts) return;
|
||||
if (appStarted || 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 (appStarted) return;
|
||||
if (recoveryAttempt >= maxRecoveryAttempts) {
|
||||
setSummary(
|
||||
"The gateway is still unavailable. Try again, then check the troubleshooting guide if the problem persists.",
|
||||
|
|
@ -386,7 +373,7 @@
|
|||
|
||||
function retryCurrentDocument() {
|
||||
if (
|
||||
appMounted() ||
|
||||
appStarted ||
|
||||
recoveryInFlight ||
|
||||
recoveryAttempt >= maxRecoveryAttempts ||
|
||||
typeof window.fetch !== "function"
|
||||
|
|
@ -414,7 +401,7 @@
|
|||
})
|
||||
.then(function (response) {
|
||||
if (!response.ok) throw new Error("gateway unavailable");
|
||||
window.location.replace(documentUrl.href);
|
||||
if (!appStarted) window.location.replace(documentUrl.href);
|
||||
})
|
||||
.catch(function () {
|
||||
finishRecoveryAttempt();
|
||||
|
|
@ -425,7 +412,7 @@
|
|||
}
|
||||
|
||||
function showFallback() {
|
||||
if (appMounted()) return;
|
||||
if (appStarted) return;
|
||||
retryCurrentDocument();
|
||||
fallback.hidden = false;
|
||||
document.body.classList.add("openclaw-mount-fallback-active");
|
||||
|
|
@ -445,16 +432,16 @@
|
|||
|
||||
armFallbackTimer();
|
||||
|
||||
if (window.customElements && typeof window.customElements.whenDefined === "function") {
|
||||
window.customElements.whenDefined(tagName).then(
|
||||
function () {
|
||||
window.clearTimeout(timer);
|
||||
window.clearTimeout(recoveryTimer);
|
||||
hideFallback();
|
||||
},
|
||||
function () {},
|
||||
);
|
||||
}
|
||||
window.addEventListener(
|
||||
"openclaw-control-ui-rendered",
|
||||
function () {
|
||||
appStarted = true;
|
||||
window.clearTimeout(timer);
|
||||
window.clearTimeout(recoveryTimer);
|
||||
hideFallback();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
|
||||
if (retry) {
|
||||
retry.addEventListener("click", function () {
|
||||
|
|
|
|||
|
|
@ -151,6 +151,12 @@ export class OpenClawApp extends OpenClawLightDomElement {
|
|||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
protected override firstUpdated(): void {
|
||||
if (this.runtime) {
|
||||
globalThis.dispatchEvent(new Event("openclaw-control-ui-rendered"));
|
||||
}
|
||||
}
|
||||
|
||||
private synchronizeGateway(gateway: ApplicationContext["gateway"]) {
|
||||
const sourceChanged = gateway !== this.loginGatewaySource;
|
||||
if (sourceChanged) {
|
||||
|
|
|
|||
|
|
@ -114,9 +114,8 @@ describe("Control UI mount fallback", () => {
|
|||
},
|
||||
);
|
||||
|
||||
it("shows the static troubleshooting panel when the app element is never registered", async () => {
|
||||
it("shows the static troubleshooting panel when the app never renders", async () => {
|
||||
const frameWindow = createIsolatedWindow();
|
||||
expect(frameWindow.customElements.get("openclaw-app")).toBeUndefined();
|
||||
installFallbackShell(frameWindow, await readIndexHtmlWithDelay(1));
|
||||
await waitForWindowTimeout(frameWindow, 10);
|
||||
|
||||
|
|
@ -147,20 +146,25 @@ describe("Control UI mount fallback", () => {
|
|||
expect(fallback.hidden).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the fallback hidden when the app element registers before the timeout", async () => {
|
||||
it("keeps the fallback visible until the app completes its first render", async () => {
|
||||
const frameWindow = createIsolatedWindow();
|
||||
installFallbackShell(frameWindow, await readIndexHtmlWithDelay(25));
|
||||
installFallbackShell(frameWindow, await readIndexHtmlWithDelay(1));
|
||||
if (!frameWindow.customElements.get("openclaw-app")) {
|
||||
frameWindow.customElements.define("openclaw-app", class extends frameWindow.HTMLElement {});
|
||||
}
|
||||
await frameWindow.customElements.whenDefined("openclaw-app");
|
||||
await waitForWindowTimeout(frameWindow, 35);
|
||||
await waitForWindowTimeout(frameWindow, 10);
|
||||
|
||||
const fallback = requireElementById(
|
||||
frameWindow,
|
||||
"openclaw-mount-fallback",
|
||||
frameWindow.HTMLElement,
|
||||
);
|
||||
expect(fallback.hidden).toBe(false);
|
||||
expect([...frameWindow.document.body.classList]).toEqual(["openclaw-mount-fallback-active"]);
|
||||
|
||||
frameWindow.dispatchEvent(new frameWindow.Event("openclaw-control-ui-rendered"));
|
||||
|
||||
expect(fallback.hidden).toBe(true);
|
||||
expect([...frameWindow.document.body.classList]).toEqual([]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -322,6 +322,45 @@ suite.define(() => {
|
|||
}
|
||||
});
|
||||
|
||||
it("retires the static startup fallback after rendering auth-required guidance", async () => {
|
||||
const context = await suite.browser.newContext({ viewport: { height: 900, width: 1280 } });
|
||||
const page = await context.newPage();
|
||||
await page.addInitScript(() => {
|
||||
window.addEventListener("openclaw-control-ui-rendered", () => {
|
||||
const key = "openclaw.control-ui-e2e.render-count";
|
||||
const count = Number.parseInt(sessionStorage.getItem(key) ?? "0", 10);
|
||||
sessionStorage.setItem(key, String(count + 1));
|
||||
});
|
||||
});
|
||||
await page.clock.install();
|
||||
const gateway = await installMockGateway(page, { deferredMethods: ["connect"] });
|
||||
|
||||
try {
|
||||
await page.goto(suite.server.baseUrl);
|
||||
await gateway.waitForRequest("connect");
|
||||
await gateway.rejectDeferred("connect", {
|
||||
code: "INVALID_REQUEST",
|
||||
message: "token missing",
|
||||
details: { code: ConnectErrorDetailCodes.AUTH_TOKEN_MISSING },
|
||||
});
|
||||
|
||||
const authRequired = page.locator('.login-gate__failure[data-kind="auth-required"]');
|
||||
await authRequired.waitFor({ timeout: 10_000 });
|
||||
await page.clock.runFor(12_001);
|
||||
|
||||
expect(await authRequired.isVisible()).toBe(true);
|
||||
expect(await page.locator("#openclaw-mount-fallback").isHidden()).toBe(true);
|
||||
expect((await page.locator("body").getAttribute("class")) ?? "").not.toContain(
|
||||
"openclaw-mount-fallback-active",
|
||||
);
|
||||
expect(
|
||||
await page.evaluate(() => sessionStorage.getItem("openclaw.control-ui-e2e.render-count")),
|
||||
).toBe("1");
|
||||
} finally {
|
||||
await closeContext(context);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps mobile controls compact, touchable, and keyboard-friendly", async () => {
|
||||
const context = await suite.browser.newContext({
|
||||
hasTouch: true,
|
||||
|
|
|
|||
163
ui/src/e2e/mount-fallback.e2e.test.ts
Normal file
163
ui/src/e2e/mount-fallback.e2e.test.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import { readFile } from "node:fs/promises";
|
||||
import { createServer } from "node:http";
|
||||
import path from "node:path";
|
||||
import type { Page } from "playwright";
|
||||
import { expect, it } from "vitest";
|
||||
import { pauseVirtualClock, type ControlUiE2eServer } from "../test-helpers/control-ui-e2e.ts";
|
||||
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
|
||||
|
||||
const indexHtmlPath = path.resolve(
|
||||
process.cwd(),
|
||||
path.basename(process.cwd()) === "ui" ? "index.html" : "ui/index.html",
|
||||
);
|
||||
const renderEvent = "openclaw-control-ui-rendered";
|
||||
const loadCountKey = "openclaw.control-ui-e2e.mount-fallback-loads";
|
||||
const renderCountKey = "openclaw.control-ui-e2e.mount-fallback-renders";
|
||||
let syntheticModuleRenders = false;
|
||||
|
||||
async function startRegisteredElementFixture(): Promise<ControlUiE2eServer> {
|
||||
const indexHtml = await readFile(indexHtmlPath, "utf8");
|
||||
const server = createServer((request, response) => {
|
||||
const requestUrl = new URL(request.url ?? "/", "http://127.0.0.1");
|
||||
if (requestUrl.pathname === "/src/main.ts") {
|
||||
response.setHeader("content-type", "text/javascript; charset=utf-8");
|
||||
response.end(
|
||||
syntheticModuleRenders
|
||||
? `customElements.define("openclaw-app", class extends HTMLElement {
|
||||
connectedCallback() {
|
||||
this.textContent = "Application rendered";
|
||||
window.dispatchEvent(new Event(${JSON.stringify(renderEvent)}));
|
||||
}
|
||||
});`
|
||||
: 'customElements.define("openclaw-app", class extends HTMLElement {});',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (requestUrl.pathname === "/") {
|
||||
response.setHeader("content-type", "text/html; charset=utf-8");
|
||||
response.end(indexHtml);
|
||||
return;
|
||||
}
|
||||
response.statusCode = 404;
|
||||
response.end();
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("Registered-element fixture did not acquire a loopback port");
|
||||
}
|
||||
return {
|
||||
baseUrl: `http://127.0.0.1:${address.port}/`,
|
||||
close: () =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async function addLifecycleCounters(page: Page): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({ loadKey, renderedEvent, renderKey }) => {
|
||||
const loadCount = Number.parseInt(sessionStorage.getItem(loadKey) ?? "0", 10);
|
||||
sessionStorage.setItem(loadKey, String(loadCount + 1));
|
||||
window.addEventListener(renderedEvent, () => {
|
||||
const renderCount = Number.parseInt(sessionStorage.getItem(renderKey) ?? "0", 10);
|
||||
sessionStorage.setItem(renderKey, String(renderCount + 1));
|
||||
});
|
||||
},
|
||||
{ loadKey: loadCountKey, renderedEvent: renderEvent, renderKey: renderCountKey },
|
||||
);
|
||||
}
|
||||
|
||||
const registeredElementSuite = createControlUiE2eSuite({
|
||||
name: "Control UI static mount fallback E2E",
|
||||
startServer: startRegisteredElementFixture,
|
||||
startServerBeforeBrowser: true,
|
||||
});
|
||||
|
||||
registeredElementSuite.define(() => {
|
||||
it("shows the fallback when registration never produces an application render", async () => {
|
||||
syntheticModuleRenders = false;
|
||||
await registeredElementSuite.withPage(
|
||||
{ serviceWorkers: "block", viewport: { height: 900, width: 1280 } },
|
||||
async ({ page }) => {
|
||||
await addLifecycleCounters(page);
|
||||
await page.clock.install();
|
||||
await pauseVirtualClock(page);
|
||||
await page.goto(registeredElementSuite.server.baseUrl, { waitUntil: "domcontentloaded" });
|
||||
await page.waitForFunction(() => customElements.get("openclaw-app") !== undefined);
|
||||
|
||||
expect(await page.locator("openclaw-app").textContent()).toBe("");
|
||||
expect(
|
||||
await page.evaluate((key) => sessionStorage.getItem(key), renderCountKey),
|
||||
).toBeNull();
|
||||
|
||||
await page.clock.runFor(12_001);
|
||||
await page.waitForFunction((key) => sessionStorage.getItem(key) === "2", loadCountKey);
|
||||
await page.clock.runFor(12_001);
|
||||
|
||||
await page.getByRole("heading", { name: "Control UI did not start" }).waitFor();
|
||||
expect(await page.getByRole("button", { name: "Try again" }).isVisible()).toBe(true);
|
||||
expect(await page.getByRole("button", { name: "Keep waiting" }).isVisible()).toBe(true);
|
||||
expect(
|
||||
await page.evaluate((key) => sessionStorage.getItem(key), renderCountKey),
|
||||
).toBeNull();
|
||||
|
||||
syntheticModuleRenders = true;
|
||||
await Promise.all([
|
||||
page.waitForNavigation({ waitUntil: "domcontentloaded" }),
|
||||
page.getByRole("button", { name: "Try again" }).click(),
|
||||
]);
|
||||
await page.getByText("Application rendered", { exact: true }).waitFor();
|
||||
await page.clock.runFor(12_001);
|
||||
|
||||
expect(await page.locator("#openclaw-mount-fallback").isHidden()).toBe(true);
|
||||
expect(await page.evaluate((key) => sessionStorage.getItem(key), renderCountKey)).toBe("1");
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const runtimeFailureSuite = createControlUiE2eSuite({
|
||||
name: "Control UI failed runtime mount E2E",
|
||||
startServerBeforeBrowser: true,
|
||||
});
|
||||
|
||||
runtimeFailureSuite.define(() => {
|
||||
it("does not complete startup when application runtime creation throws", async () => {
|
||||
await runtimeFailureSuite.withPage(
|
||||
{ serviceWorkers: "block", viewport: { height: 900, width: 1280 } },
|
||||
async ({ page }) => {
|
||||
await addLifecycleCounters(page);
|
||||
await page.addInitScript(() => {
|
||||
const browserGetComputedStyle = globalThis.getComputedStyle.bind(globalThis);
|
||||
Object.defineProperty(globalThis, "getComputedStyle", {
|
||||
configurable: true,
|
||||
value: (element: Element, pseudoElement?: string | null) => {
|
||||
if (customElements.get("openclaw-app")) {
|
||||
throw new Error("forced application runtime creation failure");
|
||||
}
|
||||
return browserGetComputedStyle(element, pseudoElement);
|
||||
},
|
||||
});
|
||||
});
|
||||
await page.clock.install();
|
||||
await pauseVirtualClock(page);
|
||||
await page.goto(runtimeFailureSuite.server.baseUrl, { waitUntil: "domcontentloaded" });
|
||||
await page.waitForFunction(() => customElements.get("openclaw-app") !== undefined);
|
||||
|
||||
await page.clock.runFor(12_001);
|
||||
await page.waitForFunction((key) => sessionStorage.getItem(key) === "2", loadCountKey);
|
||||
await page.clock.runFor(12_001);
|
||||
|
||||
await page.getByRole("heading", { name: "Control UI did not start" }).waitFor();
|
||||
expect(
|
||||
await page.evaluate((key) => sessionStorage.getItem(key), renderCountKey),
|
||||
).toBeNull();
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue