maint(resources): nest unit tests under parent block

This adds a modified `mocha-teamcity-reporter` that allows to nest the
unit tests under the parent block.

Fixes: #14839
Test-bot: skip
This commit is contained in:
Eberhard Beilharz 2025-09-30 17:01:52 +02:00
parent 6c2237d800
commit 229bdf8270
No known key found for this signature in database
GPG key ID: E9140597606020D3
18 changed files with 321 additions and 67 deletions

View file

@ -0,0 +1,8 @@
# Custom mocha reporter for TeamCity
This is an enhanced version of the [mocha-teamcity-reporter](https://github.com/travisjeffery/mocha-teamcity-reporter)
(renamed to *.cjs) that allows to specify the parent flowId so that the tests
can be grouped together under the parent block in TeamCity.
If [travisjeffery/mocha-teamcity-reporter#69](https://github.com/travisjeffery/mocha-teamcity-reporter/issues/69)
gets fixed one day, we can go back to using the original version.

View file

@ -0,0 +1,259 @@
/* eslint-disable complexity */
/**
* Teamcity doc reference https://confluence.jetbrains.com/display/TCD10/Build+Script+Interaction+with+TeamCity
*
* Module dependencies.
*/
'use strict';
const processPID = process.pid.toString();
const TEST_IGNORED = `##teamcity[testIgnored name='%s' message='%s']`;
const SUITE_START = `##teamcity[testSuiteStarted name='%s']`;
const SUITE_END = `##teamcity[testSuiteFinished name='%s' duration='%s']`;
const SUITE_END_NO_DURATION = `##teamcity[testSuiteFinished name='%s']`;
const TEST_START = `##teamcity[testStarted name='%s' captureStandardOutput='true']`;
const TEST_FAILED = `##teamcity[testFailed name='%s' message='%s' details='%s' captureStandardOutput='true']`;
const TEST_FAILED_COMPARISON = `##teamcity[testFailed type='comparisonFailure' name='%s' message='%s' \
details='%s' captureStandardOutput='true' actual='%s' expected='%s']`;
const TEST_END = `##teamcity[testFinished name='%s' duration='%s']`;
const TEST_END_NO_DURATION = `##teamcity[testFinished name='%s']`;
const FLOW_START = `##teamcity[flowStarted flowId='%s' parent='%s']`;
const FLOW_END = `##teamcity[flowFinished flowId='%s']`;
const Mocha = require('mocha');
const {
EVENT_SUITE_BEGIN,
EVENT_TEST_BEGIN,
EVENT_TEST_FAIL,
EVENT_TEST_PENDING,
EVENT_TEST_END,
EVENT_HOOK_BEGIN,
EVENT_HOOK_END,
EVENT_SUITE_END,
EVENT_RUN_END
} = Mocha.Runner.constants;
const util = require('util');
let Base, log, logError;
Base = require('mocha').reporters.Base;
log = console.log;
logError = console.error;
let flowIds = [];
/**
* Escape the given `str`.
*/
function escape(str) {
if (!str) return '';
return str
.toString()
.replace(/\x1B.*?m/g, '') // eslint-disable-line no-control-regex
.replace(/\|/g, '||')
.replace(/\n/g, '|n')
.replace(/\r/g, '|r')
.replace(/\[/g, '|[')
.replace(/\]/g, '|]')
.replace(/\u0085/g, '|x')
.replace(/\u2028/g, '|l')
.replace(/\u2029/g, '|p')
.replace(/'/g, '|\'');
}
function isNil(value) {
return value == null; // eslint-disable-line
}
function formatString() {
let formattedArguments = [];
const args = Array.prototype.slice.call(arguments, 0);
// Format all arguments for TC display (it escapes using the pipe char).
let tcMessage = args.shift();
args.forEach((param) => {
formattedArguments.push(escape(param));
});
formattedArguments.unshift(tcMessage);
return util.format.apply(util, formattedArguments);
}
function handleFlow(isStarted, nestFlows) {
if (!nestFlows) {
return;
}
if (isStarted) {
flowIds.push(Math.floor(Math.random() * 100000 + 1));
log(formatString(FLOW_START, flowIds[flowIds.length - 1], flowIds[flowIds.length - 2]));
} else {
log(formatString(FLOW_END, flowIds[flowIds.length - 1]));
flowIds.pop();
}
}
function getFlowId() {
return flowIds[flowIds.length - 1];
}
/**
* Initialize a new `Teamcity` reporter.
*
* @param {Runner} runner
* @param {options} options
* @api public
*/
function Teamcity(runner, options) {
options = options || {};
const reporterOptions = options.reporterOptions || {};
let flowIdOpt, useStdError, recordHookFailures, actualVsExpected, ignoreHookWithName, displayIgnoredAsIgnored;
(reporterOptions.flowId) ? flowIdOpt = reporterOptions.flowId : flowIdOpt = process.env['MOCHA_TEAMCITY_FLOWID'] || processPID;
(reporterOptions.useStdError) ? useStdError = reporterOptions.useStdError : useStdError = process.env['USE_STD_ERROR'];
(reporterOptions.recordHookFailures) ? recordHookFailures = reporterOptions.recordHookFailures : recordHookFailures = process.env['RECORD_HOOK_FAILURES'];
(reporterOptions.actualVsExpected) ? actualVsExpected = reporterOptions.actualVsExpected : actualVsExpected = process.env['ACTUAL_VS_EXPECTED'];
(reporterOptions.ignoreHookWithName) ? ignoreHookWithName = reporterOptions.ignoreHookWithName : ignoreHookWithName = process.env['IGNORE_HOOK_WITH_NAME'];
(reporterOptions.displayIgnoredAsIgnored)
? displayIgnoredAsIgnored = reporterOptions.displayIgnoredAsIgnored
: displayIgnoredAsIgnored = process.env['DISPLAY_IGNORED_AS_IGNORED'];
(useStdError) ? useStdError = (useStdError.toLowerCase() === 'true') : useStdError = false;
(recordHookFailures) ? recordHookFailures = (recordHookFailures.toLowerCase() === 'true') : recordHookFailures = false;
(displayIgnoredAsIgnored) ? displayIgnoredAsIgnored = (displayIgnoredAsIgnored.toLowerCase() === 'true') : displayIgnoredAsIgnored = false;
(ignoreHookWithName) ? ignoreHookWithName : null;
actualVsExpected ? actualVsExpected = (actualVsExpected.toLowerCase() === 'true') : actualVsExpected = false;
Base.call(this, runner);
let stats = this.stats;
const topLevelSuite = reporterOptions.topLevelSuite || process.env['MOCHA_TEAMCITY_TOP_LEVEL_SUITE'];
const parentFlowId = reporterOptions.parentFlowId || process.env['MOCHA_TEAMCITY_PARENT_FLOW_ID'];
if (parentFlowId) {
flowIds.push(parentFlowId);
} else {
flowIds.push(flowIdOpt);
}
const hasParentFlowId = !!parentFlowId;
const ignoredTests = {};
const testState = { pending: 0 };
runner.on(EVENT_SUITE_BEGIN, function (suite) {
handleFlow(true, hasParentFlowId);
if (suite.root) {
if (topLevelSuite) {
log(formatString(SUITE_START, topLevelSuite));
}
return;
}
suite.startDate = new Date();
log(formatString(SUITE_START, suite.title));
});
runner.on(EVENT_TEST_BEGIN, function (test) {
if (displayIgnoredAsIgnored && ignoredTests[`${test.title}-${getFlowId()}`] === testState.pending) {
return;
}
handleFlow(true, hasParentFlowId);
log(formatString(TEST_START, test.title));
});
runner.on(EVENT_TEST_FAIL, function (test, err) {
let isHook = false;
if (test.title.includes(`"before all" hook`) ||
test.title.includes(`"before each" hook`) ||
test.title.includes(`"after all" hook`) ||
test.title.includes(`"after each" hook`)
) {
isHook = true;
}
if(actualVsExpected && (err.actual && err.expected)){
if (useStdError) {
logError(formatString(TEST_FAILED_COMPARISON,
test.title, err.message, err.stack, err.actual, err.expected));
} else {
log(formatString(TEST_FAILED_COMPARISON, test.title, err.message, err.stack, err.actual, err.expected));
}
} else{
if (useStdError) {
logError(formatString(TEST_FAILED, test.title, err.message, err.stack));
} else {
log(formatString(TEST_FAILED, test.title, err.message, err.stack));
}
}
// Log testFinished for failed hook (hook end event is not fired for failed hook)
if (recordHookFailures && !ignoreHookWithName || recordHookFailures && ignoreHookWithName && !test.title.includes(ignoreHookWithName)) {
if (isHook) {
if(isNil(test.duration)){
log(formatString(TEST_END_NO_DURATION, test.title));
} else {
log(formatString(TEST_END, test.title, test.duration.toString()));
}
handleFlow(false, hasParentFlowId);
}
}
});
runner.on(EVENT_TEST_PENDING, function (test) {
log(formatString(TEST_IGNORED, test.title, test.title));
if (displayIgnoredAsIgnored) {
ignoredTests[`${test.title}-${getFlowId()}`] = testState.pending;
} else {
handleFlow(true, hasParentFlowId)
}
});
runner.on(EVENT_TEST_END, function (test) {
if (displayIgnoredAsIgnored && ignoredTests[`${test.title}-${getFlowId()}`] === testState.pending) {
delete ignoredTests[`${test.title}-${getFlowId()}`];
return;
}
if(isNil(test.duration)){
log(formatString(TEST_END_NO_DURATION, test.title));
} else {
log(formatString(TEST_END, test.title, test.duration.toString()));
}
handleFlow(false, hasParentFlowId);
});
runner.on(EVENT_HOOK_BEGIN, function (test) {
if (recordHookFailures && !ignoreHookWithName || recordHookFailures && ignoreHookWithName && !test.title.includes(ignoreHookWithName)) {
handleFlow(true, hasParentFlowId);
log(formatString(TEST_START, test.title));
}
});
runner.on(EVENT_HOOK_END, function (test) {
if (recordHookFailures && !ignoreHookWithName || recordHookFailures && ignoreHookWithName && !test.title.includes(ignoreHookWithName)) {
if(isNil(test.duration)){
log(formatString(TEST_END_NO_DURATION, test.title));
} else {
log(formatString(TEST_END, test.title, test.duration.toString()));
}
handleFlow(false, hasParentFlowId);
}
});
runner.on(EVENT_SUITE_END, function (suite) {
if (!suite.root) {
log(formatString(SUITE_END, suite.title, new Date() - suite.startDate));
}
handleFlow(false, hasParentFlowId);
});
runner.on(EVENT_RUN_END, function () {
let duration;
(typeof stats === 'undefined') ? duration = null : duration = stats.duration;
if (topLevelSuite) {
isNil(duration)
? log(formatString(SUITE_END_NO_DURATION, topLevelSuite))
: log(formatString(SUITE_END, topLevelSuite, duration));
handleFlow(false, hasParentFlowId);
}
});
}
/**
* Expose `Teamcity`.
*/
exports = module.exports = Teamcity;

View file

@ -18,7 +18,6 @@
"homepage": "https://github.com/keymanapp/keyman#readme",
"devDependencies": {
"@keymanapp/resources-gosh": "*",
"mocha-teamcity-reporter": "^4.0.0",
"typescript": "^5.4.5"
},
"dependencies": {

19
package-lock.json generated
View file

@ -64,7 +64,6 @@
"eslint-plugin-n": "^15.7.0",
"eslint-plugin-promise": "^6.1.1",
"mocha": "^11.2.2",
"mocha-teamcity-reporter": "^4.0.0",
"playwright": "^1.46.1",
"sinon": "^17.0.1",
"source-map-support": "^0.5.21",
@ -109,7 +108,6 @@
},
"devDependencies": {
"@keymanapp/resources-gosh": "*",
"mocha-teamcity-reporter": "^4.0.0",
"typescript": "^5.4.5"
}
},
@ -10251,17 +10249,6 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/mocha-teamcity-reporter": {
"version": "4.0.0",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
},
"peerDependencies": {
"mocha": ">=6"
}
},
"node_modules/mocha/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
@ -14235,7 +14222,6 @@
"@keymanapp/keyman-version": "*",
"@keymanapp/resources-gosh": "*",
"c8": "^7.12.0",
"mocha-teamcity-reporter": "^4.0.0",
"typescript": "^5.4.5"
}
},
@ -14247,7 +14233,6 @@
},
"devDependencies": {
"@keymanapp/resources-gosh": "*",
"mocha-teamcity-reporter": "^4.0.0",
"promise-status-async": "^1.2.10",
"typescript": "^5.4.5"
}
@ -14266,7 +14251,6 @@
"@keymanapp/web-utils": "*",
"@types/mocha": "^7.0.2",
"c8": "^7.12.0",
"mocha-teamcity-reporter": "^4.0.0",
"typescript": "^5.4.5"
}
},
@ -14285,7 +14269,6 @@
"@keymanapp/resources-gosh": "*",
"@types/mocha": "^7.0.2",
"c8": "^7.12.0",
"mocha-teamcity-reporter": "^4.0.0",
"typescript": "^5.4.5"
}
},
@ -14303,7 +14286,6 @@
"devDependencies": {
"@keymanapp/common-types": "*",
"@keymanapp/resources-gosh": "*",
"mocha-teamcity-reporter": "^4.0.0",
"typescript": "^5.4.5"
}
},
@ -14325,7 +14307,6 @@
"@keymanapp/resources-gosh": "*",
"c8": "^7.12.0",
"combine-source-map": "^0.8.0",
"mocha-teamcity-reporter": "^4.0.0",
"typescript": "^5.4.5"
}
},

