UNPKG

arela

Version:

AI-powered CTO with multi-agent orchestration, code summarization, visual testing (web + mobile) for blazing fast development.

504 lines 17.2 kB
import path from "node:path"; import fs from "fs-extra"; import pc from "picocolors"; import { remote } from "webdriverio"; import { chromium } from "playwright"; import { loadFlow } from "./flows.js"; import { reportResults, reportError, reportStart, } from "./reporter.js"; let currentContext = null; const MAX_RETRIES = 3; /** * Check if Appium server is available */ async function isAppiumAvailable() { try { const response = await fetch("http://localhost:4723/status"); return response.ok; } catch { return false; } } /** * Main entry point for running mobile apps */ export async function runMobileApp(opts) { const { platform, device, flow: flowName, app, webFallback } = opts; const cwd = process.cwd(); const screenshotsDir = path.join(cwd, ".arela", "screenshots", "mobile"); await fs.ensureDir(screenshotsDir); const flow = await loadFlow(flowName, cwd); // Check if we should use web fallback const appiumAvailable = await isAppiumAvailable(); const shouldUseWebFallback = webFallback || !appiumAvailable; if (shouldUseWebFallback) { if (!appiumAvailable && !webFallback) { console.log(pc.yellow("\n⚠️ Appium server not available, falling back to web mode")); console.log(pc.cyan("💡 Tip: Start Appium with 'npx appium' for native mobile testing\n")); } else if (webFallback) { console.log(pc.cyan("\n📱 Using web fallback mode (mobile viewport)\n")); } return await runMobileWebFallback(opts, flow, cwd, screenshotsDir); } reportStart(`${platform} ${device || "default"}`, flow.name); let driver; try { // Resolve app path const appPath = app || (await findExpoApp(platform)); if (!appPath) { console.log(pc.yellow("\n⚠️ No native app found, falling back to web mode")); console.log(pc.cyan("💡 Testing with mobile viewport in browser\n")); return await runMobileWebFallback(opts, flow, cwd, screenshotsDir); } // Launch driver with appropriate capabilities driver = await launchDriver(platform, appPath, device); currentContext = { platform, flowName: flow.name, screenshotsDir, screenshots: [], device, }; const results = await executeFlow(driver, flow, platform); reportResults(results); return results; } catch (error) { // Try web fallback if Appium fails const errorMessage = error.message; if (errorMessage.includes("ECONNREFUSED") || errorMessage.includes("Unable to connect")) { console.log(pc.yellow("\n⚠️ Failed to connect to Appium, falling back to web mode")); console.log(pc.cyan("💡 Make sure Appium is running: npx appium\n")); return await runMobileWebFallback(opts, flow, cwd, screenshotsDir); } reportError(error); throw error; } finally { currentContext = null; if (driver) { await driver.deleteSession(); } } } /** * Launch WebdriverIO driver with Appium capabilities */ async function launchDriver(platform, appPath, device) { const capabilities = platform === "ios" ? { platformName: "iOS", "appium:deviceName": device || "iPhone 15 Simulator", "appium:app": appPath, "appium:automationName": "XCUITest", "appium:bundleId": extractBundleId(appPath), } : { platformName: "Android", "appium:deviceName": device || "emulator-5554", "appium:app": appPath, "appium:automationName": "UiAutomator2", "appium:appPackage": extractPackageId(appPath), }; const driver = await remote({ hostname: "localhost", port: 4723, path: "/", logLevel: "warn", capabilities, }); return driver; } /** * Execute flow on mobile driver */ async function executeFlow(driver, flow, platform) { if (!currentContext) { throw new Error("Runner context not initialized"); } const steps = []; const issues = []; const flowStart = Date.now(); for (const step of flow.steps) { let attempt = 0; let completed = false; let lastError = null; while (attempt < MAX_RETRIES && !completed) { attempt++; const stepStart = Date.now(); try { const result = await executeStep(driver, step, platform); result.duration = Date.now() - stepStart; steps.push(result); completed = true; } catch (error) { lastError = error; if (attempt >= MAX_RETRIES) { const message = formatStepError(step, lastError); const screenshot = await captureScreenshot(driver, `${step.action}-error`); steps.push({ action: step.action, status: "fail", message, screenshot, duration: Date.now() - stepStart, }); issues.push({ severity: "critical", message, suggestion: getSuggestionForStep(step), }); } else { await driver.pause(500 * attempt); } } } } return { flow: flow.name, url: `${currentContext.platform}://${currentContext.device || "device"}`, steps, issues, screenshots: [...currentContext.screenshots], duration: Date.now() - flowStart, }; } /** * Execute individual step on mobile driver */ async function executeStep(driver, step, platform) { if (!currentContext) { throw new Error("Runner context not initialized"); } switch (step.action) { case "navigate": { const target = step.target ?? "/"; // For mobile, navigation is typically a deep link await navigateToDeepLink(driver, platform, target); return { action: "navigate", status: "pass", message: `Navigated to ${target}`, }; } case "click": { if (!step.selector) { throw new Error("Click step missing selector"); } const element = await findElement(driver, step.selector, platform); await element.click(); return { action: "click", status: "pass", message: `Clicked ${step.selector}`, }; } case "type": { if (!step.selector || typeof step.value !== "string") { throw new Error("Type step requires selector and value"); } const element = await findElement(driver, step.selector, platform); await element.clearValue(); await element.setValue(step.value); return { action: "type", status: "pass", message: `Typed into ${step.selector}`, }; } case "waitFor": { if (!step.selector) { throw new Error("waitFor step missing selector"); } await findElement(driver, step.selector, platform, 10000); return { action: "waitFor", status: "pass", message: `Waited for ${step.selector}`, }; } case "screenshot": { const label = step.name ?? "screenshot"; const screenshot = await captureScreenshot(driver, label); return { action: "screenshot", status: "pass", message: `Captured screenshot ${label}`, screenshot, }; } default: throw new Error(`Unsupported action: ${step.action}`); } } /** * Find element by selector (supports accessibility ID and XPath) */ async function findElement(driver, selector, platform, timeout = 10000) { // If selector starts with //, treat as XPath if (selector.startsWith("//")) { return driver.$(`xpath=${selector}`).waitForExist({ timeout }); } // For iOS, use accessibility ID if (platform === "ios") { try { return driver .$(`~${selector}`) .waitForExist({ timeout }); } catch { // Fallback to XPath if accessibility ID fails return driver .$(`xpath=//*[@name="${selector}"]`) .waitForExist({ timeout }); } } // For Android, use resource-id or content-desc try { return driver .$(`id=${selector}`) .waitForExist({ timeout }); } catch { // Fallback to content-desc return driver .$(`accessibility=${selector}`) .waitForExist({ timeout }); } } /** * Navigate to a deep link */ async function navigateToDeepLink(driver, platform, deepLink) { if (platform === "ios") { await driver.executeScript("mobile: launchApp", [ { bundleId: extractBundleId(deepLink), }, ]); } else { // Android deep link await driver.executeScript("mobile: startActivity", [ { action: "android.intent.action.VIEW", uri: deepLink, }, ]); } } /** * Capture screenshot and save to disk */ async function captureScreenshot(driver, label) { if (!currentContext) { throw new Error("Runner context not initialized"); } const safeLabel = sanitize(label || "screenshot"); const fileName = `${sanitize(currentContext.flowName)}-${currentContext.platform}-${safeLabel}-${Date.now()}.png`; const filePath = path.join(currentContext.screenshotsDir, fileName); const screenshot = await driver.takeScreenshot(); await fs.writeFile(filePath, screenshot, "base64"); const relativePath = path.relative(process.cwd(), filePath); currentContext.screenshots.push(relativePath); return relativePath; } /** * Find Expo app on the system */ async function findExpoApp(platform) { const cwd = process.cwd(); const expoDir = path.join(cwd, ".expo"); const appJson = path.join(cwd, "app.json"); // Check if this is an Expo project if (!(await fs.pathExists(appJson))) { return null; } // For iOS, look for .app or .app.zip in .expo directory if (platform === "ios") { const iosDir = path.join(expoDir, "ios"); if (await fs.pathExists(iosDir)) { const files = await fs.readdir(iosDir); const appFile = files.find((f) => f.endsWith(".app") || f.endsWith(".app.zip")); if (appFile) { return path.join(iosDir, appFile); } } } // For Android, look for .apk in .expo directory if (platform === "android") { const androidDir = path.join(expoDir, "android"); if (await fs.pathExists(androidDir)) { const files = await fs.readdir(androidDir); const apkFile = files.find((f) => f.endsWith(".apk")); if (apkFile) { return path.join(androidDir, apkFile); } } } return null; } /** * Extract bundle ID from .app path (iOS) */ function extractBundleId(appPath) { // Extract from app path or use default const match = appPath.match(/([a-zA-Z0-9.-]+)\.app/); return match ? match[1] : "com.expo.example"; } /** * Extract package ID from .apk path (Android) */ function extractPackageId(appPath) { // Extract from app path or use default const match = appPath.match(/([a-zA-Z0-9.]+)\.apk/); return match ? match[1] : "com.expo.example"; } /** * Run mobile app in web fallback mode with mobile viewport */ async function runMobileWebFallback(opts, flow, cwd, screenshotsDir) { // Mobile viewport dimensions const viewport = opts.platform === "ios" ? { width: 390, height: 844 } // iPhone 15 Pro : { width: 412, height: 915 }; // Pixel 7 const deviceName = opts.platform === "ios" ? opts.device || "iPhone 15 Pro" : opts.device || "Pixel 7"; console.log(pc.cyan(`📱 Testing with ${deviceName} viewport (${viewport.width}x${viewport.height})`)); // Detect app URL (Expo default or custom) const appUrl = opts.app || "http://localhost:8081"; console.log(pc.gray(`🌐 App URL: ${appUrl}\n`)); reportStart(`${opts.platform} web (${viewport.width}x${viewport.height})`, flow.name); const browser = await chromium.launch({ headless: false }); const context = await browser.newContext({ viewport, userAgent: opts.platform === "ios" ? "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15" : "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36", isMobile: true, hasTouch: true, deviceScaleFactor: 2, }); const page = await context.newPage(); const steps = []; const issues = []; const flowStart = Date.now(); try { await page.goto(appUrl, { waitUntil: "domcontentloaded" }); // Execute flow steps for (const step of flow.steps) { const stepStart = Date.now(); try { await executeWebStep(page, step, screenshotsDir, flow.name, appUrl); steps.push({ action: step.action, status: "pass", duration: Date.now() - stepStart, }); } catch (error) { steps.push({ action: step.action, status: "fail", message: error.message, duration: Date.now() - stepStart, }); issues.push({ severity: "critical", message: `Step failed: ${step.action} - ${error.message}`, }); } } const results = { flow: flow.name, url: appUrl, steps, issues, screenshots: [], duration: Date.now() - flowStart, }; reportResults(results); return results; } finally { await browser.close(); } } /** * Execute a single step in web fallback mode */ async function executeWebStep(page, step, screenshotsDir, flowName, baseUrl) { switch (step.action) { case "navigate": if (step.target) { // Handle relative URLs const url = step.target.startsWith('http') ? step.target : `${baseUrl}${step.target}`; await page.goto(url); } break; case "click": if (step.selector) { await page.click(step.selector); } break; case "type": if (step.selector && step.value) { await page.fill(step.selector, step.value); } break; case "waitFor": if (step.selector) { await page.waitForSelector(step.selector, { timeout: 10000, }); } break; case "screenshot": const timestamp = Date.now(); const filename = `${flowName.toLowerCase().replace(/\s+/g, "-")}-${step.name || "screenshot"}-${timestamp}.png`; const filepath = path.join(screenshotsDir, filename); await page.screenshot({ path: filepath }); console.log(pc.gray(` 📸 ${filepath}`)); break; default: console.log(pc.yellow(`⚠️ Action '${step.action}' not supported in web fallback mode`)); } } /** * Sanitize string for file names */ function sanitize(input) { return input .replace(/[^a-z0-9-]+/gi, "-") .replace(/-+/g, "-") .replace(/^-|-$/g, "") .toLowerCase() || "capture"; } /** * Format step error message */ function formatStepError(step, error) { return `${step.action} failed: ${error.message}`; } /** * Get suggestion for step failure */ function getSuggestionForStep(step) { switch (step.action) { case "navigate": return "Verify the deep link is correct and the app supports it"; case "click": return "Ensure the element selector exists and is visible on screen"; case "type": return "Confirm the input field selector is correct and not disabled"; case "waitFor": return "Increase wait time or verify the element appears in the flow"; default: return undefined; } } //# sourceMappingURL=mobile.js.map