Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions src/DevtoolsUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,17 @@

import {
type Issue,
type AggregatedIssue,
type IssuesManagerEventTypes,
MarkdownIssueDescription,
Marked,
Common,
I18n,
} from '../node_modules/chrome-devtools-frontend/mcp/mcp.js';

import {ISSUE_UTILS} from './issue-descriptions.js';
import {logger} from './logger.js';

export function extractUrlLikeFromDevToolsTitle(
title: string,
): string | undefined {
Expand Down Expand Up @@ -69,6 +75,48 @@ export class FakeIssuesManager extends Common.ObjectWrapper
}
}

export function mapIssueToMessageObject(issue: AggregatedIssue) {
const count = issue.getAggregatedIssuesCount();
const markdownDescription = issue.getDescription();
const filename = markdownDescription?.file;
if (!markdownDescription) {
logger(`no description found for issue:` + issue.code);
return null;
}
const rawMarkdown = filename
? ISSUE_UTILS.getIssueDescription(filename)
: null;
if (!rawMarkdown) {
logger(`no markdown ${filename} found for issue:` + issue.code);
return null;
}
let processedMarkdown: string;
let title: string | null;

try {
processedMarkdown = MarkdownIssueDescription.substitutePlaceholders(
rawMarkdown,
markdownDescription.substitutions,
);
const markdownAst = Marked.Marked.lexer(processedMarkdown);
title = MarkdownIssueDescription.findTitleFromMarkdownAst(markdownAst);
} catch {
logger('error parsing markdown for issue ' + issue.code());
return null;
}
if (!title) {
logger('cannot read issue title from ' + filename);
return null;
}
return {
type: 'issue',
item: issue,
message: title,
count,
description: processedMarkdown,
};
}

