diff --git a/docs/tool-reference.md b/docs/tool-reference.md index 1836c924..a7e3925d 100644 --- a/docs/tool-reference.md +++ b/docs/tool-reference.md @@ -300,7 +300,11 @@ so returned values have to JSON-serializable. **Description:** List all console messages for the currently selected page since the last navigation. -**Parameters:** None +**Parameters:** + +- **pageIdx** (integer) _(optional)_: Page number to return (0-based). When omitted, returns the first page. +- **pageSize** (integer) _(optional)_: Maximum number of messages to return. When omitted, returns all requests. +- **types** (array) _(optional)_: Filter messages to only return messages of the specified resource types. When omitted or empty, returns all messages. --- diff --git a/src/McpResponse.ts b/src/McpResponse.ts index 8fa0b0d2..02c399d1 100644 --- a/src/McpResponse.ts +++ b/src/McpResponse.ts @@ -7,7 +7,7 @@ import type { ImageContent, TextContent, } from '@modelcontextprotocol/sdk/types.js'; -import type {ResourceType} from 'puppeteer-core'; +import type {ConsoleMessage, ResourceType} from 'puppeteer-core'; import {formatConsoleEvent} from './formatters/consoleFormatter.js'; import { @@ -29,20 +29,30 @@ interface NetworkRequestData { responseBody?: string; } +export interface ConsoleMessageData { + type: string; + message: string; + args: string[]; +} + export class McpResponse implements Response { #includePages = false; #includeSnapshot = false; #includeVerboseSnapshot = false; #attachedNetworkRequestData?: NetworkRequestData; - #includeConsoleData = false; + #consoleMessagesData?: ConsoleMessageData[]; #textResponseLines: string[] = []; - #formattedConsoleData?: string[]; #images: ImageContentData[] = []; #networkRequestsOptions?: { include: boolean; pagination?: PaginationOptions; resourceTypes?: ResourceType[]; }; + #consoleDataOptions?: { + include: boolean; + pagination?: PaginationOptions; + types?: string[]; + }; setIncludePages(value: boolean): void { this.#includePages = value; @@ -79,8 +89,30 @@ export class McpResponse implements Response { }; } - setIncludeConsoleData(value: boolean): void { - this.#includeConsoleData = value; + setIncludeConsoleData( + value: boolean, + options?: { + pageSize?: number; + pageIdx?: number; + types?: string[]; + }, + ): void { + if (!value) { + this.#consoleDataOptions = undefined; + return; + } + + this.#consoleDataOptions = { + include: value, + pagination: + options?.pageSize || options?.pageIdx + ? { + pageSize: options.pageSize, + pageIdx: options.pageIdx, + } + : undefined, + types: options?.types, + }; } attachNetworkRequest(reqid: number): void { @@ -98,7 +130,7 @@ export class McpResponse implements Response { } get includeConsoleData(): boolean { - return this.#includeConsoleData; + return this.#consoleDataOptions?.include ?? false; } get attachedNetworkRequestId(): number | undefined { return this.#attachedNetworkRequestData?.networkRequestStableId; @@ -106,6 +138,12 @@ export class McpResponse implements Response { get networkRequestsPageIdx(): number | undefined { return this.#networkRequestsOptions?.pagination?.pageIdx; } + get consoleMessagesPageIdx(): number | undefined { + return this.#consoleDataOptions?.pagination?.pageIdx; + } + get consoleMessagesTypes(): string[] | undefined { + return this.#consoleDataOptions?.types; + } appendResponseLine(value: string): void { this.#textResponseLines.push(value); @@ -142,8 +180,6 @@ export class McpResponse implements Response { await context.createTextSnapshot(this.#includeVerboseSnapshot); } - let formattedConsoleMessages: string[]; - if (this.#attachedNetworkRequestData?.networkRequestStableId) { const request = context.getNetworkRequestById( this.#attachedNetworkRequestData.networkRequestStableId, @@ -159,14 +195,35 @@ export class McpResponse implements Response { } } - if (this.#includeConsoleData) { - const consoleMessages = context.getConsoleData(); - if (consoleMessages) { - formattedConsoleMessages = await Promise.all( - consoleMessages.map(message => formatConsoleEvent(message)), - ); - this.#formattedConsoleData = formattedConsoleMessages; - } + if (this.#consoleDataOptions?.include) { + const messages = context.getConsoleData(); + + this.#consoleMessagesData = await Promise.all( + messages.map(async (item): Promise => { + if ('args' in item) { + const consoleMessage = item as ConsoleMessage; + return { + type: consoleMessage.type(), + message: consoleMessage.text(), + args: await Promise.all( + consoleMessage.args().map(async arg => { + const stringArg = await arg.jsonValue().catch(() => { + // Ignore errors. + }); + return typeof stringArg === 'object' + ? JSON.stringify(stringArg) + : String(stringArg); + }), + ), + }; + } + return { + type: 'error', + message: (item as Error).message, + args: [], + }; + }), + ); } return this.format(toolName, context); @@ -264,10 +321,19 @@ Call ${handleDialog.name} to handle it before continuing.`); } } - if (this.#includeConsoleData && this.#formattedConsoleData) { + if (this.#consoleDataOptions?.include) { + const messages = this.#consoleMessagesData ?? []; + response.push('## Console messages'); - if (this.#formattedConsoleData.length) { - response.push(...this.#formattedConsoleData); + if (messages.length) { + const data = this.#dataWithPagination( + messages, + this.#consoleDataOptions.pagination, + ); + response.push(...data.info); + response.push( + ...data.items.map(message => formatConsoleEvent(message)), + ); } else { response.push(''); } diff --git a/src/formatters/consoleFormatter.ts b/src/formatters/consoleFormatter.ts index b6627496..40f60830 100644 --- a/src/formatters/consoleFormatter.ts +++ b/src/formatters/consoleFormatter.ts @@ -4,11 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { - ConsoleMessage, - JSHandle, - ConsoleMessageLocation, -} from 'puppeteer-core'; +import type {ConsoleMessageData} from '../McpResponse.js'; const logLevels: Record = { log: 'Log', @@ -19,78 +15,33 @@ const logLevels: Record = { assert: 'Assert', }; -export async function formatConsoleEvent( - event: ConsoleMessage | Error, -): Promise { - // Check if the event object has the .type() method, which is unique to ConsoleMessage - if ('type' in event) { - return await formatConsoleMessage(event); - } - return `Error: ${event.message}`; -} - -async function formatConsoleMessage(msg: ConsoleMessage): Promise { - const logLevel = logLevels[msg.type()]; - const args = msg.args(); +export function formatConsoleEvent(msg: ConsoleMessageData): string { + const logLevel = logLevels[msg.type] ?? 'Log'; + const text = msg.message; - if (logLevel === 'Error') { - let message = `${logLevel}> `; - if (msg.text() === 'JSHandle@error') { - const errorHandle = args[0] as JSHandle; - message += await errorHandle - .evaluate(error => { - return error.toString(); - }) - .catch(() => { - return 'Error occurred'; - }); - void errorHandle.dispose().catch(); + const formattedArgs = formatArgs(msg.args, text); + return `${logLevel}> ${text} ${formattedArgs}`.trim(); +} - const formattedArgs = await formatArgs(args.slice(1)); - if (formattedArgs) { - message += ` ${formattedArgs}`; - } - } else { - message += msg.text(); - const formattedArgs = await formatArgs(args); - if (formattedArgs) { - message += ` ${formattedArgs}`; - } - for (const frame of msg.stackTrace()) { - message += '\n' + formatStackFrame(frame); - } - } - return message; +// Only includes the first arg and indicates that there are more args +function formatArgs(args: string[], messageText: string): string { + if (args.length === 0) { + return ''; } - const formattedArgs = await formatArgs(args); - const text = msg.text(); - - return `${logLevel}> ${formatStackFrame( - msg.location(), - )}: ${text} ${formattedArgs}`.trim(); -} - -async function formatArgs(args: readonly JSHandle[]): Promise { - const argValues = await Promise.all( - args.map(arg => - arg.jsonValue().catch(() => { - // Ignore errors - }), - ), - ); + let formattedArgs = ''; + const firstArg = args[0]; - return argValues - .map(value => { - return typeof value === 'object' ? JSON.stringify(value) : String(value); - }) - .join(' '); -} + if (firstArg !== messageText) { + formattedArgs += + typeof firstArg === 'object' + ? JSON.stringify(firstArg) + : String(firstArg); + } -function formatStackFrame(stackFrame: ConsoleMessageLocation): string { - if (!stackFrame?.url) { - return ''; + if (args.length > 1) { + return `${formattedArgs} ...`; } - const filename = stackFrame.url.replace(/^.*\//, ''); - return `${filename}:${stackFrame.lineNumber}:${stackFrame.columnNumber}`; + + return formattedArgs; } diff --git a/src/tools/ToolDefinition.ts b/src/tools/ToolDefinition.ts index 9ecf49a8..e920417c 100644 --- a/src/tools/ToolDefinition.ts +++ b/src/tools/ToolDefinition.ts @@ -47,7 +47,11 @@ export interface Response { value: boolean, options?: {pageSize?: number; pageIdx?: number; resourceTypes?: string[]}, ): void; - setIncludeConsoleData(value: boolean): void; + setIncludeConsoleData( + value: boolean, + options?: {pageSize?: number; pageIdx?: number; types?: string[]}, + ): void; + setIncludeSnapshot(value: boolean): void; setIncludeSnapshot(value: boolean, verbose?: boolean): void; attachImage(value: ImageContentData): void; attachNetworkRequest(reqid: number): void; diff --git a/src/tools/console.ts b/src/tools/console.ts index 4fb752f2..5b049111 100644 --- a/src/tools/console.ts +++ b/src/tools/console.ts @@ -4,9 +4,37 @@ * SPDX-License-Identifier: Apache-2.0 */ +import type {ConsoleMessageType} from 'puppeteer-core'; +import z from 'zod'; + import {ToolCategories} from './categories.js'; import {defineTool} from './ToolDefinition.js'; +const FILTERABLE_MESSAGE_TYPES: readonly [ + ConsoleMessageType, + ...ConsoleMessageType[], +] = [ + 'log', + 'debug', + 'info', + 'error', + 'warn', + 'dir', + 'dirxml', + 'table', + 'trace', + 'clear', + 'startGroup', + 'startGroupCollapsed', + 'endGroup', + 'assert', + 'profile', + 'profileEnd', + 'count', + 'timeEnd', + 'verbose', +]; + export const consoleTool = defineTool({ name: 'list_console_messages', description: @@ -15,8 +43,35 @@ export const consoleTool = defineTool({ category: ToolCategories.DEBUGGING, readOnlyHint: true, }, - schema: {}, - handler: async (_request, response) => { - response.setIncludeConsoleData(true); + schema: { + pageSize: z + .number() + .int() + .positive() + .optional() + .describe( + 'Maximum number of messages to return. When omitted, returns all requests.', + ), + pageIdx: z + .number() + .int() + .min(0) + .optional() + .describe( + 'Page number to return (0-based). When omitted, returns the first page.', + ), + types: z + .array(z.enum(FILTERABLE_MESSAGE_TYPES)) + .optional() + .describe( + 'Filter messages to only return messages of the specified resource types. When omitted or empty, returns all messages.', + ), + }, + handler: async (request, response) => { + response.setIncludeConsoleData(true, { + pageSize: request.params.pageSize, + pageIdx: request.params.pageIdx, + types: request.params.types, + }); }, }); diff --git a/tests/McpResponse.test.ts b/tests/McpResponse.test.ts index 62c179d1..e3a174c9 100644 --- a/tests/McpResponse.test.ts +++ b/tests/McpResponse.test.ts @@ -341,8 +341,7 @@ reqid=1 GET http://example.com [pending]`, // Cannot check the full text because it contains local file path assert.ok( result[0].text.toString().startsWith(`# test response -## Console messages -Log>`), +## Console messages`), ); assert.ok(result[0].text.toString().includes('Hello from the test')); }); diff --git a/tests/formatters/consoleFormatter.test.ts b/tests/formatters/consoleFormatter.test.ts index 4fd6213c..b64adbca 100644 --- a/tests/formatters/consoleFormatter.test.ts +++ b/tests/formatters/consoleFormatter.test.ts @@ -7,208 +7,109 @@ import assert from 'node:assert'; import {describe, it} from 'node:test'; -import type {ConsoleMessage} from 'puppeteer-core'; - import {formatConsoleEvent} from '../../src/formatters/consoleFormatter.js'; - -function getMockConsoleMessage(options: { - type: string; - text: string; - location?: { - url?: string; - lineNumber?: number; - columnNumber?: number; - }; - stackTrace?: Array<{ - url: string; - lineNumber: number; - columnNumber: number; - }>; - args?: unknown[]; -}): ConsoleMessage { - return { - type() { - return options.type; - }, - text() { - return options.text; - }, - location() { - return options.location ?? {}; - }, - stackTrace() { - return options.stackTrace ?? []; - }, - args() { - return ( - options.args?.map(arg => { - return { - evaluate(fn: (arg: unknown) => unknown) { - return Promise.resolve(fn(arg)); - }, - jsonValue() { - return Promise.resolve(arg); - }, - dispose() { - return Promise.resolve(); - }, - }; - }) ?? [] - ); - }, - } as ConsoleMessage; -} +import type {ConsoleMessageData} from '../../src/McpResponse.js'; describe('consoleFormatter', () => { describe('formatConsoleEvent', () => { - it('formats a console.log message', async () => { - const message = getMockConsoleMessage({ + it('formats a console.log message', () => { + const message: ConsoleMessageData = { type: 'log', - text: 'Hello, world!', - location: { - url: 'http://example.com/script.js', - lineNumber: 10, - columnNumber: 5, - }, - }); - const result = await formatConsoleEvent(message); - assert.equal(result, 'Log> script.js:10:5: Hello, world!'); + message: 'Hello, world!', + args: [], + }; + const result = formatConsoleEvent(message); + assert.equal(result, 'Log> Hello, world!'); }); - it('formats a console.log message with arguments', async () => { - const message = getMockConsoleMessage({ + it('formats a console.log message with one argument', () => { + const message: ConsoleMessageData = { type: 'log', - text: 'Processing file:', - args: ['file.txt', {id: 1, status: 'done'}], - location: { - url: 'http://example.com/script.js', - lineNumber: 10, - columnNumber: 5, - }, - }); - const result = await formatConsoleEvent(message); - assert.equal( - result, - 'Log> script.js:10:5: Processing file: file.txt {"id":1,"status":"done"}', - ); + message: 'Processing file:', + args: ['file.txt'], + }; + const result = formatConsoleEvent(message); + assert.equal(result, 'Log> Processing file: file.txt'); }); - it('formats a console.error message', async () => { - const message = getMockConsoleMessage({ - type: 'error', - text: 'Something went wrong', - }); - const result = await formatConsoleEvent(message); - assert.equal(result, 'Error> Something went wrong'); + it('formats a console.log message with multiple arguments', () => { + const message: ConsoleMessageData = { + type: 'log', + message: 'Processing file:', + args: ['file.txt', JSON.stringify({id: 1, status: 'done'})], + }; + const result = formatConsoleEvent(message); + assert.equal(result, 'Log> Processing file: file.txt ...'); }); - it('formats a console.error message with arguments', async () => { - const message = getMockConsoleMessage({ + it('formats a console.error message', () => { + const message: ConsoleMessageData = { type: 'error', - text: 'Something went wrong:', - args: ['details', {code: 500}], - }); - const result = await formatConsoleEvent(message); - assert.equal(result, 'Error> Something went wrong: details {"code":500}'); + message: 'Something went wrong', + args: [], + }; + const result = formatConsoleEvent(message); + assert.equal(result, 'Error> Something went wrong'); }); - it('formats a console.error message with a stack trace', async () => { - const message = getMockConsoleMessage({ + it('formats a console.error message with one argument', () => { + const message: ConsoleMessageData = { type: 'error', - text: 'Something went wrong', - stackTrace: [ - { - url: 'http://example.com/script.js', - lineNumber: 10, - columnNumber: 5, - }, - { - url: 'http://example.com/script2.js', - lineNumber: 20, - columnNumber: 10, - }, - ], - }); - const result = await formatConsoleEvent(message); - assert.equal( - result, - 'Error> Something went wrong\nscript.js:10:5\nscript2.js:20:10', - ); + message: 'Something went wrong:', + args: ['details'], + }; + const result = formatConsoleEvent(message); + assert.equal(result, 'Error> Something went wrong: details'); }); - it('formats a console.error message with a JSHandle@error', async () => { - const message = getMockConsoleMessage({ + it('formats a console.error message with multiple arguments', () => { + const message: ConsoleMessageData = { type: 'error', - text: 'JSHandle@error', - args: [new Error('mock stack')], - }); - const result = await formatConsoleEvent(message); - assert.ok(result.startsWith('Error> Error: mock stack')); + message: 'Something went wrong:', + args: ['details', JSON.stringify({code: 500})], + }; + const result = formatConsoleEvent(message); + assert.equal(result, 'Error> Something went wrong: details ...'); }); - it('formats a console.warn message', async () => { - const message = getMockConsoleMessage({ + it('formats a console.warn message', () => { + const message: ConsoleMessageData = { type: 'warning', - text: 'This is a warning', - location: { - url: 'http://example.com/script.js', - lineNumber: 10, - columnNumber: 5, - }, - }); - const result = await formatConsoleEvent(message); - assert.equal(result, 'Warning> script.js:10:5: This is a warning'); + message: 'This is a warning', + args: [], + }; + const result = formatConsoleEvent(message); + assert.equal(result, 'Warning> This is a warning'); }); - it('formats a console.info message', async () => { - const message = getMockConsoleMessage({ + it('formats a console.info message', () => { + const message: ConsoleMessageData = { type: 'info', - text: 'This is an info message', - location: { - url: 'http://example.com/script.js', - lineNumber: 10, - columnNumber: 5, - }, - }); - const result = await formatConsoleEvent(message); - assert.equal(result, 'Info> script.js:10:5: This is an info message'); - }); - - it('formats a page error', async () => { - const error = new Error('Page crashed'); - error.stack = 'Error: Page crashed\n at :1:1'; - const result = await formatConsoleEvent(error); - assert.equal(result, 'Error: Page crashed'); - }); - - it('formats a page error without a stack', async () => { - const error = new Error('Page crashed'); - error.stack = undefined; - const result = await formatConsoleEvent(error); - assert.equal(result, 'Error: Page crashed'); + message: 'This is an info message', + args: [], + }; + const result = formatConsoleEvent(message); + assert.equal(result, 'Info> This is an info message'); }); - it('formats a console.log message from a removed iframe - no location', async () => { - const message = getMockConsoleMessage({ - type: 'log', - text: 'Hello from iframe', - location: {}, - }); - const result = await formatConsoleEvent(message); - assert.equal(result, 'Log> : Hello from iframe'); + it('formats a page error', () => { + const error: ConsoleMessageData = { + type: 'error', + message: 'Error: Page crashed', + args: [], + }; + const result = formatConsoleEvent(error); + assert.equal(result, 'Error> Error: Page crashed'); }); - it('formats a console.log message from a removed iframe with partial location', async () => { - const message = getMockConsoleMessage({ - type: 'log', - text: 'Hello from iframe', - location: { - lineNumber: 10, - columnNumber: 5, - }, - }); - const result = await formatConsoleEvent(message); - assert.equal(result, 'Log> : Hello from iframe'); + it('formats a page error without a stack', () => { + const error: ConsoleMessageData = { + type: 'error', + message: 'Error: Page crashed', + args: [], + }; + const result = formatConsoleEvent(error); + assert.equal(result, 'Error> Error: Page crashed'); }); }); }); diff --git a/tests/tools/console.test.ts b/tests/tools/console.test.ts index b25ef15b..cd91587c 100644 --- a/tests/tools/console.test.ts +++ b/tests/tools/console.test.ts @@ -17,5 +17,22 @@ describe('console', () => { assert.ok(response.includeConsoleData); }); }); + + it('lists error messages', async () => { + await withBrowser(async (response, context) => { + const page = await context.newPage(); + await page.setContent( + '', + ); + await consoleTool.handler({params: {}}, response, context); + await response.handle('test', context); + + const formattedResponse = response.format('test', context); + + const textContent = formattedResponse[0] as {text: string}; + assert.ok(textContent.text.includes('Error>')); + assert.ok(textContent.text.includes('This is an error')); + }); + }); }); });