mirror of
https://github.com/mattermost/mattermost-mobile.git
synced 2026-11-13 06:30:29 +00:00
Apps form: Reads both manual_time_entry and the deprecated allow_manual_time_entry keys, preserving compatibility across supported server versions. Tests cover both keys, precedence, and the unset case. Channel Attributes E2E: Gates the suite on the presence of the server feature flag so it does not run against 11.x servers where the feature does not exist. E2E reliability: Fixes several main-branch failure modes, including cross-shard interference from server-global required channel attributes, Maestro account-screen timing, transient channel-creation failures, and bookmark setup/recovery after ambiguous network responses. Bookmark setup: Makes channel creation idempotent by name and verifies membership through a read-back rather than blindly retrying writes. Test coverage: Adds component-level coverage for the not_set channel-attribute state while documenting the intended re-homing of the skipped E2E case. Test conventions/documentation: Aligns new test names and comments with project conventions and clarifies server-version behavior.
276 lines
10 KiB
JavaScript
276 lines
10 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 {isRetriableCloudWarmupError} = require('../utils/retriable_cloud_error');
|
|
|
|
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',
|
|
]);
|
|
|
|
// Match Maestro seed.ts: Matterwick can 403 / serve the inactive portal for ~2min.
|
|
const WARMUP_RETRIES = 8;
|
|
const WARMUP_DELAY_MS = 15000;
|
|
|
|
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 body = err.response?.data;
|
|
const retriable = isRetriableCloudWarmupError({
|
|
status,
|
|
message: err.message,
|
|
body,
|
|
});
|
|
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})`,
|
|
);
|
|
}
|
|
process.stderr.write(
|
|
`[globalSetup] ⚠️ ${label} attempt ${attempt}/${retries} failed (${err.message}), retrying in ${delayMs}ms…\n`,
|
|
);
|
|
await new Promise((r) => setTimeout(r, delayMs)); // 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;
|
|
|
|
await retryAxios(
|
|
() => axios.get(`${SITE_URL}/api/v4/system/ping?get_server_status=true`).then((res) => {
|
|
if (res.data?.status !== 'OK') {
|
|
const snippet = typeof res.data === 'string'
|
|
? res.data.slice(0, 200)
|
|
: JSON.stringify(res.data);
|
|
const err = new Error(`Server health check failed: ${snippet}`);
|
|
err.response = res;
|
|
throw err;
|
|
}
|
|
return res;
|
|
}),
|
|
{retries: WARMUP_RETRIES, delayMs: WARMUP_DELAY_MS, label: 'health check'},
|
|
);
|
|
process.stdout.write('[globalSetup] ✅ Server health check passed\n');
|
|
|
|
const clientConfig = await retryAxios(
|
|
() => axios.get(`${SITE_URL}/api/v4/config/client?format=old`),
|
|
{retries: WARMUP_RETRIES, delayMs: WARMUP_DELAY_MS, 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`,
|
|
);
|
|
|
|
// Jest starts workers after globalSetup, so this env reaches every spec file, letting a
|
|
// suite gate itself synchronously at describe() time. A flag the server does not define is
|
|
// ABSENT from the client config rather than "false", so presence is the feature signal.
|
|
// Version is always present, so its absence means this is not a client config at all —
|
|
// treating that as "feature absent" would quarantine a suite silently.
|
|
if (!cfg.Version) {
|
|
throw new Error(
|
|
`[globalSetup] client config from ${SITE_URL} has no Version field, so feature detection is unreliable. ` +
|
|
`Received keys: ${Object.keys(cfg).slice(0, 10).join(', ') || '(none)'}`,
|
|
);
|
|
}
|
|
|
|
process.env.MM_SERVER_HAS_CHANNEL_ATTRIBUTES = cfg.FeatureFlagChannelAttributes === undefined ? 'false' : 'true';
|
|
if (process.env.MM_SERVER_HAS_CHANNEL_ATTRIBUTES === 'false') {
|
|
process.stdout.write(`[globalSetup] FeatureFlagChannelAttributes is absent on ${cfg.Version} — the Channel Attributes suite will be skipped\n`);
|
|
}
|
|
|
|
const login = await retryAxios(
|
|
() => axios.post(`${SITE_URL}/api/v4/users/login`, {
|
|
login_id: ADMIN_USERNAME,
|
|
password: ADMIN_PASSWORD,
|
|
}),
|
|
{retries: WARMUP_RETRIES, delayMs: WARMUP_DELAY_MS, 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')();
|
|
};
|