mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 13:26:04 -06:00
fix(ui): stop stale clients from acting connected (#124772)
* fix(ui): stop stale clients from acting connected Carry reload metadata on terminal Control UI build rejections, render an explicit refresh-required recovery state, fence reconnect-only actions, and surface disconnected approval failures with accessible modal controls. * fix(ui): preserve reconnecting session drafts * fix(ui): preserve offline preference intent * fix(ui): return owned fallback digest bytes * test(ui): follow passive approval presentation * fix(ui): distinguish stale builds from protocol mismatches * style(ui): format gateway recovery phase
This commit is contained in:
committed by
GitHub
parent
8277cb24a1
commit
486b272e8c
@@ -45,10 +45,13 @@ describe("readConnectErrorDetailCode", () => {
|
||||
});
|
||||
|
||||
describe("readControlUiBuildMismatchId", () => {
|
||||
it("returns a bounded reload target", () => {
|
||||
it.each([
|
||||
ConnectErrorDetailCodes.PROTOCOL_MISMATCH,
|
||||
ConnectErrorDetailCodes.CONTROL_UI_BUILD_MISMATCH,
|
||||
])("returns a bounded reload target for %s", (code) => {
|
||||
expect(
|
||||
readControlUiBuildMismatchId({
|
||||
code: ConnectErrorDetailCodes.CONTROL_UI_BUILD_MISMATCH,
|
||||
code,
|
||||
gatewayBuildId: "gateway-build",
|
||||
reloadRequired: true,
|
||||
}),
|
||||
|
||||
@@ -232,7 +232,11 @@ export function readConnectErrorDetailCode(details: unknown): string | null {
|
||||
|
||||
/** Read the exact target artifact from an untrusted reload-required rejection. */
|
||||
export function readControlUiBuildMismatchId(details: unknown): string | null {
|
||||
if (readConnectErrorDetailCode(details) !== ConnectErrorDetailCodes.CONTROL_UI_BUILD_MISMATCH) {
|
||||
const code = readConnectErrorDetailCode(details);
|
||||
if (
|
||||
code !== ConnectErrorDetailCodes.PROTOCOL_MISMATCH &&
|
||||
code !== ConnectErrorDetailCodes.CONTROL_UI_BUILD_MISMATCH
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const raw = details as { gatewayBuildId?: unknown; reloadRequired?: unknown };
|
||||
|
||||
@@ -332,7 +332,11 @@ export async function attachAuthenticatedGatewayConnect(
|
||||
});
|
||||
sendHandshakeErrorResponse(ErrorCodes.UNAVAILABLE, message, {
|
||||
retryable: false,
|
||||
details: { code: ConnectErrorDetailCodes.PROTOCOL_MISMATCH },
|
||||
details: {
|
||||
code: ConnectErrorDetailCodes.PROTOCOL_MISMATCH,
|
||||
gatewayBuildId: controlUiBuildMismatch.gatewayBuildId,
|
||||
reloadRequired: true,
|
||||
},
|
||||
});
|
||||
logWsControl.warn(
|
||||
`control ui build rejected conn=${connId} clientBuild=${formatForLog(controlUiBuildMismatch.clientBuildId ?? "legacy")} gatewayBuild=${formatForLog(controlUiBuildMismatch.gatewayBuildId)}; reload required`,
|
||||
|
||||
+14
-2
@@ -5,7 +5,10 @@ import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { WebSocket, WebSocketServer } from "ws";
|
||||
import { ConnectErrorDetailCodes } from "../../../../packages/gateway-protocol/src/connect-error-details.js";
|
||||
import {
|
||||
ConnectErrorDetailCodes,
|
||||
readControlUiBuildMismatchId,
|
||||
} from "../../../../packages/gateway-protocol/src/connect-error-details.js";
|
||||
import { ErrorCodes, PROTOCOL_VERSION } from "../../../../packages/gateway-protocol/src/index.js";
|
||||
import { rawDataToString } from "../../../infra/ws.js";
|
||||
import type { GatewayRequestContext } from "../../server-methods/types.js";
|
||||
@@ -290,9 +293,18 @@ describe("Control UI build admission over WebSocket", () => {
|
||||
code: ErrorCodes.UNAVAILABLE,
|
||||
message: "protocol mismatch: Control UI updated; reload this page to continue",
|
||||
retryable: false,
|
||||
details: { code: ConnectErrorDetailCodes.PROTOCOL_MISMATCH },
|
||||
details: {
|
||||
code: ConnectErrorDetailCodes.PROTOCOL_MISMATCH,
|
||||
gatewayBuildId: "gateway-build",
|
||||
reloadRequired: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(
|
||||
readControlUiBuildMismatchId(
|
||||
(rejection.error as { details?: unknown } | undefined)?.details,
|
||||
),
|
||||
).toBe("gateway-build");
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
|
||||
@@ -287,8 +287,9 @@ export class OpenClawApp extends OpenClawLightDomElement {
|
||||
</openclaw-tooltip-provider>
|
||||
`;
|
||||
}
|
||||
const showLoginGate =
|
||||
!gatewayConnected && (this.loginGatePinned || gatewaySnapshot.phase !== "reconnecting");
|
||||
const shellOwnsRecovery =
|
||||
gatewaySnapshot.phase === "reconnecting" || gatewaySnapshot.phase === "reload-required";
|
||||
const showLoginGate = !gatewayConnected && !shellOwnsRecovery;
|
||||
if (showLoginGate) {
|
||||
return html`
|
||||
<openclaw-tooltip-provider>
|
||||
|
||||
@@ -274,6 +274,15 @@ export function renderApplicationShell(host: ShellViewHost) {
|
||||
const custodianPanelAvailable =
|
||||
gatewayConnected && isGatewayMethodAdvertised(gatewaySnapshot, "openclaw.chat") === true;
|
||||
const activeRoute = host.routeState.routeId ?? "chat";
|
||||
// Chat has an offline outbox, New Session keeps a local draft, and Appearance
|
||||
// persists local preference intent for replay. Their server actions are
|
||||
// independently gated; other pages cannot submit useful disconnected work.
|
||||
const pageActionsBlocked =
|
||||
gatewaySnapshot.phase === "reload-required" ||
|
||||
(!gatewayConnected &&
|
||||
activeRoute !== "chat" &&
|
||||
activeRoute !== "new-session" &&
|
||||
activeRoute !== "appearance");
|
||||
// Plugin tabs share one route; the search picks the active item.
|
||||
const activePluginRef =
|
||||
activeRoute === "plugin"
|
||||
@@ -595,7 +604,14 @@ export function renderApplicationShell(host: ShellViewHost) {
|
||||
onRefresh: () => host.refreshControlUi(),
|
||||
onHoldUpdate: () => context.overlays.holdUpdate(),
|
||||
})}
|
||||
${pageActionsBlocked && gatewaySnapshot.phase !== "reload-required"
|
||||
? html`<div class="connection-action-block" role="status" aria-live="polite">
|
||||
${t("connection.actionsUnavailable")}
|
||||
</div>`
|
||||
: nothing}
|
||||
<openclaw-router-outlet
|
||||
?inert=${pageActionsBlocked}
|
||||
aria-disabled=${pageActionsBlocked ? "true" : nothing}
|
||||
.router=${runtime.router}
|
||||
.retryContext=${context}
|
||||
.onNotFound=${() => host.replaceChatWithCurrentSession()}
|
||||
|
||||
@@ -547,6 +547,51 @@ describe("createApplicationGateway connection phase", () => {
|
||||
expect(gateway.snapshot.phase).toBe("offline");
|
||||
});
|
||||
|
||||
it("schedules the guarded reload and publishes a terminal phase for a stale build", () => {
|
||||
const { gateway, current } = createStore();
|
||||
gateway.start();
|
||||
|
||||
current().opts.onClose?.({
|
||||
code: 1008,
|
||||
reason: "protocol mismatch: Control UI updated; reload this page to continue",
|
||||
error: {
|
||||
code: "UNAVAILABLE",
|
||||
message: "protocol mismatch: Control UI updated; reload this page to continue",
|
||||
details: {
|
||||
code: ConnectErrorDetailCodes.PROTOCOL_MISMATCH,
|
||||
gatewayBuildId: "replacement-build",
|
||||
reloadRequired: true,
|
||||
},
|
||||
},
|
||||
willRetry: false,
|
||||
});
|
||||
|
||||
expect(scheduleStaleChunkReloadMock).toHaveBeenCalledExactlyOnceWith({
|
||||
buildId: "replacement-build",
|
||||
});
|
||||
expect(gateway.snapshot.phase).toBe("reload-required");
|
||||
});
|
||||
|
||||
it("keeps a bare protocol mismatch in the login-gate path", () => {
|
||||
const { gateway, current } = createStore();
|
||||
gateway.start();
|
||||
|
||||
current().opts.onClose?.({
|
||||
code: 1008,
|
||||
reason: "protocol mismatch",
|
||||
error: {
|
||||
code: "INVALID_REQUEST",
|
||||
message: "protocol mismatch",
|
||||
details: { code: ConnectErrorDetailCodes.PROTOCOL_MISMATCH },
|
||||
},
|
||||
willRetry: false,
|
||||
});
|
||||
|
||||
expect(scheduleStaleChunkReloadMock).not.toHaveBeenCalled();
|
||||
expect(gateway.snapshot.phase).toBe("stopped");
|
||||
expect(gateway.snapshot.lastErrorCode).toBe(ConnectErrorDetailCodes.PROTOCOL_MISMATCH);
|
||||
});
|
||||
|
||||
it("keeps reconnecting across event-gap recovery with a fresh client", () => {
|
||||
const { gateway, clients, current } = createStore();
|
||||
gateway.start();
|
||||
|
||||
@@ -437,23 +437,27 @@ export function createApplicationGateway(
|
||||
if (mismatchedBuildId) {
|
||||
void scheduleStaleChunkReload({ buildId: mismatchedBuildId });
|
||||
}
|
||||
const lastErrorCode = resolveGatewayErrorDetailCode(error) ?? error?.code ?? null;
|
||||
setSnapshot({
|
||||
...snapshot,
|
||||
client: nextClient,
|
||||
phase: everConnected
|
||||
? willRetry
|
||||
? "reconnecting"
|
||||
: "offline"
|
||||
: willRetry
|
||||
? "connecting"
|
||||
: "stopped",
|
||||
phase:
|
||||
mismatchedBuildId !== null
|
||||
? "reload-required"
|
||||
: everConnected
|
||||
? willRetry
|
||||
? "reconnecting"
|
||||
: "offline"
|
||||
: willRetry
|
||||
? "connecting"
|
||||
: "stopped",
|
||||
hello: null,
|
||||
canvasPluginSurfaceUrl: null,
|
||||
selfUser: null,
|
||||
lastError: error?.message
|
||||
? formatUiError(error.message)
|
||||
: `disconnected (${code}): ${formatUiExternalText(reason, t("common.unknown"))}`,
|
||||
lastErrorCode: resolveGatewayErrorDetailCode(error) ?? error?.code ?? null,
|
||||
lastErrorCode,
|
||||
});
|
||||
},
|
||||
onGap: ({ expected, received }) => {
|
||||
|
||||
@@ -8,6 +8,7 @@ export type ApplicationGatewayPhase =
|
||||
| "connecting"
|
||||
| "connected"
|
||||
| "reconnecting"
|
||||
| "reload-required"
|
||||
| "offline";
|
||||
|
||||
export type ApplicationGatewaySnapshot = {
|
||||
|
||||
@@ -121,6 +121,9 @@ export function createGatewayHarness(
|
||||
},
|
||||
gateway,
|
||||
connect,
|
||||
replaceSnapshotWithoutPublishing(next: Partial<ApplicationGatewaySnapshot>) {
|
||||
snapshot = { ...snapshot, ...next };
|
||||
},
|
||||
update(next: Partial<ApplicationGatewaySnapshot>) {
|
||||
snapshot = { ...snapshot, ...next };
|
||||
for (const listener of snapshotListeners) {
|
||||
|
||||
@@ -60,6 +60,21 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("Control UI refresh nudge", () => {
|
||||
it("flags a terminal build rejection without requiring a hello", () => {
|
||||
const gatewayClient = client(async () => []);
|
||||
const harness = createGatewayHarness(null, false);
|
||||
const overlays = createApplicationOverlays(harness.gateway);
|
||||
|
||||
harness.update({
|
||||
client: gatewayClient,
|
||||
phase: "reload-required",
|
||||
hello: null,
|
||||
});
|
||||
|
||||
expect(overlays.snapshot.controlUiRefreshRequired).toBe(true);
|
||||
overlays.dispose();
|
||||
});
|
||||
|
||||
it("does not flag an independently built configured UI root", () => {
|
||||
const gatewayClient = client(async () => []);
|
||||
const harness = createGatewayHarness(null, false);
|
||||
@@ -510,6 +525,26 @@ describe("application approval overlays", () => {
|
||||
overlays.dispose();
|
||||
});
|
||||
|
||||
it("surfaces a connection error when a rendered approval races a disconnect", async () => {
|
||||
const request = vi.fn<RequestFn>((method) =>
|
||||
Promise.resolve(method.endsWith(".list") ? [] : { ok: true }),
|
||||
);
|
||||
const harness = createGatewayHarness(client(request));
|
||||
const overlays = createApplicationOverlays(harness.gateway);
|
||||
harness.emitApproval("approval-disconnected", 1_000);
|
||||
|
||||
// The rendered modal can dispatch its click before Lit consumes the
|
||||
// Gateway snapshot notification that removes the stale card.
|
||||
harness.replaceSnapshotWithoutPublishing({ phase: "reconnecting" });
|
||||
await overlays.decideApproval("allow-once", "approval-disconnected");
|
||||
|
||||
expect(overlays.snapshot.approvalErrors.get("approval-disconnected")).toBe(
|
||||
"Connect to the Gateway to change sessions.",
|
||||
);
|
||||
expect(request).not.toHaveBeenCalledWith("exec.approval.resolve", expect.anything());
|
||||
overlays.dispose();
|
||||
});
|
||||
|
||||
it("keeps A's failure visible after deciding B successfully", async () => {
|
||||
const firstResolve = deferred();
|
||||
const secondResolve = deferred();
|
||||
|
||||
+12
-9
@@ -291,7 +291,9 @@ export function createApplicationOverlays(
|
||||
updateRunning: false,
|
||||
};
|
||||
updateCampaignPoller.stop();
|
||||
if (!next.client) {
|
||||
if (next.phase === "reload-required") {
|
||||
snapshot = { ...snapshot, controlUiRefreshRequired: true };
|
||||
} else if (!next.client) {
|
||||
connectedEpoch = 0;
|
||||
snapshot = { ...snapshot, controlUiRefreshRequired: false };
|
||||
} else if (next.hello) {
|
||||
@@ -561,14 +563,15 @@ export function createApplicationOverlays(
|
||||
? promptState.execApprovalQueue.find((entry) => entry.id === approvalId)
|
||||
: promptState.execApprovalQueue[0];
|
||||
const client = gateway.snapshot.client;
|
||||
if (
|
||||
!active ||
|
||||
!client ||
|
||||
promptState.execApprovalBusy ||
|
||||
disposed ||
|
||||
gateway.snapshot.phase !== "connected" ||
|
||||
!readGatewayOperatorAccess(gateway.snapshot).canGrantApprovals
|
||||
) {
|
||||
if (!active || promptState.execApprovalBusy || disposed) {
|
||||
return;
|
||||
}
|
||||
if (!client || gateway.snapshot.phase !== "connected") {
|
||||
promptState.execApprovalErrors.set(active.id, t("sessionsView.actionRequiresConnection"));
|
||||
publish();
|
||||
return;
|
||||
}
|
||||
if (!readGatewayOperatorAccess(gateway.snapshot).canGrantApprovals) {
|
||||
return;
|
||||
}
|
||||
promptState.execApprovalBusy = true;
|
||||
|
||||
@@ -209,6 +209,7 @@ export function renderExecApprovalCard(props: ExecApprovalCardProps) {
|
||||
return html`<button
|
||||
class=${decisionClass(decision)}
|
||||
type="button"
|
||||
aria-label=${label}
|
||||
?disabled=${props.busy}
|
||||
title=${props.variant === "modal" ? `${label} (${decisionShortcut(decision)})` : label}
|
||||
@click=${() => props.onDecision(active.id, decision)}
|
||||
|
||||
@@ -114,6 +114,21 @@ describe("openclaw-exec-approval", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("exposes labelled, focusable decision buttons", async () => {
|
||||
await renderOpenedApproval(createExecRequest());
|
||||
await getRenderedModalDialog(container);
|
||||
|
||||
const buttons = Array.from(
|
||||
container.querySelectorAll<HTMLButtonElement>(".exec-approval-actions button"),
|
||||
);
|
||||
expect(buttons.map((button) => button.getAttribute("aria-label"))).toEqual([
|
||||
"Allow once",
|
||||
"Always allow",
|
||||
"Deny",
|
||||
]);
|
||||
expect(buttons.every((button) => button.tabIndex === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not show exec unavailable copy for restricted plugin approvals", async () => {
|
||||
await renderOpenedApproval(
|
||||
createExecRequest({
|
||||
|
||||
@@ -52,6 +52,8 @@ describe("openclaw-modal-dialog", () => {
|
||||
|
||||
expect(dialog.open).toBe(true);
|
||||
expect(dialog.localName).toBe("dialog");
|
||||
expect(dialog.getAttribute("role")).toBe("dialog");
|
||||
expect(dialog.getAttribute("aria-modal")).toBe("true");
|
||||
expect(dialog.getAttribute("aria-label")).toBe("Confirm action");
|
||||
expect(dialog.getAttribute("aria-description")).toBe("Review the operation before continuing.");
|
||||
expect(dialog.getRootNode()).toBe(webAwesomeDialog.shadowRoot);
|
||||
|
||||
@@ -204,6 +204,8 @@ export class OpenClawModalDialog extends OpenClawLitElement {
|
||||
if (!dialog) {
|
||||
return;
|
||||
}
|
||||
dialog.setAttribute("role", "dialog");
|
||||
dialog.setAttribute("aria-modal", "true");
|
||||
if (this.label) {
|
||||
dialog.setAttribute("aria-label", this.label);
|
||||
} else {
|
||||
|
||||
@@ -59,6 +59,12 @@ suite.define(() => {
|
||||
approval("approval-active", "echo active", 1_000),
|
||||
);
|
||||
await currentPage.getByText("echo active", { exact: true }).waitFor();
|
||||
await currentPage.getByRole("button", { name: "Allow once" }).focus();
|
||||
expect(
|
||||
await currentPage
|
||||
.getByRole("button", { name: "Allow once" })
|
||||
.evaluate((button) => button === document.activeElement),
|
||||
).toBe(true);
|
||||
await currentPage.getByRole("button", { name: "Allow once" }).click();
|
||||
|
||||
await gateway.emitGatewayEvent(
|
||||
@@ -80,6 +86,7 @@ suite.define(() => {
|
||||
.toBe("Approval failed: gateway unavailable");
|
||||
|
||||
await approvalAttentionChip(currentPage).click();
|
||||
await currentPage.getByRole("dialog", { name: "Exec approval needed" }).waitFor();
|
||||
const approvalModal = await waitForConfirmModal(currentPage);
|
||||
await approvalModal.getByText("echo newer", { exact: true }).click();
|
||||
await expect
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
// Control UI tests cover the responsive disconnected login gate.
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { BrowserContext, Page } from "playwright";
|
||||
import { expect, it } from "vitest";
|
||||
import { ConnectErrorDetailCodes } from "../../../packages/gateway-protocol/src/connect-error-details.js";
|
||||
@@ -11,6 +13,7 @@ const suite = createControlUiE2eSuite({
|
||||
unavailableMessage: (executablePath) =>
|
||||
`Playwright Chromium is not installed or cannot start at ${executablePath}. Run \`pnpm --dir ui exec playwright install --with-deps chromium\`, or set OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM=1 only when intentionally skipping this lane.`,
|
||||
});
|
||||
const RECOVERY_ARTIFACT_DIR = path.resolve(".artifacts/control-ui-e2e/zombie-reload");
|
||||
|
||||
async function renderLoginGate(page: Page): Promise<void> {
|
||||
const response = await page.goto(suite.server.baseUrl);
|
||||
@@ -86,7 +89,7 @@ suite.define(() => {
|
||||
code: "UNAVAILABLE",
|
||||
message: "Control UI updated; reload this page to continue",
|
||||
details: {
|
||||
code: ConnectErrorDetailCodes.CONTROL_UI_BUILD_MISMATCH,
|
||||
code: ConnectErrorDetailCodes.PROTOCOL_MISMATCH,
|
||||
gatewayBuildId: "replacement-build",
|
||||
reloadRequired: true,
|
||||
},
|
||||
@@ -128,7 +131,7 @@ suite.define(() => {
|
||||
code: "UNAVAILABLE",
|
||||
message: "Control UI updated; reload this page to continue",
|
||||
details: {
|
||||
code: ConnectErrorDetailCodes.CONTROL_UI_BUILD_MISMATCH,
|
||||
code: ConnectErrorDetailCodes.PROTOCOL_MISMATCH,
|
||||
gatewayBuildId: "replacement-build",
|
||||
reloadRequired: true,
|
||||
},
|
||||
@@ -148,12 +151,14 @@ suite.define(() => {
|
||||
|
||||
await gateway.waitForRequest("connect");
|
||||
await gateway.rejectDeferred("connect", mismatch);
|
||||
const failure = page.locator('.login-gate__failure[data-kind="build-mismatch"]');
|
||||
await failure.waitFor({ timeout: 10_000 });
|
||||
expect(await failure.locator(".login-gate__failure-title").textContent()).toBe(
|
||||
"Server updated",
|
||||
);
|
||||
expect(await failure.locator(".login-gate__failure-refresh").isVisible()).toBe(true);
|
||||
await page.getByRole("button", { name: /Server updated/u }).waitFor({ timeout: 10_000 });
|
||||
expect(await page.locator("openclaw-login-gate").count()).toBe(0);
|
||||
expect(await page.locator("openclaw-router-outlet").getAttribute("inert")).not.toBeNull();
|
||||
await mkdir(RECOVERY_ARTIFACT_DIR, { recursive: true });
|
||||
await page.screenshot({
|
||||
path: path.join(RECOVERY_ARTIFACT_DIR, "01-reload-required.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
expect(await gateway.getRequests("terminal.open")).toHaveLength(0);
|
||||
expect(
|
||||
await page.evaluate(() =>
|
||||
@@ -165,7 +170,7 @@ suite.define(() => {
|
||||
}
|
||||
});
|
||||
|
||||
it("shows a protocol mismatch without reconnecting", async () => {
|
||||
it("shows a bare protocol mismatch as compatibility guidance without reconnecting", async () => {
|
||||
const context = await suite.browser.newContext({ viewport: { height: 900, width: 1280 } });
|
||||
const page = await context.newPage();
|
||||
await page.clock.install();
|
||||
@@ -180,12 +185,12 @@ suite.define(() => {
|
||||
details: { code: ConnectErrorDetailCodes.PROTOCOL_MISMATCH },
|
||||
});
|
||||
|
||||
const failure = page.locator(".login-gate__failure-summary");
|
||||
const failure = page.locator('.login-gate__failure[data-kind="protocol-mismatch"]');
|
||||
await failure.waitFor({ timeout: 10_000 });
|
||||
expect((await failure.textContent())?.toLowerCase()).toContain(
|
||||
"supported connection protocol",
|
||||
);
|
||||
expect(await page.locator(".login-gate__failure-refresh").isVisible()).toBe(true);
|
||||
expect(await failure.locator(".login-gate__failure-refresh").isVisible()).toBe(true);
|
||||
await page.clock.runFor(1_600);
|
||||
expect(await gateway.getRequests("connect")).toHaveLength(1);
|
||||
} finally {
|
||||
@@ -193,6 +198,80 @@ suite.define(() => {
|
||||
}
|
||||
});
|
||||
|
||||
it("lets reload-required recovery outrank a manually pinned login gate", async () => {
|
||||
const context = await suite.browser.newContext({ viewport: { height: 900, width: 1280 } });
|
||||
const page = await context.newPage();
|
||||
await page.addInitScript(() => {
|
||||
sessionStorage.setItem("openclaw.controlUi.staleChunkReloadBuildId", "replacement-build");
|
||||
});
|
||||
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 },
|
||||
});
|
||||
await page.locator('.login-gate__failure[data-kind="auth-required"]').waitFor();
|
||||
|
||||
await gateway.deferNext("connect");
|
||||
await page.getByRole("button", { name: "Connect" }).click();
|
||||
await expect.poll(async () => (await gateway.getRequests("connect")).length).toBe(2);
|
||||
await gateway.rejectDeferred("connect", {
|
||||
code: "UNAVAILABLE",
|
||||
message: "protocol mismatch: Control UI updated; reload this page to continue",
|
||||
details: {
|
||||
code: ConnectErrorDetailCodes.PROTOCOL_MISMATCH,
|
||||
gatewayBuildId: "replacement-build",
|
||||
reloadRequired: true,
|
||||
},
|
||||
retryable: false,
|
||||
});
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => {
|
||||
const app = document.querySelector("openclaw-app") as HTMLElement & {
|
||||
runtime?: { context: { gateway: { snapshot: { phase: string } } } };
|
||||
};
|
||||
return app.runtime?.context.gateway.snapshot.phase;
|
||||
}),
|
||||
)
|
||||
.toBe("reload-required");
|
||||
await page.getByRole("button", { name: /Server updated/u }).waitFor();
|
||||
expect(await page.locator("openclaw-login-gate").count()).toBe(0);
|
||||
} finally {
|
||||
await closeContext(context);
|
||||
}
|
||||
});
|
||||
|
||||
it("blocks non-chat page actions visibly while reconnecting", async () => {
|
||||
const context = await suite.browser.newContext({ viewport: { height: 900, width: 1280 } });
|
||||
const page = await context.newPage();
|
||||
const gateway = await installMockGateway(page);
|
||||
|
||||
try {
|
||||
await page.goto(new URL("settings/connection", suite.server.baseUrl).href);
|
||||
await page.locator("openclaw-app-shell").waitFor();
|
||||
await gateway.deferNext("connect");
|
||||
await gateway.closeLatest(1012, "test reconnect");
|
||||
|
||||
await page.getByText("Actions are unavailable while the Gateway reconnects.").waitFor();
|
||||
const outlet = page.locator("openclaw-router-outlet");
|
||||
expect(await outlet.getAttribute("inert")).not.toBeNull();
|
||||
expect(await outlet.getAttribute("aria-disabled")).toBe("true");
|
||||
await mkdir(RECOVERY_ARTIFACT_DIR, { recursive: true });
|
||||
await page.screenshot({
|
||||
path: path.join(RECOVERY_ARTIFACT_DIR, "02-reconnecting-actions-blocked.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
} finally {
|
||||
await closeContext(context);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "missing token",
|
||||
|
||||
@@ -3750,6 +3750,7 @@ export const en: TranslationMap = {
|
||||
queuedCount: "{count} queued",
|
||||
reconnecting: "Reconnecting…",
|
||||
retryNow: "Retry now",
|
||||
actionsUnavailable: "Actions are unavailable while the Gateway reconnects.",
|
||||
scopeUpgrade: {
|
||||
limited: "This browser has limited access.",
|
||||
guidance:
|
||||
|
||||
@@ -25,7 +25,7 @@ hashes.sha512Async = async (message: Uint8Array) => {
|
||||
if (globalThis.crypto?.subtle && subtleSha512Async) {
|
||||
return await subtleSha512Async(message);
|
||||
}
|
||||
return (await loadPureSha2()).sha512(message);
|
||||
return Uint8Array.from((await loadPureSha2()).sha512(message));
|
||||
};
|
||||
|
||||
type GatewayRequestClient = {
|
||||
|
||||
@@ -3867,6 +3867,15 @@ wa-dropdown.sidebar-identity-menu::part(menu) {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.connection-action-block {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--secondary);
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.content--chat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
Reference in New Issue
Block a user