diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b19bd972de34..e1976caa03fe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1405,7 +1405,10 @@ jobs: # Compatibility targets pin a frozen Control UI whose e2e expectations track # that release, not current main. if: needs.preflight.outputs.run_ui_tests == 'true' && needs.preflight.outputs.compatibility_target != 'true' - runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }} + # Chromium, Vite, and the Vitest coordinator share this runner even though + # files are serial. Four-vCPU runners let host-side CDP polling starve while + # the browser kept making progress, producing rotating timeout failures. + runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-24.04') }} # The suite runs one file at a time (fileParallelism: false) because each # file owns a Chromium context and a mocked Gateway; ~21 min measured, so # this cannot share checks-ui's 20-minute budget. diff --git a/docs/ci.md b/docs/ci.md index 4c6738ab2207..999806416542 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -182,7 +182,7 @@ for commands and recovery. | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ubuntu-24.04` | `security-fast`, manual CI dispatch and non-canonical repository fallbacks, the QA Smoke aggregate, CodeQL security and quality scans, workflow-sanity, labeler, auto-response, the standalone Docs workflow, and the whole Install Smoke workflow | | `blacksmith-4vcpu-ubuntu-2404` | `preflight`, `pnpm-store-warmup`, `native-i18n`, `checks-fast-core` except QA Smoke CI, plugin/channel contract shards, most bundled/lower-weight Linux Node shards, `check-*` lanes except `check-lint`, selected `check-additional-*` shards, `check-docs`, and `skills-python` | -| `blacksmith-8vcpu-ubuntu-2404` | Retained heavy Linux Node suites, boundary/extension-heavy `check-additional-*` shards, and `android` | +| `blacksmith-8vcpu-ubuntu-2404` | Retained heavy Linux Node suites, the serial Chromium/Vite `checks-ui-e2e` lane, boundary/extension-heavy `check-additional-*` shards, and `android` | | `blacksmith-16vcpu-ubuntu-2404` | Automatic QA Smoke CI shards, `build-artifacts` in CI and Testbox, and `check-lint` (CPU-sensitive enough that 8 vCPU cost more than they saved) | | `blacksmith-8vcpu-windows-2025` | `checks-windows` | | `blacksmith-6vcpu-macos-15` | `macos-node` on `openclaw/openclaw`; forks fall back to `macos-15` | diff --git a/test/scripts/ci-workflow-guards.test.ts b/test/scripts/ci-workflow-guards.test.ts index 30efb861fcd5..ee0984122813 100644 --- a/test/scripts/ci-workflow-guards.test.ts +++ b/test/scripts/ci-workflow-guards.test.ts @@ -4304,7 +4304,8 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}" expect(uiE2e.if).toBe( "needs.preflight.outputs.run_ui_tests == 'true' && needs.preflight.outputs.compatibility_target != 'true'", ); - expect(uiE2e["runs-on"]).toBe(ui["runs-on"]); + expect(uiE2e["runs-on"]).toContain("blacksmith-8vcpu-ubuntu-2404"); + expect(uiE2e["runs-on"]).not.toBe(ui["runs-on"]); // The full suite runs one file at a time (fileParallelism: false), so it // needs a wider budget than the single-file gate this job replaced. expect(uiE2e["timeout-minutes"]).toBe(45); diff --git a/ui/src/e2e/new-session-page.test-support.ts b/ui/src/e2e/new-session-page.test-support.ts index efbaa8fc5077..241a14a696a8 100644 --- a/ui/src/e2e/new-session-page.test-support.ts +++ b/ui/src/e2e/new-session-page.test-support.ts @@ -6,6 +6,7 @@ import { controlUiSessionPath, controlUiSessionUrl, installMockGateway, + waitForControlUiRoute, } from "../test-helpers/control-ui-e2e.ts"; import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts"; @@ -146,32 +147,7 @@ export async function navigateInApp(page: Page, routeId: string, search = "") { * the successful active match and browser location to agree before leaving. */ export async function waitForCommittedChatRoute(page: Page) { - await page.waitForURL((url) => url.pathname.startsWith("/chat/")); - await expect - .poll(() => - page.evaluate(() => { - const app = document.querySelector("openclaw-app") as HTMLElement & { - runtime?: { - router: { - getState: () => { - status: string; - resolvedLocation: { pathname: string } | null; - matches: { routeId: string }[]; - pendingMatches: unknown[]; - }; - }; - }; - }; - const state = app.runtime?.router.getState(); - return ( - state?.status === "success" && - state.matches[0]?.routeId === "chat" && - state.resolvedLocation?.pathname === window.location.pathname && - state.pendingMatches.length === 0 - ); - }), - ) - .toBe(true); + await waitForControlUiRoute(page, { pathnamePrefix: "/chat/", routeId: "chat" }); } export async function choosePackagesFolder(page: Page) { diff --git a/ui/src/e2e/sidebar-customization.e2e.test.ts b/ui/src/e2e/sidebar-customization.e2e.test.ts index a4908407f090..cd24285d0dee 100644 --- a/ui/src/e2e/sidebar-customization.e2e.test.ts +++ b/ui/src/e2e/sidebar-customization.e2e.test.ts @@ -10,6 +10,7 @@ import { installMockGateway, resolvePlaywrightChromiumExecutablePath, startControlUiE2eServer, + waitForControlUiSettingsTakeover, type ControlUiE2eServer, } from "../test-helpers/control-ui-e2e.ts"; @@ -36,15 +37,6 @@ async function roundedWidth(locator: Locator): Promise { return Math.round((await locator.boundingBox())?.width ?? 0); } -async function waitForSettingsSidebar(page: Page) { - const sidebar = page.locator(".settings-sidebar"); - const search = sidebar.getByRole("searchbox", { name: "Search settings" }); - await sidebar.waitFor({ state: "visible" }); - // The route shell can paint before the takeover controls settle on a loaded CI host. - await search.waitFor({ state: "visible" }); - return { search, sidebar }; -} - function visibleDrawerButton(page: Page) { return page.locator(".topbar-nav-toggle:visible, .chat-pane__nav-toggle:visible").first(); } @@ -170,7 +162,7 @@ describeControlUiE2e("Control UI sidebar customization mocked Gateway E2E", () = try { await page.goto(`${server.baseUrl}settings/general`); const { search: settingsSearchInput, sidebar: settingsSidebar } = - await waitForSettingsSidebar(page); + await waitForControlUiSettingsTakeover(page); const settingsSearchShell = settingsSidebar.locator(".settings-sidebar__search"); const settingsNav = settingsSidebar.locator(".settings-sidebar__nav"); const firstSettingsLink = settingsSidebar.locator(".settings-sidebar__item").first(); @@ -340,12 +332,8 @@ describeControlUiE2e("Control UI sidebar customization mocked Gateway E2E", () = }; await expect.poll(() => identityCard.isVisible()).toBe(true); await openSettingsFromIdentity(); - await expect.poll(() => new URL(page.url()).pathname).toBe("/settings/general"); - // Route changes paint Settings before the previous app sidebar finishes yielding. - await sidebar.waitFor({ state: "hidden" }); const { search: settingsSearch, sidebar: settingsSidebar } = - await waitForSettingsSidebar(page); - await expect.poll(() => sidebar.isVisible()).toBe(false); + await waitForControlUiSettingsTakeover(page); await expect .poll(() => settingsSidebar diff --git a/ui/src/pages/workboard/workboard-routing.e2e.test.ts b/ui/src/pages/workboard/workboard-routing.e2e.test.ts index 1fe75f222d1a..c55b03d26fb5 100644 --- a/ui/src/pages/workboard/workboard-routing.e2e.test.ts +++ b/ui/src/pages/workboard/workboard-routing.e2e.test.ts @@ -7,6 +7,7 @@ import { installMockGateway, resolvePlaywrightChromiumExecutablePath, startControlUiE2eServer, + waitForControlUiRoute, type ControlUiE2eServer, } from "../../test-helpers/control-ui-e2e.ts"; @@ -147,7 +148,11 @@ describeControlUiE2e("Control UI Workboard routing", () => { }); await page.goto(`${server.baseUrl}workboard?board=ops&agent=main`); - await expect.poll(() => new URL(page.url()).pathname).toBe("/workboard/ops"); + await waitForControlUiRoute(page, { + pathname: "/workboard/ops", + routeId: "workboard", + search: "?agent=main", + }); expect(new URL(page.url()).searchParams.get("board")).toBeNull(); expect(new URL(page.url()).searchParams.get("agent")).toBe("main"); @@ -160,7 +165,11 @@ describeControlUiE2e("Control UI Workboard routing", () => { }); await page.goto(`${server.baseUrl}workboard/deleted?agent=main`); - await expect.poll(() => new URL(page.url()).pathname).toBe("/workboard"); + await waitForControlUiRoute(page, { + pathname: "/workboard", + routeId: "workboard", + search: "?agent=main", + }); expect(new URL(page.url()).searchParams.get("agent")).toBe("main"); await page.locator(".workboard-page-title", { hasText: "Workboard" }).waitFor(); } finally { diff --git a/ui/src/test-helpers/control-ui-e2e.ts b/ui/src/test-helpers/control-ui-e2e.ts index 9e4aed41711d..4d9edd08ed06 100644 --- a/ui/src/test-helpers/control-ui-e2e.ts +++ b/ui/src/test-helpers/control-ui-e2e.ts @@ -6,7 +6,7 @@ import { createServer as createNetServer } from "node:net"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { buildControlUiSessionPath } from "@openclaw/session-url-contract"; -import type { Page } from "playwright"; +import type { Locator, Page } from "playwright"; import type { ViteDevServer } from "vite"; import { PROTOCOL_VERSION } from "../../../packages/gateway-protocol/src/version.js"; import { CONTROL_UI_BOOTSTRAP_CONFIG_PATH } from "../../../src/gateway/control-ui-contract.js"; @@ -37,6 +37,65 @@ export function controlUiSessionUrl(baseUrl: string, sessionKey: string): string return url.toString(); } +type ControlUiRouteTarget = { + hash?: string; + pathname?: string; + pathnamePrefix?: string; + routeId: string; + search?: string; +}; + +/** + * Wait for the browser router to commit a route, not merely update the URL. + * Browser-local polling keeps readiness independent of host-side CDP scheduling. + */ +export async function waitForControlUiRoute(page: Page, target: ControlUiRouteTarget) { + const handle = await page.waitForFunction( + (expected) => { + const app = document.querySelector("openclaw-app") as HTMLElement & { + runtime?: { + router: { + getState: () => { + status: string; + resolvedLocation: { pathname: string } | null; + matches: { routeId: string }[]; + pendingMatches: unknown[]; + }; + }; + }; + }; + const state = app.runtime?.router.getState(); + const pathname = window.location.pathname; + return ( + state?.status === "success" && + state.matches[0]?.routeId === expected.routeId && + state.resolvedLocation?.pathname === pathname && + state.pendingMatches.length === 0 && + (expected.pathname === undefined || pathname === expected.pathname) && + (expected.pathnamePrefix === undefined || pathname.startsWith(expected.pathnamePrefix)) && + (expected.search === undefined || window.location.search === expected.search) && + (expected.hash === undefined || window.location.hash === expected.hash) + ); + }, + target, + { timeout: 30_000 }, + ); + await handle.dispose(); +} + +export async function waitForControlUiSettingsTakeover( + page: Page, + pathname = "/settings/general", +): Promise<{ search: Locator; sidebar: Locator }> { + await waitForControlUiRoute(page, { pathname, routeId: "config" }); + const appSidebar = page.locator("openclaw-app-sidebar"); + const sidebar = page.locator(".settings-sidebar"); + const search = sidebar.getByRole("searchbox", { name: "Search settings" }); + await appSidebar.waitFor({ state: "detached" }); + await search.waitFor({ state: "visible" }); + return { search, sidebar }; +} + const require = createRequire(import.meta.url); const json5EsmPath = require.resolve("json5/dist/index.mjs"); const commonJsOptimizeDeps = [