feat(wait_for): support opt-in visible text matching (#3212)

Addresses #2946 with the **opt-in behavior suggested by maintainers**,
without changing existing callers.

### Why

`wait_for` currently resolves on text in the DOM even when it has
`display:none`. That can be useful for DOM-presence waits, but agents
waiting for visible confirmations and toasts need an explicit visibility
option. Maintainers suggested retaining the default and offering an
opt-in flag in the issue discussion.

### Change

- Add optional `visible: true` to `wait_for`.
- Use Puppeteer locator `setVisibility('visible')` on aria and text
candidates when enabled.
- Preserve existing default behavior when `visible` is omitted or false.
- Update tool types, generated documentation and regression tests.

Example: `wait_for({text:["Saved
successfully"],visible:true,timeout:5000})` waits for the visible
message instead of hidden DOM presence.

### Verification

- `npm run build` — PASS
- ESLint and Prettier on changed TypeScript files — PASS
- `node scripts/test.js tests/McpPage.test.ts
tests/tools/snapshot.test.ts` — **85/85 tests passed** on local Chrome
for Testing (Node 22).
- Tests cover backward-compatible hidden-text matching, delayed display,
permanently hidden text timeout, already-visible content, and MCP tool
argument forwarding.

This is separate from the dynamic-frame fix in #3210.

---------

Co-authored-by: Alex Rudenko <alexrudenko@chromium.org>
This commit is contained in:
Robert McConnellandAlex Rudenko authored and GitHub committed 2026-10-20 15:08:16 +00:00
1 parent cc850f5ad9
commit eef92da078
7 files changed
+120 -15

No files matched your search

+2 -1
View File
@@ -263,13 +263,14 @@
### `wait_for`
**Description:** Wait for the specified text to appear on the selected page.
**Description:** Wait for the specified text to appear on the selected page. By default, hidden text is also matched. Set visible to true to wait until the matched text is visible.
**Parameters:**
- **pageId** (number) **(required)**: Targets a specific page by ID.
- **text** (array) **(required)**: Non-empty list of texts. Resolves when any value appears on the page.
- **timeout** (integer) _(optional)_: Maximum wait time in milliseconds. If set to 0, the default timeout will be used.
- **visible** (boolean) _(optional)_: If true, wait until the matching text is visible. By default, hidden text also counts.
---
+14 -5
View File
@@ -1353,15 +1353,24 @@ export class McpPage implements ContextPage {
);
}
waitForTextOnPage(text: string[], timeout?: number): Promise<Element> {
waitForTextOnPage(
text: string[],
options: {timeout?: number; visible?: boolean} = {},
): Promise<Element> {
const {timeout, visible} = options;
const frames = this.pptrPage.frames();
let locator = this.#locatorClass.race(
frames.flatMap(frame =>
text.flatMap(value => [
frame.locator(`aria/${value}`),
frame.locator(`text/${value}`),
]),
text.flatMap(value => {
const locators = [
frame.locator(`aria/${value}`),
frame.locator(`text/${value}`),
];
return visible
? locators.map(locator => locator.setVisibility('visible'))
: locators;
}),
),
);
+4
View File
@@ -708,6 +708,10 @@
{
"name": "page_id",
"argType": "number"
},
{
"name": "visible",
"argType": "boolean"
}
]
},
+4 -1
View File
@@ -532,7 +532,10 @@ export type ContextPage = Readonly<{
prefersReducedMotion?: PrefersReducedMotion | 'auto';
viewport?: Viewport;
}): Promise<void>;
waitForTextOnPage(text: string[], timeout?: number): Promise<Element>;
waitForTextOnPage(
text: string[],
options?: {timeout?: number; visible?: boolean},
): Promise<Element>;
getDevToolsPage(): Promise<Page | undefined>;
openDevTools(): Promise<Page | undefined>;
ensureDevToolsCommentBridge(
+12 -2
View File
@@ -48,7 +48,7 @@ in the DevTools Elements panel (if any).`,
export const waitFor = definePageTool(() => ({
name: 'wait_for',
description: `Wait for the specified text to appear on the selected page.`,
description: `Wait for the specified text to appear on the selected page. By default, hidden text is also matched. Set visible to true to wait until the matched text is visible.`,
annotations: {
category: ToolCategory.NAVIGATION,
readOnlyHint: true,
@@ -61,13 +61,23 @@ export const waitFor = definePageTool(() => ({
'Non-empty list of texts. Resolves when any value appears on the page.',
),
...timeoutSchema,
visible: zod
.boolean()
.default(false)
.optional()
.describe(
'If true, wait until the matching text is visible. By default, hidden text also counts.',
),
},
blockedByDialog: true,
concurrency: 'page',
verifyFilesSchema: {},
handler: async (request, response) => {
const page = request.page;
await page.waitForTextOnPage(request.params.text, request.params.timeout);
await page.waitForTextOnPage(request.params.text, {
timeout: request.params.timeout,
visible: request.params.visible,
});
response.appendResponseLine(
`Element matching one of ${JSON.stringify(request.params.text)} found.`,
+66
View File
@@ -1154,6 +1154,72 @@ describe('McpPage', () => {
assert.ok(element);
});
});
it('preserves default matching for hidden DOM text', async () => {
await withMcpContext(async (_response, context) => {
const mcpPage = context.getSelectedMcpPage();
await mcpPage.pptrPage.setContent(
'<p style="display:none">Saved successfully</p>',
);
const element = await mcpPage.waitForTextOnPage(
['Saved successfully'],
{timeout: 500},
);
assert.ok(element);
});
});
it('waits for hidden text to become visible when requested', async () => {
await withMcpContext(async (_response, context) => {
const mcpPage = context.getSelectedMcpPage();
const page = mcpPage.pptrPage;
await page.setContent(
'<p id="saved" style="display:none">Saved successfully</p>',
);
await page.evaluate(() => {
setTimeout(() => {
document.getElementById('saved')!.style.display = 'block';
}, 250);
});
const element = await mcpPage.waitForTextOnPage(
['Saved successfully'],
{timeout: 1500, visible: true},
);
assert.ok(element);
});
});
it('times out when visible text never appears', async () => {
await withMcpContext(async (_response, context) => {
const mcpPage = context.getSelectedMcpPage();
await mcpPage.pptrPage.setContent(
'<p style="display:none">Saved successfully</p>',
);
await assert.rejects(
mcpPage.waitForTextOnPage(['Saved successfully'], {
timeout: 300,
visible: true,
}),
/Timed out/,
);
});
});
it('visible text remains immediately matchable when requested', async () => {
await withMcpContext(async (_response, context) => {
const mcpPage = context.getSelectedMcpPage();
await mcpPage.pptrPage.setContent('<p>Visible from the start</p>');
assert.ok(
await mcpPage.waitForTextOnPage(['Visible from the start'], {
timeout: 500,
visible: true,
}),
);
});
});
});
describe('DevToolsCommentBridge lifecycle', () => {
+18 -6
View File
@@ -44,11 +44,10 @@ describe('snapshot', () => {
response,
context,
);
sinon.assert.calledOnceWithExactly(
page.waitForTextOnPage,
['Hello'],
undefined,
);
sinon.assert.calledOnceWithExactly(page.waitForTextOnPage, ['Hello'], {
timeout: undefined,
visible: undefined,
});
sinon.assert.calledOnceWithExactly(
response.appendResponseLine,
'Element matching one of ["Hello"] found.',
@@ -66,7 +65,7 @@ describe('snapshot', () => {
sinon.assert.calledOnceWithExactly(
page.waitForTextOnPage,
['Complete', 'Error'],
5000,
{timeout: 5000, visible: undefined},
);
sinon.assert.calledOnceWithExactly(
response.appendResponseLine,
@@ -74,5 +73,18 @@ describe('snapshot', () => {
);
sinon.assert.calledOnceWithExactly(response.includeSnapshot);
});
it('forwards visible:true to the page matcher', async () => {
const {page, context, response, args} = createHandlerMocks();
await waitFor(args).handler(
{params: {text: ['Saved'], timeout: 2000, visible: true}, page},
response,
context,
);
sinon.assert.calledOnceWithExactly(page.waitForTextOnPage, ['Saved'], {
timeout: 2000,
visible: true,
});
});
});
});