View file

@ -22,7 +22,6 @@
"eslint-plugin-n": "^15.7.0",
"eslint-plugin-promise": "^6.1.1",
"mocha": "^11.2.2",
"mocha-teamcity-reporter": "^4.0.0",
"playwright": "^1.46.1",
"sinon": "^17.0.1",
"source-map-support": "^0.5.21",
@ -61,11 +60,11 @@
],
"dependencies": {
"@keymanapp/common-types": "file:common/web/types",
"@keymanapp/langtags": "file:common/web/langtags",
"@keymanapp/developer-test-helpers": "file:developer/src/common/web/test-helpers",
"@keymanapp/developer-utils": "file:developer/src/common/web/utils",
"@keymanapp/hextobin": "file:common/tools/hextobin",
"@keymanapp/keyman-version": "file:common/web/keyman-version",
"@keymanapp/langtags": "file:common/web/langtags",
"@keymanapp/ldml-keyboard-constants": "file:core/include/ldml"
},
"overrides": {

View file

@ -12,8 +12,7 @@
},
"devDependencies": {
"chai": "^5.1.0",
"mocha": "^11.2.2",
"mocha-teamcity-reporter": "^4.0.0"
"mocha": "^11.2.2"
}
},
"node_modules/@actions/core": {
@ -904,19 +903,6 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/mocha-teamcity-reporter": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/mocha-teamcity-reporter/-/mocha-teamcity-reporter-4.2.0.tgz",
"integrity": "sha512-H08IvAIsiCcdXAEObzp/VvJHFfLummxt5wpr0gU4yxx6pTb7ZmKnVjyXkFi+3gYi3bu/j2iJdoK9Aknz16mbBg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
},
"peerDependencies": {
"mocha": ">=6"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",

View file

@ -6,9 +6,8 @@
"@actions/github": "^6.0.1"
},
"devDependencies": {
"mocha": "^11.2.2",
"mocha-teamcity-reporter": "^4.0.0",
"chai": "^5.1.0"
"chai": "^5.1.0",
"mocha": "^11.2.2"
},
"license": "MIT",
"main": "pr-build-status.mjs",

