From a0530259e2fa8c3ebbec1f17fbabb550af27eb83 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Jul 2026 23:00:04 -0400 Subject: [PATCH] fix(ui): preserve navigation during startup (#114828) --- ui/src/app/bootstrap.test.ts | 57 ++++++- ui/src/app/bootstrap.ts | 29 +++- ui/src/e2e/new-session-page.e2e.test.ts | 198 ++++++++++++------------ 3 files changed, 177 insertions(+), 107 deletions(-) diff --git a/ui/src/app/bootstrap.test.ts b/ui/src/app/bootstrap.test.ts index 8b4d8c3fd241..f8022b18588e 100644 --- a/ui/src/app/bootstrap.test.ts +++ b/ui/src/app/bootstrap.test.ts @@ -1,9 +1,12 @@ import type { RouteLocation } from "@openclaw/uirouter"; import { describe, expect, it, vi } from "vitest"; import type { GatewayBrowserClient } from "../api/gateway.ts"; -import type { RouteId } from "../app-routes.ts"; +import { routeIdFromPath, type RouteId } from "../app-routes.ts"; import { sessionRefFromPath } from "../app-session-route-paths.ts"; -import { startModelSetupFirstRunRedirectAfterLocation } from "../pages/model-setup/first-run.ts"; +import { + isDefaultChatLanding, + startModelSetupFirstRunRedirectAfterLocation, +} from "../pages/model-setup/first-run.ts"; import { normalizeInitialApplicationLocation, resolveInitialApplicationLocation, @@ -367,6 +370,56 @@ describe("normalizeInitialApplicationLocation", () => { expect(replaceRoute).toHaveBeenCalledWith("model-setup", { search: "?firstRun=1" }); }); + it("does not replace a user route with the deferred default chat location", async () => { + const currentLocation = { pathname: "/new", search: "", hash: "" }; + const installLocation = vi.fn(); + + await startModelSetupFirstRunRedirectAfterLocation({ + context: {} as ApplicationContext, + enabled: false, + history: { location: () => currentLocation, replace: vi.fn() }, + initialLocationReady: Promise.resolve({ pathname: "/chat/main", search: "", hash: "" }), + installLocation, + shouldInstallLocation: () => isDefaultChatLanding(currentLocation, "", routeIdFromPath), + }); + + expect(installLocation).not.toHaveBeenCalled(); + }); + + it("keeps the latest navigation requested before router start", async () => { + const previousSettings = loadSettings(); + const previousUrl = window.location.href; + saveSettings({ + ...previousSettings, + sessionKey: "agent:main:main", + lastActiveSessionKey: "agent:main:main", + }); + window.history.replaceState({}, "", "/chat"); + const sessionPathBuilder = deferred(); + const runtime = bootstrapApplication({ sessionPathBuilderReady: sessionPathBuilder.promise }); + const pushState = vi.spyOn(window.history, "pushState"); + + try { + const start = runtime.start(); + runtime.context.replace("about"); + runtime.context.navigate("new-session"); + expect(window.location.pathname).toBe("/chat"); + + sessionPathBuilder.resolve(); + await start; + + expect(runtime.router.getState().matches[0]?.routeId).toBe("new-session"); + expect(runtime.router.getState().resolvedLocation?.pathname).toBe("/new"); + expect(window.location.pathname).toBe("/new"); + expect(pushState).toHaveBeenCalledWith({}, "", "/new"); + } finally { + pushState.mockRestore(); + runtime.stop(); + saveSettings(previousSettings); + window.history.replaceState({}, "", previousUrl); + } + }); + it("does not restart routing after stop wins the session-path loader race", async () => { const previousSettings = loadSettings(); const previousUrl = window.location.href; diff --git a/ui/src/app/bootstrap.ts b/ui/src/app/bootstrap.ts index 514a835f5c09..a7bb509c16be 100644 --- a/ui/src/app/bootstrap.ts +++ b/ui/src/app/bootstrap.ts @@ -1,3 +1,4 @@ +import type { RouteLocation } from "@openclaw/uirouter"; import type { GatewayBrowserClient } from "../api/gateway.ts"; import { sessionRouteNamespaceFromPath } from "../app-route-paths.ts"; import { @@ -232,6 +233,12 @@ type BootstrapApplicationDependencies = { sessionPathBuilderReady?: Promise; }; +type PendingRouterStartNavigation = { + routeId: RouteId; + location: RouteLocation; + mode: "push" | "replace"; +}; + export function bootstrapApplication( dependencies: BootstrapApplicationDependencies = {}, ): ApplicationRuntime { @@ -344,6 +351,10 @@ export function bootstrapApplication( const initialUserMessage = createInitialUserMessageHandoff(); applyThemePresentation(settings); const router = createApplicationRouter(); + let routerStarted = false; + // Pre-start navigations are invisible to history; retain the latest request so + // router.start() cannot resolve the stale browser URL over the user's route. + let pendingRouterStartNavigation: PendingRouterStartNavigation | null = null; let pendingGatewayConnection = startup.pendingGatewayUrl !== null ? { @@ -430,15 +441,23 @@ export function bootstrapApplication( skillWorkshopRevision, initialUserMessage, navigate: (routeId, options) => { + const location = routeLocation(routeId, options); + if (!routerStarted) { + pendingRouterStartNavigation = { routeId, location, mode: "push" }; + } void router - .navigate(routeId, context, { history: "push" }, routeLocation(routeId, options)) + .navigate(routeId, context, { history: "push" }, location) .catch((error: unknown) => { console.error("[openclaw] route navigation failed", error); }); }, replace: (routeId, options) => { + const location = routeLocation(routeId, options); + if (!routerStarted) { + pendingRouterStartNavigation = { routeId, location, mode: "replace" }; + } void router - .navigate(routeId, context, { history: "replace" }, routeLocation(routeId, options)) + .navigate(routeId, context, { history: "replace" }, location) .catch((error: unknown) => { console.error("[openclaw] route replacement failed", error); }); @@ -482,6 +501,12 @@ export function bootstrapApplication( }); if (!documentMode) { steps.push(async () => { + const pendingNavigation = pendingRouterStartNavigation; + pendingRouterStartNavigation = null; + routerStarted = true; + if (pendingNavigation) { + history[pendingNavigation.mode](pendingNavigation.location); + } await startApplicationRouter(router, history, basePath, context); return stopRouter; }); diff --git a/ui/src/e2e/new-session-page.e2e.test.ts b/ui/src/e2e/new-session-page.e2e.test.ts index 860c390c7145..8d98126549fd 100644 --- a/ui/src/e2e/new-session-page.e2e.test.ts +++ b/ui/src/e2e/new-session-page.e2e.test.ts @@ -43,6 +43,8 @@ const NODE_HOME = "/Users/peter"; const NODE_PICKED = "/Users/peter/Projects"; const NODE_UNC = "\\\\server\\share\\repo"; const EXEC_ONLY_PICKED = "C:\\Users\\peter\\repo"; +const LOCATOR_TEXT_READ_TIMEOUT_MS = 500; +const LOCATOR_TEXT_POLL_TIMEOUT_MS = 10_000; const ONE_PIXEL_PNG_B64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/woAAn8B9FD5fHAAAAAASUVORK5CYII="; @@ -52,6 +54,12 @@ const SESSION_LIST_DEFAULTS = { modelProvider: "openai", }; +function pollLocatorText(locator: Locator) { + return expect.poll(() => locator.textContent({ timeout: LOCATOR_TEXT_READ_TIMEOUT_MS }), { + timeout: LOCATOR_TEXT_POLL_TIMEOUT_MS, + }); +} + function createdSessionListResult(sessionKey: string) { return { count: 1, @@ -348,7 +356,7 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => { await gateway.waitForRequest("chat.startup"); await page.getByText("SKILL.md", { exact: true }).waitFor(); - await expect.poll(() => page.locator(".chat-group.user").textContent()).toContain(message); + await pollLocatorText(page.locator(".chat-group.user")).toContain(message); const userRow = await page.locator(".chat-group.user").boundingBox(); const toolRow = await page.getByText("SKILL.md", { exact: true }).boundingBox(); expect(userRow).not.toBeNull(); @@ -390,7 +398,7 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => { timeout: 30_000, }); await gateway.waitForRequest("chat.startup"); - await expect.poll(() => page.locator(".chat-group.user").textContent()).toContain(message); + await pollLocatorText(page.locator(".chat-group.user")).toContain(message); const socketsBeforeReconnect = await gateway.getSocketCount(); await gateway.setOnline(false); @@ -426,7 +434,7 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => { }); } - await expect.poll(() => page.locator(".chat-group.user").textContent()).toContain(message); + await pollLocatorText(page.locator(".chat-group.user")).toContain(message); await expect.poll(() => page.locator(".chat-group.user").count()).toBe(1); } finally { await context.close(); @@ -494,8 +502,8 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => { await expect.poll(() => userImage.getAttribute("src")).toMatch(/^data:image\/png;base64,/u); const initialImageSrc = await userImage.getAttribute("src"); await userImage.evaluate((image) => image.setAttribute("data-initial-image-node", "true")); - await expect.poll(() => userRow.textContent()).toContain(message); - await expect.poll(() => userRow.textContent()).not.toContain("Attached image"); + await pollLocatorText(userRow).toContain(message); + await pollLocatorText(userRow).not.toContain("Attached image"); await gateway.resolveDeferred("chat.startup"); @@ -503,8 +511,8 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => { await expect.poll(() => userImage.count()).toBe(1); await expect.poll(() => userImage.getAttribute("data-initial-image-node")).toBe("true"); await expect.poll(() => userImage.getAttribute("src")).toBe(initialImageSrc); - await expect.poll(() => userRow.textContent()).toContain(message); - await expect.poll(() => userRow.textContent()).not.toContain("Attached image"); + await pollLocatorText(userRow).toContain(message); + await pollLocatorText(userRow).not.toContain("Attached image"); } finally { await context.close(); } @@ -889,9 +897,9 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => { await expect.poll(() => modelSelect.getAttribute("data-chat-thinking-value")).toBe("high"); await page.goto(`${server.baseUrl}new`); - await expect - .poll(() => placeTrigger.locator(".new-session-page__trigger-label").textContent()) - .toBe("packages"); + await pollLocatorText(placeTrigger.locator(".new-session-page__trigger-label")).toBe( + "packages", + ); await expect.poll(() => placeTrigger.getAttribute("data-worktree")).toBe("true"); await expect .poll(() => modelSelect.getAttribute("data-chat-select-value")) @@ -1138,9 +1146,9 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => { }); await page.reload(); await navigateInApp(page, "new-session"); - await expect - .poll(() => placeTrigger.locator(".new-session-page__trigger-label").textContent()) - .toBe("openclaw-next"); + await pollLocatorText(placeTrigger.locator(".new-session-page__trigger-label")).toBe( + "openclaw-next", + ); const modelSelect = page.locator('[data-chat-model-select="true"]'); await modelSelect.click(); @@ -1237,9 +1245,9 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => { message: `Error: ENOENT: no such file or directory, scandir '${PICKED}'`, }); const placeTrigger = page.locator("#new-session-place-trigger"); - await expect - .poll(() => placeTrigger.locator(".new-session-page__trigger-label").textContent()) - .toBe("openclaw"); + await pollLocatorText(placeTrigger.locator(".new-session-page__trigger-label")).toBe( + "openclaw", + ); const pickedListRequests = (await gateway.getRequests("fs.listDir")).filter( (request) => @@ -1251,9 +1259,9 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => { await navigateInApp(page, "chat"); await waitForCommittedChatRoute(page); await navigateInApp(page, "new-session"); - await expect - .poll(() => placeTrigger.locator(".new-session-page__trigger-label").textContent()) - .toBe("openclaw"); + await pollLocatorText(placeTrigger.locator(".new-session-page__trigger-label")).toBe( + "openclaw", + ); await expect.poll(() => placeTrigger.getAttribute("data-worktree")).toBe("false"); await expect .poll( @@ -1350,9 +1358,9 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => { home: "/home/peter", entries: [], }); - await expect - .poll(() => placeTrigger.locator(".new-session-page__trigger-label").textContent()) - .toBe("openclaw"); + await pollLocatorText(placeTrigger.locator(".new-session-page__trigger-label")).toBe( + "openclaw", + ); await page.locator(".new-session-page__message").fill("keep the newer choice"); await page.getByRole("button", { name: "Start thread" }).click(); @@ -1422,9 +1430,9 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => { await expect .poll(() => page.getByRole("button", { name: "Start thread" }).isDisabled()) .toBe(false); - await expect - .poll(() => trigger.locator(".new-session-page__trigger-label").textContent()) - .toContain("target-repo"); + await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toContain( + "target-repo", + ); const storedPreference = await page.evaluate(() => { const key = Array.from({ length: localStorage.length }, (_, index) => localStorage.key(index), @@ -1558,9 +1566,9 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => { // The place trigger labels the workspace and opens the unified menu. const placeSelect = page.locator("wa-popover.new-session-page__place-popover"); const placeTrigger = page.locator("#new-session-place-trigger"); - await expect - .poll(() => placeTrigger.locator(".new-session-page__trigger-label").textContent()) - .toBe("openclaw"); + await pollLocatorText(placeTrigger.locator(".new-session-page__trigger-label")).toBe( + "openclaw", + ); // Browse from the workspace, descend one level, then adopt the folder. await placeTrigger.click(); @@ -1576,9 +1584,9 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => { await expect .poll(() => page.evaluate(() => document.activeElement?.id)) .toBe("new-session-place-trigger"); - await expect - .poll(() => placeTrigger.locator(".new-session-page__trigger-label").textContent()) - .toBe("packages"); + await pollLocatorText(placeTrigger.locator(".new-session-page__trigger-label")).toBe( + "packages", + ); // Git-backed custom folders stay direct until the user explicitly chooses isolation. await expect.poll(() => placeTrigger.getAttribute("data-worktree")).toBe("false"); @@ -1684,9 +1692,9 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => { await expect .poll(async () => (await gateway.getRequests("worktrees.branches")).at(-1)?.params) .toEqual({ repoRoot: "/home", includeRepositoryStatus: true }); - await expect - .poll(() => trigger.locator(".new-session-page__trigger-label").textContent()) - .toBe("home · Gateway · local"); + await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe( + "home · Gateway · local", + ); await trigger.click(); expect(await place.getByRole("button", { name: "Worktree" }).count()).toBe(0); @@ -1726,9 +1734,7 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => { await page.goto(`${server.baseUrl}new`); await gateway.waitForRequest("node.list"); const trigger = page.locator("#new-session-place-trigger"); - await expect - .poll(() => trigger.locator(".new-session-page__trigger-label").textContent()) - .toBe("openclaw"); + await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe("openclaw"); await trigger.click(); const place = page.locator("wa-popover.new-session-page__place-popover"); expect(await place.getByText("Places", { exact: true }).count()).toBe(0); @@ -1769,9 +1775,9 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => { await page.goto(`${server.baseUrl}new`); await gateway.waitForRequest("system.info"); const trigger = page.locator("#new-session-place-trigger"); - await expect - .poll(() => trigger.locator(".new-session-page__trigger-label").textContent()) - .toBe("openclaw · Gateway · Peters-Mac-Studio"); + await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe( + "openclaw · Gateway · Peters-Mac-Studio", + ); await trigger.click(); const place = page.locator("wa-popover.new-session-page__place-popover"); await place.getByRole("button", { name: "Gateway · Peters-Mac-Studio" }).waitFor(); @@ -1788,9 +1794,7 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => { await expect .poll(async () => (await gateway.getRequests("node.list")).length) .toBeGreaterThan(nodeRequests); - await expect - .poll(() => trigger.locator(".new-session-page__trigger-label").textContent()) - .toBe("openclaw"); + await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe("openclaw"); await trigger.click(); await place.getByText("Runs on Gateway · Peters-Mac-Studio", { exact: true }).waitFor(); } finally { @@ -1849,18 +1853,18 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => { const first = page.locator('[data-value="node:11111111aaaaaaaa"]'); const second = page.locator('[data-value="node:22222222bbbbbbbb"]'); const phone = page.locator('[data-value="node:33333333cccccccc"]'); - await expect.poll(() => first.locator(".session-menu__sub").textContent()).toBe("Mac14,12"); - await expect.poll(() => second.locator(".session-menu__sub").textContent()).toBe("Mac15,14"); - await expect.poll(() => phone.locator(".session-menu__text").textContent()).toBe("iPhone"); + await pollLocatorText(first.locator(".session-menu__sub")).toBe("Mac14,12"); + await pollLocatorText(second.locator(".session-menu__sub")).toBe("Mac15,14"); + await pollLocatorText(phone.locator(".session-menu__text")).toBe("iPhone"); expect(await first.locator(".session-menu__icon svg").count()).toBe(1); expect(await second.locator(".session-menu__icon svg").count()).toBe(1); expect(await phone.locator(".session-menu__icon svg").count()).toBe(1); expect(await first.getAttribute("title")).toBe("macOS · Mac14,12 · 192.168.1.11"); expect(await second.getAttribute("title")).toContain("192.168.1.12"); await second.click(); - await expect - .poll(() => trigger.locator(".new-session-page__trigger-label").textContent()) - .toBe("Agent workspace · Mac Studio"); + await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe( + "Agent workspace · Mac Studio", + ); expect(await trigger.textContent()).not.toContain("Mac15,14"); expect(await trigger.textContent()).not.toContain("192.168.1.12"); } finally { @@ -1898,8 +1902,8 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => { await trigger.click(); const first = page.locator('[data-value="recent::/a/openclaw"]'); const second = page.locator('[data-value="recent::/b/openclaw"]'); - await expect.poll(() => first.locator(".session-menu__sub").textContent()).toBe("a"); - await expect.poll(() => second.locator(".session-menu__sub").textContent()).toBe("b"); + await pollLocatorText(first.locator(".session-menu__sub")).toBe("a"); + await pollLocatorText(second.locator(".session-menu__sub")).toBe("b"); await second.click(); await page.locator(".new-session-page__message").fill("continue in work checkout"); await page.getByRole("button", { name: "Start thread" }).click(); @@ -1964,9 +1968,9 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => { .locator("wa-popover.new-session-page__place-popover") .getByRole("button", { name: "Projects · MacBook", exact: true }) .click(); - await expect - .poll(() => trigger.locator(".new-session-page__trigger-label").textContent()) - .toBe("Projects · MacBook"); + await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe( + "Projects · MacBook", + ); await page.locator(".new-session-page__message").fill("continue on the recent node"); await page.getByRole("button", { name: "Start thread" }).click(); @@ -2139,9 +2143,9 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => { await expect.poll(() => trigger.getAttribute("data-cloud-profile")).toBe("aws"); await expect.poll(() => trigger.getAttribute("data-worktree")).toBe("true"); await trigger.click(); - await expect - .poll(() => place.locator(".new-session-page__menu-note").textContent()) - .toContain("Syncs target-repo to the cloud worker"); + await pollLocatorText(place.locator(".new-session-page__menu-note")).toContain( + "Syncs target-repo to the cloud worker", + ); await captureUiProof(page, "01-cloud-worker-target.png"); await page.keyboard.press("Escape"); @@ -2188,9 +2192,9 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => { code: "UNAVAILABLE", message: "allocation response lost", }); - await expect - .poll(() => page.locator(".new-session-page__error").textContent()) - .toContain("cloud worker placement could not be verified"); + await pollLocatorText(page.locator(".new-session-page__error")).toContain( + "cloud worker placement could not be verified", + ); const alert = page.locator(".new-session-page__alert"); await expect.poll(() => alert.getAttribute("role")).toBe("alert"); await expect.poll(() => alert.locator("svg").count()).toBe(1); @@ -2361,7 +2365,7 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => { .poll(async () => (await gateway.getRequests("environments.list")).length) .toBeGreaterThan(profileRequests); await expect.poll(() => trigger.getAttribute("data-cloud-profile")).toBe("aws"); - await expect.poll(() => trigger.textContent()).toContain("Cloud · aws"); + await pollLocatorText(trigger).toContain("Cloud · aws"); await expect .poll(() => page.getByRole("button", { name: "Start thread" }).isDisabled()) .toBe(true); @@ -2487,9 +2491,9 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => { code: "UNAVAILABLE", message: "send outcome unknown", }); - await expect - .poll(() => page.locator(".new-session-page__error").textContent()) - .toContain("send outcome unknown"); + await pollLocatorText(page.locator(".new-session-page__error")).toContain( + "send outcome unknown", + ); await gateway.setMethodResponse("sessions.send", { runId: "run-reload-recovery", status: "started", @@ -2800,9 +2804,9 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => { message: "cleanup unavailable", }); - await expect - .poll(() => page.locator(".new-session-page__error").textContent()) - .toContain("cleanup unavailable"); + await pollLocatorText(page.locator(".new-session-page__error")).toContain( + "cleanup unavailable", + ); const stagedIdentity = staged as { messageId: string; profileId: string; agentId: string }; expect(await readRecovery()).toMatchObject({ sessionKey, @@ -2912,9 +2916,9 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => { message: "send outcome unknown", }); - await expect - .poll(() => page.locator(".new-session-page__error").textContent()) - .toContain("send outcome unknown"); + await pollLocatorText(page.locator(".new-session-page__error")).toContain( + "send outcome unknown", + ); await expect.poll(() => page.locator(".new-session-page__message").isDisabled()).toBe(true); expect(await page.locator(".new-session-page__message").inputValue()).toBe(message); expect(new URL(page.url()).pathname).toContain("/new"); @@ -3010,7 +3014,7 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => { catalogId: "claude", }); const runtime = page.locator(".new-session-page__runtime"); - await expect.poll(() => runtime.textContent()).toContain("Claude Code"); + await pollLocatorText(runtime).toContain("Claude Code"); expect(await runtime.getAttribute("title")).toBe(model); expect(await page.locator('.new-session-page__trigger[title="Agent"]').count()).toBe(0); expect(await page.locator('[data-chat-model-select="true"]').count()).toBe(0); @@ -3171,9 +3175,7 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => { const message = page.locator(".new-session-page__message"); await message.fill("keep this reconnect draft"); - await expect - .poll(() => page.locator(".new-session-page__runtime").textContent()) - .toContain("claude"); + await pollLocatorText(page.locator(".new-session-page__runtime")).toContain("claude"); await expect .poll(() => page.getByRole("button", { name: "Start thread" }).isEnabled()) .toBe(false); @@ -3197,13 +3199,9 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => { timeout: 10_000, }) .toBe(3); - await expect - .poll(() => page.locator(".new-session-page__runtime").textContent()) - .toContain("Claude Code"); + await pollLocatorText(page.locator(".new-session-page__runtime")).toContain("Claude Code"); await expect.poll(() => message.inputValue()).toBe("keep this reconnect draft"); - await expect - .poll(() => page.getByRole("heading").first().textContent()) - .toContain("Research"); + await pollLocatorText(page.getByRole("heading").first()).toContain("Research"); await page.getByRole("button", { name: "Start thread" }).click(); const create = await gateway.waitForRequest("sessions.create"); @@ -3304,14 +3302,10 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => { .poll(async () => (await gateway.getRequests("agents.list")).length) .toBe(agentRequestsBefore + 1); await expect.poll(() => message.inputValue()).toBe("keep my selected agent"); - await expect - .poll(() => page.getByRole("heading").first().textContent()) - .toContain("Research"); - await expect - .poll(() => - page.locator("#new-session-place-trigger .new-session-page__trigger-label").textContent(), - ) - .toBe("research-next"); + await pollLocatorText(page.getByRole("heading").first()).toContain("Research"); + await pollLocatorText( + page.locator("#new-session-place-trigger .new-session-page__trigger-label"), + ).toBe("research-next"); await expect .poll(async () => (await gateway.getRequests("worktrees.branches")).length) .toBe(branchRequestsBefore + 1); @@ -3807,9 +3801,9 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => { placeSelect.evaluate((element) => (element as HTMLElement & { open: boolean }).open), ) .toBe(false); - await expect - .poll(() => placeTrigger.locator(".new-session-page__trigger-label").textContent()) - .toBe("target-repo · Gateway · local"); + await pollLocatorText(placeTrigger.locator(".new-session-page__trigger-label")).toBe( + "target-repo · Gateway · local", + ); const branchRequests = await gateway.getRequests("worktrees.branches"); expect(branchRequests.at(-1)?.params).toEqual({ @@ -3986,17 +3980,15 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => { const folderLabel = page.locator( "#new-session-place-trigger .new-session-page__trigger-label", ); - await expect.poll(() => folderLabel.textContent()).toBe("research"); + await pollLocatorText(folderLabel).toBe("research"); await page.evaluate(() => { history.pushState(null, "", "new?agent=main&catalog=claude"); dispatchEvent(new PopStateEvent("popstate")); }); - await expect - .poll(() => page.locator(".new-session-page__runtime").textContent()) - .toContain("Claude Code"); - await expect.poll(() => folderLabel.textContent()).toBe("openclaw"); + await pollLocatorText(page.locator(".new-session-page__runtime")).toContain("Claude Code"); + await pollLocatorText(folderLabel).toBe("openclaw"); await page.locator(".new-session-page__message").fill("retarget this draft"); await page.getByRole("button", { name: "Start thread" }).click(); @@ -4376,7 +4368,7 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => { // Pick the node from Places. await placeTrigger.click(); await placeSelect.getByRole("button", { name: "MacBook" }).click(); - await expect.poll(() => placeLabel.textContent()).toBe("Agent workspace · MacBook"); + await pollLocatorText(placeLabel).toBe("Agent workspace · MacBook"); // Node sessions cannot use managed worktrees, so the menu drops the item. await placeTrigger.click(); expect(await placeSelect.getByRole("button", { name: "Worktree" }).count()).toBe(0); @@ -4402,7 +4394,7 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => { // Destination selection stays in Places; browsing is fixed to the current target. await placeSelect.getByRole("button", { name: "Gateway · local" }).click(); - await expect.poll(() => placeLabel.textContent()).toBe("openclaw · Gateway · local"); + await pollLocatorText(placeLabel).toBe("openclaw · Gateway · local"); await placeTrigger.click(); expect(await placeSelect.getByRole("button", { name: "Offline node" }).count()).toBe(0); await placeSelect.getByRole("button", { name: "MacBook" }).click(); @@ -4412,7 +4404,7 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => { await page.getByRole("button", { name: "Use this folder" }).click(); // Using a node folder retargets the draft to that node. - await expect.poll(() => placeLabel.textContent()).toBe("Projects · MacBook"); + await pollLocatorText(placeLabel).toBe("Projects · MacBook"); // A node cwd belongs to the selected agent's draft and must not leak // across an agent change, even though the execution node stays selected. @@ -4423,7 +4415,7 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => { .filter({ hasText: "Research" }) .click(); await page.getByRole("heading", { name: "Research" }).waitFor(); - await expect.poll(() => placeLabel.textContent()).toBe("Agent workspace · MacBook"); + await pollLocatorText(placeLabel).toBe("Agent workspace · MacBook"); // Clearing the path applies the node's default directory (empty folder), // the state the replaced clearable folder textbox could express. @@ -4432,7 +4424,7 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => { await expect.poll(() => pathInput.inputValue()).toBe(NODE_HOME); await pathInput.fill(""); await page.getByRole("button", { name: "Use this folder" }).click(); - await expect.poll(() => placeLabel.textContent()).toBe("Agent workspace · MacBook"); + await pollLocatorText(placeLabel).toBe("Agent workspace · MacBook"); // Browse back to the custom folder, then retarget to the exec-only node // with a manual absolute path for the final create assertion. @@ -4440,7 +4432,7 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => { await placeSelect.getByRole("button", { name: "Browse folders" }).click(); await browserEntries.getByRole("button", { name: "Projects" }).click(); await page.getByRole("button", { name: "Use this folder" }).click(); - await expect.poll(() => placeLabel.textContent()).toBe("Projects · MacBook"); + await pollLocatorText(placeLabel).toBe("Projects · MacBook"); await placeTrigger.click(); await placeSelect.getByRole("button", { name: "Old node" }).click(); @@ -4455,7 +4447,7 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => { ), ).toHaveLength(0); await page.getByRole("button", { name: "Use this folder" }).click(); - await expect.poll(() => placeLabel.textContent()).toBe("repo · Old node"); + await pollLocatorText(placeLabel).toBe("repo · Old node"); await page.locator(".new-session-page__message").fill("inspect the remote checkout"); await page.getByRole("button", { name: "Start thread" }).click();