Skip to content

Commit 4098eb6

Browse files
authored
feat: error clusters (#236)
1 parent b5ade22 commit 4098eb6

11 files changed

+282
-3
lines changed

src/beats/beats.api.js

+15
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,21 @@ class BeatsApi {
4646
getBaseUrl() {
4747
return process.env.TEST_BEATS_URL || "https://app.testbeats.com";
4848
}
49+
50+
/**
51+
*
52+
* @param {string} run_id
53+
* @param {number} limit
54+
* @returns {import('./beats.types').IErrorClustersResponse}
55+
*/
56+
getErrorClusters(run_id, limit = 3) {
57+
return request.get({
58+
url: `${this.getBaseUrl()}/api/core/v1/test-runs/${run_id}/error-clusters?limit=${limit}`,
59+
headers: {
60+
'x-api-key': this.config.api_key
61+
}
62+
});
63+
}
4964
}
5065

5166
module.exports = { BeatsApi }

src/beats/beats.js

+29
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ class Beats {
3333
this.#updateTitleLink();
3434
await this.#attachFailureSummary();
3535
await this.#attachSmartAnalysis();
36+
await this.#attachErrorClusters();
3637
}
3738

3839
#setCIInfo() {
@@ -189,6 +190,34 @@ class Beats {
189190
logger.warn(`🙈 ${text} not generated in given time`);
190191
}
191192

193+
async #attachErrorClusters() {
194+
if (!this.test_run_id) {
195+
return;
196+
}
197+
if (!this.config.targets) {
198+
return;
199+
}
200+
if (this.result.status !== 'FAIL') {
201+
return;
202+
}
203+
if (this.config.show_error_clusters === false) {
204+
return;
205+
}
206+
try {
207+
logger.info('🧮 Fetching Error Clusters...');
208+
const res = await this.api.getErrorClusters(this.test_run_id, 3);
209+
this.config.extensions.push({
210+
name: 'error-clusters',
211+
hook: HOOK.AFTER_SUMMARY,
212+
inputs: {
213+
data: res.values
214+
}
215+
});
216+
} catch (error) {
217+
logger.error(`❌ Unable to attach error clusters: ${error.message}`, error);
218+
}
219+
}
220+
192221
}
193222

194223
module.exports = { Beats }

src/beats/beats.types.d.ts

+15
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,18 @@ export type IBeatExecutionMetric = {
1717
test_run_id: string
1818
org_id: string
1919
}
20+
21+
export type IPaginatedAPIResponse<T> = {
22+
page: number
23+
limit: number
24+
total: number
25+
values: T[]
26+
}
27+
28+
export type IErrorClustersResponse = {} & IPaginatedAPIResponse<IErrorCluster>;
29+
30+
export type IErrorCluster = {
31+
test_failure_id: string
32+
failure: string
33+
count: number
34+
}

src/extensions/base.extension.js

+16
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,22 @@ class BaseExtension {
8686
}
8787
}
8888

89+
/**
90+
* @param {string|number} text
91+
*/
92+
bold(text) {
93+
switch (this.target.name) {
94+
case 'teams':
95+
return `**${text}**`;
96+
case 'slack':
97+
return `*${text}*`;
98+
case 'chat':
99+
return `<b>${text}</b>`;
100+
default:
101+
break;
102+
}
103+
}
104+
89105
}
90106

