Skip to content
Open
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
51 changes: 44 additions & 7 deletions src/actions.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Page, Frame } from 'playwright-core';
import type { Page, Frame, Locator } from 'playwright-core';
import { mkdirSync } from 'node:fs';
import path from 'node:path';
import type { BrowserManager, ScreencastFrame } from './browser.js';
Expand Down Expand Up @@ -144,6 +144,43 @@ interface SnapshotData {
refs?: Record<string, { role: string; name?: string }>;
}

/**
* Safely perform check/uncheck on a locator.
* If the element is hidden (common with UI frameworks like Element UI, Ant Design, Vuetify
* which hide native checkbox inputs), falls back to using { force: true }.
* This prevents Playwright from hanging indefinitely waiting for the element to become visible.
* @see https://github.com/vercel-labs/agent-browser/issues/335
*/
export async function safeCheckAction(locator: Locator, action: 'check' | 'uncheck'): Promise<void> {
try {
// First, try with a short timeout to detect hidden elements quickly
if (action === 'check') {
await locator.check({ timeout: 5000 });
} else {
await locator.uncheck({ timeout: 5000 });
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
// If the element is not visible or the action timed out, retry with force: true
// This handles hidden native checkboxes in UI component libraries
if (
message.includes('not visible') ||
message.includes('hidden') ||
message.includes('Timeout') ||
message.includes('waiting for') ||
Comment on lines +169 to +170

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restrict forced retry to explicit visibility failures

This retry condition treats any error containing Timeout or waiting for as a hidden-element case, so safeCheckAction will force a second check/uncheck even when the first failure was due to other actionability problems (for example, an overlay intercepting pointer events or a control still disabled). In those cases the forced retry bypasses normal actionability checks and can report success instead of surfacing the real blocked-state error, which changes command semantics and can mask real UI issues. Please narrow the fallback trigger to explicit visibility/hidden signals rather than generic timeout text.

Useful? React with 👍 / 👎.

message.includes('Element is not visible')
) {
if (action === 'check') {
await locator.check({ force: true });
} else {
await locator.uncheck({ force: true });
}
} else {
throw error;
}
}
}

/**
* Convert Playwright errors to AI-friendly messages
* @internal Exported for testing
Expand Down Expand Up @@ -808,7 +845,7 @@ async function handleFill(command: FillCommand, browser: BrowserManager): Promis
async function handleCheck(command: CheckCommand, browser: BrowserManager): Promise<Response> {
const locator = browser.getLocator(command.selector);
try {
await locator.check();
await safeCheckAction(locator, 'check');
} catch (error) {
throw toAIFriendlyError(error, command.selector);
}
Expand All @@ -818,7 +855,7 @@ async function handleCheck(command: CheckCommand, browser: BrowserManager): Prom
async function handleUncheck(command: UncheckCommand, browser: BrowserManager): Promise<Response> {
const locator = browser.getLocator(command.selector);
try {
await locator.uncheck();
await safeCheckAction(locator, 'uncheck');
} catch (error) {
throw toAIFriendlyError(error, command.selector);
}
Expand Down Expand Up @@ -897,7 +934,7 @@ async function handleGetByRole(
await locator.fill(command.value ?? '');
return successResponse(command.id, { filled: true });
case 'check':
await locator.check();
await safeCheckAction(locator, 'check');
return successResponse(command.id, { checked: true });
case 'hover':
await locator.hover();
Expand Down Expand Up @@ -937,7 +974,7 @@ async function handleGetByLabel(
await locator.fill(command.value ?? '');
return successResponse(command.id, { filled: true });
case 'check':
await locator.check();
await safeCheckAction(locator, 'check');
return successResponse(command.id, { checked: true });
}
}
Expand Down Expand Up @@ -1704,7 +1741,7 @@ async function handleGetByTestId(
await locator.fill(command.value ?? '');
return successResponse(command.id, { filled: true });
case 'check':
await locator.check();
await safeCheckAction(locator, 'check');
return successResponse(command.id, { checked: true });
case 'hover':
await locator.hover();
Expand All @@ -1725,7 +1762,7 @@ async function handleNth(command: NthCommand, browser: BrowserManager): Promise<
await locator.fill(command.value ?? '');
return successResponse(command.id, { filled: true });
case 'check':
await locator.check();
await safeCheckAction(locator, 'check');
return successResponse(command.id, { checked: true });
case 'hover':
await locator.hover();
Expand Down
119 changes: 119 additions & 0 deletions src/safe-check.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { describe, it, expect, vi } from 'vitest';
import { safeCheckAction } from './actions.js';

/**
* Tests for safeCheckAction - fix for issue #335
* Ensures check/uncheck does not hang on hidden checkbox elements
* (common in Element UI, Ant Design, Vuetify, etc.)
*/
describe('safeCheckAction', () => {
it('should call check() normally when element is visible', async () => {
const locator = {
check: vi.fn().mockResolvedValue(undefined),
uncheck: vi.fn().mockResolvedValue(undefined),
} as any;

await safeCheckAction(locator, 'check');
expect(locator.check).toHaveBeenCalledWith({ timeout: 5000 });
});

it('should call uncheck() normally when element is visible', async () => {
const locator = {
check: vi.fn().mockResolvedValue(undefined),
uncheck: vi.fn().mockResolvedValue(undefined),
} as any;

await safeCheckAction(locator, 'uncheck');
expect(locator.uncheck).toHaveBeenCalledWith({ timeout: 5000 });
});

it('should retry with force:true when element is not visible (check)', async () => {
const locator = {
check: vi
.fn()
.mockRejectedValueOnce(new Error('Element is not visible'))
.mockResolvedValueOnce(undefined),
uncheck: vi.fn(),
} as any;

await safeCheckAction(locator, 'check');
expect(locator.check).toHaveBeenCalledTimes(2);
expect(locator.check).toHaveBeenLastCalledWith({ force: true });
});

it('should retry with force:true when element is not visible (uncheck)', async () => {
const locator = {
check: vi.fn(),
uncheck: vi
.fn()
.mockRejectedValueOnce(new Error('Element is not visible'))
.mockResolvedValueOnce(undefined),
} as any;

await safeCheckAction(locator, 'uncheck');
expect(locator.uncheck).toHaveBeenCalledTimes(2);
expect(locator.uncheck).toHaveBeenLastCalledWith({ force: true });
});

it('should retry with force:true on timeout (hidden checkbox)', async () => {
const locator = {
check: vi
.fn()
.mockRejectedValueOnce(new Error('Timeout 5000ms exceeded waiting for element'))
.mockResolvedValueOnce(undefined),
uncheck: vi.fn(),
} as any;

await safeCheckAction(locator, 'check');
expect(locator.check).toHaveBeenCalledTimes(2);
expect(locator.check).toHaveBeenLastCalledWith({ force: true });
});

it('should retry with force:true when element is hidden', async () => {
const locator = {
check: vi
.fn()
.mockRejectedValueOnce(new Error('element is hidden'))
.mockResolvedValueOnce(undefined),
uncheck: vi.fn(),
} as any;

await safeCheckAction(locator, 'check');
expect(locator.check).toHaveBeenCalledTimes(2);
expect(locator.check).toHaveBeenLastCalledWith({ force: true });
});

it('should retry with force:true on "waiting for" error', async () => {
const locator = {
check: vi
.fn()
.mockRejectedValueOnce(new Error('waiting for locator to be visible'))
.mockResolvedValueOnce(undefined),
uncheck: vi.fn(),
} as any;

await safeCheckAction(locator, 'check');
expect(locator.check).toHaveBeenCalledTimes(2);
expect(locator.check).toHaveBeenLastCalledWith({ force: true });
});

it('should rethrow non-visibility errors', async () => {
const locator = {
check: vi.fn().mockRejectedValue(new Error('strict mode violation')),
uncheck: vi.fn(),
} as any;

await expect(safeCheckAction(locator, 'check')).rejects.toThrow('strict mode violation');
expect(locator.check).toHaveBeenCalledTimes(1);
});

it('should rethrow unknown errors for uncheck', async () => {
const locator = {
check: vi.fn(),
uncheck: vi.fn().mockRejectedValue(new Error('some other error')),
} as any;

await expect(safeCheckAction(locator, 'uncheck')).rejects.toThrow('some other error');
expect(locator.uncheck).toHaveBeenCalledTimes(1);
});
});