fix(wait_for): find text in dynamically attached iframes (#3210)

Fixes #2945.

### Problem

`wait_for` builds a locator race from `page.frames()` only once. Text
inside an iframe attached **after** the wait starts is omitted from the
race, even though an immediate second call finds it.

### Change

- Preserve the current locator race across the frames that exist when
waiting begins.
- Listen for `frameattached` and watch text locators in each newly
attached frame.
- Stop the additional watchers with an AbortController and remove the
frame listener when a match is found or the original locator race times
out.
- Ignore individual newly attached frame failures (for example, frames
detached before loading) and preserve the original wait's timeout/error.

### Regression coverage

- Text inside a dynamically attached `srcdoc` iframe is found by the
first wait.
- The `frameattached` listener is removed after success and timeout.
- Existing `waitForTextOnPage` tests remain green.

### Local verification (Butcher, Node 22)

- `npm run build` — PASS
- `eslint src/McpPage.ts tests/McpPage.test.ts` — PASS
- `prettier --check src/McpPage.ts tests/McpPage.test.ts` — PASS
- `node scripts/test.js tests/McpPage.test.ts` — **78 tests passed**
(with a local Chrome for Testing executable).

---------

Co-authored-by: Alex Rudenko <alexrudenko@chromium.org>
Co-authored-by: Alex Rudenko <OrKoN@users.noreply.github.com>
Co-authored-by: Nikolay Vitkov <34244704+Lightning00Blade@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-10-20 17:08:19 +00:00
1 parent 756ab1a95a
commit 5f84888401
3 files changed
+151 -17

No files matched your search

+64 -16
View File
@@ -22,6 +22,7 @@ import {
PredefinedNetworkConditions,
type Dialog,
type ElementHandle,
type Frame,
type Viewport,
type WebMCPTool,
type Protocol,
@@ -1358,27 +1359,74 @@ export class McpPage implements ContextPage {
options: {timeout?: number; visible?: boolean} = {},
): Promise<Element> {
const {timeout, visible} = options;
const frames = this.pptrPage.frames();
const page = this.pptrPage;
const initialFrames = page.frames();
const observedFrames = new Set(initialFrames);
const controller = new AbortController();
let locator = this.#locatorClass.race(
frames.flatMap(frame =>
text.flatMap(value => {
const locators = [
frame.locator(`aria/${value}`),
frame.locator(`text/${value}`),
];
return visible
? locators.map(locator => locator.setVisibility('visible'))
: locators;
}),
),
);
const locatorForFrames = (frames: Frame[]): Locator<Element> =>
this.#locatorClass.race(
frames.flatMap(frame =>
text.flatMap(value => {
const locators = [
frame.locator(`aria/${value}`),
frame.locator(`text/${value}`),
];
return visible
? locators.map(locator => locator.setVisibility('visible'))
: locators;
}),
),
);
let initialLocator = locatorForFrames(initialFrames);
if (timeout) {
locator = locator.setTimeout(timeout);
initialLocator = initialLocator.setTimeout(timeout);
}
return locator.wait();
return new Promise<Element>((resolve, reject) => {
let settled = false;
const cleanup = (): boolean => {
if (settled) {
return false;
}
settled = true;
page.off('frameattached', onFrameAttached);
controller.abort();
return true;
};
const finish = (element: Element): void => {
if (cleanup()) {
resolve(element);
}
};
const fail = (error: unknown): void => {
if (cleanup()) {
reject(error);
}
};
const onFrameAttached = (frame: Frame): void => {
if (observedFrames.has(frame)) {
return;
}
observedFrames.add(frame);
// The initial locator race does not include frames added later.
// Watch each new frame until the initial race resolves or times out.
// A timeout of 0 disables the frame timer; controller.abort() stops it.
void locatorForFrames([frame])
.setTimeout(0)
.wait({signal: controller.signal})
.then(finish, error => {
if (controller.signal.aborted) {
return;
}
logger?.('Failed to wait for text in an attached frame', error);
});
};
page.on('frameattached', onFrameAttached);
void initialLocator.wait({signal: controller.signal}).then(finish, fail);
});
}
/**
+1
View File
@@ -53,6 +53,7 @@ export {
ConsoleMessage,
Dialog,
ElementHandle,
Frame,
Locator,
PredefinedNetworkConditions,
KnownDevices,
+86 -1
View File
@@ -12,7 +12,12 @@ import sinon from 'sinon';
import type {ReadonlyServerConfig} from '../src/config/ServerConfig.js';
import type {TargetUniverse} from '../src/devtools/DevtoolsUtils.js';
import {McpPage, replaceHtmlElementsWithUids} from '../src/McpPage.js';
import {DevTools, Locator, ScreenRecorder} from '../src/third_party/index.js';
import {
DevTools,
Frame,
Locator,
ScreenRecorder,
} from '../src/third_party/index.js';
import type {JSONSchema7Definition} from '../src/third_party/index.js';
import {TextSnapshot} from '../src/TextSnapshot.js';
import type {TextSnapshotNode} from '../src/types.js';
@@ -1220,6 +1225,86 @@ describe('McpPage', () => {
);
});
});
it('finds text in an iframe attached while waiting', async () => {
await withMcpContext(async (_response, context) => {
const mcpPage = context.getSelectedMcpPage();
const page = mcpPage.pptrPage;
await page.setContent('<main>Waiting for the frame</main>');
const listenersBefore = page.listenerCount('frameattached');
const waitPromise = mcpPage.waitForTextOnPage(
['Arrived in a late iframe'],
{timeout: 1500},
);
await page.evaluate(() => {
const frame = document.createElement('iframe');
frame.srcdoc = '<p>Arrived in a late iframe</p>';
document.body.appendChild(frame);
});
const element = await waitPromise;
assert.ok(element);
assert.strictEqual(
page.listenerCount('frameattached'),
listenersBefore,
);
});
});
it('cleans the frame listener after a timed out wait', async () => {
await withMcpContext(async (_response, context) => {
const mcpPage = context.getSelectedMcpPage();
const page = mcpPage.pptrPage;
await page.setContent('<main>No matching text</main>');
const listenersBefore = page.listenerCount('frameattached');
await assert.rejects(
mcpPage.waitForTextOnPage(['Text that never appears'], {
timeout: 250,
}),
);
assert.strictEqual(
page.listenerCount('frameattached'),
listenersBefore,
);
});
});
it('applies visible matching to frames attached while waiting', async () => {
const {mcpPage, pptrPage} = await createMcpPage();
pptrPage.frames.returns([]);
const initialRace = Locator.race([]);
sinon.stub(initialRace, 'wait').returns(new Promise(() => {}));
const lateRace = Locator.race([]);
const lateRaceSetTimeout = sinon
.stub(lateRace, 'setTimeout')
.returns(lateRace);
sinon.stub(lateRace, 'wait').resolves();
const race = sinon.stub(Locator, 'race');
race.onFirstCall().returns(initialRace);
race.onSecondCall().returns(lateRace);
const frameLocator = sinon.createStubInstance<Locator<Element>>(Locator);
frameLocator.setVisibility.returns(frameLocator);
const lateFrame = sinon.createStubInstance(Frame);
lateFrame.locator.returns(frameLocator);
const waitPromise = mcpPage.waitForTextOnPage(['Saved'], {visible: true});
pptrPage.emit('frameattached', lateFrame);
await waitPromise;
sinon.assert.calledWithExactly(lateFrame.locator.firstCall, 'aria/Saved');
sinon.assert.calledWithExactly(
lateFrame.locator.secondCall,
'text/Saved',
);
sinon.assert.calledTwice(frameLocator.setVisibility);
sinon.assert.alwaysCalledWithExactly(
frameLocator.setVisibility,
'visible',
);
sinon.assert.calledOnceWithExactly(lateRaceSetTimeout, 0);
});
});
describe('DevToolsCommentBridge lifecycle', () => {