diff --git a/src/formatters/IssueFormatter.ts b/src/formatters/IssueFormatter.ts index 854392421..4f1668c31 100644 --- a/src/formatters/IssueFormatter.ts +++ b/src/formatters/IssueFormatter.ts @@ -11,7 +11,7 @@ import {DevTools} from '../third_party/index.js'; export interface IssueFormatterOptions { requestIdResolver?: (requestId: string) => number | undefined; elementIdResolver?: (backendNodeId: number) => string | undefined; - id?: number; + id: number; } export interface AffectedResource { @@ -20,6 +20,22 @@ export interface AffectedResource { request?: string | number; } +interface IssueConcise { + type: 'issue'; + title?: string; + count: number; + id: number; +} + +interface IssueDetailed extends IssueConcise { + description?: string; + links?: Array<{ + link: string; + linkTitle: string; + }>; + affectedResources: AffectedResource[]; +} + export class IssueFormatter { #issue: DevTools.AggregatedIssue; #options: IssueFormatterOptions; @@ -30,70 +46,14 @@ export class IssueFormatter { } toString(): string { - const title = this.#getTitle(); - const count = this.#issue.getAggregatedIssuesCount(); - const idPart = - this.#options.id !== undefined ? `msgid=${this.#options.id} ` : ''; - return `${idPart}[issue] ${title} (count: ${count})`; + return convertIssueConciseToString(this.toJSON()); } toStringDetailed(): string { - const result: string[] = []; - if (this.#options.id !== undefined) { - result.push(`ID: ${this.#options.id}`); - } - - const bodyParts: string[] = []; - - const description = this.#getDescription(); - 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) { - bodyParts.push(processedMarkdown); - } else { - bodyParts.push(this.#getTitle() ?? 'Unknown Issue'); - } - - const links = this.#issue.getDescription()?.links; - if (links && links.length > 0) { - bodyParts.push('Learn more:'); - for (const link of links) { - bodyParts.push(`[${link.linkTitle}](${link.link})`); - } - } - - 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'); + return convertIssueDetailedToString(this.toJSONDetailed()); } - toJSON(): object { + toJSON(): IssueConcise { return { type: 'issue', title: this.#getTitle(), @@ -102,10 +62,11 @@ export class IssueFormatter { }; } - toJSONDetailed(): object { + toJSONDetailed(): IssueDetailed { return { id: this.#options.id, type: 'issue', + count: this.#issue.getAggregatedIssuesCount(), title: this.#getTitle(), description: this.#getDescription(), links: this.#issue.getDescription()?.links, @@ -251,3 +212,61 @@ export class IssueFormatter { } } } + +function convertIssueConciseToString(issue: IssueConcise): string { + return `msgid=${issue.id} [issue] ${issue.title} (count: ${issue.count})`; +} + +function convertIssueDetailedToString(issue: IssueDetailed): string { + const result: string[] = []; + result.push(`ID: ${issue.id}`); + + const bodyParts: string[] = []; + + const description = issue.description; + 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) { + bodyParts.push(processedMarkdown); + } else { + bodyParts.push(issue.title ?? 'Unknown Issue'); + } + + const links = issue.links; + if (links && links.length > 0) { + bodyParts.push('Learn more:'); + for (const link of links) { + bodyParts.push(`[${link.linkTitle}](${link.link})`); + } + } + + const affectedResources = issue.affectedResources; + 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'); +} diff --git a/tests/formatters/IssueFormatter.test.js.snapshot b/tests/formatters/IssueFormatter.test.js.snapshot index 10d939e22..d91861c97 100644 --- a/tests/formatters/IssueFormatter.test.js.snapshot +++ b/tests/formatters/IssueFormatter.test.js.snapshot @@ -1,9 +1,58 @@ -exports[`IssueFormatter > formats an issue message 1`] = ` +exports[`IssueFormatter > formats a detailed issue toJSONDetailed 1`] = ` +{ + "id": 5, + "type": "issue", + "title": "Mock Issue Title", + "description": "# Mock Issue Title\\n\\nThis is a mock issue description sub value", + "links": [ + { + "link": "http://example.com", + "linkTitle": "Link 1" + } + ], + "affectedResources": [ + { + "uid": "1_1", + "data": { + "violatingNodeAttribute": "test" + } + } + ] +} +`; + +exports[`IssueFormatter > formats a detailed issue toStringDetailed 1`] = ` ID: 5 Message: issue> Mock Issue Title -This is a mock issue description +This is a mock issue description sub value Learn more: -[Learn more](http://example.com/learnmore) -[Learn more 2](http://example.com/another-learnmore) +[Link 1](http://example.com) +### Affected resources +uid=1_1 data={"violatingNodeAttribute":"test"} +`; + +exports[`IssueFormatter > formats a simplified issue toJSON 1`] = ` +{ + "type": "issue", + "title": "Issue Title", + "count": 5, + "id": 1 +} +`; + +exports[`IssueFormatter > formats a simplified issue toString 1`] = ` +msgid=1 [issue] Issue Title (count: 5) +`; + +exports[`IssueFormatter > formats an issue message toJSON 1`] = ` +{ + "type": "issue", + "title": "Mock Issue Title", + "id": 5 +} +`; + +exports[`IssueFormatter > formats an issue message toString 1`] = ` +msgid=5 [issue] Mock Issue Title (count: undefined) `; diff --git a/tests/formatters/IssueFormatter.test.ts b/tests/formatters/IssueFormatter.test.ts index de1764857..80e96e95f 100644 --- a/tests/formatters/IssueFormatter.test.ts +++ b/tests/formatters/IssueFormatter.test.ts @@ -24,7 +24,35 @@ describe('IssueFormatter', () => { sinon.restore(); }); - it('formats an issue message', t => { + function formatterTestConcise( + label: string, + setup: (t: it.TestContext) => Promise, + ) { + it(label + ' toString', async t => { + const formatter = await setup(t); + t.assert.snapshot?.(formatter.toString()); + }); + it(label + ' toJSON', async t => { + const formatter = await setup(t); + t.assert.snapshot?.(JSON.stringify(formatter.toJSON(), null, 2)); + }); + } + + function formatterTestDetailed( + label: string, + setup: (t: it.TestContext) => Promise, + ) { + it(label + ' toStringDetailed', async t => { + const formatter = await setup(t); + t.assert.snapshot?.(formatter.toStringDetailed()); + }); + it(label + ' toJSONDetailed', async t => { + const formatter = await setup(t); + t.assert.snapshot?.(JSON.stringify(formatter.toJSONDetailed(), null, 2)); + }); + } + + formatterTestConcise('formats an issue message', async () => { const testGenericIssue = { details: () => { return { @@ -55,12 +83,55 @@ describe('IssueFormatter', () => { .withArgs('mock.md') .returns(mockDescriptionFileContent); - const formatter = new IssueFormatter(mockAggregatedIssue, { + return new IssueFormatter(mockAggregatedIssue, { id: 5, }); + }); + + formatterTestConcise('formats a simplified issue', async () => { + const mockAggregatedIssue = getMockAggregatedIssue(); + mockAggregatedIssue.getDescription.returns({ + file: 'mock.md', + links: [], + }); + mockAggregatedIssue.getAggregatedIssuesCount.returns(5); + getIssueDescriptionStub + .withArgs('mock.md') + .returns('# Issue Title\n\nIssue content'); + + return new IssueFormatter(mockAggregatedIssue, {id: 1}); + }); + + formatterTestDetailed('formats a detailed issue', async () => { + 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 result = formatter.toStringDetailed(); - t.assert.snapshot?.(result); + return new IssueFormatter(mockAggregatedIssue, { + id: 5, + elementIdResolver: () => '1_1', + }); }); describe('isValid', () => { @@ -134,76 +205,4 @@ 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, - }); - }); - }); });