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 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; 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");