From f7119fe30636d6ef25e3c713b1dcb85142772802 Mon Sep 17 00:00:00 2001 From: probe Date: Fri, 23 Oct 2026 20:14:39 +0800 Subject: [PATCH 1/3] fix(webui): give a busy browser the time it needs to answer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The isolated Chromium's command budget was fifteen seconds. On a loaded machine that is not a slow browser but a busy one: the same CI runner, with the same code, answered a page open in about half a minute and a pointer dispatch in seconds — and the fifteen-second budget turned that into a failure. The open was abandoned halfway, so the panel never received a frame and the person saw an empty browser instead of a slow one. The budget is a guard against a wedged browser, so it stays a bound, but it now fits the machine the browser runs on. The commands that wait for the browser to work on the page get a minute; an input command, which the browser answers without waiting for the page to do anything, gets half that, because a person is waiting for a click to land. --- .../isolated-chromium/cdp-connection.ts | 27 +++++++++++++++++-- .../browser/isolated-chromium/provider.ts | 25 ++++++++++++----- 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/packages/webui/src/runtime/browser/isolated-chromium/cdp-connection.ts b/packages/webui/src/runtime/browser/isolated-chromium/cdp-connection.ts index 60b3c8b12..cb14ba41f 100644 --- a/packages/webui/src/runtime/browser/isolated-chromium/cdp-connection.ts +++ b/packages/webui/src/runtime/browser/isolated-chromium/cdp-connection.ts @@ -23,8 +23,31 @@ import WebSocket from "ws"; import { browserOperationError } from "../errors.js"; -/** How long a single CDP command may take before it is abandoned. */ -export const WEBUI_BROWSER_CDP_COMMAND_TIMEOUT_MS = 15_000; +/** + * How long a single CDP command may take before it is abandoned. + * + * The budget is a guard against a wedged browser, not a performance target: a + * command that runs out of time rejects instead of hanging the caller + * forever. It has to be generous enough for a browser that is *busy* rather + * than broken, because the two are the same thing from here. + * + * On a loaded machine — a CI runner with three cores, where the WebUI suite + * drives a browser while an Agent turn is still streaming — the browser + * answers a page open in about half a minute and a pointer dispatch in + * seconds. The old fifteen-second budget turned that slowness into a + * failure: the open was abandoned halfway, so the panel never received a + * frame and the person saw an empty browser instead of a slow one. + */ +export const WEBUI_BROWSER_CDP_COMMAND_TIMEOUT_MS = 60_000; + +/** + * How long an input command may take. + * + * An input command is answered by the browser without waiting for the page to + * do anything, so it gets a shorter budget than the commands that wait for + * the browser to work on the page. A person is waiting for a click to land. + */ +export const WEBUI_BROWSER_CDP_INPUT_TIMEOUT_MS = 30_000; /** How long the WebSocket handshake may take. */ export const WEBUI_BROWSER_CDP_HANDSHAKE_TIMEOUT_MS = 10_000; diff --git a/packages/webui/src/runtime/browser/isolated-chromium/provider.ts b/packages/webui/src/runtime/browser/isolated-chromium/provider.ts index 410a2c015..717c04beb 100644 --- a/packages/webui/src/runtime/browser/isolated-chromium/provider.ts +++ b/packages/webui/src/runtime/browser/isolated-chromium/provider.ts @@ -56,6 +56,7 @@ import type { import { WEBUI_BROWSER_RESTRICTED_COMMANDS } from "../../../shared/contracts/browser.js"; import { browserOperationError } from "../errors.js"; import { + WEBUI_BROWSER_CDP_INPUT_TIMEOUT_MS, WebuiBrowserCdpConnection, type WebuiBrowserCdpEvent, } from "./cdp-connection.js"; @@ -685,12 +686,17 @@ export class WebuiIsolatedChromiumProvider implements WebuiBrowserSurfaceProvide // Input is not retried: after a CDP timeout it is unknown whether the // browser acted, and replaying a click or key sequence can duplicate it. { + // An input command is answered by the browser without waiting for the + // page to do anything, so it carries the shorter input budget rather + // than the one the commands that wait for the browser to work use. + const budget = { timeoutMs: WEBUI_BROWSER_CDP_INPUT_TIMEOUT_MS }; const base = { x: command.x, y: command.y }; if (command.action === "move") { await connection.send( "Input.dispatchMouseEvent", { ...base, type: "mouseMoved", button: "none" }, sessionId, + budget, ); } else if (command.action === "scroll") { await connection.send("Input.dispatchMouseEvent", { @@ -699,31 +705,32 @@ export class WebuiIsolatedChromiumProvider implements WebuiBrowserSurfaceProvide deltaX: command.deltaX, deltaY: command.deltaY, button: "none", - }, sessionId); + }, sessionId, budget); } else if (command.action === "press" || command.action === "release") { await connection.send("Input.dispatchMouseEvent", { ...base, type: command.action === "press" ? "mousePressed" : "mouseReleased", button: command.button, clickCount: command.clickCount ?? 1, - }, sessionId); + }, sessionId, budget); } else { await connection.send("Input.dispatchMouseEvent", { ...base, type: "mousePressed", button: command.button, clickCount: command.clickCount, - }, sessionId); + }, sessionId, budget); await connection.send("Input.dispatchMouseEvent", { ...base, type: "mouseReleased", button: command.button, clickCount: command.clickCount, - }, sessionId); + }, sessionId, budget); } } return; case "key": { + const budget = { timeoutMs: WEBUI_BROWSER_CDP_INPUT_TIMEOUT_MS }; const modifiers = command.modifiers.reduce( (mask, modifier) => mask | CDP_MODIFIER_MASK[modifier], 0, @@ -737,12 +744,18 @@ export class WebuiIsolatedChromiumProvider implements WebuiBrowserSurfaceProvide "Input.dispatchKeyEvent", { type: command.text ? "keyDown" : "rawKeyDown", text: command.text ?? "", ...base }, sessionId, + budget, ); - await connection.send("Input.dispatchKeyEvent", { type: "keyUp", ...base }, sessionId); + await connection.send("Input.dispatchKeyEvent", { type: "keyUp", ...base }, sessionId, budget); return; } case "text": - await connection.send("Input.insertText", { text: command.text }, sessionId); + await connection.send( + "Input.insertText", + { text: command.text }, + sessionId, + { timeoutMs: WEBUI_BROWSER_CDP_INPUT_TIMEOUT_MS }, + ); return; default: { const exhaustive: never = command; From a7b96d9cc72b00ecb7dfb8f624dac96be20b4404 Mon Sep 17 00:00:00 2001 From: probe Date: Fri, 23 Oct 2026 20:16:01 +0800 Subject: [PATCH 2/3] ci: give the verification jobs the ceiling the browser suite needs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The WebUI group's files now run one at a time, and the Linux browser gate installs the browser's system dependencies — an apt run that takes between a minute and a quarter of an hour depending on the runner's cache. Together they put the Ubuntu verification job close to its twenty-five minute ceiling, and a job that is cancelled by the runner is a job that never reports. The ceiling is not a budget: a job that finishes in eleven minutes still takes eleven minutes. It has to fit the slow end of what the gate does, so the jobs that run the verification pipeline get the same forty minutes the CI verify job now has. --- .github/workflows/ci.yml | 9 +++++++-- .github/workflows/cli-release.yml | 4 +++- .github/workflows/compatibility.yml | 4 +++- .github/workflows/source-candidate.yml | 4 +++- .github/workflows/webui-npm-preview.yml | 4 +++- 5 files changed, 19 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ab08e8383..a49191601 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,8 +54,13 @@ jobs: # A ceiling, not a cost: a job that finishes in eleven minutes still takes # eleven minutes. The Linux profile drives the real-browser suite, which # launches a browser per case because each case needs a clean extension - # state, and that suite does not fit in fifteen minutes any more. - timeout-minutes: 25 + # state, and that suite does not fit in fifteen minutes any more. The + # WebUI group now runs its files one at a time, and the browser gate + # installs the browser's system dependencies — an apt run that takes + # between a minute and a quarter of an hour depending on the runner's + # cache. The ceiling has to fit the slow end of that, or the runner + # cancels a job that was working. + timeout-minutes: 40 strategy: fail-fast: false matrix: diff --git a/.github/workflows/cli-release.yml b/.github/workflows/cli-release.yml index 8a0fde010..8ed30c632 100644 --- a/.github/workflows/cli-release.yml +++ b/.github/workflows/cli-release.yml @@ -29,7 +29,9 @@ concurrency: jobs: build: runs-on: ubuntu-latest - timeout-minutes: 25 + # The same ceiling the CI verify job has: this job runs the same + # verification pipeline, including the real-browser suite. + timeout-minutes: 40 outputs: version: ${{ steps.release.outputs.version }} matrix: ${{ steps.release.outputs.matrix }} diff --git a/.github/workflows/compatibility.yml b/.github/workflows/compatibility.yml index 1b716b138..4208fcce8 100644 --- a/.github/workflows/compatibility.yml +++ b/.github/workflows/compatibility.yml @@ -12,7 +12,9 @@ jobs: compatibility: name: compatibility (${{ matrix.os }}, ${{ matrix.node }}) runs-on: ${{ matrix.os }} - timeout-minutes: 15 + # This job runs the same platform profile the CI verify job runs, so it + # needs the same ceiling. + timeout-minutes: 40 strategy: fail-fast: false matrix: diff --git a/.github/workflows/source-candidate.yml b/.github/workflows/source-candidate.yml index 878f7736c..8baab4f90 100644 --- a/.github/workflows/source-candidate.yml +++ b/.github/workflows/source-candidate.yml @@ -52,7 +52,9 @@ jobs: validate: needs: export runs-on: ${{ matrix.os }} - timeout-minutes: 20 + # This job runs the same verification pipeline the CI verify job runs, so + # it needs the same ceiling. + timeout-minutes: 40 strategy: fail-fast: false matrix: diff --git a/.github/workflows/webui-npm-preview.yml b/.github/workflows/webui-npm-preview.yml index 41dec29c3..aa757bfc3 100644 --- a/.github/workflows/webui-npm-preview.yml +++ b/.github/workflows/webui-npm-preview.yml @@ -15,7 +15,9 @@ concurrency: jobs: publish: runs-on: ubuntu-latest - timeout-minutes: 30 + # This job runs the same verification pipeline the CI verify job runs, so + # it needs the same ceiling. + timeout-minutes: 45 # `secrets` cannot be read inside a step's `if:` ("Secrets cannot be # directly referenced in if: conditionals"), so a step cannot be gated on # whether the credential exists without going through a job-level env entry From 1a7caf587e81e9d1589179b8432741248e8cfc1a Mon Sep 17 00:00:00 2001 From: probe Date: Fri, 23 Oct 2026 20:39:43 +0800 Subject: [PATCH 3/3] test(webui): make the create-menu click in the agent form case unambiguous MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The case opened the Agent form twice — once from the header's create menu, and once after dismissing the form again. Both clicks asked the page for "a button named 创建 Agent" without saying where that button had to be. The create menu mounts in an effect after its trigger is clicked, so the menu entry can still be missing a frame later — and the agent list carries a button of its own with the same name as soon as an agent form has been opened. Which of the two the query found therefore depended on whether the menu had mounted yet: sometimes it clicked the agent-list button and the case passed without ever going through the menu it meant to exercise, and sometimes both were on the page at once and Playwright's strict mode rejected the click. The query is now scoped to the menu and waits for the entry to be visible, so the click lands on the menu entry every time. No expectation changed. How it was verified: the case was run eight times in a row and the whole spec three times over; every run passed. The click can no longer reach anything but the menu entry, because the query now says where the button has to live. --- .../test/browser/plugin-catalogue-layout.spec.mjs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/webui/test/browser/plugin-catalogue-layout.spec.mjs b/packages/webui/test/browser/plugin-catalogue-layout.spec.mjs index df7ca9e18..cc97ddaf6 100644 --- a/packages/webui/test/browser/plugin-catalogue-layout.spec.mjs +++ b/packages/webui/test/browser/plugin-catalogue-layout.spec.mjs @@ -155,7 +155,16 @@ test("Agent add form is centered, fields are aligned and backdrop dismissal work }); }); await openManagement(page); - await page.getByRole("button", { name: "创建 Agent" }).click(); + // The create menu mounts in an effect after its trigger is clicked, so the + // entry can still be missing a moment later — and "创建 Agent" is also the + // name of a button of its own on the agent list, which this same page shows + // once an agent form has been opened. The query is scoped to the menu and + // waits for the entry, so the click lands on the menu entry and never on + // whatever else happens to carry the same name. + const createMenu = page.getByRole("menu", { name: "创建" }); + const createAgent = createMenu.getByRole("button", { name: "创建 Agent" }); + await expect(createAgent).toBeVisible(); + await createAgent.click(); const dialog = page.getByRole("dialog", { name: "Agent" }); await expectCenteredDialog(dialog, page, ".webui-agent-form"); await expectMobileDialog(dialog, page, ".webui-agent-form"); @@ -182,7 +191,8 @@ test("Agent add form is centered, fields are aligned and backdrop dismissal work await expect(dialog).toHaveCount(0); await page.locator(".webui-plugin-create-trigger").click(); - await page.getByRole("button", { name: "创建 Agent" }).click(); + await expect(createAgent).toBeVisible(); + await createAgent.click(); const reopened = page.getByRole("dialog", { name: "Agent" }); await reopened.getByLabel("名称").fill("local-agent"); await reopened.getByLabel("描述").fill("A synthetic local agent");