mattermost-mobile-upstream/detox/e2e/global_setup.js
yasser khan ef1ecc1d9e
fix: stabilize server switch, saved posts, favorites, and related runtime races (#10122)
test(e2e): stabilize Detox and Maestro suites and harden CI

- Re-enable stabilized cases and document remaining iOS skips.
- Improve selectors, navigation recovery, state assertions, and retry limits.
- Harden API retries, fixture setup, and server configuration cleanup.
- Increase Detox shards to 24 and improve reporting, artifact uploads, and timeout handling.
- Add testIDs and unit-test fixes; revert unproven app behavior changes.
2026-09-09 00:35:15 +05:30

242 lines
8.5 KiB
JavaScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// Wraps Detox globalSetup: shuts down zombie iOS simulator clones from aborted runs.
const {execFileSync, execSync} = require('child_process');
const fs = require('fs');
const http = require('http');
const https = require('https');
const os = require('os');
const path = require('path');
const axios = require('axios');
const DEVICE_REGISTRY_PATH = path.join(
os.homedir(),
'Library', 'Detox', 'device.registry.json',
);
function isPidAlive(pid) {
try {
process.kill(pid, 0);
return true;
} catch {
return false; // ESRCH = no such process
}
}
function getZombieDeviceUdids() {
try {
const raw = fs.readFileSync(DEVICE_REGISTRY_PATH, 'utf8');
const entries = JSON.parse(raw);
return entries.
filter((entry) => entry.pid && !isPidAlive(entry.pid)).
map((entry) => entry.id);
} catch {
return []; // registry missing or malformed — nothing to clean
}
}
function shutdownZombieSimulators() {
if (process.platform !== 'darwin') {
return;
}
const zombieIds = getZombieDeviceUdids();
if (zombieIds.length === 0) {
return;
}
// Parse simulator list once.
let simsByUdid;
try {
const json = execSync('xcrun simctl list devices -j', {stdio: 'pipe'}).toString();
const {devices} = JSON.parse(json);
simsByUdid = {};
for (const sims of Object.values(devices)) {
for (const sim of sims) {
simsByUdid[sim.udid] = sim;
}
}
} catch {
return; // simctl unavailable — non-fatal
}
for (const udid of zombieIds) {
const sim = simsByUdid[udid];
if (!sim) {
continue; // not a simulator (could be an Android emulator ID)
}
if (sim.state !== 'Booted') {
continue; // already Shutdown — nothing to do
}
try {
execFileSync('xcrun', ['simctl', 'shutdown', udid], {stdio: 'pipe'});
process.stdout.write(`[globalSetup] Shut down zombie Detox simulator: ${sim.name} (${udid})\n`);
} catch {
process.stderr.write(`[globalSetup] Could not shut down ${udid} — continuing\n`);
}
}
}
// One-time server health check and CI config before tests load.
// Resolve the health-check URL the same way test_config does: an explicit SITE_1_URL
// wins (CI sets it per shard); otherwise fall back to the platform-specific URL so a
// local .env without SITE_1_URL still works on both iOS and Android runs.
const _isIos = process.env.IOS === 'true';
const _platformSiteOneUrl = _isIos ? process.env.IOS_SITE_1_URL : process.env.ANDROID_SITE_1_URL;
const SITE_URL = process.env.SITE_1_URL || _platformSiteOneUrl || 'http://localhost:8065';
const ADMIN_USERNAME = process.env.ADMIN_USERNAME || 'sysadmin';
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Sys@dmin-sample1';
// Prepackaged plugins to keep enabled.
const PREPACKAGED_PLUGINS = new Set([
'antivirus',
'mattermost-autolink',
'com.mattermost.aws-sns',
'com.mattermost.plugin-channel-export',
'com.mattermost.custom-attributes',
'github',
'com.github.manland.mattermost-plugin-gitlab',
'com.mattermost.plugin-incident-management',
'jenkins',
'jira',
'com.mattermost.calls',
'com.mattermost.nps',
'com.mattermost.welcomebot',
'zoom',
]);
async function retryAxios(fn, {retries = 4, delayMs = 3000, label = 'request'} = {}) {
let lastErr;
for (let attempt = 1; attempt <= retries; attempt++) {
try {
return await fn(); // eslint-disable-line no-await-in-loop
} catch (err) {
lastErr = err;
const status = err.response?.status;
const retriable = !status || status >= 500;
if (attempt === retries || !retriable) {
const data = err.response?.data;
let bodyLength = 0;
if (typeof data === 'string') {
bodyLength = data.length;
} else if (Buffer.isBuffer(data)) {
bodyLength = data.length;
} else if (data != null) {
try {
bodyLength = JSON.stringify(data).length;
} catch {
bodyLength = -1;
}
}
throw new Error(
`[globalSetup] ${label} failed against ${SITE_URL}` +
`${status ? ` with status ${status}` : ''}` +
`${err.code ? ` code ${err.code}` : ''}: ${err.message} (body length ${bodyLength})`,
);
}
const wait = delayMs * attempt;
process.stderr.write(
`[globalSetup] ⚠️ ${label} attempt ${attempt} failed (${err.message}), retrying in ${wait}ms…\n`,
);
await new Promise((r) => setTimeout(r, wait)); // eslint-disable-line no-await-in-loop
}
}
throw lastErr;
}
async function serverSetup() {
const nodeIndex = parseInt(process.env.CI_NODE_INDEX || '0', 10);
if (nodeIndex > 0) {
process.stdout.write(`[globalSetup] Shard ${nodeIndex}: staggering startup by ${nodeIndex * 1500}ms\n`);
await new Promise((r) => setTimeout(r, nodeIndex * 1500));
}
http.globalAgent.options.family = 4;
https.globalAgent.options.family = 4;
const ping = await retryAxios(
() => axios.get(`${SITE_URL}/api/v4/system/ping?get_server_status=true`),
{label: 'health check'},
);
if (ping.data.status !== 'OK') {
throw new Error(`Server health check failed: ${JSON.stringify(ping.data)}`);
}
process.stdout.write('[globalSetup] ✅ Server health check passed\n');
const clientConfig = await retryAxios(
() => axios.get(`${SITE_URL}/api/v4/config/client?format=old`),
{label: 'client config'},
);
const cfg = clientConfig.data || {};
process.stdout.write(
'[globalSetup] Server info:\n' +
` URL: ${SITE_URL}\n` +
` Version: ${cfg.Version}\n` +
` Build number: ${cfg.BuildNumber}\n` +
` Build date: ${cfg.BuildDate}\n` +
` Build hash: ${cfg.BuildHash}\n` +
` Enterprise: ${cfg.BuildEnterpriseReady}\n`,
);
const login = await retryAxios(
() => axios.post(`${SITE_URL}/api/v4/users/login`, {
login_id: ADMIN_USERNAME,
password: ADMIN_PASSWORD,
}),
{label: 'admin login'},
);
const token = login.headers.token;
if (!token) {
throw new Error('Admin login did not return a token');
}
const headers = {Authorization: `Bearer ${token}`};
process.stdout.write('[globalSetup] ✅ Admin login successful\n');
// Pre-warm the server so the first app request isn't also the server's cold start. This
// does not warm the simulator's TLS session, so -1005 drops are still possible.
try {
await axios.get(`${SITE_URL}/api/v4/system/ping`);
} catch (err) {
process.stderr.write(`[globalSetup] ⚠️ post-login ping failed (${err.message}) — continuing\n`);
}
try {
await axios.put(`${SITE_URL}/api/v4/config/patch`, {
ConnectedWorkspacesSettings: {EnableRemoteClusterService: true},
}, {headers});
process.stdout.write('[globalSetup] ✅ Mutable server config initialized for E2E tests\n');
} catch (err) {
process.stderr.write(`[globalSetup] ⚠️ Could not patch server config: ${err.message}\n`);
}
try {
const plugins = await axios.get(`${SITE_URL}/api/v4/plugins`, {headers});
const active = plugins.data?.active || [];
const toDisable = active.filter(
(plugin) => !PREPACKAGED_PLUGINS.has(plugin.id) && plugin.id !== 'com.mattermost.demo-plugin',
);
await Promise.all(toDisable.map((plugin) => {
process.stdout.write(`[globalSetup] Disabling plugin: ${plugin.id}\n`);
return axios.post(
`${SITE_URL}/api/v4/plugins/${encodeURIComponent(plugin.id)}/disable`,
null,
{headers},
);
}));
process.stdout.write('[globalSetup] ✅ Plugin cleanup done\n');
} catch (err) {
process.stderr.write(`[globalSetup] ⚠️ Could not clean up plugins: ${err.message}\n`);
}
}
module.exports = async () => {
shutdownZombieSimulators();
await serverSetup();
return require('detox/runners/jest/globalSetup')();
};