91107
module.exports = { BaseExtension }
+46
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
const { BaseExtension } = require('./base.extension');
2+
const { STATUS, HOOK } = require("../helpers/constants");
3+
4+
class ErrorClustersExtension extends BaseExtension {
5+
6+
constructor(target, extension, result, payload, root_payload) {
7+
super(target, extension, result, payload, root_payload);
8+
this.#setDefaultOptions();
9+
this.#setDefaultInputs();
10+
this.updateExtensionInputs();
11+
}
12+
13+
run() {
14+
this.#setText();
15+
this.attach();
16+
}
17+
18+
#setDefaultOptions() {
19+
this.default_options.hook = HOOK.AFTER_SUMMARY,
20+
this.default_options.condition = STATUS.PASS_OR_FAIL;
21+
}
22+
23+
#setDefaultInputs() {
24+
this.default_inputs.title = 'Top Errors';
25+
this.default_inputs.title_link = '';
26+
}
27+
28+
#setText() {
29+
const data = this.extension.inputs.data;
30+
if (!data || !data.length) {
31+
return;
32+
}
33+
34+
const clusters = data;
35+
36+
this.extension.inputs.title = `Top ${clusters.length} Errors`;
37+
38+
const texts = [];
39+
for (const cluster of clusters) {
40+
texts.push(`${this.bold(`(${cluster.count})`)} - ${cluster.failure}`);
41+
}
42+
this.text = this.mergeTexts(texts);
43+
}
44+
}
45+
46+
module.exports = { ErrorClustersExtension }

src/extensions/index.js

+3
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const { CustomExtension } = require('./custom.extension');
1212
const { EXTENSION } = require('../helpers/constants');
1313
const { checkCondition } = require('../helpers/helper');
1414
const logger = require('../utils/logger');
15+
const { ErrorClustersExtension } = require('./error-clusters.extension');
1516

