fix(ui): prevent encoded-path readiness timeouts (#130666)

This commit is contained in:
Peter Steinberger
2026-08-26 21:23:59 -07:00
committed by GitHub
parent cd321938f6
commit 6b842df52e
3 changed files with 77 additions and 6 deletions
@@ -3,7 +3,12 @@ import path from "node:path";
import { expect, it } from "vitest";
import { decodeResumeHandoff } from "../../../src/shared/resume-handoff.js";
import type { ChatPaneElement } from "../pages/chat/route-draft-focus-handoff.ts";
import { controlUiSessionUrl, installMockGateway } from "../test-helpers/control-ui-e2e.ts";
import {
controlUiSessionPath,
controlUiSessionUrl,
installMockGateway,
waitForControlUiRoute,
} from "../test-helpers/control-ui-e2e.ts";
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
const suite = createControlUiE2eSuite({
@@ -100,6 +105,13 @@ suite.define(() => {
await expect
.poll(() => activePane.evaluate((pane) => (pane as ChatPaneElement).sessionKey))
.toBe(sessionKey);
await waitForControlUiRoute(page, {
routeId: "chat",
pathname: controlUiSessionPath(sessionKey, basePath),
pathnamePrefix: `${basePath}/chat/`,
search: "",
hash: "",
});
await activePane.getByText("Ready for terminal continuation.").waitFor({ timeout: 10_000 });
const menuTrigger = activePane.getByRole("button", {
@@ -182,6 +194,7 @@ suite.define(() => {
await expect
.poll(() => activePane.evaluate((pane) => (pane as ChatPaneElement).sessionKey))
.toBe(sessionKey);
await waitForControlUiRoute(page, { routeId: "chat" });
await activePane
.getByRole("paragraph")
.filter({ hasText: /^Mobile session menu proof\.$/ })
@@ -0,0 +1,44 @@
import { expect, it } from "vitest";
import type { ChatPaneElement } from "../pages/chat/route-draft-focus-handoff.ts";
import {
controlUiSessionUrl,
installMockGateway,
navigateToControlUiSession,
} from "../test-helpers/control-ui-e2e.ts";
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
const suite = createControlUiE2eSuite({ name: "Control UI route readiness" });
suite.define(() => {
it.each([
{ name: "root", basePath: "" },
{ name: "encoded mount", basePath: "/nested/$&;=()+,![]{}'`/%25PATH%25" },
])("navigates exact session keys at the $name", async ({ basePath }) => {
await suite.withPage({ viewport: { width: 1200, height: 800 } }, async ({ page }) => {
const initialSessionKey = "agent:runner:route:initial";
await installMockGateway(page, { basePath, sessionKey: initialSessionKey });
await page.goto(controlUiSessionUrl(suite.server.baseUrl, initialSessionKey));
const visiblePane = page.locator("openclaw-chat-pane.chat-pane-cache__pane--visible");
await expect
.poll(() => visiblePane.evaluate((pane) => (pane as ChatPaneElement).sessionKey))
.toBe(initialSessionKey);
const mountUrl = new URL(suite.server.baseUrl);
mountUrl.pathname = basePath || "/";
const encodedBase = basePath ? mountUrl.pathname : "";
for (const [rest, suffix] of [
["a/b", "a%2Fb"],
["a:b", "a/b"],
["%2F%25%3F%23", "%252F%2525%253F%2523"],
["a?b#c", "a%3Fb%23c"],
]) {
const sessionKey = `agent:runner:route:${rest}`;
await navigateToControlUiSession(page, sessionKey);
expect(new URL(page.url()).pathname).toBe(`${encodedBase}/chat/runner/route/${suffix}`);
expect(await visiblePane.evaluate((pane) => (pane as ChatPaneElement).sessionKey)).toBe(
sessionKey,
);
}
});
});
});
+19 -5
View File
@@ -34,10 +34,11 @@ export function controlUiSessionUrl(baseUrl: string, sessionKey: string): string
}
export async function navigateToControlUiSession(page: Page, sessionKey: string): Promise<void> {
await page.evaluate((pathname) => {
const expectedPathname = await page.evaluate((sessionPath) => {
const app = document.querySelector("openclaw-app") as HTMLElement & {
runtime?: {
context: {
basePath: string;
navigate: (routeId: string, options: { pathname: string }) => void;
};
};
@@ -45,9 +46,13 @@ export async function navigateToControlUiSession(page: Page, sessionKey: string)
if (!app.runtime) {
throw new Error("OpenClaw application runtime is unavailable");
}
const pathname = `${app.runtime.context.basePath}${sessionPath}`;
const url = new URL(window.location.href);
url.pathname = pathname;
app.runtime.context.navigate("chat", { pathname });
return url.pathname;
}, controlUiSessionPath(sessionKey));
await page.waitForURL((url) => url.pathname === controlUiSessionPath(sessionKey));
await page.waitForURL((url) => url.pathname === expectedPathname);
await page.waitForFunction(
(targetSessionKey) =>
[...document.querySelectorAll<HTMLElement>("openclaw-chat-pane")].some(
@@ -109,13 +114,22 @@ export async function waitForControlUiRoute(page: Page, target: ControlUiRouteTa
};
const state = app.runtime?.router.getState();
const pathname = window.location.pathname;
// Router paths retain literal characters that browser history percent-encodes.
// Serialize as a pathname; decoding would alias encoded delimiters and percent data.
const browserPathname = (value: string) => {
const url = new URL(window.location.href);
url.pathname = value;
return url.pathname;
};
return (
state?.status === "success" &&
state.matches[0]?.routeId === expected.routeId &&
state.resolvedLocation?.pathname === pathname &&
state.resolvedLocation !== null &&
browserPathname(state.resolvedLocation.pathname) === pathname &&
state.pendingMatches.length === 0 &&
(expected.pathname === undefined || pathname === expected.pathname) &&
(expected.pathnamePrefix === undefined || pathname.startsWith(expected.pathnamePrefix)) &&
(expected.pathname === undefined || pathname === browserPathname(expected.pathname)) &&
(expected.pathnamePrefix === undefined ||
pathname.startsWith(browserPathname(expected.pathnamePrefix))) &&
(expected.search === undefined || window.location.search === expected.search) &&
(expected.hash === undefined || window.location.hash === expected.hash)
);