View file

@ -10,12 +10,13 @@
# 1: coverage_threshold optional, minimum coverage for c8 to pass tests,
# defaults to 90 (percent)
typescript_run_eslint_mocha_tests() {
local MOCHA_FLAGS=
local MOCHA_FLAGS=()
local TEST_DIR
if builder_is_running_on_teamcity; then
# we're running in TeamCity
MOCHA_FLAGS="-reporter mocha-teamcity-reporter"
MOCHA_FLAGS+=(--reporter "${KEYMAN_ROOT}/common/test/resources/mocha-teamcity-reporter/teamcity.cjs" --reporter-options parentFlowId="unit_tests")
echo "##teamcity[flowStarted flowId='unit_tests']"
fi
eslint .
@ -45,10 +46,15 @@ typescript_run_eslint_mocha_tests() {
THRESHOLD_PARAMS="--lines 90 --statements 90 --branches 80 --functions 80"
fi
c8 --reporter=lcov --reporter=text --exclude-after-remap --check-coverage $THRESHOLD_PARAMS mocha ${MOCHA_FLAGS} "${builder_extra_params[@]}"
c8 --reporter=lcov --reporter=text --exclude-after-remap --check-coverage ${THRESHOLD_PARAMS} mocha "${MOCHA_FLAGS[@]}" "${builder_extra_params[@]}"
if [[ ! -z "${C8_THRESHOLD}" ]]; then
builder_echo warning "Coverage thresholds are currently ${C8_THRESHOLD}%, which is lower than ideal."
builder_echo warning "Please increase threshold in build.sh as test coverage improves."
fi
if builder_is_running_on_teamcity; then
# we're running in TeamCity
echo "##teamcity[flowFinished flowId='unit_tests']"
fi
}

View file

@ -100,18 +100,24 @@ function test-headless() {
tsc --project "${KEYMAN_ROOT}/web/src/test/auto/tsconfig.json"
fi
TEST_OPTS=
if builder_is_ci_build; then
TEST_OPTS="--reporter mocha-teamcity-reporter"
TEST_OPTS=()
if builder_is_running_on_teamcity; then
TEST_OPTS+=(--reporter "${KEYMAN_ROOT}/common/test/resources/mocha-teamcity-reporter/teamcity.cjs" --reporter-options parentFlowId="unit_tests")
echo "##teamcity[flowStarted flowId='unit_tests']"
fi
if [[ -n "$TEST_EXTENSIONS" ]]; then
TEST_OPTS="$TEST_OPTS --extension $TEST_EXTENSIONS"
if [[ -n "${TEST_EXTENSIONS}" ]]; then
TEST_OPTS+=(--extension "${TEST_EXTENSIONS}")
fi
if [[ -e .c8rc.json ]]; then
c8 mocha --recursive "${TEST_BASE}${TEST_FOLDER}" $TEST_OPTS
c8 mocha --recursive "${TEST_BASE}${TEST_FOLDER}" "${TEST_OPTS[@]}"
else
mocha --recursive "${TEST_BASE}${TEST_FOLDER}" $TEST_OPTS
mocha --recursive "${TEST_BASE}${TEST_FOLDER}" "${TEST_OPTS[@]}"
fi
if builder_is_running_on_teamcity; then
# we're running in TeamCity
echo "##teamcity[flowFinished flowId='unit_tests']"
fi
}

View file

@ -48,13 +48,19 @@ function do_build() {
function do_test() {
builder_heading "Running web-utils test suite"
local FLAGS=
if builder_is_ci_build; then
local FLAGS=()
if builder_is_running_on_teamcity; then
echo "Replacing user-friendly test reports with CI-friendly versions."
FLAGS="$FLAGS --reporter mocha-teamcity-reporter"
FLAGS+=(--reporter "${KEYMAN_ROOT}/common/test/resources/mocha-teamcity-reporter/teamcity.cjs" --reporter-options parentFlowId="unit_tests")
echo "##teamcity[flowStarted flowId='unit_tests']"
fi
c8 mocha --recursive $FLAGS ./src/tests/
c8 mocha --recursive "${FLAGS[@]}" ./src/tests/
if builder_is_running_on_teamcity; then
# we're running in TeamCity
echo "##teamcity[flowFinished flowId='unit_tests']"
fi
}
builder_run_action configure node_select_version_and_npm_ci

View file

@ -27,7 +27,6 @@
"@keymanapp/keyman-version": "*",
"@keymanapp/resources-gosh": "*",
"c8": "^7.12.0",
"mocha-teamcity-reporter": "^4.0.0",
"typescript": "^5.4.5"
},
"type": "module",

View file

@ -3,7 +3,6 @@
"description": "The core gesture-recognition engine used by Keyman's Web-based OSKs.",
"devDependencies": {
"@keymanapp/resources-gosh": "*",
"mocha-teamcity-reporter": "^4.0.0",
"promise-status-async": "^1.2.10",
"typescript": "^5.4.5"
},

View file

@ -55,7 +55,6 @@
"@keymanapp/web-utils": "*",
"@types/mocha": "^7.0.2",
"c8": "^7.12.0",
"mocha-teamcity-reporter": "^4.0.0",
"typescript": "^5.4.5"
},
"dependencies": {

View file

@ -48,13 +48,19 @@ function do_build() {
}
function do_test() {
local FLAGS=
local FLAGS=()
if builder_is_ci_build; then
FLAGS="-reporter mocha-teamcity-reporter"
if builder_is_running_on_teamcity; then
FLAGS+=(--reporter "${KEYMAN_ROOT}/common/test/resources/mocha-teamcity-reporter/teamcity.cjs" --reporter-options parentFlowId="unit_tests")
echo "##teamcity[flowStarted flowId='unit_tests']"
fi
c8 mocha ${FLAGS} tests
c8 mocha "${FLAGS[@]}" tests
if builder_is_running_on_teamcity; then
# we're running in TeamCity
echo "##teamcity[flowFinished flowId='unit_tests']"
fi
}
builder_run_action configure do_configure

View file

@ -58,7 +58,6 @@
"@keymanapp/resources-gosh": "*",
"@types/mocha": "^7.0.2",
"c8": "^7.12.0",
"mocha-teamcity-reporter": "^4.0.0",
"typescript": "^5.4.5"
},
"type": "module"

