mattermost-mobile-upstream/detox/e2e/test/setup.ts
yasser khan e83c44959d
feat(e2e): Run e2e against different server version when rc branch is pushed (#9800)
* ci(e2e): scheduled CMT trigger, fix push double-fire, drop cleanup call

Aligns mobile E2E workflows with matterwick's webhook-driven model:

- cmt-provisioner.yml: replace the old stub (which relied on the dead
  workflow_run-inputs path) with a lightweight monthly scheduled trigger
  (no inputs). matterwick provisions servers for its hardcoded version set
  and dispatches compatibility-matrix-testing.yml.

- compatibility-matrix-testing.yml: remove the /cleanup_e2e curl step;
  matterwick destroys servers on the workflow_run completed event (SHA match).
2026-07-07 18:02:13 +05:30

343 lines
13 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';
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> {
const HEALTH_MAX_ATTEMPTS = 5;
for (let healthAttempt = 1; healthAttempt <= HEALTH_MAX_ATTEMPTS; healthAttempt++) {
try {
await System.apiCheckSystemHealth(siteOneUrl);
break;
} catch (error) {
if (healthAttempt === HEALTH_MAX_ATTEMPTS) {
throw error;
}
console.warn(`⚠️ System health check attempt ${healthAttempt} failed, retrying...`);
await new Promise((resolve) => setTimeout(resolve, 3000 * healthAttempt));
}
}
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}`);
await ensureServerConfigForE2E();
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));
}
}
// E2E feature flags — idempotent per call; module-level flags reset each test file (setupFilesAfterEnv).
async function ensureServerConfigForE2E(): Promise<void> {
try {
const {config, error} = await System.apiGetConfig(siteOneUrl);
if (!error && config?.FeatureFlags?.ChannelBookmarks === true) {
return; // Already set — skip the patch and the resulting server load.
}
await System.apiUpdateConfig(siteOneUrl, {
FeatureFlags: {ChannelBookmarks: true},
});
console.info('✅ E2E server config initialized (FeatureFlags.ChannelBookmarks=true)');
} catch (err) {
// Non-fatal: gated tests fail on their own if config patch did not apply.
console.warn(`⚠️ ensureServerConfigForE2E failed: ${(err as Error).message}`);
}
}
// 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 device.enableSynchronization();
}
}
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') {
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);