Repository navigation
fix(server): preview clicks fail within their timeout and say why #34922
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
6449644
e7ddb18
26fc6b5
c339cf7
0f02232
5540305
c23c096
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -22,6 +22,7 @@ import { constVoid } from "effect/Function"; | |
| import * as Schema from "effect/Schema"; | ||
| import type { CDPSession, ConsoleMessage, Frame, Locator, Page } from "playwright-core"; | ||
| import * as NodeCrypto from "node:crypto"; | ||
| import * as NodeUtil from "node:util"; | ||
| import { BrowserControlInterrupted } from "./SessionControl.ts"; | ||
|
|
||
| const isBrowserControlInterrupted = Schema.is(BrowserControlInterrupted); | ||
|
|
@@ -62,7 +63,7 @@ export const toOperationError = ( | |
| if (cause instanceof Error && cause.name === "TimeoutError") { | ||
| return new ServerBrowserOperationError({ | ||
| tag: "PreviewAutomationTimeoutError", | ||
| message: firstLine, | ||
| message: withActionabilityReason(firstLine, cause), | ||
| detail: timeoutMs === undefined ? undefined : { timeoutMs }, | ||
| }); | ||
| } | ||
|
|
@@ -84,6 +85,17 @@ export const toOperationError = ( | |
| }); | ||
| }; | ||
|
|
||
| /** Playwright puts why an element was not actionable in its call log, below the first line. */ | ||
| const withActionabilityReason = (message: string, cause: Error) => { | ||
| // Playwright dims call-log lines with ANSI escapes when colour is on. | ||
| const reason = NodeUtil.stripVTControlCharacters(cause.message) | ||
| .split("\n") | ||
| .map((line) => line.trim()) | ||
| .findLast((line) => line.startsWith("- element is ")) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Select a failure reason, not a successful actionability check. When an overlay blocks a button, Playwright 1.64.1 logs Select failed checks explicitly. Normalize pointer interception to a bounded reason instead of exposing the overlay's raw element description. Extend the existing timeout test with an overlay case.
|
||
| ?.slice(2); | ||
| return reason === undefined ? message : `${message} Playwright last saw: ${reason}.`; | ||
| }; | ||
|
|
||
| const DEFAULT_TIMEOUT_MS = 15_000; | ||
| const TARGET_COUNT_MS = 250; | ||
| const MAX_TARGET_CHARS = 200; | ||
|
|
@@ -168,11 +180,11 @@ const targetPoint = async ( | |
| page: Page, | ||
| locator: Locator | null, | ||
| input: { readonly x?: number | undefined; readonly y?: number | undefined }, | ||
| timeout: number, | ||
| timeout: () => number, | ||
| ) => { | ||
| if (locator === null) return { x: input.x ?? 0, y: input.y ?? 0 }; | ||
| await scrollIntoView(locator, timeout); | ||
| const box = await locator.boundingBox({ timeout }); | ||
| await scrollIntoView(locator, timeout()); | ||
| const box = await locator.boundingBox({ timeout: timeout() }); | ||
| if (box) return { x: box.x + box.width / 2, y: box.y + box.height / 2 }; | ||
| const viewport = page.viewportSize(); | ||
| return { x: (viewport?.width ?? 0) / 2, y: (viewport?.height ?? 0) / 2 }; | ||
|
|
@@ -711,10 +723,12 @@ export const click = async ( | |
| input: PreviewAutomationClickInput, | ||
| pointer: PointerReporter = noPointer, | ||
| ): Promise<{ readonly x: number; readonly y: number }> => { | ||
| const timeout = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; | ||
| // One deadline across steps keeps the reply inside the broker's grace; Playwright reads 0 as none. | ||
| const deadline = Date.now() + (input.timeoutMs ?? DEFAULT_TIMEOUT_MS); | ||
| const remaining = () => Math.max(1, deadline - Date.now()); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Check the deadline before the click is dispatched.
A coordinate click that starts near the deadline can therefore run after the request has timed out. A caller that retries after the timeout can repeat the action. Check the deadline after
|
||
| const locator = targetLocator(page, input); | ||
| try { | ||
| return await clickTarget(page, locator, input, timeout, pointer); | ||
| return await clickTarget(page, locator, input, remaining, pointer); | ||
| } catch (cause) { | ||
| throw locator === null ? cause : await namedTargetTimeout(locator, input, cause); | ||
| } | ||
|
|
@@ -750,10 +764,11 @@ const namedTargetTimeout = async ( | |
| : matches === 0 | ||
| ? `The click may have happened and removed its target or navigated; check the page before retrying. No element matches ${target} after the timeout; take a fresh snapshot and use its locator.` | ||
| : `The click may have happened; check the page before retrying. ${target} still matches an element after the timeout.`; | ||
| const message = withActionabilityReason(reason, cause); | ||
| return new ServerBrowserOperationError({ | ||
| tag: "PreviewAutomationTimeoutError", | ||
| message: reason, | ||
| detail: { reason }, | ||
| message, | ||
| detail: { reason: message }, | ||
| cause, | ||
| }); | ||
| }; | ||
|
|
@@ -762,7 +777,7 @@ const clickTarget = async ( | |
| page: Page, | ||
| locator: Locator | null, | ||
| input: PreviewAutomationClickInput, | ||
| timeout: number, | ||
| timeout: () => number, | ||
| pointer: PointerReporter, | ||
| ) => { | ||
| const point = await targetPoint(page, locator, input, timeout); | ||
|
|
@@ -771,7 +786,7 @@ const clickTarget = async ( | |
| const clicked = | ||
| locator === null | ||
| ? page.mouse.click(point.x, point.y, options) | ||
| : locator.click({ ...options, timeout }); | ||
| : locator.click({ ...options, timeout: timeout() }); | ||
| let onDialog = constVoid; | ||
| const dialogOpened = new Promise<"dialog">((resolve) => { | ||
| onDialog = () => resolve("dialog"); | ||
|
|
@@ -852,7 +867,7 @@ export const hover = async ( | |
| ) => { | ||
| const timeout = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; | ||
| const locator = targetLocator(page, input); | ||
| await pointer(await targetPoint(page, locator, input, timeout), "move"); | ||
| await pointer(await targetPoint(page, locator, input, () => timeout), "move"); | ||
| if (locator === null) { | ||
| await page.mouse.move(input.x ?? 0, input.y ?? 0); | ||
| return; | ||
|
|
@@ -903,7 +918,7 @@ export const drag = async ( | |
| const timeout = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; | ||
| const source = targetLocator(page, { locator: input.source })!; | ||
| const target = targetLocator(page, { locator: input.target })!; | ||
| await pointer(await targetPoint(page, source, {}, timeout), "move"); | ||
| await pointer(await targetPoint(page, source, {}, () => timeout), "move"); | ||
| // Observe drag rejection immediately; an unhandled rejection during cursor movement exits the server. | ||
| // Chromium delivers a single move before dragstart; steps give the page mousemoves during the drag. | ||
| const dragFailure = source.dragTo(target, { timeout, steps: 5 }).then( | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Preserve the click's shorter timeout.
This assignment replaces
input.timeoutMswith the remaining broker budget. If the broker has 8 seconds left and the click specifies 2 seconds, the click now receives 8 seconds. When the input omitstimeoutMs, this assignment also bypasses the 15-second default inServerBrowserPage.click.Use the minimum of the remaining broker budget and the original click timeout, with the 15-second fallback. Otherwise, a target can become actionable and receive a click after the click's requested timeout.