mattermost-mobile-upstream/detox/utils/merge-jest-results-for-tsio.js
yasser khan f3af9dbe99
Fix/detox hittest cluster (#10023)
fix(e2e): stabilize Detox and Maestro test suite

Restore main features, and harden the E2E harness against flaky UI, network, retry, synchronization, and classification-lock failures.

Stabilize channel creation, saved messages, bookmarks, search, deep links, keyboard dismissal, and post verification.
Bound network, transport, health-check, synchronization, and lock retries by elapsed time.
Fix iOS and Android Maestro/Detox races and flaky interaction flows.
Restore Session Attributes and deep-link fixes lost during merge.
Improve test isolation, global-setting cleanup, and Jest/TSIO reporting.
Enforce Maestro flow-header validation in local and CI checks.
2026-08-27 08:58:03 +05:30

275 lines
8.8 KiB
JavaScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/* eslint-disable no-console, no-process-env -- CI utility script */
/**
* Merge per-shard Jest `--json` reports into the shape TSIO's Detox ingest
* expects (`testFilePath` + nested `testResults`), not Jest CLI's
* (`name` + `assertionResults`).
*
* See mattermost-test-system-io extractDetox().
*/
const fs = require('fs');
const path = require('path');
const {parseArgs} = require('./cli-args');
/**
* Resolve candidate repo roots for stripping absolute CI paths.
* Prefer explicit override, then GITHUB_WORKSPACE, then cwd.
*
* @param {string} [explicitRoot]
* @returns {string[]}
*/
function resolveRepoRoots(explicitRoot) {
const roots = [];
if (explicitRoot) {
roots.push(explicitRoot);
}
if (process.env.GITHUB_WORKSPACE) {
roots.push(process.env.GITHUB_WORKSPACE);
}
roots.push(process.cwd());
return [...new Set(roots.map((r) => path.resolve(r)))];
}
/**
* Make a test file path repo-relative without hardcoding detox/e2e layout.
* Absolute CI paths are stripped against workspace roots; already-relative
* paths are returned unchanged (so `e2e/detox/...` and `detox/e2e/...` both work).
*
* @param {string} filePath
* @param {{repoRoot?: string}} [opts]
* @returns {string}
*/
function relativizeDetoxPath(filePath, opts = {}) {
if (!filePath) {
return filePath;
}
const normalized = filePath.replace(/\\/g, '/');
// Already repo-relative — keep as-is for stable TSIO identity across moves.
if (!path.isAbsolute(filePath) && !normalized.startsWith('/')) {
return normalized.replace(/^\.\//, '');
}
const abs = path.resolve(filePath);
for (const root of resolveRepoRoots(opts.repoRoot)) {
const rootNorm = root.replace(/\\/g, '/').replace(/\/$/, '');
const absNorm = abs.replace(/\\/g, '/');
if (absNorm === rootNorm) {
return '';
}
if (absNorm.startsWith(`${rootNorm}/`)) {
return absNorm.slice(rootNorm.length + 1);
}
}
// GitHub Actions / nested workspaces: .../work/<repo>/<repo>/<relative>
const workMatch = normalized.match(/\/work\/[^/]+\/[^/]+\/(.+)$/);
if (workMatch) {
return workMatch[1];
}
return normalized.replace(/^\.\//, '');
}
/**
* Map one suite from Jest CLI JSON (or already-TSIO-shaped) into TSIO Detox shape.
*
* @param {object} suite
* @param {{repoRoot?: string}} [opts]
* @returns {object|null}
*/
function toTsioDetoxSuite(suite, opts = {}) {
if (!suite || typeof suite !== 'object') {
return null;
}
const testFilePath = suite.testFilePath || suite.name || '';
const cases = Array.isArray(suite.testResults) && suite.testResults.length > 0 ?
suite.testResults :
(suite.assertionResults || []);
if (!testFilePath && cases.length === 0) {
return null;
}
const startTime = typeof suite.startTime === 'number' ? suite.startTime : undefined;
const out = {
testFilePath: relativizeDetoxPath(testFilePath, opts),
testResults: cases.map((c) => ({
ancestorTitles: c.ancestorTitles || [],
duration: c.duration ?? null,
failureMessages: c.failureMessages || [],
fullName: c.fullName || c.title || '',
status: c.status || 'failed',
title: c.title || c.fullName || 'unnamed',
})),
};
if (startTime != null) {
out.perfStats = {start: startTime};
} else if (suite.perfStats?.start) {
out.perfStats = {start: suite.perfStats.start};
}
return out;
}
/**
* @param {string[]} inputPaths
* @param {{repoRoot?: string}} [opts]
* @returns {{testResults: object[]}}
*/
function mergeJestResultsForTsio(inputPaths, opts = {}) {
const testResults = [];
for (const inputPath of inputPaths) {
const report = JSON.parse(fs.readFileSync(inputPath, 'utf8'));
const suites = report.testResults || [];
for (const suite of suites) {
const converted = toTsioDetoxSuite(suite, opts);
if (converted) {
testResults.push(converted);
}
}
}
return {testResults};
}
/**
* When CI shards fail before uploading jest-results.json, TSIO otherwise shows
* only the shards that reported — CMT looks like "Android didn't run tests".
* Append a failed stub so the missing machines are visible.
*
* @param {{testResults: object[]}} merged
* @param {number} foundCount
* @param {number} expectedCount
* @returns {{testResults: object[]}}
*/
function appendMissingShardStub(merged, foundCount, expectedCount) {
if (!(expectedCount > 0) || foundCount >= expectedCount) {
return merged;
}
const missing = expectedCount - foundCount;
const reason = `${missing} of ${expectedCount} Detox shards uploaded no jest-results.json (found ${foundCount}). Those machines failed or timed out before tests reported.`;
const now = Date.now();
merged.testResults.push({
testFilePath: 'ci/missing-shards.stub',
perfStats: {start: now},
testResults: [{
ancestorTitles: ['CI shard reporting'],
duration: 0,
failureMessages: [reason],
fullName: `CI shard reporting — ${missing} shard(s) missing jest-results.json`,
status: 'failed',
title: 'missing shard results',
}],
});
return merged;
}
/**
* Recursively collect jest-results.json under dir, skipping `excludePath`.
*
* @param {string} dir
* @param {string} [excludePath]
* @returns {string[]}
*/
function findJestResultFiles(dir, excludePath) {
const exclude = excludePath ? path.resolve(excludePath) : null;
const found = [];
/** @param {string} current */
function walk(current) {
let entries;
try {
entries = fs.readdirSync(current, {withFileTypes: true});
} catch {
return;
}
for (const entry of entries) {
const full = path.join(current, entry.name);
if (exclude && path.resolve(full) === exclude) {
continue;
}
if (entry.isDirectory()) {
walk(full);
} else if (entry.isFile() && entry.name === 'jest-results.json') {
found.push(full);
}
}
}
walk(dir);
return found.sort();
}
/**
* @param {string[]} inputPaths
* @param {string} outputPath
* @param {{repoRoot?: string}} [opts]
* @returns {{suites: number, tests: number}}
*/
function writeMergedJestResultsForTsio(inputPaths, outputPath, opts = {}) {
const merged = mergeJestResultsForTsio(inputPaths, opts);
fs.mkdirSync(path.dirname(outputPath), {recursive: true});
fs.writeFileSync(outputPath, JSON.stringify(merged));
const tests = merged.testResults.reduce((n, s) => n + (s.testResults?.length || 0), 0);
return {suites: merged.testResults.length, tests};
}
function main() {
const args = parseArgs(process.argv);
const output = args.output;
if (!output) {
console.error('merge-jest-results-for-tsio: --output is required');
process.exit(1);
}
let inputPaths = [];
if (args.dir) {
inputPaths = findJestResultFiles(args.dir, output);
}
const opts = {
repoRoot: args['repo-root'] || process.env.GITHUB_WORKSPACE || process.cwd(),
};
const expectedCount = Number.parseInt(args['expected-count'] || '0', 10);
// Exiting here when a shard count is known would leave TSIO with no report at all,
// so the commit status stays pending forever instead of failing. Fall through and let
// appendMissingShardStub below produce the failure rows.
if (inputPaths.length === 0 && !(expectedCount > 0)) {
console.error('merge-jest-results-for-tsio: no jest-results.json found and no expected shard count');
process.exit(1);
}
const merged = mergeJestResultsForTsio(inputPaths, opts);
if (expectedCount > 0 && inputPaths.length < expectedCount) {
console.error(`merge-jest-results-for-tsio: found ${inputPaths.length} jest-results.json, expected ${expectedCount}`);
appendMissingShardStub(merged, inputPaths.length, expectedCount);
}
fs.mkdirSync(path.dirname(output), {recursive: true});
fs.writeFileSync(output, JSON.stringify(merged));
const suites = merged.testResults.length;
const tests = merged.testResults.reduce((n, s) => n + (s.testResults?.length || 0), 0);
console.log(`Merged ${inputPaths.length} shard JSON(s) -> ${suites} suite(s), ${tests} test(s) -> ${output}`);
}
if (require.main === module) {
main();
}
module.exports = {
mergeJestResultsForTsio,
writeMergedJestResultsForTsio,
appendMissingShardStub,
toTsioDetoxSuite,
relativizeDetoxPath,
resolveRepoRoots,
findJestResultFiles,
};