diff --git a/src/McpResponse.ts b/src/McpResponse.ts index b7de0440..af7c8b51 100644 --- a/src/McpResponse.ts +++ b/src/McpResponse.ts @@ -255,7 +255,7 @@ export class McpResponse implements Response { const mappedIssueMessage = mapIssueToMessageObject(message); if (!mappedIssueMessage) throw new Error( - "Can't prpovide detals for the msgid " + consoleMessageStableId, + "Can't provide detals for the msgid " + consoleMessageStableId, ); consoleData = { consoleMessageStableId, @@ -402,7 +402,7 @@ Call ${handleDialog.name} to handle it before continuing.`); } response.push(...this.#formatNetworkRequestData(context, data.bodies)); - response.push(...this.#formatConsoleData(data.consoleData)); + response.push(...this.#formatConsoleData(context, data.consoleData)); if (this.#networkRequestsOptions?.include) { let requests = context.getNetworkRequests( @@ -500,13 +500,16 @@ Call ${handleDialog.name} to handle it before continuing.`); }; } - #formatConsoleData(data: ConsoleMessageData | undefined): string[] { + #formatConsoleData( + context: McpContext, + data: ConsoleMessageData | undefined, + ): string[] { const response: string[] = []; if (!data) { return response; } - response.push(formatConsoleEventVerbose(data)); + response.push(formatConsoleEventVerbose(data, context)); return response; } diff --git a/src/formatters/consoleFormatter.ts b/src/formatters/consoleFormatter.ts index 0e4672aa..7c56d490 100644 --- a/src/formatters/consoleFormatter.ts +++ b/src/formatters/consoleFormatter.ts @@ -5,6 +5,7 @@ */ import type {AggregatedIssue} from '../../node_modules/chrome-devtools-frontend/mcp/mcp.js'; +import type {McpContext} from '../McpContext.js'; export interface ConsoleMessageData { consoleMessageStableId: number; @@ -36,11 +37,14 @@ function getArgs(msg: ConsoleMessageData) { } // The verbose format for a console message, including all details. -export function formatConsoleEventVerbose(msg: ConsoleMessageData): string { +export function formatConsoleEventVerbose( + msg: ConsoleMessageData, + context?: McpContext, +): string { const aggregatedIssue = msg.item; const result = [ `ID: ${msg.consoleMessageStableId}`, - `Message: ${msg.type}> ${aggregatedIssue ? formatIssue(aggregatedIssue, msg.description) : msg.message}`, + `Message: ${msg.type}> ${aggregatedIssue ? formatIssue(aggregatedIssue, msg.description, context) : msg.message}`, aggregatedIssue ? undefined : formatArgs(msg), ].filter(line => !!line); return result.join('\n'); @@ -65,10 +69,19 @@ function formatArgs(consoleData: ConsoleMessageData): string { return result.join('\n'); } - +interface IssueDetailsWithResources { + violatingNodeId?: number; + nodeId?: number; + documentNodeId?: number; + request?: { + requestId?: string; + url: string; + }; +} export function formatIssue( issue: AggregatedIssue, description?: string, + context?: McpContext, ): string { const result: string[] = []; @@ -87,6 +100,86 @@ export function formatIssue( } } + const issues: Array<{ + details?: () => IssueDetailsWithResources; + getDetails?: () => IssueDetailsWithResources; + }> = [ + ...issue.getCorsIssues(), + ...issue.getMixedContentIssues(), + ...issue.getGenericIssues(), + ...issue.getLowContrastIssues(), + ...issue.getElementAccessibilityIssues(), + ...issue.getQuirksModeIssues(), + ]; + const affectedResources: Array<{ + uid?: string; + data?: object; + request?: string | number; + }> = []; + for (const singleIssue of issues) { + if (!singleIssue.details && !singleIssue.getDetails) continue; + + let details = + singleIssue.details?.() as unknown as IssueDetailsWithResources; + if (!details) + details = + singleIssue.getDetails?.() as unknown as IssueDetailsWithResources; + if (!details) continue; + + let uid; + let request: number | string | undefined; + if (details.violatingNodeId && context) { + uid = context.resolveCdpElementId(details.violatingNodeId); + } + if (details.nodeId && context) { + uid = context.resolveCdpElementId(details.nodeId); + } + if (details.documentNodeId && context) { + uid = context.resolveCdpElementId(details.documentNodeId); + } + + if (details.request) { + request = details.request.url; + if (details.request.requestId && context) { + const resolvedId = context.resolveCdpRequestId( + details.request.requestId, + ); + if (resolvedId) { + request = resolvedId; + } + } + } + + // eslint-disable-next-line + const data = structuredClone(details) as any; + delete data.violatingNodeId; + delete data.nodeId; + delete data.documentNodeId; + delete data.errorType; + delete data.frameId; + delete data.request; + affectedResources.push({ + uid, + data: data, + request, + }); + } + if (affectedResources.length) { + result.push('### Affected resources'); + } + result.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(' '); + }), + ); if (result.length === 0) return 'No details provided for the issue ' + issue.code(); return result.join('\n'); diff --git a/tests/McpResponse.test.ts b/tests/McpResponse.test.ts index b045454d..9031f6dd 100644 --- a/tests/McpResponse.test.ts +++ b/tests/McpResponse.test.ts @@ -10,11 +10,8 @@ import {tmpdir} from 'node:os'; import {join} from 'node:path'; import {describe, it} from 'node:test'; -import sinon from 'sinon'; - -import {AggregatedIssue} from '../node_modules/chrome-devtools-frontend/mcp/mcp.js'; - import { + getMockAggregatedIssue, getMockRequest, getMockResponse, html, @@ -302,7 +299,7 @@ describe('McpResponse', () => { it("doesn't list the issue message if mapping returns null", async () => { await withBrowser(async (response, context) => { - const mockAggregatedIssue = sinon.createStubInstance(AggregatedIssue); + const mockAggregatedIssue = getMockAggregatedIssue(); const mockDescription = { file: 'not-existing-description-file.md', links: [], @@ -321,7 +318,7 @@ describe('McpResponse', () => { it('throws error if mapping returns null on get issue details', async () => { await withBrowser(async (response, context) => { - const mockAggregatedIssue = sinon.createStubInstance(AggregatedIssue); + const mockAggregatedIssue = getMockAggregatedIssue(); const mockDescription = { file: 'not-existing-description-file.md', links: [], @@ -335,7 +332,7 @@ describe('McpResponse', () => { try { await response.handle('test', context); } catch (e) { - assert.ok(e.message.includes("Can't prpovide detals for the msgid 1")); + assert.ok(e.message.includes("Can't provide detals for the msgid 1")); } }); }); diff --git a/tests/formatters/consoleFormatter.test.js.snapshot b/tests/formatters/consoleFormatter.test.js.snapshot index d6803572..2052e435 100644 --- a/tests/formatters/consoleFormatter.test.js.snapshot +++ b/tests/formatters/consoleFormatter.test.js.snapshot @@ -43,4 +43,6 @@ This is a mock issue description Learn more: [Learn more](http://example.com/learnmore) [Learn more 2](http://example.com/another-learnmore) +### Affected resources +data={"violatingNodeAttribute":"test"} `; diff --git a/tests/formatters/consoleFormatter.test.ts b/tests/formatters/consoleFormatter.test.ts index eabe2a44..8734a91f 100644 --- a/tests/formatters/consoleFormatter.test.ts +++ b/tests/formatters/consoleFormatter.test.ts @@ -6,14 +6,12 @@ import {describe, it} from 'node:test'; -import sinon from 'sinon'; - -import {AggregatedIssue} from '../../node_modules/chrome-devtools-frontend/mcp/mcp.js'; import type {ConsoleMessageData} from '../../src/formatters/consoleFormatter.js'; import { formatConsoleEventShort, formatConsoleEventVerbose, } from '../../src/formatters/consoleFormatter.js'; +import {getMockAggregatedIssue} from '../utils.js'; describe('consoleFormatter', () => { describe('formatConsoleEventShort', () => { @@ -97,7 +95,15 @@ describe('consoleFormatter', () => { }); it('formats a console.log message with issue type', t => { - const mockAggregatedIssue = sinon.createStubInstance(AggregatedIssue); + const testGenericIssue = { + details: () => { + return { + violatingNodeId: 2, + violatingNodeAttribute: 'test', + }; + }, + }; + const mockAggregatedIssue = getMockAggregatedIssue(); const mockDescription = { file: 'mock.md', links: [ @@ -109,6 +115,8 @@ describe('consoleFormatter', () => { ], }; mockAggregatedIssue.getDescription.returns(mockDescription); + // @ts-expect-error generic issue stub bypass + mockAggregatedIssue.getGenericIssues.returns(new Set([testGenericIssue])); const mockDescriptionFileContent = '# Mock Issue Title\n\nThis is a mock issue description'; diff --git a/tests/tools/console.test.js.snapshot b/tests/tools/console.test.js.snapshot new file mode 100644 index 00000000..a9d01c1d --- /dev/null +++ b/tests/tools/console.test.js.snapshot @@ -0,0 +1,29 @@ +exports[`console > get_console_message > issues type > gets issue details with node id parsing 1`] = ` +# test response +ID: 1 +Message: issue> An element doesn't have an autocomplete attribute + +A form field has an \`id\` or \`name\` attribute that the browser's autofill recognizes. However, it doesn't have an \`autocomplete\` attribute assigned. This might prevent the browser from correctly autofilling the form. + +To fix this issue, provide an \`autocomplete\` attribute. +Learn more: +[HTML attribute: autocomplete](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete#values) +### Affected resources +uid=1_1 data={"violatingNodeAttribute":"name"} +`; + +exports[`console > get_console_message > issues type > gets issue details with request id parsing 1`] = ` +# test response +ID: +Message: issue> Ensure CORS response header values are valid + +A cross-origin resource sharing (CORS) request was blocked because of invalid or missing response headers of the request or the associated [preflight request](issueCorsPreflightRequest). + +To fix this issue, ensure the response to the CORS request and/or the associated [preflight request](issueCorsPreflightRequest) are not missing headers and use valid header values. + +Note that if an opaque response is sufficient, the request's mode can be set to \`no-cors\` to fetch the resource with CORS disabled; that way CORS headers are not required but the response content is inaccessible (opaque). +Learn more: +[Cross-Origin Resource Sharing (\`CORS\`)](https://web.dev/cross-origin-resource-sharing) +### Affected resources +reqid=1 data={"corsErrorStatus":{"corsError":"PreflightMissingAllowOriginHeader","failedParameter":""},"isWarning":false,"initiatorOrigin":"","clientSecurityState":{"initiatorIsSecureContext":false,"initiatorIPAddressSpace":"Loopback","privateNetworkRequestPolicy":"BlockFromInsecureToMorePrivate"}} +`; diff --git a/tests/tools/console.test.ts b/tests/tools/console.test.ts index 0724a503..06bcbc18 100644 --- a/tests/tools/console.test.ts +++ b/tests/tools/console.test.ts @@ -7,6 +7,7 @@ import assert from 'node:assert'; import {afterEach, before, beforeEach, describe, it} from 'node:test'; +import {AggregatedIssue} from '../../node_modules/chrome-devtools-frontend/mcp/mcp.js'; import {setIssuesEnabled} from '../../src/features.js'; import {loadIssueDescriptions} from '../../src/issue-descriptions.js'; import {McpResponse} from '../../src/McpResponse.js'; @@ -14,14 +15,14 @@ import { getConsoleMessage, listConsoleMessages, } from '../../src/tools/console.js'; +import {serverHooks} from '../server.js'; import {withBrowser} from '../utils.js'; describe('console', () => { + before(async () => { + await loadIssueDescriptions(); + }); describe('list_console_messages', () => { - before(async () => { - await loadIssueDescriptions(); - }); - it('list messages', async () => { await withBrowser(async (response, context) => { await listConsoleMessages.handler({params: {}}, response, context); @@ -153,6 +154,7 @@ describe('console', () => { }); describe('issues type', () => { + const server = serverHooks(); beforeEach(() => { setIssuesEnabled(true); }); @@ -160,7 +162,7 @@ describe('console', () => { setIssuesEnabled(false); }); - it('gets issue details', async () => { + it('gets issue details with node id parsing', async t => { await withBrowser(async (response, context) => { const page = await context.newPage(); const issuePromise = new Promise(resolve => { @@ -169,6 +171,7 @@ describe('console', () => { }); }); await page.setContent(''); + await context.createTextSnapshot(); await issuePromise; await listConsoleMessages.handler({params: {}}, response, context); const response2 = new McpResponse(); @@ -178,16 +181,64 @@ describe('console', () => { context, ); const formattedResponse = await response2.handle('test', context); - const textContent = formattedResponse[0] as {text: string}; - const learnMoreLinks = - '[HTML attribute: autocomplete](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete#values)'; - const detailsDescription = - "A form field has an `id` or `name` attribute that the browser's autofill recognizes. However, it doesn't have an `autocomplete` attribute assigned. This might prevent the browser from correctly autofilling the form.\n\nTo fix this issue, provide an `autocomplete` attribute."; - const title = - "Message: issue> An element doesn't have an autocomplete attribute"; - assert.ok(textContent.text.includes(title)); - assert.ok(textContent.text.includes(detailsDescription)); - assert.ok(textContent.text.includes(learnMoreLinks)); + t.assert.snapshot?.(formattedResponse[0].text); + }); + }); + it('gets issue details with request id parsing', async t => { + server.addRoute('/data.json', (_req, res) => { + res.setHeader('Content-Type', 'application/json'); + res.statusCode = 200; + res.end(JSON.stringify({data: 'test data'})); + }); + + await withBrowser(async (response, context) => { + const page = await context.newPage(); + const issuePromise = new Promise(resolve => { + page.once('issue', () => { + resolve(); + }); + }); + + const url = server.getRoute('/data.json'); + await page.setContent(` + + `); + await context.createTextSnapshot(); + await issuePromise; + const messages = context.getConsoleData(); + let issueMsg; + for (const message of messages) { + if (message instanceof AggregatedIssue) { + issueMsg = message; + break; + } + } + assert.ok(issueMsg); + const id = context.getConsoleMessageStableId(issueMsg); + assert.ok(id); + await listConsoleMessages.handler( + {params: {types: ['issue']}}, + response, + context, + ); + const response2 = new McpResponse(); + await getConsoleMessage.handler( + {params: {msgid: id}}, + response2, + context, + ); + const formattedResponse = await response2.handle('test', context); + const rawText = formattedResponse[0].text as string; + const sanitizedText = rawText.replaceAll(/ID: \d+/g, 'ID: '); + t.assert.snapshot?.(sanitizedText); }); }); }); diff --git a/tests/utils.ts b/tests/utils.ts index 3a717381..8cd5d03e 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -13,7 +13,9 @@ import type { HTTPResponse, LaunchOptions, } from 'puppeteer-core'; +import sinon from 'sinon'; +import {AggregatedIssue} from '../node_modules/chrome-devtools-frontend/mcp/mcp.js'; import {McpContext} from '../src/McpContext.js'; import {McpResponse} from '../src/McpResponse.js'; import {stableIdSymbol} from '../src/PageCollector.js'; @@ -180,3 +182,14 @@ export function stabilizeResponseOutput(text: unknown) { output = output.replaceAll(savedSnapshot, 'Saved snapshot to '); return output; } + +export function getMockAggregatedIssue(): sinon.SinonStubbedInstance { + const mockAggregatedIssue = sinon.createStubInstance(AggregatedIssue); + mockAggregatedIssue.getGenericIssues.returns(new Set()); + mockAggregatedIssue.getLowContrastIssues.returns(new Set()); + mockAggregatedIssue.getElementAccessibilityIssues.returns(new Set()); + mockAggregatedIssue.getQuirksModeIssues.returns(new Set()); + mockAggregatedIssue.getCorsIssues.returns(new Set()); + mockAggregatedIssue.getMixedContentIssues.returns(new Set()); + return mockAggregatedIssue; +}