feat(lcov) use really lcov.info file

This commit is contained in:
Julien Catania 2024-01-12 16:18:04 +01:00
parent 712fabc3c8
commit f19a7213c3
5 changed files with 261 additions and 138 deletions

View file

@ -2,136 +2,58 @@
exports[`lcov report coverage report from facebook/jest test results matches snapshot 1`] = ` exports[`lcov report coverage report from facebook/jest test results matches snapshot 1`] = `
TestRunResult { TestRunResult {
"path": "fixtures/lcov.json", "path": "fixtures/lcov.info",
"suites": Array [ "suites": Array [
TestSuiteResult { TestSuiteResult {
"groups": Array [ "groups": Array [
TestGroupResult { TestGroupResult {
"name": "src/core/dao.service.ts", "name": "src/services/notifier/NotifierService.js",
"tests": Array [ "tests": Array [
Object { Object {
"name": "statement", "name": "lines 100% (21/21)",
"result": "success", "result": "success",
"time": 0, "time": 0,
}, },
Object { Object {
"name": "fonction", "name": "functions 100% (10/10)",
"result": "success", "result": "success",
"time": 0, "time": 0,
}, },
Object { Object {
"name": "branche", "name": "branches 50% (3/6)",
"result": "success", "result": "failed",
"time": 0, "time": 0,
}, },
], ],
}, },
], ],
"name": "src/core/dao.service.ts", "name": "src/services/notifier/NotifierService.js",
"totalTime": undefined, "totalTime": undefined,
}, },
TestSuiteResult { TestSuiteResult {
"groups": Array [ "groups": Array [
TestGroupResult { TestGroupResult {
"name": "src/domains/auth/auth.controller.ts", "name": "src/services/notifier/providers/DiscordNotifierProvider.js",
"tests": Array [ "tests": Array [
Object { Object {
"name": "statement", "name": "lines 100% (17/17)",
"result": "success", "result": "success",
"time": 0, "time": 0,
}, },
Object { Object {
"name": "fonction", "name": "functions 100% (3/3)",
"result": "failed",
"time": 0,
},
Object {
"name": "branche",
"result": "success", "result": "success",
"time": 0, "time": 0,
}, },
],
},
],
"name": "src/domains/auth/auth.controller.ts",
"totalTime": undefined,
},
TestSuiteResult {
"groups": Array [
TestGroupResult {
"name": "src/shared/notif/providers/fcm/fcm.service.spec.ts",
"tests": Array [
Object { Object {
"name": "statement", "name": "branches 75% (3/4)",
"result": "failed",
"time": 0,
},
Object {
"name": "fonction",
"result": "failed",
"time": 0,
},
Object {
"name": "branche",
"result": "failed", "result": "failed",
"time": 0, "time": 0,
}, },
], ],
}, },
], ],
"name": "src/shared/notif/providers/fcm/fcm.service.spec.ts", "name": "src/services/notifier/providers/DiscordNotifierProvider.js",
"totalTime": undefined,
},
TestSuiteResult {
"groups": Array [
TestGroupResult {
"name": "src/shared/notif/providers/fcm/fcm.service.ts",
"tests": Array [
Object {
"name": "statement",
"result": "failed",
"time": 0,
},
Object {
"name": "fonction",
"result": "failed",
"time": 0,
},
Object {
"name": "branche",
"result": "success",
"time": 0,
},
],
},
],
"name": "src/shared/notif/providers/fcm/fcm.service.ts",
"totalTime": undefined,
},
TestSuiteResult {
"groups": Array [
TestGroupResult {
"name": "src/shared/notif/providers/mail/mail-service.ts",
"tests": Array [
Object {
"name": "statement",
"result": "failed",
"time": 0,
},
Object {
"name": "fonction",
"result": "failed",
"time": 0,
},
Object {
"name": "branche",
"result": "success",
"time": 0,
},
],
},
],
"name": "src/shared/notif/providers/mail/mail-service.ts",
"totalTime": undefined, "totalTime": undefined,
}, },
], ],

View file

@ -3,17 +3,15 @@ import * as path from 'path'
import {getReport} from '../src/report/get-report' import {getReport} from '../src/report/get-report'
import {normalizeFilePath} from '../src/utils/path-utils' import {normalizeFilePath} from '../src/utils/path-utils'
import { LcovParser } from "../src/parsers/lcov/lcov-parser"; import {LcovParser} from '../src/parsers/lcov/lcov-parser'
describe('lcov report coverage', () => { describe('lcov report coverage', () => {
it('report from facebook/jest test results matches snapshot', async () => { it('report from facebook/jest test results matches snapshot', async () => {
const fixturePath = path.join(__dirname, 'fixtures', 'lcov.info') const fixturePath = path.join(__dirname, 'fixtures', 'lcov.info')
const outputPath = path.join(__dirname, '__outputs__', 'lcov-report-results.md') const outputPath = path.join(__dirname, '__outputs__', 'lcov-report-results.md')
const filePath = normalizeFilePath(path.relative(__dirname, fixturePath)) const filePath = normalizeFilePath(path.relative(__dirname, fixturePath))
const fileContent = fs.readFileSync(fixturePath, {encoding: 'utf8'}) const fileContent = fs.readFileSync(fixturePath, {encoding: 'utf8'})
const parser = new LcovParser({parseErrors: true, trackedFiles: []}) const parser = new LcovParser({parseErrors: true, trackedFiles: []})
const result = await parser.parse(filePath, fileContent) const result = await parser.parse(filePath, fileContent)
expect(result).toMatchSnapshot() expect(result).toMatchSnapshot()

247
dist/index.js generated vendored
View file

@ -265,10 +265,10 @@ const dotnet_trx_parser_1 = __nccwpck_require__(2664);
const java_junit_parser_1 = __nccwpck_require__(676); const java_junit_parser_1 = __nccwpck_require__(676);
const jest_junit_parser_1 = __nccwpck_require__(1113); const jest_junit_parser_1 = __nccwpck_require__(1113);
const mocha_json_parser_1 = __nccwpck_require__(6043); const mocha_json_parser_1 = __nccwpck_require__(6043);
const swift_xunit_parser_1 = __nccwpck_require__(5366);
const path_utils_1 = __nccwpck_require__(4070); const path_utils_1 = __nccwpck_require__(4070);
const github_utils_1 = __nccwpck_require__(3522); const github_utils_1 = __nccwpck_require__(3522);
const markdown_utils_1 = __nccwpck_require__(6482); const lcov_parser_1 = __nccwpck_require__(5698);
const lcov_parser_1 = __nccwpck_require__(5804);
function main() { function main() {
return __awaiter(this, void 0, void 0, function* () { return __awaiter(this, void 0, void 0, function* () {
try { try {
@ -294,6 +294,7 @@ class TestReporter {
this.listTests = core.getInput('list-tests', { required: true }); this.listTests = core.getInput('list-tests', { required: true });
this.maxAnnotations = parseInt(core.getInput('max-annotations', { required: true })); this.maxAnnotations = parseInt(core.getInput('max-annotations', { required: true }));
this.failOnError = core.getInput('fail-on-error', { required: true }) === 'true'; this.failOnError = core.getInput('fail-on-error', { required: true }) === 'true';
this.failOnEmpty = core.getInput('fail-on-empty', { required: true }) === 'true';
this.workDirInput = core.getInput('working-directory', { required: false }); this.workDirInput = core.getInput('working-directory', { required: false });
this.onlySummary = core.getInput('only-summary', { required: false }) === 'true'; this.onlySummary = core.getInput('only-summary', { required: false }) === 'true';
this.token = core.getInput('token', { required: true }); this.token = core.getInput('token', { required: true });
@ -365,7 +366,7 @@ class TestReporter {
core.setFailed(`Failed test were found and 'fail-on-error' option is set to ${this.failOnError}`); core.setFailed(`Failed test were found and 'fail-on-error' option is set to ${this.failOnError}`);
return; return;
} }
if (results.length === 0) { if (results.length === 0 && this.failOnEmpty) {
core.setFailed(`No test report files were found`); core.setFailed(`No test report files were found`);
return; return;
} }
@ -402,16 +403,21 @@ class TestReporter {
const annotations = (0, get_annotations_1.getAnnotations)(results, this.maxAnnotations); const annotations = (0, get_annotations_1.getAnnotations)(results, this.maxAnnotations);
const isFailed = this.failOnError && results.some(tr => tr.result === 'failed'); const isFailed = this.failOnError && results.some(tr => tr.result === 'failed');
const conclusion = isFailed ? 'failure' : 'success'; const conclusion = isFailed ? 'failure' : 'success';
const icon = isFailed ? markdown_utils_1.Icon.fail : markdown_utils_1.Icon.success; const passed = results.reduce((sum, tr) => sum + tr.passed, 0);
const failed = results.reduce((sum, tr) => sum + tr.failed, 0);
const skipped = results.reduce((sum, tr) => sum + tr.skipped, 0);
const shortSummary = `${passed} passed, ${failed} failed and ${skipped} skipped `;
core.info(`Updating check run conclusion (${conclusion}) and output`); core.info(`Updating check run conclusion (${conclusion}) and output`);
const resp = yield this.octokit.rest.checks.update(Object.assign({ check_run_id: createResp.data.id, conclusion, status: 'completed', output: { const resp = yield this.octokit.rest.checks.update(Object.assign({ check_run_id: createResp.data.id, conclusion, status: 'completed', output: {
title: `${name} ${icon}`, title: shortSummary,
summary, summary,
annotations annotations
} }, github.context.repo)); } }, github.context.repo));
core.info(`Check run create response: ${resp.status}`); core.info(`Check run create response: ${resp.status}`);
core.info(`Check run URL: ${resp.data.url}`); core.info(`Check run URL: ${resp.data.url}`);
core.info(`Check run HTML: ${resp.data.html_url}`); core.info(`Check run HTML: ${resp.data.html_url}`);
core.setOutput('url', resp.data.url);
core.setOutput('url_html', resp.data.html_url);
return results; return results;
}); });
} }
@ -429,6 +435,8 @@ class TestReporter {
return new jest_junit_parser_1.JestJunitParser(options); return new jest_junit_parser_1.JestJunitParser(options);
case 'mocha-json': case 'mocha-json':
return new mocha_json_parser_1.MochaJsonParser(options); return new mocha_json_parser_1.MochaJsonParser(options);
case 'swift-xunit':
return new swift_xunit_parser_1.SwiftXunitParser(options);
case 'lcov': case 'lcov':
return new lcov_parser_1.LcovParser(options); return new lcov_parser_1.LcovParser(options);
default: default:
@ -1209,7 +1217,7 @@ class JestJunitParser {
const suites = junit.testsuites.testsuite === undefined const suites = junit.testsuites.testsuite === undefined
? [] ? []
: junit.testsuites.testsuite.map(ts => { : junit.testsuites.testsuite.map(ts => {
const name = ts.$.name.trim(); const name = this.escapeCharacters(ts.$.name.trim());
const time = parseFloat(ts.$.time) * 1000; const time = parseFloat(ts.$.time) * 1000;
const sr = new test_results_1.TestSuiteResult(name, this.getGroups(ts), time); const sr = new test_results_1.TestSuiteResult(name, this.getGroups(ts), time);
return sr; return sr;
@ -1278,13 +1286,16 @@ class JestJunitParser {
var _a, _b; var _a, _b;
return ((_b = (_a = this.options.workDir) !== null && _a !== void 0 ? _a : this.assumedWorkDir) !== null && _b !== void 0 ? _b : (this.assumedWorkDir = (0, path_utils_1.getBasePath)(path, this.options.trackedFiles))); return ((_b = (_a = this.options.workDir) !== null && _a !== void 0 ? _a : this.assumedWorkDir) !== null && _b !== void 0 ? _b : (this.assumedWorkDir = (0, path_utils_1.getBasePath)(path, this.options.trackedFiles)));
} }
escapeCharacters(s) {
return s.replace(/([<>])/g, '\\$1');
}
} }
exports.JestJunitParser = JestJunitParser; exports.JestJunitParser = JestJunitParser;
/***/ }), /***/ }),
/***/ 5804: /***/ 5698:
/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { /***/ (function(__unused_webpack_module, exports, __nccwpck_require__) {
"use strict"; "use strict";
@ -1301,19 +1312,21 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
Object.defineProperty(exports, "__esModule", ({ value: true })); Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.LcovParser = void 0; exports.LcovParser = void 0;
const test_results_1 = __nccwpck_require__(2768); const test_results_1 = __nccwpck_require__(2768);
const lcov_utils_1 = __nccwpck_require__(4750);
class LcovParser { class LcovParser {
constructor(options) { constructor(options) {
this.options = options; this.options = options;
} }
parse(path, content) { parse(path, content) {
return __awaiter(this, void 0, void 0, function* () { return __awaiter(this, void 0, void 0, function* () {
const report = this.parseFile(path, content); const report = yield this.parseFile(path, content);
return this.getTestRunResult(path, report); return this.getTestRunResult(path, report);
}); });
} }
parseFile(path, content) { parseFile(path, content) {
try { try {
return JSON.parse(content); return (0, lcov_utils_1.parseProm)(content);
//return JSON.parse(content) as LcovReport
} }
catch (e) { catch (e) {
throw new Error(`Invalid JSON at ${path}\n\n${e}`); throw new Error(`Invalid JSON at ${path}\n\n${e}`);
@ -1322,47 +1335,67 @@ class LcovParser {
getTestRunResult(path, report) { getTestRunResult(path, report) {
return __awaiter(this, void 0, void 0, function* () { return __awaiter(this, void 0, void 0, function* () {
const suites = []; const suites = [];
for (const key of Object.keys(report)) { for (let reportElement of report) {
const s = this.getParsedStat(report[key].s); const fileName = reportElement.file;
const f = this.getParsedStat(report[key].f);
const b = this.getParsedStat(report[key].b);
const statementCaseResult = { const statementCaseResult = {
name: 'statement', name: `lines ${this.getPartInfo(reportElement.lines)}`,
time: 0, time: 0,
result: s.percentage >= 80 ? 'success' : 'failed' result: this.getPercentage(reportElement.lines) >= 80 ? 'success' : 'failed'
}; };
const fonctionCaseResult = { const fonctionCaseResult = {
name: 'fonction', name: `functions ${this.getPartInfo(reportElement.functions)}`,
time: 0, time: 0,
result: f.percentage >= 80 ? 'success' : 'failed' result: this.getPercentage(reportElement.functions) >= 80 ? 'success' : 'failed'
}; };
const brancheCaseResult = { const brancheCaseResult = {
name: 'branche', name: `branches ${this.getPartInfo(reportElement.branches)}`,
time: 0, time: 0,
result: b.percentage >= 80 ? 'success' : 'failed' result: this.getPercentage(reportElement.branches) >= 80 ? 'success' : 'failed'
}; };
const testCases = [statementCaseResult, fonctionCaseResult, brancheCaseResult]; const testCases = [statementCaseResult, fonctionCaseResult, brancheCaseResult];
const goups = [new test_results_1.TestGroupResult(key, testCases)]; const groups = [new test_results_1.TestGroupResult(fileName, testCases)];
const suite = new test_results_1.TestSuiteResult(key, goups); const suite = new test_results_1.TestSuiteResult(fileName, groups);
suites.push(suite); suites.push(suite);
console.log({ key, s, f, b });
} }
return new test_results_1.TestRunResult(path, suites); return new test_results_1.TestRunResult(path, suites);
}); });
} }
getParsedStat(stat) { getPercentage(stat) {
const max = Object.keys(stat).length; return stat ? stat.hit / stat.found * 100 : 100;
const nonCovered = this.zeroLength(stat);
const percentage = ((max - nonCovered) / max) * 100;
return { max, nonCovered, percentage };
} }
zeroLength(report) { getPartInfo(stat) {
return Object.keys(report).filter(key => report[key] === 0).length; return `${this.getPercentage(stat)}% (${stat.hit}/${stat.found})`;
} }
} }
exports.LcovParser = LcovParser; exports.LcovParser = LcovParser;
/***/ }),
/***/ 4750:
/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) {
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.parseProm = void 0;
const lcov_parse_1 = __importDefault(__nccwpck_require__(7454));
const parseProm = (pathOrStr) => {
return new Promise((resolve, reject) => {
(0, lcov_parse_1.default)(pathOrStr, (err, data) => {
if (err) {
reject(err);
}
resolve(data !== null && data !== void 0 ? data : []);
});
});
};
exports.parseProm = parseProm;
/***/ }), /***/ }),
/***/ 6043: /***/ 6043:
@ -1476,6 +1509,25 @@ class MochaJsonParser {
exports.MochaJsonParser = MochaJsonParser; exports.MochaJsonParser = MochaJsonParser;
/***/ }),
/***/ 5366:
/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => {
"use strict";
Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.SwiftXunitParser = void 0;
const java_junit_parser_1 = __nccwpck_require__(676);
class SwiftXunitParser extends java_junit_parser_1.JavaJunitParser {
constructor(options) {
super(options);
this.options = options;
}
}
exports.SwiftXunitParser = SwiftXunitParser;
/***/ }), /***/ }),
/***/ 5867: /***/ 5867:
@ -2279,8 +2331,8 @@ function parseIsoDate(str) {
} }
exports.parseIsoDate = parseIsoDate; exports.parseIsoDate = parseIsoDate;
function getFirstNonEmptyLine(stackTrace) { function getFirstNonEmptyLine(stackTrace) {
const lines = stackTrace.split(/\r?\n/g); const lines = stackTrace === null || stackTrace === void 0 ? void 0 : stackTrace.split(/\r?\n/g);
return lines.find(str => !/^\s*$/.test(str)); return lines === null || lines === void 0 ? void 0 : lines.find(str => !/^\s*$/.test(str));
} }
exports.getFirstNonEmptyLine = getFirstNonEmptyLine; exports.getFirstNonEmptyLine = getFirstNonEmptyLine;
@ -22198,6 +22250,139 @@ class Keyv extends EventEmitter {
module.exports = Keyv; module.exports = Keyv;
/***/ }),
/***/ 7454:
/***/ ((module, __unused_webpack_exports, __nccwpck_require__) => {
/*
Copyright (c) 2012, Yahoo! Inc. All rights reserved.
Code licensed under the BSD License:
http://yuilibrary.com/license/
*/
var fs = __nccwpck_require__(7147),
path = __nccwpck_require__(1017);
/* istanbul ignore next */
var exists = fs.exists || path.exists;
var walkFile = function(str, cb) {
var data = [], item;
[ 'end_of_record' ].concat(str.split('\n')).forEach(function(line) {
line = line.trim();
var allparts = line.split(':'),
parts = [allparts.shift(), allparts.join(':')],
lines, fn;
switch (parts[0].toUpperCase()) {
case 'TN':
item.title = parts[1].trim();
break;
case 'SF':
item.file = parts.slice(1).join(':').trim();
break;
case 'FNF':
item.functions.found = Number(parts[1].trim());
break;
case 'FNH':
item.functions.hit = Number(parts[1].trim());
break;
case 'LF':
item.lines.found = Number(parts[1].trim());
break;
case 'LH':
item.lines.hit = Number(parts[1].trim());
break;
case 'DA':
lines = parts[1].split(',');
item.lines.details.push({
line: Number(lines[0]),
hit: Number(lines[1])
});
break;
case 'FN':
fn = parts[1].split(',');
item.functions.details.push({
name: fn[1],
line: Number(fn[0])
});
break;
case 'FNDA':
fn = parts[1].split(',');
item.functions.details.some(function(i, k) {
if (i.name === fn[1] && i.hit === undefined) {
item.functions.details[k].hit = Number(fn[0]);
return true;
}
});
break;
case 'BRDA':
fn = parts[1].split(',');
item.branches.details.push({
line: Number(fn[0]),
block: Number(fn[1]),
branch: Number(fn[2]),
taken: ((fn[3] === '-') ? 0 : Number(fn[3]))
});
break;
case 'BRF':
item.branches.found = Number(parts[1]);
break;
case 'BRH':
item.branches.hit = Number(parts[1]);
break;
}
if (line.indexOf('end_of_record') > -1) {
data.push(item);
item = {
lines: {
found: 0,
hit: 0,
details: []
},
functions: {
hit: 0,
found: 0,
details: []
},
branches: {
hit: 0,
found: 0,
details: []
}
};
}
});
data.shift();
if (data.length) {
cb(null, data);
} else {
cb('Failed to parse string');
}
};
var parse = function(file, cb) {
exists(file, function(x) {
if (!x) {
return walkFile(file, cb);
}
fs.readFile(file, 'utf8', function(err, str) {
walkFile(str, cb);
});
});
};
module.exports = parse;
module.exports.source = walkFile;
/***/ }), /***/ }),
/***/ 9662: /***/ 9662:

29
dist/licenses.txt generated vendored
View file

@ -1056,6 +1056,35 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
keyv keyv
MIT MIT
lcov-parse
BSD-3-Clause
Copyright 2012 Yahoo! Inc.
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
* Neither the name of the Yahoo! Inc. nor the
names of its contributors may be used to endorse or promote products
derived from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL YAHOO! INC. BE LIABLE FOR ANY
DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
lowercase-keys lowercase-keys
MIT MIT
MIT License MIT License

View file

@ -1,11 +1,7 @@
import {DEFAULT_LOCALE} from './utils/node-utils' import {DEFAULT_LOCALE} from './utils/node-utils'
export class TestRunResult { export class TestRunResult {
constructor( constructor(readonly path: string, readonly suites: TestSuiteResult[], private totalTime?: number) {}
readonly path: string,
readonly suites: TestSuiteResult[],
private totalTime?: number
) {}
get tests(): number { get tests(): number {
return this.suites.reduce((sum, g) => sum + g.tests, 0) return this.suites.reduce((sum, g) => sum + g.tests, 0)
@ -44,11 +40,7 @@ export class TestRunResult {
} }
export class TestSuiteResult { export class TestSuiteResult {
constructor( constructor(readonly name: string, readonly groups: TestGroupResult[], private totalTime?: number) {}
readonly name: string,
readonly groups: TestGroupResult[],
private totalTime?: number
) {}
get tests(): number { get tests(): number {
return this.groups.reduce((sum, g) => sum + g.tests.length, 0) return this.groups.reduce((sum, g) => sum + g.tests.length, 0)
@ -86,10 +78,7 @@ export class TestSuiteResult {
} }
export class TestGroupResult { export class TestGroupResult {
constructor( constructor(readonly name: string | undefined | null, readonly tests: TestCaseResult[]) {}
readonly name: string | undefined | null,
readonly tests: TestCaseResult[]
) {}
get passed(): number { get passed(): number {
return this.tests.reduce((sum, t) => (t.result === 'success' ? sum + 1 : sum), 0) return this.tests.reduce((sum, t) => (t.result === 'success' ? sum + 1 : sum), 0)