mirror of
https://github.com/ChromeDevTools/chrome-devtools-mcp.git
synced 2026-11-04 15:00:11 +00:00
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:
3 files changed
+151
-17
No files matched your search
+64
-16
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Vendored
+1
@@ -53,6 +53,7 @@ export {
|
||||
ConsoleMessage,
|
||||
Dialog,
|
||||
ElementHandle,
|
||||
Frame,
|
||||
Locator,
|
||||
PredefinedNetworkConditions,
|
||||
KnownDevices,
|
||||
|
||||
+86
-1
@@ -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', () => {
|
||||
|
||||
Reference in new issue
Block a user