feat: descriptive click timeout errors + faster action timeouts for agents

Two improvements for agent experience with Playwright actions:

1. Descriptive timeout errors (via @xmorse/playwright-core 1.59.6):
   When locator.click() times out, the error now explains WHY — e.g.
   'Element is not visible', '<div id="overlay"> intercepts pointer events'.
   The 'not visible' case includes tips about common causes (collapsed
   <details>, inactive tab, CSS) and suggests { force: true }.

2. Faster action timeouts in executor:
   - Action timeout (click, fill, hover): 2s (was 10s)
   - Navigation timeout (goto, reload): 10s (unchanged)
   - Code execution timeout: 10s (unchanged)
   Agents now get fast failure with descriptive errors instead of waiting
   10 seconds for a generic 'Timeout exceeded.'

Also removed the context.setDefaultTimeout(timeout) override in execute()
that was resetting action timeouts back to 10s on every call.

Tests added for three click failure scenarios: hidden element in collapsed
<details>, element covered by overlay, and display:none element.
This commit is contained in:
Tommy D. Rossi
2026-02-28 15:20:10 +01:00
parent 9a92c840d9
commit 61873b27a4
5 changed files with 108 additions and 3 deletions
+7
View File
@@ -1,5 +1,12 @@
# Changelog # Changelog
## 0.0.80
### Improvements
- **Descriptive click timeout errors**: When `locator.click()` times out due to actionability failures, the error now includes the reason (e.g. "Element is not visible", "Element is not stable", "<button> intercepts pointer events") instead of just "Timeout exceeded."
- **Faster action timeouts for agents**: Default Playwright action timeout reduced from 10s to 2s. Navigation timeout remains at 10s. Agents now get fast failure with descriptive errors instead of waiting 10 seconds for a generic timeout.
## 0.0.79 ## 0.0.79
### Improvements ### Improvements
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "playwriter", "name": "playwriter",
"description": "", "description": "",
"version": "0.0.79", "version": "0.0.80",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
+10 -1
View File
@@ -592,6 +592,11 @@ export class PlaywrightExecutor {
const contexts = browser.contexts() const contexts = browser.contexts()
const context = contexts.length > 0 ? contexts[0] : await browser.newContext() const context = contexts.length > 0 ? contexts[0] : await browser.newContext()
// Action timeout (click, fill, hover, etc.) is short for fast agent failure.
// Navigation timeout (goto, reload) is longer since page loads are slower.
context.setDefaultTimeout(2000)
context.setDefaultNavigationTimeout(10000)
context.on('page', (page) => { context.on('page', (page) => {
this.setupPageListeners(page) this.setupPageListeners(page)
}) })
@@ -669,6 +674,11 @@ export class PlaywrightExecutor {
const contexts = browser.contexts() const contexts = browser.contexts()
const context = contexts.length > 0 ? contexts[0] : await browser.newContext() const context = contexts.length > 0 ? contexts[0] : await browser.newContext()
// Action timeout (click, fill, hover, etc.) is short for fast agent failure.
// Navigation timeout (goto, reload) is longer since page loads are slower.
context.setDefaultTimeout(2000)
context.setDefaultNavigationTimeout(10000)
context.on('page', (page) => { context.on('page', (page) => {
this.setupPageListeners(page) this.setupPageListeners(page)
}) })
@@ -717,7 +727,6 @@ export class PlaywrightExecutor {
await this.ensureConnection() await this.ensureConnection()
const page = await this.getCurrentPage(timeout) const page = await this.getCurrentPage(timeout)
const context = this.context || page.context() const context = this.context || page.context()
context.setDefaultTimeout(timeout)
this.logger.log('Executing code:', code) this.logger.log('Executing code:', code)
+89
View File
@@ -1018,4 +1018,93 @@ describe('Relay Core Tests', () => {
// Cleanup // Cleanup
await page2.close() await page2.close()
}, 60000) }, 60000)
it('should show descriptive error when clicking a hidden element', async () => {
// Create a fresh page and set content with a collapsed details element
await client.callTool({
name: 'execute',
arguments: {
code: js`
state.errorTestPage = await context.newPage();
await state.errorTestPage.setContent(\`
<details>
<summary>Toggle</summary>
<button id="hidden-btn">Hidden Button</button>
</details>
\`);
`,
},
})
const result = await client.callTool({
name: 'execute',
arguments: {
code: js`
await state.errorTestPage.click('#hidden-btn');
`,
},
})
expect((result as any).isError).toBe(true)
const text = (result as any).content[0].text
// Strip stack traces and call logs to only match the descriptive error line
const errorLine = text.split('\n').find((l: string) => l.includes('Timeout') || l.includes('not visible') || l.includes('not stable'))
expect(errorLine).toMatchInlineSnapshot(`"Error executing code: page.click: Timeout 2000ms exceeded. Element is not visible"`)
// Cleanup
await client.callTool({ name: 'execute', arguments: { code: js`await state.errorTestPage.close(); delete state.errorTestPage;` } })
}, 30000)
it('should show descriptive error when clicking an element covered by another', async () => {
await client.callTool({
name: 'execute',
arguments: {
code: js`
state.errorTestPage = await context.newPage();
await state.errorTestPage.setContent(\`
<div style="position:relative">
<button id="covered-btn" style="position:absolute;top:0;left:0">Covered</button>
<div id="overlay" style="position:absolute;top:0;left:0;width:200px;height:200px;background:red;z-index:10">Overlay</div>
</div>
\`);
`,
},
})
const result = await client.callTool({
name: 'execute',
arguments: {
code: js`
await state.errorTestPage.click('#covered-btn');
`,
},
})
expect((result as any).isError).toBe(true)
const text = (result as any).content[0].text
const errorLine = text.split('\n').find((l: string) => l.includes('Timeout') || l.includes('intercepts'))
expect(errorLine).toMatchInlineSnapshot(`"Error executing code: page.click: Timeout 2000ms exceeded. <div id="overlay">Overlay</div> intercepts pointer events"`)
await client.callTool({ name: 'execute', arguments: { code: js`await state.errorTestPage.close(); delete state.errorTestPage;` } })
}, 30000)
it('should show descriptive error when clicking a display:none element', async () => {
await client.callTool({
name: 'execute',
arguments: {
code: js`
state.errorTestPage = await context.newPage();
await state.errorTestPage.setContent('<button id="invisible" style="display:none">Invisible</button>');
`,
},
})
const result = await client.callTool({
name: 'execute',
arguments: {
code: js`
await state.errorTestPage.click('#invisible');
`,
},
})
expect((result as any).isError).toBe(true)
const text = (result as any).content[0].text
const errorLine = text.split('\n').find((l: string) => l.includes('Timeout') || l.includes('not visible'))
expect(errorLine).toMatchInlineSnapshot(`"Error executing code: page.click: Timeout 2000ms exceeded. Element is not visible"`)
await client.callTool({ name: 'execute', arguments: { code: js`await state.errorTestPage.close(); delete state.errorTestPage;` } })
}, 30000)
}) })