diff --git a/src/McpContext.ts b/src/McpContext.ts index b4fecf363..71f1968fe 100644 --- a/src/McpContext.ts +++ b/src/McpContext.ts @@ -56,9 +56,36 @@ interface McpContextOptions { performanceCrux: boolean; } +export interface PageSummary { + id: number; + url: string; + selected: boolean; + isExtension: boolean; + isolatedContextName?: string; +} + const DEFAULT_TIMEOUT = 5_000; const NAVIGATION_TIMEOUT = 10_000; +async function resolveWithTimeout( + promise: Promise, + timeoutMs: number, +): Promise { + let timeout: ReturnType | undefined; + const timeoutPromise = new Promise(resolve => { + timeout = setTimeout(() => { + resolve(undefined); + }, timeoutMs); + }); + try { + return await Promise.race([promise, timeoutPromise]); + } finally { + if (timeout !== undefined) { + clearTimeout(timeout); + } + } +} + export class McpContext implements Context { browser: Browser; logger: Debugger; @@ -69,13 +96,18 @@ export class McpContext implements Context { #nextIsolatedContextId = 1; #pages: Page[] = []; + #pageSummaries: PageSummary[] = []; #extensionServiceWorkers: ExtensionServiceWorker[] = []; #mcpPages = new Map(); + #pageTargets = new Map(); + #targetsByPageId = new Map(); #selectedPage?: McpPage; + #selectedPageId?: number; #networkCollector: NetworkCollector; #consoleCollector: ConsoleCollector; #devtoolsUniverseManager: UniverseManager; + #collectorsInitialized = false; #isRunningTrace = false; #screenRecorderData: {recorder: ScreenRecorder; filePath: string} | null = @@ -124,11 +156,14 @@ export class McpContext implements Context { } async #init() { - const pages = await this.createPagesSnapshot(); + this.createPageTargetSnapshot(); + await this.#ensureInitialSelectedPage(); + const pages = this.#selectedPage ? [this.#selectedPage.pptrPage] : []; await this.createExtensionServiceWorkersSnapshot(); await this.#networkCollector.init(pages); await this.#consoleCollector.init(pages); await this.#devtoolsUniverseManager.init(pages); + this.#collectorsInitialized = true; } dispose() { @@ -256,22 +291,24 @@ export class McpContext implements Context { } else { page = await this.browser.newPage({background}); } - await this.createPagesSnapshot(); - this.selectPage(this.#getMcpPage(page)); - this.#networkCollector.addPage(page); - this.#consoleCollector.addPage(page); - return this.#getMcpPage(page); + this.createPageTargetSnapshot(); + const target = page.target(); + const pageId = this.#getOrCreatePageIdForTarget(target); + const mcpPage = this.#registerPage(page, pageId); + mcpPage.isolatedContextName = isolatedContextName; + this.selectPage(mcpPage); + return mcpPage; } async closePage(pageId: number): Promise { - if (this.#pages.length === 1) { + this.createPageTargetSnapshot(); + if (this.#pageSummaries.length === 1) { throw new Error(CLOSE_PAGE_ERROR); } - const page = this.getPageById(pageId); - if (page) { - page.dispose(); - this.#mcpPages.delete(page.pptrPage); - } + const page = await this.ensurePageById(pageId); + page.dispose(); + this.#mcpPages.delete(page.pptrPage); await page.pptrPage.close({runBeforeUnload: false}); + this.createPageTargetSnapshot(); } getNetworkRequestById(page: McpPage, reqid: number): HTTPRequest { @@ -424,6 +461,33 @@ export class McpContext implements Context { return page; } + async ensurePageById(pageId: number): Promise { + const existingPage = this.#mcpPages + .values() + .find(mcpPage => mcpPage.id === pageId); + if (existingPage && !existingPage.pptrPage.isClosed()) { + return existingPage; + } + + this.createPageTargetSnapshot(); + const target = this.#targetsByPageId.get(pageId); + if (!target) { + throw new Error('No page found'); + } + + let page = await this.#resolveTargetPage(target); + if (!page && target.url().startsWith('chrome-extension://')) { + page = await this.#resolveTargetPage(target, true); + } + if (!page) { + throw new Error( + `Page ${pageId} is not responding. Call ${listPages().name} to see open pages.`, + ); + } + + return this.#registerPage(page, pageId); + } + getPageId(page: Page): number | undefined { return this.#mcpPages.get(page)?.id; } @@ -446,6 +510,7 @@ export class McpContext implements Context { selectPage(newPage: McpPage): void { this.#selectedPage = newPage; + this.#selectedPageId = newPage.id; this.#updateSelectedPageTimeouts(); } @@ -514,19 +579,76 @@ export class McpContext implements Context { return this.#extensionServiceWorkers; } + createPageTargetSnapshot(): PageSummary[] { + const contextToName = this.#getContextToName(); + const targets = this.browser.targets().filter(target => { + return ( + this.#isPageTarget(target) && + (this.#options.experimentalDevToolsDebugging || + !target.url().startsWith('devtools://')) + ); + }); + const currentTargets = new Set(targets); + + for (const target of targets) { + this.#getOrCreatePageIdForTarget(target); + } + + for (const [target, id] of this.#pageTargets) { + if (!currentTargets.has(target)) { + this.#pageTargets.delete(target); + this.#targetsByPageId.delete(id); + } + } + + for (const [page, mcpPage] of this.#mcpPages) { + if (page.isClosed()) { + mcpPage.dispose(); + this.#mcpPages.delete(page); + } + } + + if (this.#selectedPage && this.#selectedPage.pptrPage.isClosed()) { + this.#selectedPage = undefined; + } + + if ( + this.#selectedPageId === undefined || + !targets.some(target => { + return this.#pageTargets.get(target) === this.#selectedPageId; + }) + ) { + this.#selectedPageId = targets[0] + ? this.#getOrCreatePageIdForTarget(targets[0]) + : undefined; + this.#selectedPage = undefined; + } + + this.#pageSummaries = targets.map(target => { + const id = this.#getOrCreatePageIdForTarget(target); + const isolatedContextName = contextToName.get(target.browserContext()); + const summary: PageSummary = { + id, + url: target.url(), + selected: id === this.#selectedPageId, + isExtension: target.url().startsWith('chrome-extension://'), + }; + if (isolatedContextName) { + summary.isolatedContextName = isolatedContextName; + } + return summary; + }); + + return this.#pageSummaries; + } + async createPagesSnapshot(): Promise { const {pages: allPages, isolatedContextNames} = await this.#getAllPages(); for (const page of allPages) { - let mcpPage = this.#mcpPages.get(page); - if (!mcpPage) { - mcpPage = new McpPage(page, this.#nextPageId++); - this.#mcpPages.set(page, mcpPage); - // We emulate a focused page for all pages to support multi-agent workflows. - void page.emulateFocusedPage(true).catch(error => { - this.logger('Error turning on focused page emulation', error); - }); - } + const target = page.target(); + const pageId = this.#getOrCreatePageIdForTarget(target); + const mcpPage = this.#registerPage(page, pageId); mcpPage.isolatedContextName = isolatedContextNames.get(page); } @@ -554,6 +676,7 @@ export class McpContext implements Context { this.selectPage(this.#getMcpPage(this.#pages[0])); } + this.createPageTargetSnapshot(); await this.detectOpenDevToolsWindows(); return this.#pages; @@ -563,10 +686,20 @@ export class McpContext implements Context { pages: Page[]; isolatedContextNames: Map; }> { - const defaultCtx = this.browser.defaultBrowserContext(); - const allPages = await this.browser.pages( - this.#options.experimentalIncludeAllPages, - ); + const pagePromises: Array> = []; + for (const target of this.browser.targets()) { + if (!this.#isPageTarget(target)) { + continue; + } + pagePromises.push(this.#resolveTargetPage(target)); + } + + const allPages: Page[] = []; + for (const page of await Promise.all(pagePromises)) { + if (page && !allPages.includes(page)) { + allPages.push(page); + } + } const allTargets = this.browser.targets(); const extensionTargets = allTargets.filter(target => { @@ -578,17 +711,15 @@ export class McpContext implements Context { for (const target of extensionTargets) { // Right now target.page() returns null for popup and side panel pages. - let page = await target.page(); + let page = await this.#resolveTargetPage(target); if (!page) { // We need to cache pages instances for targets because target.asPage() // returns a new page instance every time. page = this.#extensionPages.get(target) ?? null; if (!page) { - try { - page = await target.asPage(); + page = await this.#resolveTargetPage(target, true); + if (page) { this.#extensionPages.set(target, page); - } catch (e) { - this.logger('Failed to get page for extension target', e); } } } @@ -598,7 +729,22 @@ export class McpContext implements Context { } } - // Build a reverse lookup from BrowserContext instance → name. + const contextToName = this.#getContextToName(); + // Map each page to its isolated context name (if any). + const isolatedContextNames = new Map(); + for (const page of allPages) { + const ctx = page.browserContext(); + const name = contextToName.get(ctx); + if (name) { + isolatedContextNames.set(page, name); + } + } + + return {pages: allPages, isolatedContextNames}; + } + + #getContextToName(): Map { + const defaultCtx = this.browser.defaultBrowserContext(); const contextToName = new Map(); for (const [name, ctx] of this.#isolatedContexts) { contextToName.set(ctx, name); @@ -614,23 +760,91 @@ export class McpContext implements Context { contextToName.set(ctx, name); } } + return contextToName; + } - // Map each page to its isolated context name (if any). - const isolatedContextNames = new Map(); - for (const page of allPages) { - const ctx = page.browserContext(); - const name = contextToName.get(ctx); - if (name) { - isolatedContextNames.set(page, name); + #getOrCreatePageIdForTarget(target: Target): number { + const existingId = this.#pageTargets.get(target); + if (existingId) { + return existingId; + } + const id = this.#nextPageId++; + this.#pageTargets.set(target, id); + this.#targetsByPageId.set(id, target); + return id; + } + + #registerPage(page: Page, pageId: number): McpPage { + let mcpPage = this.#mcpPages.get(page); + if (!mcpPage) { + mcpPage = new McpPage(page, pageId); + this.#mcpPages.set(page, mcpPage); + // We emulate a focused page for all pages to support multi-agent workflows. + void page.emulateFocusedPage(true).catch(error => { + this.logger('Error turning on focused page emulation', error); + }); + if (this.#collectorsInitialized) { + this.#networkCollector.addPage(page); + this.#consoleCollector.addPage(page); } } + return mcpPage; + } - return {pages: allPages, isolatedContextNames}; + async #ensureInitialSelectedPage(): Promise { + const pageId = this.#selectedPageId; + if (pageId === undefined) { + return; + } + try { + const page = await this.ensurePageById(pageId); + this.selectPage(page); + } catch (error) { + this.logger(`Failed to load initial selected page ${pageId}`, error); + } + } + + #isPageTarget(target: Target): boolean { + const type = target.type(); + if (type === 'page') { + return true; + } + if (!this.#options.experimentalIncludeAllPages) { + return false; + } + return type === 'background_page' || type === 'webview' || type === 'other'; + } + + async #resolveTargetPage( + target: Target, + force = false, + ): Promise { + try { + const pagePromise: Promise = force + ? target.asPage() + : target.page(); + const page = await resolveWithTimeout(pagePromise, DEFAULT_TIMEOUT); + if (page === undefined) { + this.logger( + `Timed out getting page for target ${target.type()} ${target.url()}`, + ); + return null; + } + return page; + } catch (error) { + this.logger( + `Failed to get page for target ${target.type()} ${target.url()}`, + error, + ); + return null; + } } async detectOpenDevToolsWindows() { this.logger('Detecting open DevTools windows'); - const {pages} = await this.#getAllPages(); + const pages = [...this.#mcpPages.keys()].filter(page => { + return !page.isClosed(); + }); await Promise.all( pages.map(async page => { @@ -669,6 +883,10 @@ export class McpContext implements Context { return this.#pages; } + getPageSummaries(): PageSummary[] { + return this.#pageSummaries; + } + getIsolatedContextName(page: Page): string | undefined { return this.#mcpPages.get(page)?.isolatedContextName; } diff --git a/src/McpResponse.ts b/src/McpResponse.ts index 246536b77..4470c6927 100644 --- a/src/McpResponse.ts +++ b/src/McpResponse.ts @@ -14,6 +14,7 @@ import {IssueFormatter} from './formatters/IssueFormatter.js'; import {NetworkFormatter} from './formatters/NetworkFormatter.js'; import {SnapshotFormatter} from './formatters/SnapshotFormatter.js'; import type {McpContext} from './McpContext.js'; +import type {PageSummary} from './McpContext.js'; import type {McpPage} from './McpPage.js'; import {UncaughtError} from './PageCollector.js'; import {TextSnapshot} from './TextSnapshot.js'; @@ -21,7 +22,6 @@ import {DevTools, type Protocol} from './third_party/index.js'; import type { ConsoleMessage, ImageContent, - Page, ResourceType, TextContent, JSONSchema7Definition, @@ -455,7 +455,7 @@ export class McpResponse implements Response { structuredContent: object; }> { if (this.#includePages) { - await context.createPagesSnapshot(); + context.createPageTargetSnapshot(); } if (this.#includeExtensionServiceWorkers) { @@ -788,11 +788,14 @@ Call ${handleDialog.name} to handle it before continuing.`); } if (this.#includePages) { - const allPages = context.getPages(); + const allPages = context.getPageSummaries(); const {regularPages, extensionPages} = allPages.reduce( - (acc: {regularPages: Page[]; extensionPages: Page[]}, page: Page) => { - if (page.url().startsWith('chrome-extension://')) { + ( + acc: {regularPages: PageSummary[]; extensionPages: PageSummary[]}, + page: PageSummary, + ) => { + if (page.isExtension) { acc.extensionPages.push(page); } else { acc.regularPages.push(page); @@ -806,14 +809,13 @@ Call ${handleDialog.name} to handle it before continuing.`); const parts = [`## Pages`]; const structuredPages = []; for (const page of regularPages) { - const isolatedContextName = context.getIsolatedContextName(page); - const contextLabel = isolatedContextName - ? ` isolatedContext=${isolatedContextName}` + const contextLabel = page.isolatedContextName + ? ` isolatedContext=${page.isolatedContextName}` : ''; parts.push( - `${context.getPageId(page)}: ${page.url()}${context.isPageSelected(page) ? ' [selected]' : ''}${contextLabel}`, + `${page.id}: ${page.url}${page.selected ? ' [selected]' : ''}${contextLabel}`, ); - structuredPages.push(createStructuredPage(page, context)); + structuredPages.push(createStructuredPageSummary(page)); } response.push(...parts); structuredContent.pages = structuredPages; @@ -824,14 +826,13 @@ Call ${handleDialog.name} to handle it before continuing.`); response.push(`## Extension Pages`); const structuredExtensionPages = []; for (const page of extensionPages) { - const isolatedContextName = context.getIsolatedContextName(page); - const contextLabel = isolatedContextName - ? ` isolatedContext=${isolatedContextName}` + const contextLabel = page.isolatedContextName + ? ` isolatedContext=${page.isolatedContextName}` : ''; response.push( - `${context.getPageId(page)}: ${page.url()}${context.isPageSelected(page) ? ' [selected]' : ''}${contextLabel}`, + `${page.id}: ${page.url}${page.selected ? ' [selected]' : ''}${contextLabel}`, ); - structuredExtensionPages.push(createStructuredPage(page, context)); + structuredExtensionPages.push(createStructuredPageSummary(page)); } structuredContent.extensionPages = structuredExtensionPages; } @@ -1153,20 +1154,19 @@ Call ${handleDialog.name} to handle it before continuing.`); this.#textResponseLines = []; } } -function createStructuredPage(page: Page, context: McpContext) { - const isolatedContextName = context.getIsolatedContextName(page); +function createStructuredPageSummary(page: PageSummary) { const entry: { - id: number | undefined; + id: number; url: string; selected: boolean; isolatedContext?: string; } = { - id: context.getPageId(page), - url: page.url(), - selected: context.isPageSelected(page), + id: page.id, + url: page.url, + selected: page.selected, }; - if (isolatedContextName) { - entry.isolatedContext = isolatedContextName; + if (page.isolatedContextName) { + entry.isolatedContext = page.isolatedContextName; } return entry; } diff --git a/src/bin/chrome-devtools-mcp-cli-options.ts b/src/bin/chrome-devtools-mcp-cli-options.ts index 09a78d8cb..4ada31723 100644 --- a/src/bin/chrome-devtools-mcp-cli-options.ts +++ b/src/bin/chrome-devtools-mcp-cli-options.ts @@ -300,9 +300,8 @@ export function parseArguments(version: string, argv = process.argv) { // Yargs will complain if ( !args.channel && - !args.browserUrl && - !args.wsEndpoint && - !args.executablePath + (args.autoConnect || + (!args.browserUrl && !args.wsEndpoint && !args.executablePath)) ) { args.channel = 'stable'; } diff --git a/src/browser.ts b/src/browser.ts index 7deea75b4..916df3235 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -43,10 +43,24 @@ function makeTargetFilter(enableExtensions = false) { }; } +function toPuppeteerChannel(channel: Channel): ChromeReleaseChannel { + switch (channel) { + case 'canary': + return 'chrome-canary'; + case 'dev': + return 'chrome-dev'; + case 'beta': + return 'chrome-beta'; + case 'stable': + return 'chrome'; + } +} + export async function ensureBrowserConnected(options: { browserURL?: string; wsEndpoint?: string; wsHeaders?: Record; + autoConnect?: boolean; devtools: boolean; channel?: Channel; userDataDir?: string; @@ -57,24 +71,62 @@ export async function ensureBrowserConnected(options: { return browser; } - const connectOptions: Parameters[0] = { - targetFilter: makeTargetFilter(enableExtensions), - defaultViewport: null, - handleDevToolsAsPage: true, + const createConnectOptions = (): Parameters[0] => { + return { + targetFilter: makeTargetFilter(enableExtensions), + defaultViewport: null, + handleDevToolsAsPage: true, + }; + }; + + const connect = async ( + connectOptions: Parameters[0], + ): Promise => { + logger('Connecting Puppeteer to ', JSON.stringify(connectOptions)); + const connectedBrowser = await puppeteer.connect(connectOptions); + logger('Connected Puppeteer'); + return connectedBrowser; }; - let autoConnect = false; - if (options.wsEndpoint) { - connectOptions.browserWSEndpoint = options.wsEndpoint; - if (options.wsHeaders) { - connectOptions.headers = options.wsHeaders; + const connectionError = (autoConnect: boolean, cause: unknown): Error => { + return new Error( + `Could not connect to Chrome. ${autoConnect ? `Check if Chrome is running and remote debugging is enabled by going to chrome://inspect/#remote-debugging.` : `Check if Chrome is running.`}`, + { + cause, + }, + ); + }; + + if (options.wsEndpoint || options.browserURL) { + const connectOptions = createConnectOptions(); + if (options.wsEndpoint) { + connectOptions.browserWSEndpoint = options.wsEndpoint; + if (options.wsHeaders) { + connectOptions.headers = options.wsHeaders; + } + } else { + connectOptions.browserURL = options.browserURL; } - } else if (options.browserURL) { - connectOptions.browserURL = options.browserURL; - } else if (channel || options.userDataDir) { + try { + browser = await connect(connectOptions); + return browser; + } catch (error) { + if (!options.autoConnect) { + throw connectionError(false, error); + } + logger( + 'Direct browser connection failed; falling back to auto-connect', + error, + ); + } + } + + const connectOptions = createConnectOptions(); + let autoConnect = false; + if (channel || options.userDataDir || options.autoConnect) { const userDataDir = options.userDataDir; + autoConnect = true; if (userDataDir) { - autoConnect = true; // TODO: re-expose this logic via Puppeteer. const portPath = path.join(userDataDir, 'DevToolsActivePort'); try { @@ -105,12 +157,7 @@ export async function ensureBrowserConnected(options: { ); } } else { - if (!channel) { - throw new Error('Channel must be provided if userDataDir is missing'); - } - connectOptions.channel = ( - channel === 'stable' ? 'chrome' : `chrome-${channel}` - ) as ChromeReleaseChannel; + connectOptions.channel = toPuppeteerChannel(channel ?? 'stable'); } } else { throw new Error( @@ -118,18 +165,11 @@ export async function ensureBrowserConnected(options: { ); } - logger('Connecting Puppeteer to ', JSON.stringify(connectOptions)); try { - browser = await puppeteer.connect(connectOptions); + browser = await connect(connectOptions); } catch (err) { - throw new Error( - `Could not connect to Chrome. ${autoConnect ? `Check if Chrome is running and remote debugging is enabled by going to chrome://inspect/#remote-debugging.` : `Check if Chrome is running.`}`, - { - cause: err, - }, - ); + throw connectionError(autoConnect, err); } - logger('Connected Puppeteer'); return browser; } diff --git a/src/index.ts b/src/index.ts index 125d6ffde..6c5c03f0f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -30,6 +30,20 @@ import {pageIdSchema} from './tools/ToolDefinition.js'; import {createTools} from './tools/tools.js'; import {VERSION} from './version.js'; +function resolveChannel(channel: unknown): Channel { + switch (channel) { + case 'canary': + return 'canary'; + case 'dev': + return 'dev'; + case 'beta': + return 'beta'; + case 'stable': + default: + return 'stable'; + } +} + export async function createMcpServer( serverArgs: ReturnType, options: { @@ -106,9 +120,10 @@ export async function createMcpServer( browserURL: serverArgs.browserUrl, wsEndpoint: serverArgs.wsEndpoint, wsHeaders: serverArgs.wsHeaders, + autoConnect: serverArgs.autoConnect, // Important: only pass channel, if autoConnect is true. channel: serverArgs.autoConnect - ? (serverArgs.channel as Channel) + ? resolveChannel(serverArgs.channel) : undefined, userDataDir: serverArgs.userDataDir, devtools, @@ -238,7 +253,7 @@ export async function createMcpServer( serverArgs.experimentalPageIdRouting && params.pageId && !serverArgs.slim - ? context.getPageById(params.pageId) + ? await context.ensurePageById(params.pageId) : context.getSelectedMcpPage(); response.setPage(page); await tool.handler( diff --git a/src/tools/ToolDefinition.ts b/src/tools/ToolDefinition.ts index 91c42dcc2..81a17ef46 100644 --- a/src/tools/ToolDefinition.ts +++ b/src/tools/ToolDefinition.ts @@ -177,6 +177,7 @@ export type Context = Readonly<{ recordedTraces(): TraceResult[]; storeTraceRecording(result: TraceResult): void; getPageById(pageId: number): ContextPage; + ensurePageById(pageId: number): Promise; newPage( background?: boolean, isolatedContextName?: string, diff --git a/src/tools/pages.ts b/src/tools/pages.ts index 92fe6afcd..6b149e26f 100644 --- a/src/tools/pages.ts +++ b/src/tools/pages.ts @@ -111,7 +111,7 @@ export const selectPage = defineTool({ .describe('Whether to focus the page and bring it to the top.'), }, handler: async (request, response, context) => { - const page = context.getPageById(request.params.pageId); + const page = await context.ensurePageById(request.params.pageId); context.selectPage(page); response.setIncludePages(true); response.setListInPageTools(); @@ -186,9 +186,13 @@ export const newPage = defineTool(args => { ...timeoutSchema, }, handler: async (request, response, context) => { + const isolatedContext = + request.params.isolatedContext === '' + ? undefined + : request.params.isolatedContext; const page = await context.newPage( request.params.background, - request.params.isolatedContext, + isolatedContext, ); await navigateWithInterception( @@ -480,7 +484,7 @@ export const getTabId = definePageTool({ ), }, handler: async (request, response, context) => { - const page = context.getPageById(request.params.pageId); + const page = await context.ensurePageById(request.params.pageId); const tabId = (page.pptrPage as unknown as CdpPage)._tabId; response.setTabId(tabId); }, diff --git a/src/tools/script.ts b/src/tools/script.ts index e91e3b8de..77e075e40 100644 --- a/src/tools/script.ts +++ b/src/tools/script.ts @@ -93,9 +93,10 @@ Example with arguments: \`(el) => { return; } - const mcpPage = cliArgs?.experimentalPageIdRouting - ? context.getPageById(request.params.pageId) - : context.getSelectedMcpPage(); + const mcpPage = + cliArgs?.experimentalPageIdRouting && request.params.pageId + ? await context.ensurePageById(request.params.pageId) + : context.getSelectedMcpPage(); const page: Page = mcpPage.pptrPage; const args: Array> = []; diff --git a/tests/McpContext.test.ts b/tests/McpContext.test.ts index 12d2c640b..0536fdf65 100644 --- a/tests/McpContext.test.ts +++ b/tests/McpContext.test.ts @@ -9,15 +9,99 @@ import path from 'node:path'; import {afterEach, describe, it} from 'node:test'; import {pathToFileURL} from 'node:url'; +import {TargetType} from 'puppeteer-core'; import sinon from 'sinon'; import {NetworkFormatter} from '../src/formatters/NetworkFormatter.js'; import {TextSnapshot} from '../src/TextSnapshot.js'; -import type {HTTPResponse} from '../src/third_party/index.js'; +import type { + Browser, + BrowserContext, + CDPSession, + HTTPResponse, + Page, + Target, + WebWorker, +} from '../src/third_party/index.js'; import type {TraceResult} from '../src/trace-processing/parse.js'; import {getMockRequest, html, withMcpContext} from './utils.js'; +function createHangingPageTarget( + browser: Browser, + browserContext: BrowserContext, +): Target { + return { + page(): Promise { + return new Promise(() => { + // Intentionally never resolves to mimic a stalled target. + }); + }, + asPage(): Promise { + return new Promise(() => { + // Intentionally never resolves to mimic a stalled target. + }); + }, + url(): string { + return 'https://example.com/hanging'; + }, + createCDPSession(): Promise { + return Promise.reject(new Error('Not implemented')); + }, + type(): TargetType { + return TargetType.PAGE; + }, + browser(): Browser { + return browser; + }, + browserContext(): BrowserContext { + return browserContext; + }, + opener(): Target | undefined { + return; + }, + worker(): Promise { + return Promise.resolve(null); + }, + }; +} + +function createSpyHangingPageTarget( + browser: Browser, + browserContext: BrowserContext, + page: sinon.SinonSpy<[], Promise>, +): Target { + return { + page, + asPage(): Promise { + return new Promise(() => { + // Intentionally never resolves to mimic a stalled target. + }); + }, + url(): string { + return 'https://example.com/hanging'; + }, + createCDPSession(): Promise { + return Promise.reject(new Error('Not implemented')); + }, + type(): TargetType { + return TargetType.PAGE; + }, + browser(): Browser { + return browser; + }, + browserContext(): BrowserContext { + return browserContext; + }, + opener(): Target | undefined { + return; + }, + worker(): Promise { + return Promise.resolve(null); + }, + }; +} + describe('McpContext', () => { afterEach(() => { sinon.restore(); @@ -100,6 +184,66 @@ describe('McpContext', () => { }, ); }); + + it('skips page targets that do not resolve while creating a pages snapshot', async () => { + await withMcpContext(async (_response, context) => { + const page = context.getSelectedPptrPage(); + const targets = context.browser.targets(); + const hangingTarget = createHangingPageTarget( + context.browser, + page.browserContext(), + ); + sinon + .stub(context.browser, 'targets') + .returns([...targets, hangingTarget]); + sinon.stub(context, 'detectOpenDevToolsWindows').resolves(); + const clock = sinon.useFakeTimers(); + + const pagesPromise = context.createPagesSnapshot(); + await clock.tickAsync(5_000); + const pages = await pagesPromise; + + assert.ok(pages.includes(page)); + }); + }); + + it('lists page targets without resolving every page', async () => { + await withMcpContext(async (response, context) => { + const page = context.getSelectedPptrPage(); + const targets = context.browser.targets(); + const pageSpy = sinon.spy( + (): Promise => + new Promise(() => { + // Intentionally never resolves to mimic a stalled target. + }), + ); + const hangingTarget = createSpyHangingPageTarget( + context.browser, + page.browserContext(), + pageSpy, + ); + sinon + .stub(context.browser, 'targets') + .returns([...targets, hangingTarget]); + sinon.stub(context, 'detectOpenDevToolsWindows').resolves(); + const clock = sinon.useFakeTimers(); + + response.setIncludePages(true); + const resultPromise = response.handle('list_pages', context); + await clock.tickAsync(5_000); + const result = await resultPromise; + const structuredContent: {pages?: Array<{url: string}>} = + result.structuredContent; + + assert.strictEqual(pageSpy.called, false); + assert.ok( + structuredContent.pages?.some( + page => page.url === 'https://example.com/hanging', + ), + ); + }); + }); + it('resolves uid from a non-selected page snapshot', async () => { await withMcpContext(async (_response, context) => { // Page 1: set content and snapshot diff --git a/tests/browser.test.ts b/tests/browser.test.ts index b0835bf96..99bfc2cbf 100644 --- a/tests/browser.test.ts +++ b/tests/browser.test.ts @@ -7,13 +7,19 @@ import assert from 'node:assert'; import os from 'node:os'; import path from 'node:path'; -import {describe, it} from 'node:test'; +import {afterEach, describe, it} from 'node:test'; import {executablePath} from 'puppeteer'; +import sinon from 'sinon'; import {detectDisplay, ensureBrowserConnected, launch} from '../src/browser.js'; +import {puppeteer} from '../src/third_party/index.js'; describe('browser', () => { + afterEach(() => { + sinon.restore(); + }); + it('detects display does not crash', () => { detectDisplay(); }); @@ -100,4 +106,29 @@ describe('browser', () => { await browser.close(); } }); + + it('falls back to auto-connect when browser url cannot connect', async () => { + const connect = sinon.stub(puppeteer, 'connect'); + connect.onFirstCall().rejects(new Error('port unavailable')); + connect.onSecondCall().rejects(new Error('auto-connect unavailable')); + + await assert.rejects( + ensureBrowserConnected({ + browserURL: 'http://127.0.0.1:9222', + autoConnect: true, + channel: 'stable', + devtools: false, + }), + /Could not connect to Chrome/, + ); + + assert.strictEqual(connect.callCount, 2); + assert.strictEqual( + connect.firstCall.args[0].browserURL, + 'http://127.0.0.1:9222', + ); + assert.strictEqual(connect.firstCall.args[0].channel, undefined); + assert.strictEqual(connect.secondCall.args[0].browserURL, undefined); + assert.strictEqual(connect.secondCall.args[0].channel, 'chrome'); + }); }); diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 95f9da824..7eaef923e 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -255,6 +255,28 @@ describe('cli args parsing', () => { }); }); + it('uses stable channel for browser url with auto-connect fallback', async () => { + const args = parseArguments('1.0.0', [ + 'node', + 'main.js', + '--browserUrl', + 'http://127.0.0.1:9222', + '--auto-connect', + ]); + assert.deepStrictEqual(args, { + ...defaultArgs, + _: [], + headless: false, + $0: 'npx chrome-devtools-mcp@latest', + 'browser-url': 'http://127.0.0.1:9222', + browserUrl: 'http://127.0.0.1:9222', + u: 'http://127.0.0.1:9222', + channel: 'stable', + 'auto-connect': true, + autoConnect: true, + }); + }); + it('parses usage statistics flag', async () => { // Test default (should be true). const defaultArgs = parseArguments('1.0.0', ['node', 'main.js']); diff --git a/tests/tools/pages.test.ts b/tests/tools/pages.test.ts index 51c9a9b0f..27b6d36a0 100644 --- a/tests/tools/pages.test.ts +++ b/tests/tools/pages.test.ts @@ -363,6 +363,20 @@ describe('pages', () => { }); }); + it('treats an empty isolatedContext as the default context', async () => { + await withMcpContext(async (response, context) => { + const defaultContext = context.getSelectedPptrPage().browserContext(); + await newPage().handler( + {params: {url: 'about:blank', isolatedContext: ''}}, + response, + context, + ); + const page = context.getSelectedPptrPage(); + assert.strictEqual(context.getIsolatedContextName(page), undefined); + assert.strictEqual(page.browserContext(), defaultContext); + }); + }); + it('closes an isolated page without errors', async () => { await withMcpContext(async (response, context) => { await newPage().handler(