Skip to main content
Glama
test-app.test.ts41.9 kB
/** * Test App E2E Tests * * Comprehensive tests that build, install, and test against the * Specter Test Subject KMM app (test-apps/specter-test-subject/). * * These tests validate the full MCP tool workflow: * 1. Build the app (build_app) * 2. Install on device (install_app) * 3. Launch the app (launch_app) * 4. Interact with UI (get_ui_context, interact_with_ui, deep_link_navigate) * 5. Run Maestro E2E flows (run_maestro_flow) * * NOTE: Android and iOS tests run in PARALLEL for faster execution. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { existsSync } from 'fs'; import { resolve } from 'path'; import { getToolRegistry, registerAllTools } from '../../src/tools/register.js'; import { resetConfig, setConfig } from '../../src/config.js'; import { ensureDevicesAvailable, type DeviceSetupResult, } from './setup.js'; /** * Helper to run Android and iOS tests in parallel * Returns results for both platforms */ async function runParallel<T>( deviceSetup: DeviceSetupResult, androidTest: () => Promise<T>, iosTest: () => Promise<T> ): Promise<{ android?: T; ios?: T; errors: string[] }> { const errors: string[] = []; const results: { android?: T; ios?: T } = {}; const promises: Promise<void>[] = []; if (deviceSetup.androidAvailable) { promises.push( androidTest() .then(r => { results.android = r; }) .catch(e => { errors.push(`Android: ${e.message}`); }) ); } if (deviceSetup.iosAvailable) { promises.push( iosTest() .then(r => { results.ios = r; }) .catch(e => { errors.push(`iOS: ${e.message}`); }) ); } await Promise.all(promises); return { ...results, errors }; } /** * Helper to run Android and iOS tests sequentially * Used for Maestro tests which can't run in parallel (resource conflicts) */ async function runSequential<T>( deviceSetup: DeviceSetupResult, androidTest: () => Promise<T>, iosTest: () => Promise<T> ): Promise<{ android?: T; ios?: T; errors: string[] }> { const errors: string[] = []; const results: { android?: T; ios?: T } = {}; // Run Android first if (deviceSetup.androidAvailable) { try { results.android = await androidTest(); } catch (e: unknown) { errors.push(`Android: ${(e as Error).message}`); } } // Then run iOS if (deviceSetup.iosAvailable) { try { results.ios = await iosTest(); } catch (e: unknown) { errors.push(`iOS: ${(e as Error).message}`); } } return { ...results, errors }; } // Test app configuration const TEST_APP = { projectPath: resolve(process.cwd(), 'test-apps/specter-test-subject'), android: { appId: 'com.specter.testsubject', module: 'androidApp', apkPath: 'androidApp/build/outputs/apk/debug/androidApp-debug.apk', }, ios: { bundleId: 'com.specter.counter', scheme: 'SpecterCounter', appPath: 'build/DerivedData/Build/Products/Debug-iphonesimulator/SpecterCounter.app', }, deepLinks: { home: 'specter://app', counter: 'specter://counter', form: 'specter://form', debug: 'specter://debug', }, maestro: { counterFlow: 'maestro/counter_flow.yaml', formFlow: 'maestro/form_flow.yaml', fullFlow: 'maestro/full_flow.yaml', }, }; describe('Test App E2E Suite', () => { let deviceSetup: DeviceSetupResult; let androidBuildArtifact: string | null = null; let iosBuildArtifact: string | null = null; beforeAll(async () => { resetConfig(); setConfig({ debug: false, logLevel: 'error' }); await registerAllTools(); // Auto-launch emulators/simulators if not running deviceSetup = await ensureDevicesAvailable(); }, 180000); // 3 minute timeout for device launch afterAll(() => { resetConfig(); getToolRegistry().clear(); }); describe('Build Phase', () => { it('should build Android and iOS apps in parallel', async () => { const registry = getToolRegistry(); const buildTool = registry.getTool('build_app'); expect(buildTool).toBeDefined(); const originalCwd = process.cwd(); process.chdir(TEST_APP.projectPath); try { const results = await runParallel( deviceSetup, // Android build async () => { const result = await buildTool!.handler({ platform: 'android', variant: 'debug', clean: false, androidModule: TEST_APP.android.module, }) as { success: boolean; artifactPath?: string; error?: string }; expect(result.success).toBe(true); androidBuildArtifact = resolve(TEST_APP.projectPath, TEST_APP.android.apkPath); expect(existsSync(androidBuildArtifact)).toBe(true); console.log(`✓ Android build successful: ${androidBuildArtifact}`); return result; }, // iOS build async () => { const result = await buildTool!.handler({ platform: 'ios', variant: 'debug', clean: false, iosScheme: TEST_APP.ios.scheme, iosDestination: `platform=iOS Simulator,id=${deviceSetup.iosDeviceId}`, }) as { success: boolean; artifactPath?: string; error?: string }; expect(result.success).toBe(true); iosBuildArtifact = resolve(TEST_APP.projectPath, TEST_APP.ios.appPath); console.log(`✓ iOS build successful: ${iosBuildArtifact}`); return result; } ); if (results.errors.length > 0) { console.log('Build errors:', results.errors); } expect(results.errors.length).toBe(0); } finally { process.chdir(originalCwd); } }, 600000); // 10 minute timeout for parallel builds }); describe('Install Phase', () => { it('should install apps on both platforms in parallel', async () => { const registry = getToolRegistry(); const installTool = registry.getTool('install_app'); expect(installTool).toBeDefined(); const results = await runParallel( deviceSetup, // Android install async () => { const apkPath = androidBuildArtifact || resolve(TEST_APP.projectPath, TEST_APP.android.apkPath); expect(existsSync(apkPath), `APK not found at ${apkPath}`).toBe(true); const result = await installTool!.handler({ platform: 'android', appPath: apkPath, deviceId: deviceSetup.androidDeviceId, }) as { success: boolean; error?: string }; expect(result.success).toBe(true); console.log('✓ Android app installed successfully'); return result; }, // iOS install async () => { const appPath = iosBuildArtifact || resolve(TEST_APP.projectPath, TEST_APP.ios.appPath); expect(existsSync(appPath), `iOS app not found at ${appPath}`).toBe(true); const result = await installTool!.handler({ platform: 'ios', appPath: appPath, deviceId: deviceSetup.iosDeviceId, }) as { success: boolean; error?: string }; expect(result.success).toBe(true); console.log('✓ iOS app installed successfully'); return result; } ); if (results.errors.length > 0) { console.log('Install errors:', results.errors); } expect(results.errors.length).toBe(0); }, 120000); }); describe('Launch Phase', () => { it('should launch apps on both platforms in parallel', async () => { const registry = getToolRegistry(); const launchTool = registry.getTool('launch_app'); expect(launchTool).toBeDefined(); const results = await runParallel( deviceSetup, // Android launch async () => { const result = await launchTool!.handler({ platform: 'android', appId: TEST_APP.android.appId, deviceId: deviceSetup.androidDeviceId, clearData: true, }) as { success: boolean; error?: string }; expect(result.success).toBe(true); console.log('✓ Android app launched successfully'); await new Promise(resolve => setTimeout(resolve, 3000)); return result; }, // iOS launch async () => { const result = await launchTool!.handler({ platform: 'ios', appId: TEST_APP.ios.bundleId, deviceId: deviceSetup.iosDeviceId, }) as { success: boolean; error?: string }; expect(result.success).toBe(true); console.log('✓ iOS app launched successfully'); await new Promise(resolve => setTimeout(resolve, 3000)); return result; } ); if (results.errors.length > 0) { console.log('Launch errors:', results.errors); } expect(results.errors.length).toBe(0); }, 30000); }); describe('UI Context Phase (requires app running)', () => { it('should capture screenshots on both platforms in parallel', async () => { const registry = getToolRegistry(); const uiTool = registry.getTool('get_ui_context'); expect(uiTool).toBeDefined(); const results = await runParallel( deviceSetup, // Android screenshot async () => { const result = await uiTool!.handler({ platform: 'android', deviceId: deviceSetup.androidDeviceId, skipScreenshot: false, includeAllElements: true, }) as { screenshot?: { data: string }; elements?: unknown[]; error?: string }; expect(result.screenshot).toBeDefined(); expect(result.screenshot!.data.length).toBeGreaterThan(0); console.log(`✓ Android screenshot: ${result.screenshot!.data.length} bytes, ${result.elements?.length || 0} elements`); return result; }, // iOS screenshot async () => { const result = await uiTool!.handler({ platform: 'ios', deviceId: deviceSetup.iosDeviceId, skipScreenshot: false, includeAllElements: true, }) as { screenshot?: { data: string }; elements?: unknown[]; error?: string }; expect(result.screenshot).toBeDefined(); console.log(`✓ iOS screenshot: ${result.screenshot!.data.length} bytes, ${result.elements?.length || 0} elements`); return result; } ); if (results.errors.length > 0) { console.log('Screenshot errors:', results.errors); } // At least one platform should succeed expect(results.android || results.ios).toBeDefined(); }, 30000); }); describe('Deep Link Navigation (requires app installed)', () => { it('should navigate via deep links on both platforms in parallel', async () => { const registry = getToolRegistry(); const deepLinkTool = registry.getTool('deep_link_navigate'); expect(deepLinkTool).toBeDefined(); const results = await runParallel( deviceSetup, // Android deep links async () => { // Navigate to counter const counterResult = await deepLinkTool!.handler({ platform: 'android', deviceId: deviceSetup.androidDeviceId, uri: TEST_APP.deepLinks.counter, }) as { success: boolean; error?: string }; expect(counterResult.success).toBe(true); console.log('✓ Android: Deep link to counter'); await new Promise(resolve => setTimeout(resolve, 500)); // Navigate to form const formResult = await deepLinkTool!.handler({ platform: 'android', deviceId: deviceSetup.androidDeviceId, uri: TEST_APP.deepLinks.form, }) as { success: boolean; error?: string }; expect(formResult.success).toBe(true); console.log('✓ Android: Deep link to form'); return { counter: counterResult, form: formResult }; }, // iOS deep links async () => { const result = await deepLinkTool!.handler({ platform: 'ios', deviceId: deviceSetup.iosDeviceId, uri: TEST_APP.deepLinks.counter, }) as { success: boolean; error?: string }; expect(result.success).toBe(true); console.log('✓ iOS: Deep link to counter'); return result; } ); if (results.errors.length > 0) { console.log('Deep link errors:', results.errors); } expect(results.errors.length).toBe(0); }, 15000); }); describe('UI Interaction (requires app running)', () => { it('should perform UI interactions on Android (tap, input, swipe)', async () => { expect(deviceSetup.androidAvailable, 'Test requires Android device but none available').toBe(true); const registry = getToolRegistry(); const interactTool = registry.getTool('interact_with_ui'); // Tap in center of screen const tapResult = await interactTool!.handler({ platform: 'android', deviceId: deviceSetup.androidDeviceId, action: 'tap', x: 540, y: 960, }) as { success: boolean; error?: string }; expect(tapResult.success).toBe(true); console.log('✓ Android: Tap executed'); // Input text const inputResult = await interactTool!.handler({ platform: 'android', deviceId: deviceSetup.androidDeviceId, action: 'input_text', text: 'test@example.com', }) as { success: boolean; error?: string }; expect(inputResult.success).toBe(true); console.log('✓ Android: Text input executed'); // Swipe const swipeResult = await interactTool!.handler({ platform: 'android', deviceId: deviceSetup.androidDeviceId, action: 'swipe', x: 540, y: 1200, direction: 'up', durationMs: 300, }) as { success: boolean; error?: string }; expect(swipeResult.success).toBe(true); console.log('✓ Android: Swipe executed'); }, 30000); }); describe('Log Inspection (requires app running)', () => { it('should capture logs on both platforms in parallel', async () => { const registry = getToolRegistry(); const logTool = registry.getTool('inspect_logs'); expect(logTool).toBeDefined(); const results = await runParallel( deviceSetup, // Android logs async () => { const result = await logTool!.handler({ platform: 'android', deviceId: deviceSetup.androidDeviceId, appId: TEST_APP.android.appId, timeoutMs: 3000, maxEntries: 100, }) as { success: boolean; entries?: unknown[]; error?: string }; expect(result.success).toBe(true); console.log(`✓ Android: ${(result.entries as unknown[])?.length || 0} log entries`); return result; }, // iOS logs async () => { const result = await logTool!.handler({ platform: 'ios', deviceId: deviceSetup.iosDeviceId, appId: TEST_APP.ios.bundleId, timeoutMs: 3000, }) as { success: boolean; entries?: unknown[]; error?: string }; expect(result.success).toBe(true); console.log(`✓ iOS: ${(result.entries as unknown[])?.length || 0} log entries`); return result; } ); if (results.errors.length > 0) { console.log('Log capture errors:', results.errors); } expect(results.errors.length).toBe(0); }, 15000); }); }); describe('Maestro E2E Flows', () => { let deviceSetup: DeviceSetupResult; beforeAll(async () => { resetConfig(); setConfig({ debug: false, logLevel: 'error' }); await registerAllTools(); deviceSetup = await ensureDevicesAvailable(); }, 180000); afterAll(() => { resetConfig(); getToolRegistry().clear(); }); // NOTE: Maestro tests run sequentially because Maestro can't control // multiple devices in parallel reliably (causes resource conflicts/timeouts) it('should run counter flow on both platforms sequentially', async () => { const flowPath = resolve(TEST_APP.projectPath, TEST_APP.maestro.counterFlow); expect(existsSync(flowPath), `Maestro flow file not found at ${flowPath}`).toBe(true); const registry = getToolRegistry(); const maestroTool = registry.getTool('run_maestro_flow'); expect(maestroTool).toBeDefined(); const results = await runSequential( deviceSetup, // Android counter flow async () => { const result = await maestroTool!.handler({ flowPath, platform: 'android', deviceId: deviceSetup.androidDeviceId, appId: TEST_APP.android.appId, timeoutMs: 120000, generateFailureBundle: true, }) as { flowResult: { success: boolean; passedSteps: number; totalSteps: number; error?: string }; summary: string }; console.log('✓ Android counter flow:', result.summary); expect(result.flowResult.success, `Android counter flow failed: ${result.flowResult.error}`).toBe(true); return result; }, // iOS counter flow async () => { const result = await maestroTool!.handler({ flowPath, platform: 'ios', deviceId: deviceSetup.iosDeviceId, appId: TEST_APP.ios.bundleId, timeoutMs: 120000, generateFailureBundle: true, }) as { flowResult: { success: boolean; error?: string }; summary: string }; console.log('✓ iOS counter flow:', result.summary); expect(result.flowResult.success, `iOS counter flow failed: ${result.flowResult.error}`).toBe(true); return result; } ); if (results.errors.length > 0) { console.log('Maestro counter flow errors:', results.errors); } expect(results.errors.length).toBe(0); }, 180000); it('should run form flow on both platforms sequentially', async () => { const flowPath = resolve(TEST_APP.projectPath, TEST_APP.maestro.formFlow); expect(existsSync(flowPath), `Maestro flow file not found at ${flowPath}`).toBe(true); const registry = getToolRegistry(); const maestroTool = registry.getTool('run_maestro_flow'); const results = await runSequential( deviceSetup, // Android form flow async () => { const result = await maestroTool!.handler({ flowPath, platform: 'android', deviceId: deviceSetup.androidDeviceId, appId: TEST_APP.android.appId, timeoutMs: 120000, generateFailureBundle: true, }) as { flowResult: { success: boolean; error?: string }; summary: string }; console.log('✓ Android form flow:', result.summary); expect(result.flowResult.success, `Android form flow failed: ${result.flowResult.error}`).toBe(true); return result; }, // iOS form flow async () => { const result = await maestroTool!.handler({ flowPath, platform: 'ios', deviceId: deviceSetup.iosDeviceId, appId: TEST_APP.ios.bundleId, timeoutMs: 120000, generateFailureBundle: true, }) as { flowResult: { success: boolean; error?: string }; summary: string }; console.log('✓ iOS form flow:', result.summary); expect(result.flowResult.success, `iOS form flow failed: ${result.flowResult.error}`).toBe(true); return result; } ); if (results.errors.length > 0) { console.log('Maestro form flow errors:', results.errors); } expect(results.errors.length).toBe(0); }, 180000); it('should run full E2E flow on both platforms sequentially', async () => { const flowPath = resolve(TEST_APP.projectPath, TEST_APP.maestro.fullFlow); expect(existsSync(flowPath), `Maestro flow file not found at ${flowPath}`).toBe(true); const registry = getToolRegistry(); const maestroTool = registry.getTool('run_maestro_flow'); const results = await runSequential( deviceSetup, // Android full flow async () => { const result = await maestroTool!.handler({ flowPath, platform: 'android', deviceId: deviceSetup.androidDeviceId, appId: TEST_APP.android.appId, timeoutMs: 180000, generateFailureBundle: true, }) as { flowResult: { success: boolean; durationMs: number; error?: string }; summary: string }; console.log('✓ Android full flow:', result.summary); console.log(` Duration: ${(result.flowResult.durationMs / 1000).toFixed(2)}s`); expect(result.flowResult.success, `Android full flow failed: ${result.flowResult.error}`).toBe(true); return result; }, // iOS full flow async () => { const result = await maestroTool!.handler({ flowPath, platform: 'ios', deviceId: deviceSetup.iosDeviceId, appId: TEST_APP.ios.bundleId, timeoutMs: 180000, generateFailureBundle: true, }) as { flowResult: { success: boolean; durationMs: number; error?: string }; summary: string }; console.log('✓ iOS full flow:', result.summary); console.log(` Duration: ${(result.flowResult.durationMs / 1000).toFixed(2)}s`); expect(result.flowResult.success, `iOS full flow failed: ${result.flowResult.error}`).toBe(true); return result; } ); if (results.errors.length > 0) { console.log('Maestro full flow errors:', results.errors); } expect(results.errors.length).toBe(0); }, 240000); }); describe('Integration Tests - MCP Tools with Test App', () => { let deviceSetup: DeviceSetupResult; beforeAll(async () => { resetConfig(); setConfig({ debug: false, logLevel: 'error' }); await registerAllTools(); deviceSetup = await ensureDevicesAvailable(); }, 180000); afterAll(() => { resetConfig(); getToolRegistry().clear(); }); describe('Full Workflow: Build -> Install -> Launch -> Interact', () => { it('should complete full Android workflow', async () => { expect(deviceSetup.androidAvailable, 'Test requires Android device but none available').toBe(true); const registry = getToolRegistry(); const apkPath = resolve(TEST_APP.projectPath, TEST_APP.android.apkPath); expect(existsSync(apkPath), `APK not found at ${apkPath} - run build test first`).toBe(true); // 1. Install const installTool = registry.getTool('install_app'); const installResult = await installTool!.handler({ platform: 'android', appPath: apkPath, deviceId: deviceSetup.androidDeviceId, }) as { success: boolean }; expect(installResult.success, 'Install failed').toBe(true); // 2. Launch const launchTool = registry.getTool('launch_app'); const launchResult = await launchTool!.handler({ platform: 'android', appId: TEST_APP.android.appId, deviceId: deviceSetup.androidDeviceId, clearData: true, }) as { success: boolean }; expect(launchResult.success).toBe(true); await new Promise(resolve => setTimeout(resolve, 3000)); // 3. Capture UI (returns UIContext with screenshot property) const uiTool = registry.getTool('get_ui_context'); const uiResult = await uiTool!.handler({ platform: 'android', deviceId: deviceSetup.androidDeviceId, skipScreenshot: false, }) as { success?: boolean; screenshot?: { data: string; format: string }; elements: unknown[] }; // UIContext should succeed expect(uiResult.elements).toBeDefined(); if (uiResult.screenshot && uiResult.screenshot.data.length > 0) { console.log(`Screenshot captured: ${uiResult.screenshot.data.length} bytes`); } else { console.log('Screenshot capture returned empty data'); } // 4. Navigate via deep link const deepLinkTool = registry.getTool('deep_link_navigate'); const deepLinkResult = await deepLinkTool!.handler({ platform: 'android', deviceId: deviceSetup.androidDeviceId, uri: TEST_APP.deepLinks.form, }) as { success: boolean }; expect(deepLinkResult.success).toBe(true); await new Promise(resolve => setTimeout(resolve, 1000)); // 5. Capture logs const logTool = registry.getTool('inspect_logs'); const logResult = await logTool!.handler({ platform: 'android', deviceId: deviceSetup.androidDeviceId, packageName: TEST_APP.android.appId, timeoutMs: 2000, }) as { success: boolean }; expect(logResult.success).toBe(true); console.log('Full Android workflow completed successfully!'); }, 120000); }); describe('App State Inspection', () => { it('should inspect Android app state', async () => { expect(deviceSetup.androidAvailable, 'Test requires Android device but none available').toBe(true); const registry = getToolRegistry(); const stateTool = registry.getTool('inspect_app_state'); expect(stateTool, 'inspect_app_state tool not available').toBeDefined(); const result = await stateTool.handler({ platform: 'android', deviceId: deviceSetup.androidDeviceId, appId: TEST_APP.android.appId, }) as { success: boolean; state?: unknown }; if (result.success) { console.log('App state inspection completed'); } else { console.log('App state inspection not available for this app'); } }, 30000); }); describe('Debug Screen - Crash Analysis Testing', () => { it('should navigate to Debug screen via deep link on Android', async () => { expect(deviceSetup.androidAvailable, 'Test requires Android device but none available').toBe(true); const registry = getToolRegistry(); // Navigate to Debug screen via deep link const deepLinkTool = registry.getTool('deep_link_navigate'); const result = await deepLinkTool!.handler({ platform: 'android', deviceId: deviceSetup.androidDeviceId, uri: TEST_APP.deepLinks.debug, }) as { success: boolean }; expect(result.success).toBe(true); await new Promise(resolve => setTimeout(resolve, 1000)); console.log('Navigated to Debug screen via deep link'); }, 30000); it('should trigger caught exception and detect it in logs on Android', async () => { expect(deviceSetup.androidAvailable, 'Test requires Android device but none available').toBe(true); // Check if APK exists (app must be built) const apkPath = resolve(TEST_APP.projectPath, TEST_APP.android.apkPath); expect(existsSync(apkPath), `APK not found at ${apkPath} - run build phase first`).toBe(true); const registry = getToolRegistry(); // 1. Navigate to Debug screen const deepLinkTool = registry.getTool('deep_link_navigate'); const navResult = await deepLinkTool!.handler({ platform: 'android', deviceId: deviceSetup.androidDeviceId, uri: TEST_APP.deepLinks.debug, }) as { success: boolean }; expect(navResult.success).toBe(true); await new Promise(resolve => setTimeout(resolve, 2000)); // 2. Tap the caught exception button const interactTool = registry.getTool('interact_with_ui'); const tapResult = await interactTool!.handler({ platform: 'android', deviceId: deviceSetup.androidDeviceId, action: 'tap', element: 'btn_caught_exception', }) as { success: boolean; error?: string }; if (!tapResult.success) { console.log('Tap failed:', tapResult.error); } expect(tapResult.success, `Failed to tap btn_caught_exception: ${tapResult.error}`).toBe(true); await new Promise(resolve => setTimeout(resolve, 1000)); console.log('Triggered caught exception via Debug screen'); // 3. Analyze crash logs to detect the exception const crashTool = registry.getTool('analyze_crash'); const result = await crashTool!.handler({ platform: 'android', appId: TEST_APP.android.appId, deviceId: deviceSetup.androidDeviceId, timeRangeSeconds: 60, }) as { success: boolean; platform: string; deviceLogs?: { totalEntries: number; errorCount: number; crashIndicators: unknown[]; keyErrors: string[]; } }; expect(result).toHaveProperty('success'); expect(result).toHaveProperty('platform', 'android'); expect(result).toHaveProperty('deviceLogs'); if (result.deviceLogs) { console.log(`Android crash analysis: ${result.deviceLogs.totalEntries} entries, ${result.deviceLogs.errorCount} errors`); // Check for our test exception in key errors const hasTestException = result.deviceLogs.keyErrors.some( (err: string) => err.includes('SpecterTestSubject') || err.includes('Caught exception') ); if (hasTestException) { console.log('Successfully detected test exception in logs!'); } } }, 60000); it('should trigger error log and detect it via inspect_logs on Android', async () => { expect(deviceSetup.androidAvailable, 'Test requires Android device but none available').toBe(true); // Check if APK exists (app must be built) const apkPath = resolve(TEST_APP.projectPath, TEST_APP.android.apkPath); expect(existsSync(apkPath), `APK not found at ${apkPath} - run build phase first`).toBe(true); const registry = getToolRegistry(); // 1. Navigate to Debug screen const deepLinkTool = registry.getTool('deep_link_navigate'); const navResult = await deepLinkTool!.handler({ platform: 'android', deviceId: deviceSetup.androidDeviceId, uri: TEST_APP.deepLinks.debug, }) as { success: boolean }; expect(navResult.success).toBe(true); await new Promise(resolve => setTimeout(resolve, 2000)); // 2. Tap the error log button const interactTool = registry.getTool('interact_with_ui'); const tapResult = await interactTool!.handler({ platform: 'android', deviceId: deviceSetup.androidDeviceId, action: 'tap', element: 'btn_log_error', }) as { success: boolean; error?: string }; if (!tapResult.success) { console.log('Tap failed:', tapResult.error); } expect(tapResult.success, `Failed to tap btn_log_error: ${tapResult.error}`).toBe(true); await new Promise(resolve => setTimeout(resolve, 500)); console.log('Triggered error log via Debug screen'); // 3. Inspect logs to verify const logTool = registry.getTool('inspect_logs'); const logResult = await logTool!.handler({ platform: 'android', deviceId: deviceSetup.androidDeviceId, appId: TEST_APP.android.appId, minLevel: 'error', maxEntries: 100, }) as { success: boolean; entries?: Array<{ message?: string }> }; expect(logResult.success).toBe(true); console.log(`Found ${logResult.entries?.length || 0} error log entries`); }, 60000); it('should analyze iOS device logs for crash indicators', async () => { expect(deviceSetup.iosAvailable, 'Test requires iOS simulator but none available').toBe(true); const registry = getToolRegistry(); // Use analyze_crash on iOS to check device logs const crashTool = registry.getTool('analyze_crash'); const result = await crashTool!.handler({ platform: 'ios', appId: TEST_APP.ios.bundleId, deviceId: deviceSetup.iosDeviceId, timeRangeSeconds: 60, }) as { success: boolean; platform: string; deviceLogs?: { totalEntries: number; errorCount: number; crashIndicators: unknown[]; } }; expect(result).toHaveProperty('success'); expect(result).toHaveProperty('platform', 'ios'); expect(result).toHaveProperty('deviceLogs'); if (result.deviceLogs) { console.log(`iOS crash analysis: ${result.deviceLogs.totalEntries} entries, ${result.deviceLogs.errorCount} errors, ${result.deviceLogs.crashIndicators.length} crash indicators`); } }, 30000); it('should return structured crash analysis result with all expected fields', async () => { expect(deviceSetup.androidAvailable, 'Test requires Android device but none available').toBe(true); const registry = getToolRegistry(); const crashTool = registry.getTool('analyze_crash'); const result = await crashTool!.handler({ platform: 'android', appId: TEST_APP.android.appId, deviceId: deviceSetup.androidDeviceId, timeRangeSeconds: 60, }) as Record<string, unknown>; // Verify all expected fields in ExtendedCrashAnalysis expect(result).toHaveProperty('success'); expect(result).toHaveProperty('platform'); expect(result).toHaveProperty('report'); expect(result).toHaveProperty('summary'); expect(result).toHaveProperty('patterns'); expect(result).toHaveProperty('suggestions'); expect(result).toHaveProperty('durationMs'); expect(result).toHaveProperty('description'); expect(result).toHaveProperty('suspects'); expect(result).toHaveProperty('reproducible'); expect(result).toHaveProperty('category'); expect(result).toHaveProperty('dsymStatus'); expect(result).toHaveProperty('deviceLogs'); console.log('Crash analysis returned all expected fields'); }, 30000); it('should trigger real crash and detect it in logs on Android', async () => { expect(deviceSetup.androidAvailable, 'Test requires Android device but none available').toBe(true); const registry = getToolRegistry(); // 1. Navigate to Debug screen const deepLinkTool = registry.getTool('deep_link_navigate'); await deepLinkTool!.handler({ platform: 'android', uri: 'specter://debug', packageName: TEST_APP.android.appId, deviceId: deviceSetup.androidDeviceId, waitAfterMs: 1000, }); // 2. Trigger NullPointerException crash via UI const interactTool = registry.getTool('interact_with_ui'); try { await interactTool!.handler({ platform: 'android', action: 'tap', element: 'Trigger NullPointerException', deviceId: deviceSetup.androidDeviceId, }); } catch { // Expected - app will crash } // 3. Wait for crash to complete await new Promise(resolve => setTimeout(resolve, 3000)); // 4. Relaunch app const launchTool = registry.getTool('launch_app'); await launchTool!.handler({ platform: 'android', appId: TEST_APP.android.appId, deviceId: deviceSetup.androidDeviceId, }); // 5. Analyze crash logs const crashTool = registry.getTool('analyze_crash'); const result = await crashTool!.handler({ platform: 'android', appId: TEST_APP.android.appId, deviceId: deviceSetup.androidDeviceId, timeRangeSeconds: 120, }) as { success: boolean; deviceLogs?: { totalEntries: number; errorCount: number; crashIndicators: unknown[]; keyErrors: string[]; } }; expect(result.success).toBe(true); expect(result.deviceLogs).toBeDefined(); // Check crashIndicators (the proper crash detection mechanism) const hasCrashIndicators = (result.deviceLogs?.crashIndicators?.length ?? 0) > 0; // Also check keyErrors for crash-related strings as backup const hasNullPointerInErrors = result.deviceLogs?.keyErrors.some( (err: string) => err.includes('NullPointer') || err.includes('FATAL') || err.includes('AndroidRuntime') ); const crashDetected = hasCrashIndicators || hasNullPointerInErrors; console.log(`Crash analysis after real crash: ${result.deviceLogs?.errorCount} errors, ${result.deviceLogs?.crashIndicators?.length ?? 0} crash indicators, detected: ${crashDetected}`); // Log first few key errors for debugging if (result.deviceLogs?.keyErrors && result.deviceLogs.keyErrors.length > 0) { console.log('Sample key errors:', result.deviceLogs.keyErrors.slice(0, 3)); } if (result.deviceLogs?.crashIndicators && result.deviceLogs.crashIndicators.length > 0) { console.log('Crash indicators:', result.deviceLogs.crashIndicators.slice(0, 3)); } // At minimum we should see error logs expect(result.deviceLogs!.errorCount).toBeGreaterThan(0); }, 90000); it('should detect random crash type on Android', async () => { expect(deviceSetup.androidAvailable, 'Test requires Android device but none available').toBe(true); const registry = getToolRegistry(); // List of crash types to randomly choose from const crashTypes = [ { element: 'Trigger NullPointerException', name: 'NullPointerException' }, { element: 'Trigger IllegalStateException', name: 'IllegalStateException' }, { element: 'Trigger OutOfMemoryError', name: 'OutOfMemoryError' }, ]; // Pick a random crash type const randomCrash = crashTypes[Math.floor(Math.random() * crashTypes.length)]; console.log(`Testing random crash type: ${randomCrash.name}`); // 1. Navigate to Debug screen const deepLinkTool = registry.getTool('deep_link_navigate'); await deepLinkTool!.handler({ platform: 'android', uri: 'specter://debug', packageName: TEST_APP.android.appId, deviceId: deviceSetup.androidDeviceId, waitAfterMs: 1000, }); // 2. Trigger random crash via UI const interactTool = registry.getTool('interact_with_ui'); try { await interactTool!.handler({ platform: 'android', action: 'tap', element: randomCrash.element, deviceId: deviceSetup.androidDeviceId, }); } catch { // Expected - app will crash } // 3. Wait for crash to complete await new Promise(resolve => setTimeout(resolve, 3000)); // 4. Relaunch app const launchTool = registry.getTool('launch_app'); await launchTool!.handler({ platform: 'android', appId: TEST_APP.android.appId, deviceId: deviceSetup.androidDeviceId, }); // 5. Analyze crash logs const crashTool = registry.getTool('analyze_crash'); const result = await crashTool!.handler({ platform: 'android', appId: TEST_APP.android.appId, deviceId: deviceSetup.androidDeviceId, timeRangeSeconds: 120, }) as { success: boolean; category: string; deviceLogs?: { totalEntries: number; errorCount: number; crashIndicators: Array<{ type: string; message: string; severity: string }>; keyErrors: string[]; } }; expect(result.success).toBe(true); expect(result.deviceLogs).toBeDefined(); // Should detect crash indicators const crashIndicators = result.deviceLogs?.crashIndicators ?? []; const hasCrashIndicators = crashIndicators.length > 0; // Check if the specific crash type was detected const crashTypeDetected = crashIndicators.some( (indicator) => indicator.message.includes(randomCrash.name) || indicator.message.includes('FATAL') || indicator.message.includes('AndroidRuntime') ); console.log(`Random crash (${randomCrash.name}): ${crashIndicators.length} indicators, detected: ${crashTypeDetected}`); if (crashIndicators.length > 0) { console.log('First crash indicator:', crashIndicators[0]); } // Verify crash was detected expect(hasCrashIndicators).toBe(true); expect(result.deviceLogs!.errorCount).toBeGreaterThan(0); }, 90000); }); describe('Error Handling', () => { it('should handle non-existent app gracefully', async () => { expect(deviceSetup.androidAvailable, 'Test requires Android device but none available').toBe(true); const registry = getToolRegistry(); const launchTool = registry.getTool('launch_app'); try { await launchTool!.handler({ platform: 'android', appId: 'com.nonexistent.app.that.does.not.exist', deviceId: deviceSetup.androidDeviceId, }); // Should not reach here expect(true).toBe(false); } catch (error) { // Expected - app doesn't exist expect(error).toBeDefined(); console.log('Correctly handled non-existent app error'); } }, 15000); it('should handle invalid deep link gracefully', async () => { expect(deviceSetup.androidAvailable, 'Test requires Android device but none available').toBe(true); const registry = getToolRegistry(); const deepLinkTool = registry.getTool('deep_link_navigate'); const result = await deepLinkTool!.handler({ platform: 'android', deviceId: deviceSetup.androidDeviceId, uri: 'invalid-uri-without-scheme', }) as { success: boolean; error?: string }; expect(result.success).toBe(false); expect(result.error).toBeDefined(); console.log('Correctly handled invalid deep link'); }, 15000); }); });

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/abd3lraouf/specter-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server