I18n.DevToolsLocale.DevToolsLocale.instance({
create: true,
data: {
Expand Down
7 changes: 6 additions & 1 deletion src/McpContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,8 +199,13 @@ export class McpContext implements Context {
this.logger('no cdpBackendNodeId');
return;
}
if (this.#textSnapshot === null)
throw new Error(
"The snapshot is not defined, can't resolve backendNodeId: " +
cdpBackendNodeId,
);
// TODO: index by backendNodeId instead.
const queue = [this.#textSnapshot?.root];
const queue = [this.#textSnapshot.root];
while (queue.length) {
const current = queue.pop()!;
if (current.backendNodeId === cdpBackendNodeId) {
Expand Down
43 changes: 15 additions & 28 deletions src/McpResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,9 @@
* SPDX-License-Identifier: Apache-2.0
*/

import {
AggregatedIssue,
Marked,
MarkdownIssueDescription,
} from '../node_modules/chrome-devtools-frontend/mcp/mcp.js';
import {AggregatedIssue} from '../node_modules/chrome-devtools-frontend/mcp/mcp.js';

import {mapIssueToMessageObject} from './DevtoolsUtils.js';
import type {ConsoleMessageData} from './formatters/consoleFormatter.js';
import {
formatConsoleEventShort,
Expand All @@ -23,8 +20,6 @@ import {
getStatusFromRequest,
} from './formatters/networkFormatter.js';
import {formatSnapshotNode} from './formatters/snapshotFormatter.js';
import {getIssueDescription} from './issue-descriptions.js';
import {logger} from './logger.js';
import type {McpContext} from './McpContext.js';
import type {
ConsoleMessage,
Expand Down Expand Up @@ -256,6 +251,16 @@ export class McpResponse implements Response {
}),
),
};
} else if (message instanceof AggregatedIssue) {
const mappedIssueMessage = mapIssueToMessageObject(message);
if (!mappedIssueMessage)
throw new Error(
"Can't prpovide detals for the msgid " + consoleMessageStableId,
);
consoleData = {
consoleMessageStableId,
...mappedIssueMessage,
};
} else {
consoleData = {
consoleMessageStableId,
Expand Down Expand Up @@ -309,29 +314,11 @@ export class McpResponse implements Response {
};
}
if (item instanceof AggregatedIssue) {
const count = item.getAggregatedIssuesCount();
const filename = item.getDescription()?.file;
const rawMarkdown = filename
? getIssueDescription(filename)
: null;
if (!rawMarkdown) {
logger(`no markdown ${filename} found for issue:` + item.code);
return null;
}
const markdownAst = Marked.Marked.lexer(rawMarkdown);
const title =
MarkdownIssueDescription.findTitleFromMarkdownAst(markdownAst);
if (!title) {
logger('cannot read issue title from ' + filename);
return null;
}
const mappedIssueMessage = mapIssueToMessageObject(item);
if (!mappedIssueMessage) return null;
return {
consoleMessageStableId,
type: 'issue',
item,
message: title,
count,
args: [],
...mappedIssueMessage,
};
}
return {
Expand Down
37 changes: 33 additions & 4 deletions src/formatters/consoleFormatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,15 @@
* SPDX-License-Identifier: Apache-2.0
*/

import type {AggregatedIssue} from '../../node_modules/chrome-devtools-frontend/mcp/mcp.js';

export interface ConsoleMessageData {
consoleMessageStableId: number;
type?: string;
item?: unknown;
item?: AggregatedIssue;
message?: string;
count?: number;
description?: string;
args?: string[];
}

Expand All @@ -34,12 +37,12 @@ function getArgs(msg: ConsoleMessageData) {

// The verbose format for a console message, including all details.
export function formatConsoleEventVerbose(msg: ConsoleMessageData): string {
const aggregatedIssue = msg.item;
const result = [
`ID: ${msg.consoleMessageStableId}`,
`Message: ${msg.type}> ${msg.message}`,
formatArgs(msg),
`Message: ${msg.type}> ${aggregatedIssue ? formatIssue(aggregatedIssue, msg.description) : msg.message}`,
aggregatedIssue ? undefined : formatArgs(msg),
].filter(line => !!line);

return result.join('\n');
}

Expand All @@ -62,3 +65,29 @@ function formatArgs(consoleData: ConsoleMessageData): string {

return result.join('\n');
}

export function formatIssue(
issue: AggregatedIssue,
description?: string,
): string {
const result: string[] = [];

let processedMarkdown = description?.trim();
// Remove heading in order not to conflict with the whole console message response markdown
if (processedMarkdown?.startsWith('# ')) {
processedMarkdown = processedMarkdown.substring(2).trimStart();
}
if (processedMarkdown) result.push(processedMarkdown);

const links = issue.getDescription()?.links;
if (links && links.length > 0) {
result.push('Learn more:');
for (const link of links) {
result.push(`[${link.linkTitle}](${link.link})`);
}
}

if (result.length === 0)
return 'No details provided for the issue ' + issue.code();
return result.join('\n');
}
5 changes: 5 additions & 0 deletions src/issue-descriptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,8 @@ export async function loadIssueDescriptions(): Promise<void> {
export function getIssueDescription(fileName: string): string | null {
return issueDescriptions[fileName] ?? null;
}

export const ISSUE_UTILS = {
loadIssueDescriptions,
getIssueDescription,
};
108 changes: 107 additions & 1 deletion tests/DevtoolsUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,17 @@
*/

import assert from 'node:assert';
import {describe, it} from 'node:test';
import {afterEach, describe, it} from 'node:test';

import sinon from 'sinon';

import {AggregatedIssue} from '../node_modules/chrome-devtools-frontend/mcp/mcp.js';
import {
extractUrlLikeFromDevToolsTitle,
urlsEqual,
mapIssueToMessageObject,
} from '../src/DevtoolsUtils.js';
import {ISSUE_UTILS} from '../src/issue-descriptions.js';

describe('extractUrlFromDevToolsTitle', () => {
it('deals with no trailing /', () => {
Expand Down Expand Up @@ -70,3 +75,104 @@ describe('urlsEqual', () => {
);
});
});

describe('mapIssueToMessageObject', () => {
const mockDescription = {
file: 'mock-issue.md',
substitutions: new Map([['PLACEHOLDER_VALUE', 'substitution value']]),
links: [
{link: 'http://example.com/learnmore', linkTitle: 'Learn more'},
{
link: 'http://example.com/another-learnmore',
linkTitle: 'Learn more 2',
},
],
};

afterEach(() => {
sinon.restore();
});

it('maps aggregated issue with substituted description', () => {
const mockAggregatedIssue = sinon.createStubInstance(AggregatedIssue);
mockAggregatedIssue.getDescription.returns(mockDescription);
mockAggregatedIssue.getAggregatedIssuesCount.returns(1);

const getIssueDescriptionStub = sinon.stub(
ISSUE_UTILS,
'getIssueDescription',
);

getIssueDescriptionStub
.withArgs('mock-issue.md')
.returns(
'# Mock Issue Title\n\nThis is a mock issue description with a {PLACEHOLDER_VALUE}.',
);

const result = mapIssueToMessageObject(mockAggregatedIssue);
const expected = {
type: 'issue',
item: mockAggregatedIssue,
message: 'Mock Issue Title',
count: 1,
description:
'# Mock Issue Title\n\nThis is a mock issue description with a substitution value.',
};
assert.deepStrictEqual(result, expected);
});

it('returns null for the issue with no description', () => {
const mockAggregatedIssue = sinon.createStubInstance(AggregatedIssue);
mockAggregatedIssue.getDescription.returns(null);

const result = mapIssueToMessageObject(mockAggregatedIssue);
assert.equal(result, null);
});

it('returns null if there is no desciption file', () => {
const mockAggregatedIssue = sinon.createStubInstance(AggregatedIssue);
mockAggregatedIssue.getDescription.returns(mockDescription);
mockAggregatedIssue.getAggregatedIssuesCount.returns(1);

const getIssueDescriptionStub = sinon.stub(
ISSUE_UTILS,
'getIssueDescription',
);

getIssueDescriptionStub.withArgs('mock-issue.md').returns(null);
const result = mapIssueToMessageObject(mockAggregatedIssue);
assert.equal(result, null);
});

it("returns null if can't parse the title", () => {
const mockAggregatedIssue = sinon.createStubInstance(AggregatedIssue);
mockAggregatedIssue.getDescription.returns(mockDescription);
mockAggregatedIssue.getAggregatedIssuesCount.returns(1);

const getIssueDescriptionStub = sinon.stub(
ISSUE_UTILS,
'getIssueDescription',
);

getIssueDescriptionStub
.withArgs('mock-issue.md')
.returns('No title test {PLACEHOLDER_VALUE}');
assert.deepStrictEqual(mapIssueToMessageObject(mockAggregatedIssue), null);
});

it('returns null if devtools utill function throws an error', () => {
const mockAggregatedIssue = sinon.createStubInstance(AggregatedIssue);
mockAggregatedIssue.getDescription.returns(mockDescription);
mockAggregatedIssue.getAggregatedIssuesCount.returns(1);

const getIssueDescriptionStub = sinon.stub(
ISSUE_UTILS,
'getIssueDescription',
);
// An error will be thrown if placeholder doesn't start from PLACEHOLDER_
getIssueDescriptionStub
.withArgs('mock-issue.md')
.returns('No title test {WRONG_PLACEHOLDER}');
assert.deepStrictEqual(mapIssueToMessageObject(mockAggregatedIssue), null);
});
});
Loading