diff --git a/src/McpResponse.ts b/src/McpResponse.ts index 738397079..422bd250c 100644 --- a/src/McpResponse.ts +++ b/src/McpResponse.ts @@ -432,6 +432,8 @@ Call ${handleDialog.name} to handle it before continuing.`); tabId?: string; networkRequest?: object; networkRequests?: object[]; + consoleMessage?: object; + consoleMessages?: object[]; } = {}; if (this.#tabId) { @@ -454,9 +456,12 @@ Call ${handleDialog.name} to handle it before continuing.`); structuredContent.networkRequest = data.detailedNetworkRequest.toJSONDetailed(); } - response.push( - ...this.#formatConsoleData(context, data.detailedConsoleMessage), - ); + + if (data.detailedConsoleMessage) { + response.push(data.detailedConsoleMessage.toStringDetailed()); + structuredContent.consoleMessage = + data.detailedConsoleMessage.toJSONDetailed(); + } if (this.#networkRequestsOptions?.include) { let requests = context.getNetworkRequests( @@ -503,13 +508,9 @@ Call ${handleDialog.name} to handle it before continuing.`); this.#consoleDataOptions.pagination, ); response.push(...data.info); - response.push( - ...data.items.map(message => { - if (message instanceof IssueFormatter) { - return message.toString(); - } - return message.toString(); - }), + response.push(...data.items.map(message => message.toString())); + structuredContent.consoleMessages = data.items.map(message => + message.toJSON(), ); } else { response.push(''); @@ -559,23 +560,6 @@ Call ${handleDialog.name} to handle it before continuing.`); }; } - #formatConsoleData( - context: McpContext, - data: ConsoleFormatter | IssueFormatter | undefined, - ): string[] { - const response: string[] = []; - if (!data) { - return response; - } - - if (data instanceof IssueFormatter) { - response.push(data.toStringDetailed()); - } else { - response.push(data.toStringDetailed()); - } - return response; - } - resetResponseLineForTesting() { this.#textResponseLines = []; } diff --git a/src/formatters/ConsoleFormatter.ts b/src/formatters/ConsoleFormatter.ts index b31d713cc..366f1a234 100644 --- a/src/formatters/ConsoleFormatter.ts +++ b/src/formatters/ConsoleFormatter.ts @@ -171,4 +171,27 @@ export class ConsoleFormatter { } return result; } + toJSON(): object { + return { + type: this.#getType(), + text: this.#getText(), + argsCount: + this.#msg instanceof Error + ? 0 + : this.#resolvedArgs.length || this.#msg.args().length, + id: this.#id, + }; + } + + toJSONDetailed(): object { + return { + id: this.#id, + type: this.#getType(), + text: this.#getText(), + args: this.#getArgs().map(arg => + typeof arg === 'object' ? arg : String(arg), + ), + stackTrace: this.#resolvedStackTrace, + }; + } } diff --git a/src/formatters/IssueFormatter.ts b/src/formatters/IssueFormatter.ts index d5cf34c1a..854392421 100644 --- a/src/formatters/IssueFormatter.ts +++ b/src/formatters/IssueFormatter.ts @@ -14,6 +14,12 @@ export interface IssueFormatterOptions { id?: number; } +export interface AffectedResource { + uid?: string; + data?: unknown; + request?: string | number; +} + export class IssueFormatter { #issue: DevTools.AggregatedIssue; #options: IssueFormatterOptions; @@ -59,6 +65,55 @@ export class IssueFormatter { } } + const affectedResources = this.#getAffectedResources(); + if (affectedResources.length) { + bodyParts.push('### Affected resources'); + bodyParts.push( + ...affectedResources.map(item => { + const details = []; + if (item.uid) { + details.push(`uid=${item.uid}`); + } + if (item.request) { + details.push( + (typeof item.request === 'number' ? `reqid=` : 'url=') + + item.request, + ); + } + if (item.data) { + details.push(`data=${JSON.stringify(item.data)}`); + } + return details.join(' '); + }), + ); + } + + result.push(`Message: issue> ${bodyParts.join('\n')}`); + + return result.join('\n'); + } + + toJSON(): object { + return { + type: 'issue', + title: this.#getTitle(), + count: this.#issue.getAggregatedIssuesCount(), + id: this.#options.id, + }; + } + + toJSONDetailed(): object { + return { + id: this.#options.id, + type: 'issue', + title: this.#getTitle(), + description: this.#getDescription(), + links: this.#issue.getDescription()?.links, + affectedResources: this.#getAffectedResources(), + }; + } + + #getAffectedResources(): AffectedResource[] { const issues = this.#issue.getAllIssues(); const affectedResources: Array<{ uid?: string; @@ -73,8 +128,10 @@ export class IssueFormatter { // We send the remaining details as untyped JSON because the DevTools // frontend code is currently not re-usable. - // eslint-disable-next-line - const data = structuredClone(details) as any; + const data = structuredClone(details) as unknown as Record< + string, + unknown + >; let uid; let request: number | string | undefined; @@ -111,7 +168,8 @@ export class IssueFormatter { ); if (resolvedId) { request = resolvedId; - delete data.request.requestId; + const requestData = data.request as Record; + delete requestData.requestId; } } } @@ -125,31 +183,7 @@ export class IssueFormatter { request, }); } - if (affectedResources.length) { - bodyParts.push('### Affected resources'); - bodyParts.push( - ...affectedResources.map(item => { - const details = []; - if (item.uid) { - details.push(`uid=${item.uid}`); - } - if (item.request) { - details.push( - (typeof item.request === 'number' ? `reqid=` : 'url=') + - item.request, - ); - } - if (item.data) { - details.push(`data=${JSON.stringify(item.data)}`); - } - return details.join(' '); - }), - ); - } - - result.push(`Message: issue> ${bodyParts.join('\n')}`); - - return result.join('\n'); + return affectedResources; } isValid(): boolean { diff --git a/tests/formatters/ConsoleFormatter.test.ts b/tests/formatters/ConsoleFormatter.test.ts index e72713541..985bf013e 100644 --- a/tests/formatters/ConsoleFormatter.test.ts +++ b/tests/formatters/ConsoleFormatter.test.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import assert from 'node:assert'; import {describe, it} from 'node:test'; import {ConsoleFormatter} from '../../src/formatters/ConsoleFormatter.js'; @@ -163,4 +164,77 @@ describe('ConsoleFormatter', () => { t.assert.snapshot?.(result); }); }); + describe('toJSON', () => { + it('formats a console.log message', async () => { + const message = createMockMessage({ + type: () => 'log', + text: () => 'Hello, world!', + }); + const result = (await ConsoleFormatter.from(message, {id: 1})).toJSON(); + assert.deepStrictEqual(result, { + type: 'log', + text: 'Hello, world!', + argsCount: 0, + id: 1, + }); + }); + + it('formats a console.log message with args', async () => { + const message = createMockMessage({ + type: () => 'log', + text: () => 'Processing file:', + args: () => [ + {jsonValue: async () => 'file.txt'}, + {jsonValue: async () => 'another file'}, + ], + }); + const result = (await ConsoleFormatter.from(message, {id: 1})).toJSON(); + assert.deepStrictEqual(result, { + type: 'log', + text: 'Processing file:', + argsCount: 2, + id: 1, + }); + }); + }); + + describe('toJSONDetailed', () => { + it('formats a console.log message', async () => { + const message = createMockMessage({ + type: () => 'log', + text: () => 'Hello, world!', + }); + const result = ( + await ConsoleFormatter.from(message, {id: 1}) + ).toJSONDetailed(); + assert.deepStrictEqual(result, { + id: 1, + type: 'log', + text: 'Hello, world!', + args: [], + stackTrace: undefined, + }); + }); + + it('formats a console.log message with args', async () => { + const message = createMockMessage({ + type: () => 'log', + text: () => 'Processing file:', + args: () => [ + {jsonValue: async () => 'file.txt'}, + {jsonValue: async () => 'another file'}, + ], + }); + const result = ( + await ConsoleFormatter.from(message, {id: 2, fetchDetailedData: true}) + ).toJSONDetailed(); + assert.deepStrictEqual(result, { + id: 2, + type: 'log', + text: 'Processing file:', + args: ['file.txt', 'another file'], + stackTrace: undefined, + }); + }); + }); }); diff --git a/tests/formatters/IssueFormatter.test.ts b/tests/formatters/IssueFormatter.test.ts index 499d34078..c46fbb6fa 100644 --- a/tests/formatters/IssueFormatter.test.ts +++ b/tests/formatters/IssueFormatter.test.ts @@ -134,4 +134,76 @@ describe('IssueFormatter', () => { assert.ok(detailed.includes('Valid Title')); }); }); + describe('toJSON', () => { + it('formats a simplified issue', () => { + const mockAggregatedIssue = getMockAggregatedIssue(); + mockAggregatedIssue.getDescription.returns({ + file: 'mock.md', + links: [], + }); + mockAggregatedIssue.getAggregatedIssuesCount.returns(5); + getIssueDescriptionStub + .withArgs('mock.md') + .returns('# Issue Title\n\nIssue content'); + + const formatter = new IssueFormatter(mockAggregatedIssue, {id: 1}); + assert.deepStrictEqual(formatter.toJSON(), { + type: 'issue', + title: 'Issue Title', + count: 5, + id: 1, + }); + }); + }); + + describe('toJSONDetailed', () => { + it('formats a detailed issue', () => { + const testGenericIssue = { + details: () => { + return { + violatingNodeId: 2, + violatingNodeAttribute: 'test', + }; + }, + }; + const mockAggregatedIssue = getMockAggregatedIssue(); + const mockDescription = { + file: 'mock.md', + links: [{link: 'http://example.com', linkTitle: 'Link 1'}], + substitutions: new Map([['PLACEHOLDER_VALUE', 'sub value']]), + }; + mockAggregatedIssue.getDescription.returns(mockDescription); + // @ts-expect-error stubbed generic issue does not match the complete type. + mockAggregatedIssue.getAllIssues.returns([testGenericIssue]); + + const mockDescriptionFileContent = + '# Mock Issue Title\n\nThis is a mock issue description {PLACEHOLDER_VALUE}'; + + getIssueDescriptionStub + .withArgs('mock.md') + .returns(mockDescriptionFileContent); + + const formatter = new IssueFormatter(mockAggregatedIssue, { + id: 5, + }); + + const detailedResult = formatter.toJSONDetailed() as unknown as Record< + string, + object + > & {affectedResources: Array<{data: object}>}; + assert.strictEqual(detailedResult.id, 5); + assert.strictEqual(detailedResult.type, 'issue'); + assert.strictEqual(detailedResult.title, 'Mock Issue Title'); + assert.strictEqual( + detailedResult.description, + '# Mock Issue Title\n\nThis is a mock issue description sub value', + ); + assert.deepStrictEqual(detailedResult.links, mockDescription.links); + assert.strictEqual(detailedResult.affectedResources.length, 1); + assert.deepStrictEqual(detailedResult.affectedResources[0].data, { + violatingNodeAttribute: 'test', + violatingNodeId: 2, + }); + }); + }); });