View file

@ -44,7 +44,6 @@
"devDependencies": {
"@keymanapp/common-types": "*",
"@keymanapp/resources-gosh": "*",
"mocha-teamcity-reporter": "^4.0.0",
"typescript": "^5.4.5"
},
"dependencies": {

View file

@ -26,14 +26,19 @@ builder_describe "Runs all tests for the language-modeling / predictive-text lay
builder_parse "$@"
function do_test_headless() {
MOCHA_FLAGS=${FLAGS}
MOCHA_FLAGS=(${FLAGS})
if builder_is_running_on_teamcity; then
MOCHA_FLAGS="${MOCHA_FLAGS} --reporter mocha-teamcity-reporter"
MOCHA_FLAGS+=(--reporter "${KEYMAN_ROOT}/common/test/resources/mocha-teamcity-reporter/teamcity.cjs" --reporter-options parentFlowId="unit_tests")
echo "##teamcity[flowStarted flowId='unit_tests']"
fi
mocha --recursive ${MOCHA_FLAGS} ./headless/*.js ./headless/**/*.js
mocha --recursive "${MOCHA_FLAGS[@]}" ./headless/*.js ./headless/**/*.js
if builder_is_running_on_teamcity; then
# we're running in TeamCity
echo "##teamcity[flowFinished flowId='unit_tests']"
fi
}
function do_test_browser() {

View file

@ -33,7 +33,6 @@
"@keymanapp/resources-gosh": "*",
"c8": "^7.12.0",
"combine-source-map": "^0.8.0",
"mocha-teamcity-reporter": "^4.0.0",
"typescript": "^5.4.5"
},
"dependencies": {