Skip to content

Commit 57a9853

Browse files
Fix: playwright scrolling element into view (#654)
* attempt force click on timeout * add eval * changeset * better error messages * update based on new eval structure --------- Co-authored-by: Anirudh Kamath <[email protected]>
1 parent 169e7ea commit 57a9853

File tree

4 files changed

+109
-7
lines changed

4 files changed

+109
-7
lines changed

Diff for: .changeset/plenty-corners-bake.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@browserbasehq/stagehand": patch
3+
---
4+
5+
fix repeated up & down scrolling bug for clicks inside `act`

Diff for: evals/evals.config.json

+4
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,10 @@
305305
{
306306
"name": "prevChunk",
307307
"categories": ["regression", "act"]
308+
},
309+
{
310+
"name": "google_flights",
311+
"categories": ["act"]
308312
}
309313
]
310314
}

Diff for: evals/tasks/google_flights.ts

+55
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { EvalFunction } from "@/types/evals";
2+
import { ObserveResult } from "@/types/stagehand";
3+
4+
/**
5+
* This eval attempts to click on an element that should not pass the playwright actionability check
6+
* which happens by default if you call locator.click (more information here:
7+
* https://playwright.dev/docs/actionability)
8+
*
9+
* If this eval passes, it means that we have correctly set {force: true} in performPlaywrightMethod,
10+
* and the click was successful even though the target element (found by the xpath) did not
11+
* pass the actionability check.
12+
*/
13+
14+
export const google_flights: EvalFunction = async ({
15+
debugUrl,
16+
sessionUrl,
17+
stagehand,
18+
logger,
19+
}) => {
20+
await stagehand.page.goto(
21+
"https://browserbase.github.io/stagehand-eval-sites/sites/google-flights/",
22+
);
23+
24+
const observeResult: ObserveResult = {
25+
selector:
26+
"xpath=/html/body/c-wiz[2]/div/div[2]/c-wiz/div[1]/c-wiz/div[2]/div[2]/div[2]/div/div[2]/div[1]/ul/li[1]/div/div[1]",
27+
description: "the first departing flight",
28+
method: "click",
29+
arguments: [],
30+
};
31+
await stagehand.page.act(observeResult);
32+
33+
const expectedUrl =
34+
"https://browserbase.github.io/stagehand-eval-sites/sites/google-flights/return-flight.html";
35+
const currentUrl = stagehand.page.url();
36+
37+
await stagehand.close();
38+
39+
if (currentUrl === expectedUrl) {
40+
return {
41+
_success: true,
42+
currentUrl,
43+
debugUrl,
44+
sessionUrl,
45+
logs: logger.getLogs(),
46+
};
47+
}
48+
return {
49+
_success: false,
50+
error: "The current URL does not match expected.",
51+
logs: logger.getLogs(),
52+
debugUrl,
53+
sessionUrl,
54+
};
55+
};

Diff for: lib/handlers/handlerUtils/actHandlerUtils.ts

+45-7
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Page, Locator } from "@playwright/test";
1+
import { Page, Locator, errors as PlaywrightErrors } from "@playwright/test";
22
import { PlaywrightCommandException } from "../../../types/playwright";
33
import { StagehandPage } from "../../StagehandPage";
44
import { getNodeFromXpath } from "@/lib/dom/utils";
@@ -348,8 +348,11 @@ export async function clickElement(ctx: MethodHandlerContext) {
348348
return el instanceof HTMLInputElement && el.type === "radio";
349349
});
350350

351-
const clickArg = args.length ? args[0] : undefined;
351+
// Extract the click options (if any) from args[0]
352+
const clickArg = (args[0] ?? {}) as Record<string, unknown>;
352353

354+
// Decide which locator we actually want to click (for radio inputs, prefer label if present)
355+
let finalLocator = locator;
353356
if (isRadio) {
354357
const inputId = await locator.evaluate(
355358
(el) => (el as HTMLInputElement).id,
@@ -378,12 +381,44 @@ export async function clickElement(ctx: MethodHandlerContext) {
378381
}
379382

380383
if ((await labelLocator.count()) > 0) {
381-
await labelLocator.click(clickArg);
384+
finalLocator = labelLocator;
385+
}
386+
}
387+
388+
// Try clicking with a short (5s) timeout
389+
try {
390+
await finalLocator.click({
391+
...clickArg,
392+
timeout: 5000,
393+
});
394+
} catch (error) {
395+
// If it's a TimeoutError, retry with force: true
396+
if (error instanceof PlaywrightErrors.TimeoutError) {
397+
logger({
398+
category: "action",
399+
message: "First click attempt timed out, retrying with force...",
400+
level: 2,
401+
});
402+
try {
403+
await finalLocator.click({
404+
...clickArg,
405+
force: true,
406+
});
407+
} catch (forceError) {
408+
// If forced click also fails, throw a more descriptive error
409+
throw new PlaywrightCommandException(
410+
`Failed to click element at [${xpath}]. ` +
411+
`Timeout after 5s, then force-click also failed. ` +
412+
`Original timeout error: ${error.message}, ` +
413+
`Force-click error: ${forceError.message}`,
414+
);
415+
}
382416
} else {
383-
await locator.click(clickArg);
417+
// Non-timeout error on the first click
418+
throw new PlaywrightCommandException(
419+
`Failed to click element at [${xpath}]. ` + `Error: ${error.message}`,
420+
);
384421
}
385-
} else {
386-
await locator.click(clickArg);
387422
}
388423
} catch (e) {
389424
logger({
@@ -398,7 +433,10 @@ export async function clickElement(ctx: MethodHandlerContext) {
398433
args: { value: JSON.stringify(args), type: "object" },
399434
},
400435
});
401-
throw new PlaywrightCommandException(e.message);
436+
437+
throw new PlaywrightCommandException(
438+
`Could not complete click action at [${xpath}]. Reason: ${e.message}`,
439+
);
402440
}
403441

404442
await handlePossiblePageNavigation(

0 commit comments

Comments
 (0)