Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/cli-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/compatibility.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/source-candidate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/webui-npm-preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
25 changes: 19 additions & 6 deletions packages/webui/src/runtime/browser/isolated-chromium/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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", {
Expand All @@ -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,
Expand All @@ -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;
Expand Down
14 changes: 12 additions & 2 deletions packages/webui/test/browser/plugin-catalogue-layout.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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");
Expand Down
Loading