mirror of
https://github.com/ChromeDevTools/chrome-devtools-mcp.git
synced 2026-11-04 15:00:11 +00:00
refactor: clean up more of the context getters (#1062)
- moves more code to the McpPage - deleted Context specific tests
This commit is contained in:
1 parent
4cb5a17b57
commit
9628dabcb4
10 files changed
+115
-416
No files matched your search
+25
-116
@@ -23,7 +23,6 @@ import type {
|
||||
BrowserContext,
|
||||
ConsoleMessage,
|
||||
Debugger,
|
||||
ElementHandle,
|
||||
HTTPRequest,
|
||||
Page,
|
||||
ScreenRecorder,
|
||||
@@ -34,7 +33,6 @@ import type {
|
||||
import {Locator} from './third_party/index.js';
|
||||
import {PredefinedNetworkConditions} from './third_party/index.js';
|
||||
import {listPages} from './tools/pages.js';
|
||||
import {takeSnapshot} from './tools/snapshot.js';
|
||||
import {CLOSE_PAGE_ERROR} from './tools/ToolDefinition.js';
|
||||
import type {
|
||||
Context,
|
||||
@@ -109,7 +107,7 @@ export class McpContext implements Context {
|
||||
#extensionServiceWorkers: ExtensionServiceWorker[] = [];
|
||||
|
||||
#mcpPages = new Map<Page, McpPage>();
|
||||
#selectedPage?: Page;
|
||||
#selectedPage?: McpPage;
|
||||
#networkCollector: NetworkCollector;
|
||||
#consoleCollector: ConsoleCollector;
|
||||
#devtoolsUniverseManager: UniverseManager;
|
||||
@@ -290,7 +288,7 @@ export class McpContext implements Context {
|
||||
page = await this.browser.newPage({background});
|
||||
}
|
||||
await this.createPagesSnapshot();
|
||||
this.selectPage(page);
|
||||
this.selectPage(this.#getMcpPage(page));
|
||||
this.#networkCollector.addPage(page);
|
||||
this.#consoleCollector.addPage(page);
|
||||
return this.#getMcpPage(page);
|
||||
@@ -300,16 +298,15 @@ export class McpContext implements Context {
|
||||
throw new Error(CLOSE_PAGE_ERROR);
|
||||
}
|
||||
const page = this.getPageById(pageId);
|
||||
const mcpPage = this.#mcpPages.get(page);
|
||||
if (mcpPage) {
|
||||
mcpPage.dispose();
|
||||
this.#mcpPages.delete(page);
|
||||
if (page) {
|
||||
page.dispose();
|
||||
this.#mcpPages.delete(page.pptrPage);
|
||||
}
|
||||
const ctx = page.browserContext();
|
||||
if (this.#focusedPagePerContext.get(ctx) === page) {
|
||||
const ctx = page.pptrPage.browserContext();
|
||||
if (this.#focusedPagePerContext.get(ctx) === page.pptrPage) {
|
||||
this.#focusedPagePerContext.delete(ctx);
|
||||
}
|
||||
await page.close({runBeforeUnload: false});
|
||||
await page.pptrPage.close({runBeforeUnload: false});
|
||||
}
|
||||
|
||||
getNetworkRequestById(page: McpPage, reqid: number): HTTPRequest {
|
||||
@@ -461,12 +458,12 @@ export class McpContext implements Context {
|
||||
if (!page) {
|
||||
throw new Error('No page selected');
|
||||
}
|
||||
if (page.isClosed()) {
|
||||
if (page.pptrPage.isClosed()) {
|
||||
throw new Error(
|
||||
`The selected page has been closed. Call ${listPages().name} to see open pages.`,
|
||||
);
|
||||
}
|
||||
return page;
|
||||
return page.pptrPage;
|
||||
}
|
||||
|
||||
getSelectedMcpPage(): McpPage {
|
||||
@@ -474,16 +471,8 @@ export class McpContext implements Context {
|
||||
return this.#getMcpPage(page);
|
||||
}
|
||||
|
||||
resolvePageById(pageId?: number): McpPage {
|
||||
if (pageId === undefined) {
|
||||
return this.getSelectedMcpPage();
|
||||
}
|
||||
const page = this.getPageById(pageId);
|
||||
return this.#getMcpPage(page);
|
||||
}
|
||||
|
||||
getPageById(pageId: number): Page {
|
||||
const page = this.#pages.find(p => this.#mcpPages.get(p)?.id === pageId);
|
||||
getPageById(pageId: number): McpPage {
|
||||
const page = this.#mcpPages.values().find(mcpPage => mcpPage.id === pageId);
|
||||
if (!page) {
|
||||
throw new Error('No page found');
|
||||
}
|
||||
@@ -507,7 +496,7 @@ export class McpContext implements Context {
|
||||
}
|
||||
|
||||
isPageSelected(page: Page): boolean {
|
||||
return this.#selectedPage === page;
|
||||
return this.#selectedPage?.pptrPage === page;
|
||||
}
|
||||
|
||||
assertPageIsFocused(pageToCheck: Page | ContextPage): void {
|
||||
@@ -524,20 +513,22 @@ export class McpContext implements Context {
|
||||
}
|
||||
}
|
||||
|
||||
selectPage(pageToSelect: Page | ContextPage): void {
|
||||
const newPage =
|
||||
'pptrPage' in pageToSelect ? pageToSelect.pptrPage : pageToSelect;
|
||||
const ctx = newPage.browserContext();
|
||||
selectPage(newPage: McpPage): void {
|
||||
const ctx = newPage.pptrPage.browserContext();
|
||||
const oldFocused = this.#focusedPagePerContext.get(ctx);
|
||||
if (oldFocused && oldFocused !== newPage && !oldFocused.isClosed()) {
|
||||
if (
|
||||
oldFocused &&
|
||||
oldFocused !== newPage.pptrPage &&
|
||||
!oldFocused.isClosed()
|
||||
) {
|
||||
void oldFocused.emulateFocusedPage(false).catch(error => {
|
||||
this.logger('Error turning off focused page emulation', error);
|
||||
});
|
||||
}
|
||||
this.#focusedPagePerContext.set(ctx, newPage);
|
||||
this.#focusedPagePerContext.set(ctx, newPage.pptrPage);
|
||||
this.#selectedPage = newPage;
|
||||
this.#updateSelectedPageTimeouts();
|
||||
void newPage.emulateFocusedPage(true).catch(error => {
|
||||
void newPage.pptrPage.emulateFocusedPage(true).catch(error => {
|
||||
this.logger('Error turning on focused page emulation', error);
|
||||
});
|
||||
}
|
||||
@@ -572,63 +563,6 @@ export class McpContext implements Context {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async getElementByUid(
|
||||
uid: string,
|
||||
page?: Page,
|
||||
): Promise<ElementHandle<Element>> {
|
||||
if (page) {
|
||||
// Scoped search: only look in the target page's snapshot.
|
||||
const mcpPage = this.#mcpPages.get(page);
|
||||
if (!mcpPage?.textSnapshot) {
|
||||
throw new Error(
|
||||
`No snapshot found for page ${mcpPage?.id ?? '?'}. Use ${takeSnapshot.name} to capture one.`,
|
||||
);
|
||||
}
|
||||
const node = mcpPage.textSnapshot.idToNode.get(uid);
|
||||
if (!node) {
|
||||
throw new Error(
|
||||
`Element uid "${uid}" not found on page ${mcpPage.id}.`,
|
||||
);
|
||||
}
|
||||
return this.#resolveElementHandle(node, uid);
|
||||
}
|
||||
|
||||
// Cross-page search with context-focus validation.
|
||||
let anySnapshot = false;
|
||||
for (const [searchPage, mcpPage] of this.#mcpPages.entries()) {
|
||||
if (!mcpPage.textSnapshot) {
|
||||
continue;
|
||||
}
|
||||
anySnapshot = true;
|
||||
const node = mcpPage.textSnapshot.idToNode.get(uid);
|
||||
if (node) {
|
||||
const ctx = searchPage.browserContext();
|
||||
const contextSelectedPage = this.#focusedPagePerContext.get(ctx);
|
||||
if (contextSelectedPage !== searchPage) {
|
||||
const targetId = mcpPage.id;
|
||||
const selectedId = contextSelectedPage
|
||||
? this.#mcpPages.get(contextSelectedPage)?.id
|
||||
: this.#getSelectedMcpPage().id;
|
||||
throw new Error(
|
||||
`Element uid "${uid}" belongs to page ${targetId}, but page ${selectedId} is currently selected. ` +
|
||||
`Call select_page with pageId ${targetId} first.`,
|
||||
);
|
||||
}
|
||||
// Align global #selectedPage for waitForEventsAfterAction etc.
|
||||
if (this.#selectedPage !== searchPage) {
|
||||
this.#selectedPage = searchPage;
|
||||
}
|
||||
return this.#resolveElementHandle(node, uid);
|
||||
}
|
||||
}
|
||||
if (!anySnapshot) {
|
||||
throw new Error(
|
||||
`No snapshot found. Use ${takeSnapshot.name} to capture one.`,
|
||||
);
|
||||
}
|
||||
throw new Error('No such element found in any snapshot.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a snapshot of the extension service workers.
|
||||
*/
|
||||
@@ -664,24 +598,6 @@ export class McpContext implements Context {
|
||||
return this.#extensionServiceWorkers;
|
||||
}
|
||||
|
||||
async #resolveElementHandle(
|
||||
node: TextSnapshotNode,
|
||||
uid: string,
|
||||
): Promise<ElementHandle<Element>> {
|
||||
const message = `Element with uid ${uid} no longer exists on the page.`;
|
||||
try {
|
||||
const handle = await node.elementHandle();
|
||||
if (!handle) {
|
||||
throw new Error(message);
|
||||
}
|
||||
return handle;
|
||||
} catch (error) {
|
||||
throw new Error(message, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async createPagesSnapshot(): Promise<Page[]> {
|
||||
const {pages: allPages, isolatedContextNames} = await this.#getAllPages();
|
||||
|
||||
@@ -717,10 +633,11 @@ export class McpContext implements Context {
|
||||
});
|
||||
|
||||
if (
|
||||
(!this.#selectedPage || this.#pages.indexOf(this.#selectedPage) === -1) &&
|
||||
(!this.#selectedPage ||
|
||||
this.#pages.indexOf(this.#selectedPage.pptrPage) === -1) &&
|
||||
this.#pages[0]
|
||||
) {
|
||||
this.selectPage(this.#pages[0]);
|
||||
this.selectPage(this.#getMcpPage(this.#pages[0]));
|
||||
}
|
||||
|
||||
await this.detectOpenDevToolsWindows();
|
||||
@@ -943,14 +860,6 @@ export class McpContext implements Context {
|
||||
}
|
||||
}
|
||||
|
||||
getTextSnapshot(targetPage?: Page): TextSnapshot | null {
|
||||
const page = targetPage ?? this.#selectedPage;
|
||||
if (!page) {
|
||||
return null;
|
||||
}
|
||||
return this.#mcpPages.get(page)?.textSnapshot ?? null;
|
||||
}
|
||||
|
||||
async saveTemporaryFile(
|
||||
data: Uint8Array<ArrayBufferLike>,
|
||||
filename: string,
|
||||
|
||||
+1
-3
@@ -274,9 +274,7 @@ export class McpResponse implements Response {
|
||||
this.#snapshotParams.verbose,
|
||||
this.#devToolsData,
|
||||
);
|
||||
const textSnapshot = context.getTextSnapshot(
|
||||
this.#snapshotParams.page?.pptrPage,
|
||||
);
|
||||
const textSnapshot = this.#page.textSnapshot;
|
||||
if (textSnapshot) {
|
||||
const formatter = new SnapshotFormatter(textSnapshot);
|
||||
if (this.#snapshotParams.filePath) {
|
||||
|
||||
+1
-1
@@ -182,7 +182,7 @@ export async function createMcpServer(
|
||||
serverArgs.experimentalPageIdRouting &&
|
||||
params.pageId &&
|
||||
!serverArgs.slim
|
||||
? context.resolvePageById(params.pageId)
|
||||
? context.getPageById(params.pageId)
|
||||
: context.getSelectedMcpPage();
|
||||
response.setPage(page);
|
||||
await tool.handler(
|
||||
|
||||
@@ -60,7 +60,6 @@ export interface ImageContentData {
|
||||
export interface SnapshotParams {
|
||||
verbose?: boolean;
|
||||
filePath?: string;
|
||||
page?: ContextPage;
|
||||
}
|
||||
|
||||
export interface LighthouseData {
|
||||
@@ -136,16 +135,14 @@ export type Context = Readonly<{
|
||||
isCruxEnabled(): boolean;
|
||||
recordedTraces(): TraceResult[];
|
||||
storeTraceRecording(result: TraceResult): void;
|
||||
getPageById(pageId: number): Page;
|
||||
getPageById(pageId: number): ContextPage;
|
||||
newPage(
|
||||
background?: boolean,
|
||||
isolatedContextName?: string,
|
||||
): Promise<ContextPage>;
|
||||
closePage(pageId: number): Promise<void>;
|
||||
selectPage(page: Page): void;
|
||||
selectPage(page: ContextPage): void;
|
||||
assertPageIsFocused(page: Page): void;
|
||||
getElementByUid(uid: string, page?: Page): Promise<ElementHandle<Element>>;
|
||||
getAXNodeByUid(uid: string): TextSnapshotNode | undefined;
|
||||
restoreEmulation(page: ContextPage): Promise<void>;
|
||||
emulate(
|
||||
options: {
|
||||
|
||||
+20
-18
@@ -7,10 +7,11 @@
|
||||
import {logger} from '../logger.js';
|
||||
import type {McpContext, TextSnapshotNode} from '../McpContext.js';
|
||||
import {zod} from '../third_party/index.js';
|
||||
import type {ElementHandle, KeyInput, Page} from '../third_party/index.js';
|
||||
import type {ElementHandle, KeyInput} from '../third_party/index.js';
|
||||
import {parseKey} from '../utils/keyboard.js';
|
||||
|
||||
import {ToolCategory} from './categories.js';
|
||||
import type {ContextPage} from './ToolDefinition.js';
|
||||
import {definePageTool} from './ToolDefinition.js';
|
||||
|
||||
const dblClickSchema = zod
|
||||
@@ -58,7 +59,7 @@ export const click = definePageTool({
|
||||
},
|
||||
handler: async (request, response, context) => {
|
||||
const uid = request.params.uid;
|
||||
const handle = await context.getElementByUid(uid);
|
||||
const handle = await request.page.getElementByUid(uid);
|
||||
try {
|
||||
await context.waitForEventsAfterAction(async () => {
|
||||
await handle.asLocator().click({
|
||||
@@ -109,7 +110,7 @@ export const clickAt = definePageTool({
|
||||
: `Successfully clicked at the coordinates`,
|
||||
);
|
||||
if (request.params.includeSnapshot) {
|
||||
response.includeSnapshot({page});
|
||||
response.includeSnapshot();
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -131,7 +132,7 @@ export const hover = definePageTool({
|
||||
},
|
||||
handler: async (request, response, context) => {
|
||||
const uid = request.params.uid;
|
||||
const handle = await context.getElementByUid(uid);
|
||||
const handle = await request.page.getElementByUid(uid);
|
||||
try {
|
||||
await context.waitForEventsAfterAction(async () => {
|
||||
await handle.asLocator().hover();
|
||||
@@ -193,9 +194,9 @@ async function fillFormElement(
|
||||
uid: string,
|
||||
value: string,
|
||||
context: McpContext,
|
||||
page: Page,
|
||||
page: ContextPage,
|
||||
) {
|
||||
const handle = await context.getElementByUid(uid, page);
|
||||
const handle = await page.getElementByUid(uid);
|
||||
try {
|
||||
const aXNode = context.getAXNodeByUid(uid);
|
||||
// We assume that combobox needs to be handled as select if it has
|
||||
@@ -206,7 +207,7 @@ async function fillFormElement(
|
||||
// Increase timeout for longer input values.
|
||||
const timeoutPerChar = 10; // ms
|
||||
const fillTimeout =
|
||||
page.getDefaultTimeout() + value.length * timeoutPerChar;
|
||||
page.pptrPage.getDefaultTimeout() + value.length * timeoutPerChar;
|
||||
await handle.asLocator().setTimeout(fillTimeout).fill(value);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -239,12 +240,12 @@ export const fill = definePageTool({
|
||||
request.params.uid,
|
||||
request.params.value,
|
||||
context as McpContext,
|
||||
page.pptrPage,
|
||||
page,
|
||||
);
|
||||
});
|
||||
response.appendResponseLine(`Successfully filled out the element`);
|
||||
if (request.params.includeSnapshot) {
|
||||
response.includeSnapshot({page});
|
||||
response.includeSnapshot();
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -290,8 +291,10 @@ export const drag = definePageTool({
|
||||
includeSnapshot: includeSnapshotSchema,
|
||||
},
|
||||
handler: async (request, response, context) => {
|
||||
const fromHandle = await context.getElementByUid(request.params.from_uid);
|
||||
const toHandle = await context.getElementByUid(request.params.to_uid);
|
||||
const fromHandle = await request.page.getElementByUid(
|
||||
request.params.from_uid,
|
||||
);
|
||||
const toHandle = await request.page.getElementByUid(request.params.to_uid);
|
||||
try {
|
||||
await context.waitForEventsAfterAction(async () => {
|
||||
await fromHandle.drag(toHandle);
|
||||
@@ -335,13 +338,13 @@ export const fillForm = definePageTool({
|
||||
element.uid,
|
||||
element.value,
|
||||
context as McpContext,
|
||||
page.pptrPage,
|
||||
page,
|
||||
);
|
||||
});
|
||||
}
|
||||
response.appendResponseLine(`Successfully filled out the form`);
|
||||
if (request.params.includeSnapshot) {
|
||||
response.includeSnapshot({page});
|
||||
response.includeSnapshot();
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -362,11 +365,10 @@ export const uploadFile = definePageTool({
|
||||
filePath: zod.string().describe('The local path of the file to upload'),
|
||||
includeSnapshot: includeSnapshotSchema,
|
||||
},
|
||||
handler: async (request, response, context) => {
|
||||
handler: async (request, response) => {
|
||||
const {uid, filePath} = request.params;
|
||||
const handle = (await context.getElementByUid(
|
||||
const handle = (await request.page.getElementByUid(
|
||||
uid,
|
||||
request.page.pptrPage,
|
||||
)) as ElementHandle<HTMLInputElement>;
|
||||
try {
|
||||
try {
|
||||
@@ -388,7 +390,7 @@ export const uploadFile = definePageTool({
|
||||
}
|
||||
}
|
||||
if (request.params.includeSnapshot) {
|
||||
response.includeSnapshot({page: request.page});
|
||||
response.includeSnapshot();
|
||||
}
|
||||
response.appendResponseLine(`File uploaded from ${filePath}.`);
|
||||
} finally {
|
||||
@@ -432,7 +434,7 @@ export const pressKey = definePageTool({
|
||||
`Successfully pressed key: ${request.params.key}`,
|
||||
);
|
||||
if (request.params.includeSnapshot) {
|
||||
response.includeSnapshot({page});
|
||||
response.includeSnapshot();
|
||||
}
|
||||
},
|
||||
});
|
||||
+2
-2
@@ -54,7 +54,7 @@ export const selectPage = defineTool({
|
||||
context.selectPage(page);
|
||||
response.setIncludePages(true);
|
||||
if (request.params.bringToFront) {
|
||||
await page.bringToFront();
|
||||
await page.pptrPage.bringToFront();
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -386,7 +386,7 @@ export const getTabId = definePageTool({
|
||||
handler: async (request, response, context) => {
|
||||
const page = context.getPageById(request.params.pageId);
|
||||
// @ts-expect-error _tabId is internal.
|
||||
const tabId = page._tabId;
|
||||
const tabId = page.pptrPage._tabId;
|
||||
response.setTabId(tabId);
|
||||
},
|
||||
});
|
||||
@@ -57,10 +57,7 @@ export const screenshot = definePageTool({
|
||||
|
||||
let pageOrHandle: Page | ElementHandle;
|
||||
if (request.params.uid) {
|
||||
pageOrHandle = await context.getElementByUid(
|
||||
request.params.uid,
|
||||
request.page.pptrPage,
|
||||
);
|
||||
pageOrHandle = await request.page.getElementByUid(request.params.uid);
|
||||
} else {
|
||||
pageOrHandle = request.page.pptrPage;
|
||||
}
|
||||
|
||||
@@ -37,7 +37,6 @@ in the DevTools Elements panel (if any).`,
|
||||
response.includeSnapshot({
|
||||
verbose: request.params.verbose ?? false,
|
||||
filePath: request.params.filePath,
|
||||
page: request.page,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -70,6 +69,6 @@ export const waitFor = definePageTool({
|
||||
`Element matching one of ${JSON.stringify(request.params.text)} found.`,
|
||||
);
|
||||
|
||||
response.includeSnapshot({page});
|
||||
response.includeSnapshot();
|
||||
},
|
||||
});
|
||||
+5
-155
@@ -22,8 +22,8 @@ describe('McpContext', () => {
|
||||
|
||||
it('list pages', async () => {
|
||||
await withMcpContext(async (_response, context) => {
|
||||
const page = context.getSelectedPptrPage();
|
||||
await page.setContent(
|
||||
const page = context.getSelectedMcpPage();
|
||||
await page.pptrPage.setContent(
|
||||
html`<button>Click me</button>
|
||||
<input
|
||||
type="text"
|
||||
@@ -31,9 +31,9 @@ describe('McpContext', () => {
|
||||
/>`,
|
||||
);
|
||||
await context.createTextSnapshot(context.getSelectedMcpPage());
|
||||
assert.ok(await context.getElementByUid('1_1'));
|
||||
assert.ok(await page.getElementByUid('1_1'));
|
||||
await context.createTextSnapshot(context.getSelectedMcpPage());
|
||||
await context.getElementByUid('1_1');
|
||||
await page.getElementByUid('1_1');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -125,161 +125,11 @@ describe('McpContext', () => {
|
||||
assert.strictEqual(node?.name, 'Page1 Button');
|
||||
|
||||
// The element should also be retrievable when the target page is provided.
|
||||
const element = await context.getElementByUid(page1Uid, page1.pptrPage);
|
||||
const element = await page1.getElementByUid(page1Uid);
|
||||
assert.ok(element, 'should get element handle from page1 snapshot uid');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getElementByUid context-focus validation', () => {
|
||||
it('resolves for the focused page in an isolated context', async () => {
|
||||
await withMcpContext(async (_response, context) => {
|
||||
const page = await context.newPage(false, 'agent-a');
|
||||
await page.pptrPage.setContent(html`<button>A1 Button</button>`);
|
||||
await context.createTextSnapshot(page, false, undefined);
|
||||
|
||||
// page is focused for agent-a context; should resolve.
|
||||
const handle = await context.getElementByUid('1_1');
|
||||
void handle.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
it('throws for a non-focused page in the same context', async () => {
|
||||
await withMcpContext(async (_response, context) => {
|
||||
const pageA1 = await context.newPage(false, 'agent-a');
|
||||
await pageA1.pptrPage.setContent(html`<button>A1 Button</button>`);
|
||||
await context.createTextSnapshot(pageA1, false, undefined);
|
||||
const a1Uid = '1_1'; // button on pageA1
|
||||
|
||||
// Open a second page in the same context (becomes focused).
|
||||
const pageA2 = await context.newPage(false, 'agent-a');
|
||||
await pageA2.pptrPage.setContent(html`<button>A2 Button</button>`);
|
||||
await context.createTextSnapshot(pageA2, false, undefined);
|
||||
|
||||
// pageA2 is now focused for agent-a; clicking pageA1's uid should throw.
|
||||
await assert.rejects(
|
||||
() => context.getElementByUid(a1Uid),
|
||||
(err: Error) => {
|
||||
assert.ok(err.message.includes('belongs to page'));
|
||||
assert.ok(err.message.includes('currently selected'));
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves after cross-context select_page race', async () => {
|
||||
await withMcpContext(async (_response, context) => {
|
||||
// Set up two pages in separate isolated contexts.
|
||||
const pageA = await context.newPage(false, 'agent-a');
|
||||
await pageA.pptrPage.setContent(html`<button>Agent A Button</button>`);
|
||||
await context.createTextSnapshot(pageA, false, undefined);
|
||||
const uidA = '1_1';
|
||||
|
||||
const pageB = await context.newPage(false, 'agent-b');
|
||||
await pageB.pptrPage.setContent(html`<button>Agent B Button</button>`);
|
||||
await context.createTextSnapshot(pageB, false, undefined);
|
||||
const uidB = '2_1';
|
||||
|
||||
// Simulate race: agent-a selects its page, then agent-b overwrites global.
|
||||
context.selectPage(pageA);
|
||||
context.selectPage(pageB);
|
||||
// Global #selectedPage is now pageB.
|
||||
|
||||
// Agent A's uid should still resolve (per-context focus for agent-a is pageA).
|
||||
const handleA = await context.getElementByUid(uidA);
|
||||
void handleA.dispose();
|
||||
// Agent B's uid should also resolve.
|
||||
const handleB = await context.getElementByUid(uidB);
|
||||
void handleB.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
it('aligns global selectedPage after resolution', async () => {
|
||||
await withMcpContext(async (_response, context) => {
|
||||
const pageA = await context.newPage(false, 'agent-a');
|
||||
await pageA.pptrPage.setContent(html`<button>Agent A Button</button>`);
|
||||
await context.createTextSnapshot(pageA, false, undefined);
|
||||
const uidA = '1_1';
|
||||
|
||||
const pageB = await context.newPage(false, 'agent-b');
|
||||
await pageB.pptrPage.setContent(html`<button>Agent B Button</button>`);
|
||||
await context.createTextSnapshot(pageB, false, undefined);
|
||||
|
||||
// Global is on pageB after newPage.
|
||||
assert.strictEqual(context.getSelectedMcpPage(), pageB);
|
||||
|
||||
// Resolve uid from pageA; should pass and align global.
|
||||
const handle = await context.getElementByUid(uidA);
|
||||
void handle.dispose();
|
||||
assert.strictEqual(context.getSelectedMcpPage(), pageA);
|
||||
});
|
||||
});
|
||||
|
||||
it('throws for nonexistent uid', async () => {
|
||||
await withMcpContext(async (_response, context) => {
|
||||
const page = await context.newPage(false, 'agent-a');
|
||||
await page.pptrPage.setContent(html`<button>A Button</button>`);
|
||||
await context.createTextSnapshot(page, false, undefined);
|
||||
|
||||
await assert.rejects(() => context.getElementByUid('nonexistent_99'), {
|
||||
message: 'No such element found in any snapshot.',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves for default context page alongside isolated contexts', async () => {
|
||||
await withMcpContext(async (_response, context) => {
|
||||
// Default context page (already exists from withMcpContext setup).
|
||||
const defaultPage = context.getSelectedMcpPage();
|
||||
await defaultPage.pptrPage.setContent(
|
||||
html`<button>Default Button</button>`,
|
||||
);
|
||||
await context.createTextSnapshot(defaultPage, false, undefined);
|
||||
const defaultUid = '1_1';
|
||||
|
||||
// Isolated context page.
|
||||
const isoPage = await context.newPage(false, 'agent-a');
|
||||
await isoPage.pptrPage.setContent(
|
||||
html`<button>Isolated Button</button>`,
|
||||
);
|
||||
await context.createTextSnapshot(isoPage, false, undefined);
|
||||
const isoUid = '2_1';
|
||||
|
||||
// Global is now isoPage. Default context focus is still defaultPage.
|
||||
// Both should resolve via per-context lookup.
|
||||
const handleDefault = await context.getElementByUid(defaultUid);
|
||||
void handleDefault.dispose();
|
||||
const handleIso = await context.getElementByUid(isoUid);
|
||||
void handleIso.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
it('scopes search to target page when page is provided', async () => {
|
||||
await withMcpContext(async (_response, context) => {
|
||||
const pageA = await context.newPage(false, 'agent-a');
|
||||
await pageA.pptrPage.setContent(html`<button>Agent A Button</button>`);
|
||||
await context.createTextSnapshot(pageA, false, undefined);
|
||||
const uidA = '1_1';
|
||||
|
||||
const pageB = await context.newPage(false, 'agent-b');
|
||||
await pageB.pptrPage.setContent(html`<button>Agent B Button</button>`);
|
||||
await context.createTextSnapshot(pageB, false, undefined);
|
||||
|
||||
// uidA belongs to pageA; searching with pageB should throw.
|
||||
await assert.rejects(
|
||||
() => context.getElementByUid(uidA, pageB.pptrPage),
|
||||
{
|
||||
message: /not found on page/,
|
||||
},
|
||||
);
|
||||
|
||||
// Searching with the correct page should resolve.
|
||||
const handle = await context.getElementByUid(uidA, pageA.pptrPage);
|
||||
void handle.dispose();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should include network requests in structured content', async t => {
|
||||
await withMcpContext(async (response, context) => {
|
||||
const mockRequest = getMockRequest({
|
||||
|
||||
+57
-110
@@ -107,7 +107,7 @@ describe('pages', () => {
|
||||
await withMcpContext(async (response, context) => {
|
||||
assert.strictEqual(
|
||||
context.getPageById(1),
|
||||
context.getSelectedPptrPage(),
|
||||
context.getSelectedMcpPage(),
|
||||
);
|
||||
await newPage.handler(
|
||||
{params: {url: 'about:blank'}},
|
||||
@@ -116,7 +116,7 @@ describe('pages', () => {
|
||||
);
|
||||
assert.strictEqual(
|
||||
context.getPageById(2),
|
||||
context.getSelectedPptrPage(),
|
||||
context.getSelectedMcpPage(),
|
||||
);
|
||||
assert.ok(response.includePages);
|
||||
});
|
||||
@@ -124,11 +124,11 @@ describe('pages', () => {
|
||||
it('create a page in the background', async () => {
|
||||
await withMcpContext(async (response, context) => {
|
||||
const originalPage = context.getPageById(1);
|
||||
assert.strictEqual(originalPage, context.getSelectedPptrPage());
|
||||
assert.strictEqual(originalPage, context.getSelectedMcpPage());
|
||||
// Ensure original page has focus
|
||||
await originalPage.bringToFront();
|
||||
await originalPage.pptrPage.bringToFront();
|
||||
assert.strictEqual(
|
||||
await originalPage.evaluate(() => document.hasFocus()),
|
||||
await originalPage.pptrPage.evaluate(() => document.hasFocus()),
|
||||
true,
|
||||
);
|
||||
await newPage.handler(
|
||||
@@ -139,10 +139,10 @@ describe('pages', () => {
|
||||
// New page should be selected but original should retain focus
|
||||
assert.strictEqual(
|
||||
context.getPageById(2),
|
||||
context.getSelectedPptrPage(),
|
||||
context.getSelectedMcpPage(),
|
||||
);
|
||||
assert.strictEqual(
|
||||
await originalPage.evaluate(() => document.hasFocus()),
|
||||
await originalPage.pptrPage.evaluate(() => document.hasFocus()),
|
||||
true,
|
||||
);
|
||||
assert.ok(response.includePages);
|
||||
@@ -252,104 +252,47 @@ describe('pages', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolvePageById', () => {
|
||||
it('returns the correct page regardless of global selection', async () => {
|
||||
await withMcpContext(async (response, context) => {
|
||||
// Create two pages with different content.
|
||||
await newPage.handler(
|
||||
{
|
||||
params: {
|
||||
url: 'data:text/html,<h1>Page A</h1>',
|
||||
isolatedContext: 'ctx-a',
|
||||
},
|
||||
it('navigate_page targets the pageId page, not the global selection', async () => {
|
||||
await withMcpContext(async (response, context) => {
|
||||
await newPage.handler(
|
||||
{
|
||||
params: {
|
||||
url: 'data:text/html,<h1>Initial</h1>',
|
||||
isolatedContext: 'nav-ctx',
|
||||
},
|
||||
response,
|
||||
context,
|
||||
);
|
||||
const pageA = context.getSelectedMcpPage();
|
||||
const pageAId = context.getPageId(pageA.pptrPage)!;
|
||||
},
|
||||
response,
|
||||
context,
|
||||
);
|
||||
const isolatedPage = context.getSelectedMcpPage();
|
||||
|
||||
await newPage.handler(
|
||||
{
|
||||
params: {
|
||||
url: 'data:text/html,<h1>Page B</h1>',
|
||||
isolatedContext: 'ctx-b',
|
||||
},
|
||||
// Switch global selection back to the default page.
|
||||
await selectPage.handler({params: {pageId: 1}}, response, context);
|
||||
assert.notStrictEqual(context.getSelectedMcpPage(), isolatedPage);
|
||||
|
||||
// Navigate using page; should target the isolated page.
|
||||
await navigatePage.handler(
|
||||
{
|
||||
params: {
|
||||
url: 'data:text/html,<h1>Navigated</h1>',
|
||||
},
|
||||
response,
|
||||
context,
|
||||
);
|
||||
const pageB = context.getSelectedMcpPage();
|
||||
const pageBId = context.getPageId(pageB.pptrPage)!;
|
||||
page: isolatedPage,
|
||||
},
|
||||
response,
|
||||
context,
|
||||
);
|
||||
|
||||
// Global selection is now pageB (the last created page).
|
||||
assert.strictEqual(context.getSelectedMcpPage(), pageB);
|
||||
// Verify the isolated page was navigated.
|
||||
const content = await isolatedPage.pptrPage.evaluate(
|
||||
() => document.querySelector('h1')?.textContent,
|
||||
);
|
||||
assert.strictEqual(content, 'Navigated');
|
||||
|
||||
// resolvePageById should return the correct page for each ID,
|
||||
// regardless of which page is globally selected.
|
||||
assert.strictEqual(context.resolvePageById(pageAId), pageA);
|
||||
assert.strictEqual(context.resolvePageById(pageBId), pageB);
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to getSelectedPage when no pageId is provided', async () => {
|
||||
await withMcpContext(async (_response, context) => {
|
||||
const selectedPage = context.getSelectedPptrPage();
|
||||
assert.strictEqual(
|
||||
context.resolvePageById(undefined).pptrPage,
|
||||
selectedPage,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('throws for an unknown pageId', async () => {
|
||||
await withMcpContext(async (_response, context) => {
|
||||
assert.throws(() => context.resolvePageById(99999), /No page found/);
|
||||
});
|
||||
});
|
||||
|
||||
it('navigate_page targets the pageId page, not the global selection', async () => {
|
||||
await withMcpContext(async (response, context) => {
|
||||
await newPage.handler(
|
||||
{
|
||||
params: {
|
||||
url: 'data:text/html,<h1>Initial</h1>',
|
||||
isolatedContext: 'nav-ctx',
|
||||
},
|
||||
},
|
||||
response,
|
||||
context,
|
||||
);
|
||||
const isolatedPage = context.getSelectedMcpPage();
|
||||
|
||||
// Switch global selection back to the default page.
|
||||
await selectPage.handler({params: {pageId: 1}}, response, context);
|
||||
assert.notStrictEqual(context.getSelectedPptrPage(), isolatedPage);
|
||||
|
||||
// Navigate using page; should target the isolated page.
|
||||
await navigatePage.handler(
|
||||
{
|
||||
params: {
|
||||
url: 'data:text/html,<h1>Navigated</h1>',
|
||||
},
|
||||
page: isolatedPage,
|
||||
},
|
||||
response,
|
||||
context,
|
||||
);
|
||||
|
||||
// Verify the isolated page was navigated.
|
||||
const content = await isolatedPage.pptrPage.evaluate(
|
||||
() => document.querySelector('h1')?.textContent,
|
||||
);
|
||||
assert.strictEqual(content, 'Navigated');
|
||||
|
||||
// Verify the default page was NOT affected.
|
||||
const defaultContent = await context
|
||||
.getSelectedPptrPage()
|
||||
.evaluate(() => document.querySelector('h1')?.textContent);
|
||||
assert.notStrictEqual(defaultContent, 'Navigated');
|
||||
});
|
||||
// Verify the default page was NOT affected.
|
||||
const defaultContent = await context
|
||||
.getSelectedPptrPage()
|
||||
.evaluate(() => document.querySelector('h1')?.textContent);
|
||||
assert.notStrictEqual(defaultContent, 'Navigated');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -359,9 +302,9 @@ describe('pages', () => {
|
||||
const page = await context.newPage();
|
||||
assert.strictEqual(
|
||||
context.getPageById(2),
|
||||
context.getSelectedPptrPage(),
|
||||
context.getSelectedMcpPage(),
|
||||
);
|
||||
assert.strictEqual(context.getPageById(2), page.pptrPage);
|
||||
assert.strictEqual(context.getPageById(2), page);
|
||||
await closePage.handler({params: {pageId: 2}}, response, context);
|
||||
assert.ok(page.pptrPage.isClosed());
|
||||
assert.ok(response.includePages);
|
||||
@@ -386,12 +329,12 @@ describe('pages', () => {
|
||||
await context.newPage();
|
||||
assert.strictEqual(
|
||||
context.getPageById(2),
|
||||
context.getSelectedPptrPage(),
|
||||
context.getSelectedMcpPage(),
|
||||
);
|
||||
await selectPage.handler({params: {pageId: 1}}, response, context);
|
||||
assert.strictEqual(
|
||||
context.getPageById(1),
|
||||
context.getSelectedPptrPage(),
|
||||
context.getSelectedMcpPage(),
|
||||
);
|
||||
assert.ok(response.includePages);
|
||||
});
|
||||
@@ -401,19 +344,23 @@ describe('pages', () => {
|
||||
await context.newPage();
|
||||
assert.strictEqual(
|
||||
context.getPageById(2),
|
||||
context.getSelectedPptrPage(),
|
||||
context.getSelectedMcpPage(),
|
||||
);
|
||||
assert.strictEqual(
|
||||
await context.getPageById(1).evaluate(() => document.hasFocus()),
|
||||
await context
|
||||
.getPageById(1)
|
||||
.pptrPage.evaluate(() => document.hasFocus()),
|
||||
false,
|
||||
);
|
||||
await selectPage.handler({params: {pageId: 1}}, response, context);
|
||||
assert.strictEqual(
|
||||
context.getPageById(1),
|
||||
context.getSelectedPptrPage(),
|
||||
context.getSelectedMcpPage(),
|
||||
);
|
||||
assert.strictEqual(
|
||||
await context.getPageById(1).evaluate(() => document.hasFocus()),
|
||||
await context
|
||||
.getPageById(1)
|
||||
.pptrPage.evaluate(() => document.hasFocus()),
|
||||
true,
|
||||
);
|
||||
assert.ok(response.includePages);
|
||||
@@ -604,9 +551,9 @@ describe('pages', () => {
|
||||
const page = await context.newPage();
|
||||
assert.strictEqual(
|
||||
context.getPageById(2),
|
||||
context.getSelectedPptrPage(),
|
||||
context.getSelectedMcpPage(),
|
||||
);
|
||||
assert.strictEqual(context.getPageById(2), page.pptrPage);
|
||||
assert.strictEqual(context.getPageById(2), page);
|
||||
|
||||
await page.pptrPage.close();
|
||||
|
||||
|
||||
Reference in new issue
Block a user