mirror of
https://github.com/mattermost/mattermost-mobile.git
synced 2026-11-05 18:38:54 +00:00
514 lines
16 KiB
JavaScript
514 lines
16 KiB
JavaScript
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
/* eslint-disable no-process-env -- CI utility script */
|
|
|
|
/**
|
|
* Post a Mobile E2E / CMT rollup to a Mattermost incoming webhook.
|
|
*
|
|
* Expected job names (gh_job_name / tsio-shard-name):
|
|
* PR/Main: detox-ios, detox-android, detox-ipad, maestro-ios-e2e, maestro-android-e2e
|
|
* CMT: detox-ios-Server_11.9.0, maestro-android-Server_10.5.14, ...
|
|
*/
|
|
|
|
const PLATFORM_ORDER = {ios: 0, android: 1, ipad: 2};
|
|
const FRAMEWORK_ORDER = {detox: 0, maestro: 1};
|
|
const FETCH_TIMEOUT_MS = 15_000;
|
|
|
|
/**
|
|
* Fetch with an abort timeout that stays armed until `readBody` finishes.
|
|
*
|
|
* @template T
|
|
* @param {string} url
|
|
* @param {RequestInit | undefined} options
|
|
* @param {(res: Response) => Promise<T>} readBody
|
|
* @param {number} [timeoutMs]
|
|
* @returns {Promise<T>}
|
|
*/
|
|
async function fetchWithTimeout(url, options, readBody, timeoutMs = FETCH_TIMEOUT_MS) {
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
try {
|
|
const res = await fetch(url, {...(options || {}), signal: controller.signal});
|
|
return await readBody(res);
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {string} jobName
|
|
* @returns {{platform: string, framework: string, serverVersion: string, kind: string} | null}
|
|
*/
|
|
function parseMobileJobName(jobName) {
|
|
if (!jobName || typeof jobName !== 'string') {
|
|
return null;
|
|
}
|
|
|
|
// CMT: detox-ios-Server_11.9.0 / maestro-android-Server_10.5.14-rc.1
|
|
const cmtMatch = jobName.match(
|
|
/^(detox|maestro)-(ios|android|ipad)-Server_(\d+\.\d+\.\d+(?:[-.][\w.]+)?)$/,
|
|
);
|
|
if (cmtMatch) {
|
|
return {
|
|
framework: cmtMatch[1],
|
|
platform: cmtMatch[2],
|
|
serverVersion: cmtMatch[3],
|
|
kind: 'cmt',
|
|
};
|
|
}
|
|
|
|
// PR/Main: detox-ios | maestro-ios-e2e | detox-ipad
|
|
const prMatch = jobName.match(/^(detox|maestro)-(ios|android|ipad)(?:-e2e)?$/);
|
|
if (prMatch) {
|
|
return {
|
|
framework: prMatch[1],
|
|
platform: prMatch[2],
|
|
serverVersion: 'default',
|
|
kind: 'e2e',
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Webhook routing (fail closed for named report groups; mirrors desktop):
|
|
* mobile-release → MATTERMOST_CMT_WEBHOOK_URL only (MM_E2E_RELEASE_WEBHOOK_URL)
|
|
* mobile-main → MATTERMOST_MASTER_HEALTH_WEBHOOK_URL only (MM_E2E_MASTER_HEALTH_WEBHOOK_URL)
|
|
* mobile-pr → MATTERMOST_E2E_WEBHOOK_URL only (MM_MOBILE_E2E_WEBHOOK_URL)
|
|
*
|
|
* Named groups never fall back to MATTERMOST_WEBHOOK_URL (avoids posting
|
|
* CMT/PR/main to the wrong channel when a dedicated secret is missing).
|
|
* Unknown names still use MATTERMOST_WEBHOOK_URL as a generic fallback.
|
|
*
|
|
* @param {string} reportName - compositeIdentity.name
|
|
* @param {NodeJS.ProcessEnv} [env]
|
|
* @returns {string}
|
|
*/
|
|
function resolveWebhookUrl(reportName, env = process.env) {
|
|
// Per-job groups are named mobile-pr-<shard>, mobile-main-<shard>, etc.
|
|
const {webhookBucketForReportName} = require('./build-tsio-job-config');
|
|
const bucket = webhookBucketForReportName(reportName);
|
|
if (bucket === 'mobile-release') {
|
|
return env.MATTERMOST_CMT_WEBHOOK_URL || '';
|
|
}
|
|
if (bucket === 'mobile-main') {
|
|
return env.MATTERMOST_MASTER_HEALTH_WEBHOOK_URL || '';
|
|
}
|
|
if (bucket === 'mobile-pr') {
|
|
return env.MATTERMOST_E2E_WEBHOOK_URL || '';
|
|
}
|
|
return env.MATTERMOST_WEBHOOK_URL || '';
|
|
}
|
|
|
|
/**
|
|
* @param {string} baseUrl
|
|
* @param {string} reportId
|
|
* @returns {string}
|
|
*/
|
|
function buildIndividualReportUrl(baseUrl, reportId) {
|
|
return `${baseUrl.replace(/\/$/, '')}/reports/r/${reportId}`;
|
|
}
|
|
|
|
/**
|
|
* @param {Record<string, {passed?: number, failed?: number, skipped?: number, flaky?: number}>} perJobCounts
|
|
* @param {Array<{id?: string, gh_job_name?: string, display_name?: string, status?: string}>} uploadedReports
|
|
* @param {string} [baseUrl]
|
|
* @returns {Array<object>}
|
|
*/
|
|
function buildLegSummaries(perJobCounts, uploadedReports, baseUrl) {
|
|
const jobNames = new Set([
|
|
...Object.keys(perJobCounts || {}),
|
|
...(uploadedReports || []).map((r) => r.gh_job_name || r.display_name).filter(Boolean),
|
|
]);
|
|
|
|
const rows = [];
|
|
for (const jobName of jobNames) {
|
|
const parsed = parseMobileJobName(jobName);
|
|
if (!parsed) {
|
|
continue;
|
|
}
|
|
|
|
const counts = perJobCounts[jobName] || {};
|
|
const passed = (counts.passed || 0) + (counts.flaky || 0);
|
|
const failed = counts.failed || 0;
|
|
const skipped = counts.skipped || 0;
|
|
const total = passed + failed + skipped;
|
|
const uploaded = (uploadedReports || []).find(
|
|
(r) => (r.gh_job_name || r.display_name) === jobName,
|
|
);
|
|
|
|
let status;
|
|
if (total === 0) {
|
|
const uploadedOk = uploaded?.status === 'complete' || uploaded?.status === 'completed';
|
|
status = uploadedOk ? 'no-results' : 'missing';
|
|
} else {
|
|
status = failed === 0 ? 'passed' : 'failed';
|
|
}
|
|
|
|
const label = parsed.kind === 'cmt'? `${parsed.serverVersion}-${parsed.platform}-${parsed.framework}`: `${parsed.platform}-${parsed.framework}`;
|
|
|
|
rows.push({
|
|
label,
|
|
status,
|
|
passed,
|
|
failed,
|
|
skipped,
|
|
total,
|
|
platform: parsed.platform,
|
|
framework: parsed.framework,
|
|
serverVersion: parsed.serverVersion,
|
|
kind: parsed.kind,
|
|
reportUrl: uploaded?.id && baseUrl ? buildIndividualReportUrl(baseUrl, uploaded.id) : undefined,
|
|
});
|
|
}
|
|
|
|
rows.sort((a, b) => {
|
|
// detox-* first, then maestro-*; within each: ios → android → ipad
|
|
const fwCmp = (FRAMEWORK_ORDER[a.framework] ?? 9) - (FRAMEWORK_ORDER[b.framework] ?? 9);
|
|
if (fwCmp !== 0) {
|
|
return fwCmp;
|
|
}
|
|
const platCmp = (PLATFORM_ORDER[a.platform] ?? 9) - (PLATFORM_ORDER[b.platform] ?? 9);
|
|
if (platCmp !== 0) {
|
|
return platCmp;
|
|
}
|
|
return a.serverVersion.localeCompare(b.serverVersion, undefined, {numeric: true});
|
|
});
|
|
|
|
return rows;
|
|
}
|
|
|
|
const PLATFORM_EMOJI = {
|
|
ios: '📱',
|
|
ipad: '📲',
|
|
android: '🤖',
|
|
unknown: '❔',
|
|
};
|
|
|
|
/**
|
|
* Human-readable shard label, e.g. detox-ios or detox-ios (11.9.0).
|
|
*
|
|
* @param {{platform: string, framework: string, kind?: string, serverVersion: string}} leg
|
|
* @returns {string}
|
|
*/
|
|
function formatLegName(leg) {
|
|
const emoji = PLATFORM_EMOJI[leg.platform] || PLATFORM_EMOJI.unknown;
|
|
const base = `${leg.framework}-${leg.platform}`;
|
|
if (leg.kind === 'cmt' && leg.serverVersion && leg.serverVersion !== 'default') {
|
|
return `${emoji} ${base} (${leg.serverVersion})`;
|
|
}
|
|
return `${emoji} ${base}`;
|
|
}
|
|
|
|
/**
|
|
* @param {{status: string, passed: number, failed: number, skipped?: number}} leg
|
|
* @returns {string}
|
|
*/
|
|
function formatLegResultText(leg) {
|
|
if (leg.status === 'missing' || leg.status === 'no-results') {
|
|
return `⚠️ ${leg.status}`;
|
|
}
|
|
|
|
if ((leg.passed || 0) === 0 && (leg.failed || 0) === 0 && (leg.skipped || 0) > 0) {
|
|
return '⚠️ not executed';
|
|
}
|
|
const executed = leg.passed + leg.failed;
|
|
if (leg.status === 'passed') {
|
|
return `✅ ${leg.passed}/${executed || leg.passed}`;
|
|
}
|
|
return `❌ ${leg.passed}/${executed}`;
|
|
}
|
|
|
|
function reportTitleForIdentity(compositeIdentity) {
|
|
const {webhookBucketForReportName} = require('./build-tsio-job-config');
|
|
const bucket = webhookBucketForReportName(
|
|
compositeIdentity?.run_group || compositeIdentity?.name || '',
|
|
);
|
|
switch (bucket) {
|
|
case 'mobile-release':
|
|
return 'Mobile CMT';
|
|
case 'mobile-pr':
|
|
return 'Mobile PR E2E';
|
|
case 'mobile-main':
|
|
return 'Mobile Main E2E';
|
|
default:
|
|
return 'Mobile E2E';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {Object} compositeIdentity
|
|
* @returns {string}
|
|
*/
|
|
function formatMetaLine(compositeIdentity) {
|
|
const branch = (compositeIdentity.branch || '').replace(/^refs\/(heads|tags)\//, '');
|
|
const shortSha = (compositeIdentity.commit_sha || '').slice(0, 7);
|
|
const parts = [];
|
|
|
|
if (compositeIdentity.gh_pr_number) {
|
|
const repo = compositeIdentity.repository || 'mattermost/mattermost-mobile';
|
|
parts.push(`**PR:** [#${compositeIdentity.gh_pr_number}](https://github.com/${repo}/pull/${compositeIdentity.gh_pr_number})`);
|
|
}
|
|
if (branch) {
|
|
parts.push(`**Branch:** \`${branch}\``);
|
|
}
|
|
if (shortSha) {
|
|
parts.push(`**Commit:** \`${shortSha}\``);
|
|
}
|
|
return parts.join(' · ');
|
|
}
|
|
|
|
/**
|
|
* @param {Object} params
|
|
* @returns {string}
|
|
*/
|
|
function formatCmtChannelMessage({
|
|
compositeIdentity,
|
|
detail,
|
|
reportUrl,
|
|
runUrl,
|
|
baseUrl,
|
|
perJobCounts,
|
|
upstreamJobsSucceeded = true,
|
|
hasFailures = false,
|
|
}) {
|
|
const stats = detail?.test_stats || {};
|
|
|
|
const passed = (stats.passed ?? 0) + (stats.flaky ?? 0);
|
|
const failed = stats.failed ?? 0;
|
|
const skipped = stats.skipped ?? 0;
|
|
const overallFailed = failed > 0 ||
|
|
detail?.status !== 'completed' ||
|
|
!upstreamJobsSucceeded ||
|
|
hasFailures;
|
|
const title = reportTitleForIdentity(compositeIdentity);
|
|
const legs = buildLegSummaries(perJobCounts, detail?.reports || [], baseUrl);
|
|
|
|
const lines = [
|
|
`## ${overallFailed ? '❌' : '✅'} ${title}`,
|
|
'',
|
|
];
|
|
|
|
const meta = formatMetaLine(compositeIdentity);
|
|
if (meta) {
|
|
lines.push(meta, '');
|
|
}
|
|
|
|
if (failed > 0) {
|
|
lines.push(`🔴 **${failed} failing test${failed === 1 ? '' : 's'}**`, '');
|
|
}
|
|
|
|
// Single table: overall totals + one row per shard (detox-ios, maestro-android, …).
|
|
lines.push(
|
|
'| Suite | Passed | Failed | Skipped | Result |',
|
|
'|-------|------:|------:|-------:|--------|',
|
|
`| **Overall** | **${passed}** | **${failed}** | **${skipped}** | ${overallFailed ? '❌ Failed' : '✅ Passed'} |`,
|
|
);
|
|
|
|
if (legs.length > 0) {
|
|
for (const leg of legs) {
|
|
const name = formatLegName(leg);
|
|
const nameCell = leg.reportUrl ? `[${name}](${leg.reportUrl})` : name;
|
|
lines.push(
|
|
`| ${nameCell} | ${leg.passed} | ${leg.failed} | ${leg.skipped} | ${formatLegResultText(leg)} |`,
|
|
);
|
|
}
|
|
}
|
|
lines.push('');
|
|
|
|
if (legs.length === 0) {
|
|
lines.push('_No per-leg results available yet._', '');
|
|
}
|
|
|
|
// A tracked test failure already fails the E2E job (the aggregator exits 1 by
|
|
// design), so upstreamJobsSucceeded=false only means something broke *outside*
|
|
// the tests when there are no failing tests to explain it.
|
|
if (!upstreamJobsSucceeded && failed === 0) {
|
|
lines.push('_One or more CI jobs failed outside tracked tests (install/build/teardown)._', '');
|
|
} else if (hasFailures && failed === 0) {
|
|
lines.push('_TSIO reported failed shard(s) not reflected in the test totals; check the full report._', '');
|
|
}
|
|
if (detail?.status && detail.status !== 'completed' && upstreamJobsSucceeded) {
|
|
lines.push(`_TSIO report status: \`${detail.status}\`._`, '');
|
|
}
|
|
|
|
if (reportUrl) {
|
|
lines.push(`➡️ **Consolidated report:** ${reportUrl}`);
|
|
}
|
|
if (runUrl && runUrl !== reportUrl) {
|
|
lines.push(`🔗 **CI run:** ${runUrl}`);
|
|
}
|
|
|
|
return lines.join('\n').trimEnd() + '\n';
|
|
}
|
|
|
|
/**
|
|
* @param {string} baseUrl
|
|
* @param {Object} compositeIdentity
|
|
* @param {Object} groupDetail
|
|
* @returns {Promise<Record<string, {passed: number, failed: number, skipped: number, flaky: number}>>}
|
|
*/
|
|
async function fetchPerJobCountsFromConsolidated(baseUrl, compositeIdentity, groupDetail) {
|
|
const idToJob = {};
|
|
for (const report of groupDetail.reports || []) {
|
|
const name = report.gh_job_name || report.display_name;
|
|
if (report.id && name) {
|
|
idToJob[report.id] = name;
|
|
}
|
|
}
|
|
|
|
const repoTrailing = (compositeIdentity.repository || '').split('/').pop() || compositeIdentity.repository;
|
|
const params = new URLSearchParams({
|
|
repository: repoTrailing,
|
|
branch: (compositeIdentity.branch || '').replace(/^refs\/(heads|tags)\//, ''),
|
|
commit: compositeIdentity.commit_sha,
|
|
name: compositeIdentity.run_group || compositeIdentity.name,
|
|
gh_run_id: String(compositeIdentity.gh_run_id),
|
|
});
|
|
if (compositeIdentity.gh_run_attempt) {
|
|
params.set('gh_run_attempt', String(compositeIdentity.gh_run_attempt));
|
|
}
|
|
|
|
const consol = await fetchWithTimeout(
|
|
`${baseUrl}/api/v1/reports/consolidated?${params}`,
|
|
undefined,
|
|
async (res) => {
|
|
if (!res.ok) {
|
|
throw new Error(`consolidated fetch failed: ${res.status} ${await res.text()}`);
|
|
}
|
|
return res.json();
|
|
},
|
|
);
|
|
|
|
const counts = {};
|
|
const commitSha = compositeIdentity.commit_sha;
|
|
const attempt = Number.parseInt(compositeIdentity.gh_run_attempt || '1', 10);
|
|
|
|
for (const spec of consol.specs || []) {
|
|
for (const entry of spec.history || []) {
|
|
if (entry.commit_sha !== commitSha) {
|
|
continue;
|
|
}
|
|
if (Number.parseInt(entry.run_attempt || '0', 10) !== attempt) {
|
|
continue;
|
|
}
|
|
const job = idToJob[entry.report_id];
|
|
if (!job) {
|
|
continue;
|
|
}
|
|
if (!counts[job]) {
|
|
counts[job] = {passed: 0, failed: 0, skipped: 0, flaky: 0};
|
|
}
|
|
const status = entry.status || 'failed';
|
|
if (Object.prototype.hasOwnProperty.call(counts[job], status)) {
|
|
counts[job][status] += 1;
|
|
} else {
|
|
counts[job].failed += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
return counts;
|
|
}
|
|
|
|
/**
|
|
* @param {Object} params
|
|
* @param {Object} params.core
|
|
* @param {string} params.webhookUrl
|
|
* @param {string} params.text
|
|
* @param {string} [params.username]
|
|
*/
|
|
async function postMattermostWebhook({core, webhookUrl, text, username = 'Mobile E2E'}) {
|
|
if (!webhookUrl) {
|
|
core.info('MATTERMOST webhook URL not set — skipping E2E channel notify');
|
|
return;
|
|
}
|
|
|
|
const body = {
|
|
username,
|
|
icon_url: 'https://mattermost.com/wp-content/uploads/2022/02/icon.png',
|
|
text,
|
|
};
|
|
|
|
await fetchWithTimeout(
|
|
webhookUrl,
|
|
{
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify(body),
|
|
},
|
|
async (res) => {
|
|
if (!res.ok) {
|
|
throw new Error(`Mattermost webhook failed: ${res.status} ${await res.text()}`);
|
|
}
|
|
await res.text();
|
|
},
|
|
);
|
|
core.info('Posted E2E summary to Mattermost channel');
|
|
}
|
|
|
|
/**
|
|
* Build + post the channel message. Never throws to the caller — notify is best-effort.
|
|
*
|
|
* `perJobCounts` lets a caller supply already-merged counts. Rollup callers must do
|
|
* this: their `compositeIdentity` is a routing bucket (`mobile-main`) with no TSIO
|
|
* group of its own, so a bucket-scoped consolidated fetch returns nothing.
|
|
*
|
|
* @param {Object} params
|
|
*/
|
|
async function notifyCmtChannel({
|
|
core,
|
|
baseUrl,
|
|
compositeIdentity,
|
|
detail,
|
|
reportUrl,
|
|
runUrl,
|
|
perJobCounts,
|
|
upstreamJobsSucceeded = true,
|
|
hasFailures = false,
|
|
webhookUrl,
|
|
}) {
|
|
try {
|
|
const resolvedWebhook = webhookUrl || resolveWebhookUrl(compositeIdentity?.name);
|
|
let counts = perJobCounts;
|
|
if (!counts) {
|
|
counts = {};
|
|
try {
|
|
counts = await fetchPerJobCountsFromConsolidated(baseUrl, compositeIdentity, detail);
|
|
} catch (error) {
|
|
core.warning(`Could not load per-leg TSIO counts: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
const text = formatCmtChannelMessage({
|
|
compositeIdentity,
|
|
detail,
|
|
reportUrl,
|
|
runUrl,
|
|
baseUrl,
|
|
perJobCounts: counts,
|
|
upstreamJobsSucceeded,
|
|
hasFailures,
|
|
});
|
|
|
|
await postMattermostWebhook({core, webhookUrl: resolvedWebhook, text});
|
|
} catch (error) {
|
|
core.warning(`E2E Mattermost notify failed: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
parseMobileJobName,
|
|
parseCmtJobName: parseMobileJobName,
|
|
resolveWebhookUrl,
|
|
buildIndividualReportUrl,
|
|
buildLegSummaries,
|
|
formatLegResultText,
|
|
reportTitleForIdentity,
|
|
formatCmtChannelMessage,
|
|
fetchPerJobCountsFromConsolidated,
|
|
postMattermostWebhook,
|
|
notifyCmtChannel,
|
|
};
|