test(ui): stabilize browser route readiness (#115320)

This commit is contained in:
Peter Steinberger
2026-07-28 13:59:05 -04:00
committed by GitHub
parent 1944aa2ad4
commit 4683c752ed
7 changed files with 83 additions and 47 deletions
+4 -1
View File
@@ -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.
+1 -1
View File
@@ -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` |
+2 -1
View File
@@ -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);
+2 -26
View File
@@ -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) {
+3 -15
View File
@@ -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<number> {
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
@@ -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 {
+60 -1
View File
@@ -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 = [