mirror of
https://github.com/ChromeDevTools/chrome-devtools-mcp.git
synced 2026-11-04 15:00:11 +00:00
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:
1 parent
cc850f5ad9
commit
eef92da078
7 files changed
+120
-15
No files matched your search
@@ -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
@@ -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;
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -708,6 +708,10 @@
|
||||
{
|
||||
"name": "page_id",
|
||||
"argType": "number"
|
||||
},
|
||||
{
|
||||
"name": "visible",
|
||||
"argType": "boolean"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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
@@ -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.`,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user