From 4098eb67999beb8cecf53a1c79a7d0f7c819ff08 Mon Sep 17 00:00:00 2001 From: Anudeep Date: Sun, 18 Aug 2024 15:12:26 +0530 Subject: [PATCH] feat: error clusters (#236) --- src/beats/beats.api.js | 15 +++++ src/beats/beats.js | 29 ++++++++++ src/beats/beats.types.d.ts | 15 +++++ src/extensions/base.extension.js | 16 ++++++ src/extensions/error-clusters.extension.js | 46 ++++++++++++++++ src/extensions/index.js | 3 + src/helpers/constants.js | 1 + src/index.d.ts | 1 + test/beats.spec.js | 46 +++++++++++++++- test/mocks/beats.mock.js | 49 +++++++++++++++++ test/mocks/teams.mock.js | 64 ++++++++++++++++++++++ 11 files changed, 282 insertions(+), 3 deletions(-) create mode 100644 src/extensions/error-clusters.extension.js diff --git a/src/beats/beats.api.js b/src/beats/beats.api.js index eca7e72..ccebfe2 100644 --- a/src/beats/beats.api.js +++ b/src/beats/beats.api.js @@ -46,6 +46,21 @@ class BeatsApi { getBaseUrl() { return process.env.TEST_BEATS_URL || "https://app.testbeats.com"; } + + /** + * + * @param {string} run_id + * @param {number} limit + * @returns {import('./beats.types').IErrorClustersResponse} + */ + getErrorClusters(run_id, limit = 3) { + return request.get({ + url: `${this.getBaseUrl()}/api/core/v1/test-runs/${run_id}/error-clusters?limit=${limit}`, + headers: { + 'x-api-key': this.config.api_key + } + }); + } } module.exports = { BeatsApi } \ No newline at end of file diff --git a/src/beats/beats.js b/src/beats/beats.js index 501fa1f..5ef86ee 100644 --- a/src/beats/beats.js +++ b/src/beats/beats.js @@ -33,6 +33,7 @@ class Beats { this.#updateTitleLink(); await this.#attachFailureSummary(); await this.#attachSmartAnalysis(); + await this.#attachErrorClusters(); } #setCIInfo() { @@ -189,6 +190,34 @@ class Beats { logger.warn(`🙈 ${text} not generated in given time`); } + async #attachErrorClusters() { + if (!this.test_run_id) { + return; + } + if (!this.config.targets) { + return; + } + if (this.result.status !== 'FAIL') { + return; + } + if (this.config.show_error_clusters === false) { + return; + } + try { + logger.info('🧮 Fetching Error Clusters...'); + const res = await this.api.getErrorClusters(this.test_run_id, 3); + this.config.extensions.push({ + name: 'error-clusters', + hook: HOOK.AFTER_SUMMARY, + inputs: { + data: res.values + } + }); + } catch (error) { + logger.error(`❌ Unable to attach error clusters: ${error.message}`, error); + } + } + } module.exports = { Beats } \ No newline at end of file diff --git a/src/beats/beats.types.d.ts b/src/beats/beats.types.d.ts index bc954d5..ef2a245 100644 --- a/src/beats/beats.types.d.ts +++ b/src/beats/beats.types.d.ts @@ -17,3 +17,18 @@ export type IBeatExecutionMetric = { test_run_id: string org_id: string } + +export type IPaginatedAPIResponse = { + page: number + limit: number + total: number + values: T[] +} + +export type IErrorClustersResponse = {} & IPaginatedAPIResponse; + +export type IErrorCluster = { + test_failure_id: string + failure: string + count: number +} diff --git a/src/extensions/base.extension.js b/src/extensions/base.extension.js index aaf509d..313b8ad 100644 --- a/src/extensions/base.extension.js +++ b/src/extensions/base.extension.js @@ -86,6 +86,22 @@ class BaseExtension { } } + /** + * @param {string|number} text + */ + bold(text) { + switch (this.target.name) { + case 'teams': + return `**${text}**`; + case 'slack': + return `*${text}*`; + case 'chat': + return `${text}`; + default: + break; + } + } + } module.exports = { BaseExtension } \ No newline at end of file diff --git a/src/extensions/error-clusters.extension.js b/src/extensions/error-clusters.extension.js new file mode 100644 index 0000000..8acb4eb --- /dev/null +++ b/src/extensions/error-clusters.extension.js @@ -0,0 +1,46 @@ +const { BaseExtension } = require('./base.extension'); +const { STATUS, HOOK } = require("../helpers/constants"); + +class ErrorClustersExtension extends BaseExtension { + + constructor(target, extension, result, payload, root_payload) { + super(target, extension, result, payload, root_payload); + this.#setDefaultOptions(); + this.#setDefaultInputs(); + this.updateExtensionInputs(); + } + + run() { + this.#setText(); + this.attach(); + } + + #setDefaultOptions() { + this.default_options.hook = HOOK.AFTER_SUMMARY, + this.default_options.condition = STATUS.PASS_OR_FAIL; + } + + #setDefaultInputs() { + this.default_inputs.title = 'Top Errors'; + this.default_inputs.title_link = ''; + } + + #setText() { + const data = this.extension.inputs.data; + if (!data || !data.length) { + return; + } + + const clusters = data; + + this.extension.inputs.title = `Top ${clusters.length} Errors`; + + const texts = []; + for (const cluster of clusters) { + texts.push(`${this.bold(`(${cluster.count})`)} - ${cluster.failure}`); + } + this.text = this.mergeTexts(texts); + } +} + +module.exports = { ErrorClustersExtension } \ No newline at end of file diff --git a/src/extensions/index.js b/src/extensions/index.js index 24227e5..d67e619 100644 --- a/src/extensions/index.js +++ b/src/extensions/index.js @@ -12,6 +12,7 @@ const { CustomExtension } = require('./custom.extension'); const { EXTENSION } = require('../helpers/constants'); const { checkCondition } = require('../helpers/helper'); const logger = require('../utils/logger'); +const { ErrorClustersExtension } = require('./error-clusters.extension'); async function run(options) { const { target, result, hook } = options; @@ -60,6 +61,8 @@ function getExtensionRunner(extension, options) { return new AIFailureSummaryExtension(options.target, extension, options.result, options.payload, options.root_payload); case EXTENSION.SMART_ANALYSIS: return new SmartAnalysisExtension(options.target, extension, options.result, options.payload, options.root_payload); + case EXTENSION.ERROR_CLUSTERS: + return new ErrorClustersExtension(options.target, extension, options.result, options.payload, options.root_payload); default: return require(extension.name); } diff --git a/src/helpers/constants.js b/src/helpers/constants.js index 5a94632..11073ff 100644 --- a/src/helpers/constants.js +++ b/src/helpers/constants.js @@ -22,6 +22,7 @@ const TARGET = Object.freeze({ const EXTENSION = Object.freeze({ AI_FAILURE_SUMMARY: 'ai-failure-summary', SMART_ANALYSIS: 'smart-analysis', + ERROR_CLUSTERS: 'error-clusters', HYPERLINKS: 'hyperlinks', MENTIONS: 'mentions', REPORT_PORTAL_ANALYSIS: 'report-portal-analysis', diff --git a/src/index.d.ts b/src/index.d.ts index 6b2d95c..4df1c58 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -230,6 +230,7 @@ export interface PublishReport { run?: string; show_failure_summary?: boolean; show_smart_analysis?: boolean; + show_error_clusters?: boolean; targets?: Target[]; extensions?: Extension[]; results?: ParseOptions[] | PerformanceParseOptions[] | CustomResultOptions[]; diff --git a/test/beats.spec.js b/test/beats.spec.js index 4f89de3..a9074db 100644 --- a/test/beats.spec.js +++ b/test/beats.spec.js @@ -47,7 +47,8 @@ describe('TestBeats', () => { it('should send results with failures to beats', async () => { const id1 = mock.addInteraction('post test results to beats'); const id2 = mock.addInteraction('get test results from beats'); - const id3 = mock.addInteraction('post test-summary with beats to teams with ai failure summary'); + const id3 = mock.addInteraction('get empty error clusters from beats'); + const id4 = mock.addInteraction('post test-summary with beats to teams with ai failure summary'); await publish({ config: { api_key: 'api-key', @@ -74,13 +75,15 @@ describe('TestBeats', () => { assert.equal(mock.getInteraction(id1).exercised, true); assert.equal(mock.getInteraction(id2).exercised, true); assert.equal(mock.getInteraction(id3).exercised, true); + assert.equal(mock.getInteraction(id4).exercised, true); }); it('should send results with attachments to beats', async () => { const id1 = mock.addInteraction('post test results to beats'); const id2 = mock.addInteraction('get test results from beats'); const id3 = mock.addInteraction('upload attachments'); - const id4 = mock.addInteraction('post test-summary to teams with strict as false'); + const id4 = mock.addInteraction('get empty error clusters from beats'); + const id5 = mock.addInteraction('post test-summary to teams with strict as false'); await publish({ config: { api_key: 'api-key', @@ -108,6 +111,7 @@ describe('TestBeats', () => { assert.equal(mock.getInteraction(id2).exercised, true); assert.equal(mock.getInteraction(id3).exercised, true); assert.equal(mock.getInteraction(id4).exercised, true); + assert.equal(mock.getInteraction(id5).exercised, true); }); it('should send results to beats without targets', async () => { @@ -133,7 +137,8 @@ describe('TestBeats', () => { it('should send results with smart analysis to beats', async () => { const id1 = mock.addInteraction('post test results to beats'); const id2 = mock.addInteraction('get test results with smart analysis from beats'); - const id3 = mock.addInteraction('post test-summary with beats to teams with ai failure summary and smart analysis'); + const id3 = mock.addInteraction('get empty error clusters from beats'); + const id4 = mock.addInteraction('post test-summary with beats to teams with ai failure summary and smart analysis'); await publish({ config: { api_key: 'api-key', @@ -160,6 +165,41 @@ describe('TestBeats', () => { assert.equal(mock.getInteraction(id1).exercised, true); assert.equal(mock.getInteraction(id2).exercised, true); assert.equal(mock.getInteraction(id3).exercised, true); + assert.equal(mock.getInteraction(id4).exercised, true); + }); + + it('should send results with error clusters to beats', async () => { + const id1 = mock.addInteraction('post test results to beats'); + const id2 = mock.addInteraction('get test results from beats'); + const id3 = mock.addInteraction('get error clusters from beats'); + const id4 = mock.addInteraction('post test-summary with beats to teams with error clusters'); + await publish({ + config: { + api_key: 'api-key', + project: 'project-name', + run: 'build-name', + targets: [ + { + name: 'teams', + inputs: { + url: 'http://localhost:9393/message' + } + } + ], + results: [ + { + type: 'testng', + files: [ + 'test/data/testng/single-suite-failures.xml' + ] + } + ] + } + }); + assert.equal(mock.getInteraction(id1).exercised, true); + assert.equal(mock.getInteraction(id2).exercised, true); + assert.equal(mock.getInteraction(id3).exercised, true); + assert.equal(mock.getInteraction(id4).exercised, true); }); }); \ No newline at end of file diff --git a/test/mocks/beats.mock.js b/test/mocks/beats.mock.js index b15d0b1..e61b407 100644 --- a/test/mocks/beats.mock.js +++ b/test/mocks/beats.mock.js @@ -75,6 +75,55 @@ addInteractionHandler('get test results with smart analysis from beats', () => { } }); +addInteractionHandler('get error clusters from beats', () => { + return { + strict: false, + request: { + method: 'GET', + path: '/api/core/v1/test-runs/test-run-id/error-clusters', + queryParams: { + "limit": 3 + } + }, + response: { + status: 200, + body: { + values: [ + { + test_failure_id: 'test-failure-id', + failure: 'failure two', + count: 2 + }, + { + test_failure_id: 'test-failure-id', + failure: 'failure one', + count: 1 + } + ] + } + } + } +}); + +addInteractionHandler('get empty error clusters from beats', () => { + return { + strict: false, + request: { + method: 'GET', + path: '/api/core/v1/test-runs/test-run-id/error-clusters', + queryParams: { + "limit": 3 + } + }, + response: { + status: 200, + body: { + values: [] + } + } + } +}); + addInteractionHandler('upload attachments', () => { return { strict: false, diff --git a/test/mocks/teams.mock.js b/test/mocks/teams.mock.js index acb893f..c29d3fd 100644 --- a/test/mocks/teams.mock.js +++ b/test/mocks/teams.mock.js @@ -1669,6 +1669,70 @@ addInteractionHandler('post test-summary with beats to teams with ai failure sum } }); +addInteractionHandler('post test-summary with beats to teams with error clusters', () => { + return { + request: { + method: 'POST', + path: '/message', + body: { + "type": "message", + "attachments": [ + { + "contentType": "application/vnd.microsoft.card.adaptive", + "content": { + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.0", + "body": [ + { + "type": "TextBlock", + "text": "[❌ build-name](http://localhost:9393/reports/test-run-id)", + "size": "medium", + "weight": "bolder", + "wrap": true + }, + { + "@DATA:TEMPLATE@": "TEAMS_ROOT_RESULTS_SINGLE_SUITE_FAILURES", + }, + { + "type": "TextBlock", + "text": "AI Failure Summary ✨", + "isSubtle": true, + "weight": "bolder", + "separator": true, + "wrap": true + }, + { + "type": "TextBlock", + "text": "test failure summary", + "wrap": true + }, + { + "type": "TextBlock", + "text": "Top 2 Errors", + "isSubtle": true, + "weight": "bolder", + "separator": true, + "wrap": true + }, + { + "type": "TextBlock", + "text": "**(2)** - failure two\n\n**(1)** - failure one", + "wrap": true + } + ], + "actions": [] + } + } + ] + } + }, + response: { + status: 200 + } + } +}); + addInteractionHandler('post test-summary to teams with strict as false', () => { return { strict: false,