mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
test(ui): stabilize browser route readiness (#115320)
This commit is contained in:
committed by
GitHub
parent
1944aa2ad4
commit
4683c752ed
@@ -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
@@ -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` |
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user