// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. /* eslint-disable no-await-in-loop, no-console */ import {cleanupAdbReversePorts, resetAdbServer} from '@support/adb_utils'; import {ClaudePromptHandler} from '@support/pilot/ClaudePromptHandler'; import {Plugin, System, User} from '@support/server_api'; import {siteOneUrl} from '@support/test_config'; // Number of retry attempts const MAX_RETRY_ATTEMPTS = 3; // Delay between retries (in milliseconds) const RETRY_DELAY = 5000; let isFirstLaunch = true; /** * Verify Detox connection to app is healthy * @param maxAttempts - Maximum number of verification attempts * @param delayMs - Delay between attempts in milliseconds * @returns {Promise} */ async function verifyDetoxConnection(maxAttempts = 3, delayMs = 2000): Promise { for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { // Simple health check: verify device is responsive device.getPlatform(); console.info(`✅ Detox connection verified on attempt ${attempt}`); return; } catch (error) { console.warn(`❌ Detox connection check failed on attempt ${attempt}/${maxAttempts}: ${(error as Error).message}`); if (attempt < maxAttempts) { await new Promise((resolve) => setTimeout(resolve, delayMs * attempt)); // Exponential backoff } } } throw new Error('Detox connection verification failed after maximum attempts'); } /** * Wait for app to be ready (database initialized, bridge ready) * @param timeoutMs - Maximum time to wait in milliseconds * @returns {Promise} */ async function waitForAppReady(timeoutMs = 10000): Promise { const startTime = Date.now(); while (Date.now() - startTime < timeoutMs) { try { // Check if app is responsive by looking for a basic UI element // Try server screen first, then channel list screen try { await waitFor(element(by.id('server.screen'))).toBeVisible().withTimeout(2000); } catch { await waitFor(element(by.id('channel_list.screen'))).toBeVisible().withTimeout(2000); } console.info('✅ App is ready'); return; } catch { // App not ready yet, wait a bit await new Promise((resolve) => setTimeout(resolve, 500)); } } throw new Error(`App failed to become ready within ${timeoutMs}ms`); } /** * Launch the app with retry mechanism * @returns {Promise} */ export async function launchAppWithRetry(): Promise { let lastError; for (let attempt = 1; attempt <= MAX_RETRY_ATTEMPTS; attempt++) { try { if (isFirstLaunch) { // For first launch, clean install await device.launchApp({ newInstance: true, delete: true, permissions: { notifications: 'YES', camera: 'NO', medialibrary: 'NO', photos: 'NO', }, launchArgs: { detoxPrintBusyIdleResources: 'YES', detoxDebugVisibility: 'YES', detoxDisableSynchronization: 'YES', detoxDisableHierarchyDump: 'YES', reduceMotion: 'YES', }, }); isFirstLaunch = false; } else { // For subsequent launches, reuse instance await device.launchApp({ newInstance: false, launchArgs: { detoxPrintBusyIdleResources: 'YES', detoxDebugVisibility: 'YES', detoxDisableSynchronization: 'YES', detoxURLBlacklistRegex: '.*localhost.*', }, }); } console.info(`✅ App launched successfully on attempt ${attempt}`); return; // Success, exit the function } catch (error) { lastError = error; console.warn(`❌ App launch failed on attempt ${attempt}/${MAX_RETRY_ATTEMPTS}: ${(error as Error).message}`); // If this is the last attempt, don't wait if (attempt < MAX_RETRY_ATTEMPTS) { // Check if this is an ADB port binding issue (Android-specific, common in CI) const errorMessage = (error as Error).message; const isAdbPortError = errorMessage.includes('cannot bind listener') || errorMessage.includes('Address already in use') || errorMessage.includes('adb'); // Only attempt ADB cleanup if we detect an ADB-related error // These functions are safe no-ops on iOS if (isAdbPortError) { console.warn('Detected ADB-related error, attempting cleanup...'); await cleanupAdbReversePorts(); // On second retry with port issues, also reset ADB server if (attempt >= 2) { console.warn('Multiple failures detected, attempting ADB server reset...'); await resetAdbServer(); } } console.warn(`Waiting ${RETRY_DELAY}ms before retrying...`); await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY)); } } } // If we get here, all attempts failed throw new Error(`Failed to launch app after ${MAX_RETRY_ATTEMPTS} attempts. Last error: ${(lastError as Error).message}`); } /** * Initialize ClaudePromptHandler if ANTHROPIC_API_KEY is set * @returns {Promise} */ async function initializeClaudePromptHandler(): Promise { if (process.env.ANTHROPIC_API_KEY) { const promptHandler = new ClaudePromptHandler(process.env.ANTHROPIC_API_KEY); pilot.init(promptHandler); } else { console.info('To use ClaudePromptHandler, please set the ANTHROPIC_API_KEY environment variable.'); } } beforeAll(async () => { // Reset flag to ensure each test file starts with a clean app launch isFirstLaunch = true; await initializeClaudePromptHandler(); // Clean up any stale ADB reverse port mappings from previous runs // This is crucial in CI where crashed tests may leave stale connections await cleanupAdbReversePorts(); // Login as sysadmin and reset server configuration await System.apiCheckSystemHealth(siteOneUrl); await User.apiAdminLogin(siteOneUrl); await Plugin.apiDisableNonPrepackagedPlugins(siteOneUrl); await launchAppWithRetry(); // Verify Detox connection is healthy after app launch await verifyDetoxConnection(); // Wait for app to be fully ready (database initialized, bridge ready) await waitForAppReady(); }); // Add this to speed up test cleanup afterAll(async () => { try { // Dismiss keyboard if visible to prevent session invalidation issues try { await device.sendToHome(); await new Promise((resolve) => setTimeout(resolve, 500)); } catch (error) { console.warn('[Teardown] Could not send app to home:', error); } await device.terminateApp(); } catch (error) { console.error('[Teardown] Error terminating app:', error); } // Clean up ADB connections after tests complete try { await cleanupAdbReversePorts(); } catch (error) { console.error('[Teardown] Error cleaning up ADB ports:', error); } });