diff --git a/ui/src/e2e/chat-continue-in-terminal.e2e.test.ts b/ui/src/e2e/chat-continue-in-terminal.e2e.test.ts index 6648f5801a60..c71bc1f9c80b 100644 --- a/ui/src/e2e/chat-continue-in-terminal.e2e.test.ts +++ b/ui/src/e2e/chat-continue-in-terminal.e2e.test.ts @@ -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\.$/ }) diff --git a/ui/src/e2e/control-ui-route-readiness.e2e.test.ts b/ui/src/e2e/control-ui-route-readiness.e2e.test.ts new file mode 100644 index 000000000000..3421a4803798 --- /dev/null +++ b/ui/src/e2e/control-ui-route-readiness.e2e.test.ts @@ -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, + ); + } + }); + }); +}); diff --git a/ui/src/test-helpers/control-ui-e2e.ts b/ui/src/test-helpers/control-ui-e2e.ts index 3933d76b54dd..37dfcbe0ab41 100644 --- a/ui/src/test-helpers/control-ui-e2e.ts +++ b/ui/src/test-helpers/control-ui-e2e.ts @@ -34,10 +34,11 @@ export function controlUiSessionUrl(baseUrl: string, sessionKey: string): string } export async function navigateToControlUiSession(page: Page, sessionKey: string): Promise { - 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("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) );