mattermost-mobile-upstream/detox/e2e/test/setup.ts
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

368 lines
14 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/* eslint-disable no-await-in-loop, no-console */
import {execSync} from 'child_process';
import {existsSync} from 'fs';
import {ClaudePromptHandler} from '@support/pilot/ClaudePromptHandler';
import {System, User} from '@support/server_api';
import {siteOneUrl} from '@support/test_config';
import {safeEnableSynchronization} from '@support/utils';
import {logError, logWarn} from '../../provision/log';
const BUNDLE_ID = 'com.mattermost.rnbeta';
function getSimulatorId(): string {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const udid = (device as any)._deviceId || (device as any).id || process.env.SIMULATOR_ID || '';
return typeof udid === 'string' ? udid : '';
} catch {
return process.env.SIMULATOR_ID || '';
}
}
let cachedDataContainer: string | undefined;
let cachedAppGroupContainer: string | undefined;
function resolveContainerPath(simId: string, kind: string): string | undefined {
try {
const path = execSync(
`xcrun simctl get_app_container "${simId}" ${BUNDLE_ID} ${kind} 2>/dev/null`,
{encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe']},
).trim();
return path || undefined;
} catch {
return undefined;
}
}
function clearIOSAppData(): void {
const simId = getSimulatorId();
if (!simId) {
console.warn('[clearIOSAppData] No simulator ID — skipping data wipe');
return;
}
try {
const appPid = execSync(
`xcrun simctl spawn "${simId}" launchctl list 2>/dev/null | grep "${BUNDLE_ID}" | awk '{print $1}' | grep -E '^[0-9]+$' || true`,
{encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe']},
).trim();
if (appPid) {
execSync(`xcrun simctl spawn "${simId}" kill -9 "${appPid}"`, {stdio: 'pipe'});
execSync('sleep 1', {stdio: 'pipe'});
}
} catch {
// App might not be running — that's fine
}
// 2. Find the app's data container and delete its contents.
if (!cachedDataContainer) {
cachedDataContainer = resolveContainerPath(simId, 'data');
}
if (cachedDataContainer) {
try {
// Remove all contents of Documents, Library, tmp (but keep the container dir)
execSync(`find "${cachedDataContainer}" -mindepth 1 -maxdepth 1 -exec rm -rf {} +`, {stdio: 'pipe'});
console.info(`[clearIOSAppData] Cleared data container: ${cachedDataContainer}`);
} catch {
console.warn('[clearIOSAppData] data-container wipe failed');
}
} else {
console.warn('[clearIOSAppData] Could not resolve data container (app may not be installed yet)');
}
if (!cachedAppGroupContainer) {
cachedAppGroupContainer = resolveContainerPath(simId, 'group.com.mattermost.rnbeta');
}
if (cachedAppGroupContainer) {
if (existsSync(cachedAppGroupContainer)) {
try {
execSync(`find "${cachedAppGroupContainer}" -mindepth 1 -maxdepth 1 -exec rm -rf {} +`, {stdio: 'pipe'});
console.info(`[clearIOSAppData] Cleared App Group container: ${cachedAppGroupContainer}`);
} catch {
console.warn('[clearIOSAppData] App-Group-container wipe failed');
}
}
} else {
console.warn('[clearIOSAppData] Could not resolve App Group container');
}
// 3. Clear the keychain (removes stored auth tokens, certificates)
try {
execSync(`xcrun simctl keychain "${simId}" reset`, {stdio: 'pipe'});
} catch {
// Older simctl versions may not support keychain reset — non-fatal
}
}
// ─── Admin API login ─────────────────────────────────────────────────────────
async function loginAdmin(): Promise<void> {
// 8 attempts with 3s*n backoff is 84s of sleeping on its own, and each attempt can
// additionally spend the API client's whole retry budget. That overruns the 360s
// beforeAll before the attempts are even used up, which reports as a bare Jest hook
// timeout instead of "the server is not healthy". Bound the total instead.
const HEALTH_MAX_ATTEMPTS = 8;
const HEALTH_DEADLINE_MS = 150_000;
const healthDeadlineAt = Date.now() + HEALTH_DEADLINE_MS;
for (let healthAttempt = 1; healthAttempt <= HEALTH_MAX_ATTEMPTS; healthAttempt++) {
try {
await System.apiCheckSystemHealth(siteOneUrl);
break;
} catch (error) {
const backoffMs = 3000 * healthAttempt;
if (healthAttempt === HEALTH_MAX_ATTEMPTS || Date.now() + backoffMs >= healthDeadlineAt) {
throw error;
}
console.warn(`⚠️ System health check attempt ${healthAttempt} failed, retrying...`);
await new Promise((resolve) => setTimeout(resolve, backoffMs));
}
}
const MAX_ATTEMPTS = 3;
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
const {error: loginError} = await User.apiAdminLogin(siteOneUrl);
if (loginError) {
if (attempt === MAX_ATTEMPTS) {
throw new Error(`Admin login failed after ${MAX_ATTEMPTS} attempts: ${JSON.stringify(loginError)}`);
}
console.warn(`⚠️ Admin login attempt ${attempt} failed, retrying...`);
await new Promise((resolve) => setTimeout(resolve, 2000 * attempt));
continue;
}
const {error: meError} = await User.apiGetMe(siteOneUrl);
if (!meError) {
console.info(`✅ Admin session verified on attempt ${attempt}`);
return;
}
if (attempt === MAX_ATTEMPTS) {
throw new Error(`Admin session not usable after ${MAX_ATTEMPTS} login attempts`);
}
console.warn(`⚠️ Session check failed on attempt ${attempt}, retrying...`);
await new Promise((resolve) => setTimeout(resolve, 2000 * attempt));
}
}
function recoverAndroidDevice(): void {
if (device.getPlatform() !== 'android') {
return;
}
const commands = [
'adb shell input keyevent KEYCODE_WAKEUP',
'adb shell wm dismiss-keyguard',
'adb shell am broadcast -a android.intent.action.CLOSE_SYSTEM_DIALOGS',
'adb shell svc power stayon true',
];
for (const command of commands) {
try {
execSync(command, {stdio: 'pipe', timeout: 5_000});
} catch {
// Best effort — an unavailable command must not mask the launch error.
}
}
// Record what actually holds focus, so a repeat failure is diagnosable from the
// job log instead of only from the Espresso view dump.
try {
const focus = execSync(
"adb shell dumpsys window | grep -E 'mCurrentFocus|mFocusedApp'",
{encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 5_000},
);
logWarn(`[recoverAndroidDevice] window focus: ${focus.replace(/\s+/g, ' ').trim().slice(0, 300)}`);
} catch {
logError('[recoverAndroidDevice] could not read window focus from dumpsys');
}
}
// Android 13+ (API 33+): the `permissions` key in device.launchApp() only works
// on iOS simulators. On Android, notification permission must be granted via adb.
async function grantAndroidNotificationPermission(): Promise<void> {
if (device.getPlatform() !== 'android') {
return;
}
try {
execSync(`adb shell pm grant ${BUNDLE_ID} android.permission.POST_NOTIFICATIONS`, {stdio: 'pipe'});
} catch {
// API < 33 or already granted
}
}
// ─── Global beforeAll ────────────────────────────────────────────────────────
// Runs before each test file.
// Responsibilities: launch app with clean state, admin login, plugin cleanup.
beforeAll(async () => {
if (device.getPlatform() === 'android') {
try {
execSync(`adb shell am force-stop ${BUNDLE_ID}`, {stdio: 'pipe'});
} catch { /* app may not be running */ }
try {
execSync(`adb shell pm clear ${BUNDLE_ID}`, {stdio: 'pipe'});
} catch {
// Package might not be installed yet on first run
}
try {
execSync('adb shell settings put secure show_ime_with_hard_keyboard 0', {stdio: 'pipe'});
execSync('adb shell settings put secure spell_checker_enabled 0', {stdio: 'pipe'});
execSync('adb shell settings put secure auto_text_enabled 0', {stdio: 'pipe'});
} catch {
// Older AVDs may not support these — non-fatal.
}
}
const isFirstFile = !process.env.DETOX_SETUP_DONE;
const launchArgs = {detoxDisableSynchronization: 'YES'};
const APP_READY_TIMEOUT = device.getPlatform() === 'android' ? 90_000 : 30_000;
async function forceAndroidDataClear(): Promise<void> {
if (device.getPlatform() !== 'android') {
return;
}
try {
// Stop the app process first so pm clear can safely wipe its data dir.
execSync(`adb shell am force-stop ${BUNDLE_ID}`, {stdio: 'pipe'});
} catch { /* app may not be running */ }
try {
execSync(`adb shell pm clear ${BUNDLE_ID}`, {stdio: 'pipe'});
console.info('[forceAndroidDataClear] pm clear succeeded');
} catch (e) {
console.warn('[forceAndroidDataClear] pm clear failed:', String(e).slice(0, 200));
}
}
async function ensureAndroidMetroReverse(): Promise<void> {
if (device.getPlatform() !== 'android') {
return;
}
try {
execSync('adb reverse tcp:8081 tcp:8081', {stdio: 'pipe'});
const reverseList = execSync('adb reverse --list', {encoding: 'utf8'});
if (!reverseList.includes('tcp:8081')) {
console.warn('[ensureAndroidMetroReverse] tcp:8081 reverse missing after setup');
}
} catch (e) {
console.warn('[ensureAndroidMetroReverse] failed:', String(e).slice(0, 200));
}
}
async function launchAndVerify(): Promise<void> {
await grantAndroidNotificationPermission();
await ensureAndroidMetroReverse();
await device.launchApp({
newInstance: true,
...(device.getPlatform() === 'ios' ? {permissions: {notifications: 'YES'}} : {}),
launchArgs,
});
await device.disableSynchronization();
const serverScreenEl = element(by.id('server.screen'));
try {
await waitFor(serverScreenEl).toExist().withTimeout(APP_READY_TIMEOUT);
} catch {
if (device.getPlatform() === 'android') {
const channelListEl = element(by.id('channel_list.screen'));
try {
await waitFor(channelListEl).toExist().withTimeout(5_000);
console.warn(
'[launchAndVerify] App launched in logged-in state (channel_list visible). ' +
'pm clear did not take effect. Retrying with force-stop + pm clear.',
);
await forceAndroidDataClear();
await grantAndroidNotificationPermission();
await ensureAndroidMetroReverse();
await device.launchApp({newInstance: true, launchArgs});
await waitFor(serverScreenEl).toExist().withTimeout(APP_READY_TIMEOUT);
} catch {
throw new Error(
`[launchAndVerify] Neither server.screen nor channel_list.screen appeared within ${APP_READY_TIMEOUT / 1000}s`,
);
}
} else {
const channelListEl = element(by.id('channel_list.screen'));
try {
await waitFor(channelListEl).toExist().withTimeout(5_000);
console.warn(
'[launchAndVerify] iOS app launched with stale state (channel_list visible). ' +
'clearIOSAppData wipe incomplete. Re-clearing and relaunching.',
);
clearIOSAppData();
await device.launchApp({
newInstance: true,
...(device.getPlatform() === 'ios' ? {permissions: {notifications: 'YES'}} : {}),
launchArgs,
});
await waitFor(serverScreenEl).toExist().withTimeout(APP_READY_TIMEOUT);
} catch {
throw new Error(
`[launchAndVerify] server.screen did not appear within ${APP_READY_TIMEOUT / 1000}s`,
);
}
}
} finally {
// Always re-enable synchronization so subsequent test operations
// (tap, typeText, expect) re-enter the normal synchronized path.
await safeEnableSynchronization();
}
}
if (isFirstFile) {
process.env.DETOX_SETUP_DONE = 'true';
}
if (device.getPlatform() === 'ios') {
clearIOSAppData();
}
const MAX_LAUNCH_ATTEMPTS = 3;
for (let attempt = 1; attempt <= MAX_LAUNCH_ATTEMPTS; attempt++) {
try {
await launchAndVerify();
break;
} catch (launchError) {
console.warn(
`⚠️ Launch attempt ${attempt}/${MAX_LAUNCH_ATTEMPTS} failed:`,
String(launchError).slice(0, 300),
);
if (attempt === MAX_LAUNCH_ATTEMPTS) {
throw launchError;
}
if (device.getPlatform() === 'ios') {
clearIOSAppData();
} else if (device.getPlatform() === 'android') {
recoverAndroidDevice();
await forceAndroidDataClear();
await ensureAndroidMetroReverse();
}
await new Promise((resolve) => setTimeout(resolve, 3000));
}
}
console.info('✅ App launched');
// Initialize Claude AI prompt handler if available
try {
if (process.env.ANTHROPIC_API_KEY) {
pilot.init(new ClaudePromptHandler(process.env.ANTHROPIC_API_KEY));
}
} catch (e) {
console.warn('Claude init failed:', e);
}
await loginAdmin();
}, 360_000);