1617
async function run(options) {
1718
const { target, result, hook } = options;
@@ -60,6 +61,8 @@ function getExtensionRunner(extension, options) {
6061
return new AIFailureSummaryExtension(options.target, extension, options.result, options.payload, options.root_payload);
6162
case EXTENSION.SMART_ANALYSIS:
6263
return new SmartAnalysisExtension(options.target, extension, options.result, options.payload, options.root_payload);
64+
case EXTENSION.ERROR_CLUSTERS:
65+
return new ErrorClustersExtension(options.target, extension, options.result, options.payload, options.root_payload);
6366
default:
6467
return require(extension.name);
6568
}

src/helpers/constants.js

+1
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ const TARGET = Object.freeze({
2222
const EXTENSION = Object.freeze({
2323
AI_FAILURE_SUMMARY: 'ai-failure-summary',
2424
SMART_ANALYSIS: 'smart-analysis',
25+
ERROR_CLUSTERS: 'error-clusters',
2526
HYPERLINKS: 'hyperlinks',
2627
MENTIONS: 'mentions',
2728
REPORT_PORTAL_ANALYSIS: 'report-portal-analysis',

src/index.d.ts

+1
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,7 @@ export interface PublishReport {
230230
run?: string;
231231
show_failure_summary?: boolean;
232232
show_smart_analysis?: boolean;
233+
show_error_clusters?: boolean;
233234
targets?: Target[];
234235
extensions?: Extension[];
235236
results?: ParseOptions[] | PerformanceParseOptions[] | CustomResultOptions[];

test/beats.spec.js

+43-3
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,8 @@ describe('TestBeats', () => {
4747
it('should send results with failures to beats', async () => {
4848
const id1 = mock.addInteraction('post test results to beats');
4949
const id2 = mock.addInteraction('get test results from beats');
50-
const id3 = mock.addInteraction('post test-summary with beats to teams with ai failure summary');
50+
const id3 = mock.addInteraction('get empty error clusters from beats');
51+
const id4 = mock.addInteraction('post test-summary with beats to teams with ai failure summary');
5152
await publish({
5253
config: {
5354
api_key: 'api-key',
@@ -74,13 +75,15 @@ describe('TestBeats', () => {
7475
assert.equal(mock.getInteraction(id1).exercised, true);
7576
assert.equal(mock.getInteraction(id2).exercised, true);
7677
assert.equal(mock.getInteraction(id3).exercised, true);
78+
assert.equal(mock.getInteraction(id4).exercised, true);
7779
});
7880

7981
it('should send results with attachments to beats', async () => {
8082
const id1 = mock.addInteraction('post test results to beats');
8183
const id2 = mock.addInteraction('get test results from beats');
8284
const id3 = mock.addInteraction('upload attachments');
83-
const id4 = mock.addInteraction('post test-summary to teams with strict as false');
85+
const id4 = mock.addInteraction('get empty error clusters from beats');
86+
const id5 = mock.addInteraction('post test-summary to teams with strict as false');
8487
await publish({
8588
config: {
8689
api_key: 'api-key',
@@ -108,6 +111,7 @@ describe('TestBeats', () => {
108111
assert.equal(mock.getInteraction(id2).exercised, true);
109112
assert.equal(mock.getInteraction(id3).exercised, true);
110113
assert.equal(mock.getInteraction(id4).exercised, true);
114+
assert.equal(mock.getInteraction(id5).exercised, true);
111115
});
112116

113117
it('should send results to beats without targets', async () => {
@@ -133,7 +137,8 @@ describe('TestBeats', () => {
133137
it('should send results with smart analysis to beats', async () => {
134138
const id1 = mock.addInteraction('post test results to beats');
135139
const id2 = mock.addInteraction('get test results with smart analysis from beats');
136-
const id3 = mock.addInteraction('post test-summary with beats to teams with ai failure summary and smart analysis');
140+
const id3 = mock.addInteraction('get empty error clusters from beats');
141+
const id4 = mock.addInteraction('post test-summary with beats to teams with ai failure summary and smart analysis');
137142
await publish({
138143
config: {
139144
api_key: 'api-key',
@@ -160,6 +165,41 @@ describe('TestBeats', () => {
160165
assert.equal(mock.getInteraction(id1).exercised, true);
161166
assert.equal(mock.getInteraction(id2).exercised, true);
162167
assert.equal(mock.getInteraction(id3).exercised, true);
168+
assert.equal(mock.getInteraction(id4).exercised, true);
169+
});
170+
171+
it('should send results with error clusters to beats', async () => {
172+
const id1 = mock.addInteraction('post test results to beats');
173+
const id2 = mock.addInteraction('get test results from beats');
174+
const id3 = mock.addInteraction('get error clusters from beats');
175+
const id4 = mock.addInteraction('post test-summary with beats to teams with error clusters');
176+
await publish({
177+
config: {
178+
api_key: 'api-key',
179+
project: 'project-name',
180+
run: 'build-name',
181+
targets: [
182+
{
183+
name: 'teams',
184+
inputs: {
185+
url: 'http://localhost:9393/message'
186+
}
187+
}
188+
],
189+
results: [
190+
{
191+
type: 'testng',
192+
files: [
193+
'test/data/testng/single-suite-failures.xml'
194+
]
195+
}
196+
]
197+
}
198+
});
199+
assert.equal(mock.getInteraction(id1).exercised, true);
200+
assert.equal(mock.getInteraction(id2).exercised, true);
201+
assert.equal(mock.getInteraction(id3).exercised, true);
202+
assert.equal(mock.getInteraction(id4).exercised, true);
163203
});
164204

165205
});

test/mocks/beats.mock.js

+49
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,55 @@ addInteractionHandler('get test results with smart analysis from beats', () => {
7575
}
7676
});
7777

78+
addInteractionHandler('get error clusters from beats', () => {
79+
return {
80+
strict: false,
81+
request: {
82+
method: 'GET',
83+
path: '/api/core/v1/test-runs/test-run-id/error-clusters',
84+
queryParams: {
85+
"limit": 3
86+
}
87+
},
88+
response: {
89+
status: 200,
90+
body: {
91+
values: [
92+
{
93+
test_failure_id: 'test-failure-id',
94+
failure: 'failure two',
95+
count: 2
96+
},
97+
{
98+
test_failure_id: 'test-failure-id',
99+
failure: 'failure one',
100+
count: 1
101+
}
102+
]
103+
}
104+
}
105+
}
106+
});
107+
108+
addInteractionHandler('get empty error clusters from beats', () => {
109+
return {
110+
strict: false,
111+
request: {
112+
method: 'GET',
113+
path: '/api/core/v1/test-runs/test-run-id/error-clusters',
114+
queryParams: {
115+
"limit": 3
116+
}
117+
},
118+
response: {
119+
status: 200,
120+
body: {
121+
values: []
122+
}
123+
}
124+
}
125+
});
126+
78127
addInteractionHandler('upload attachments', () => {
79128
return {
80129
strict: false,

0 commit comments

Comments
 (0)