/** * ZeTa Native App Crawler — Appium-based crawler for Android APK and iOS IPA. * * Architecture: * Appium Server (running at APPIUM_SERVER_URL) → WebDriverIO session * → UiAutomator2 (Android) / XCUITest (iOS) * → accessibility tree XML → ZeTa Element records * * TODO: requires Appium server running at APPIUM_SERVER_URL env var * TODO: requires `webdriverio` package — add "webdriverio": "^9.x" to package.json * * Device farm support: * - local_emulator: Appium server + local emulator/simulator * - aws_device_farm: presigned S3 URL uploaded to Device Farm, session via WDIO * - browserstack: BrowserStack Automate Appium endpoint */ import fs from 'node:fs/promises'; import path from 'node:path'; import os from 'node:os'; import { withTenant } from '@detiq/database'; import { AppBrain, getTenantStorageConfig, uploadScreenshotToTenantStorage, type TenantStorageConfig } from '@detiq/app-brain'; import { config } from '@detiq/core'; import { logger } from './logger.js'; // --------------------------------------------------------------------------- // Public types // --------------------------------------------------------------------------- export interface NativeCrawlerOptions { tenantId: string; projectId: string; jobId: string; platform: 'android' | 'ios' | 'windows' | 'macos'; appS3Path: string; // s3://bucket/path/app.apk or s3://bucket/path/app.ipa packageName?: string; // com.example.app (Android) or bundle ID (iOS) deviceModel?: string; // optional device preference osVersion?: string; // Appium platformVersion (e.g. "14.0" for Android, "17.0" for iOS) appActivity?: string; // Android: activity to launch (e.g. ".MainActivity") appWaitActivity?: string; // Android: activity to wait for after launch deviceFarm: 'local_emulator' | 'aws_device_farm' | 'browserstack'; // Device farm connection credentials (from workspace settings, overrides env vars) appiumServerUrl?: string; browserstackUsername?: string; browserstackAccessKey?: string; awsAccessKeyId?: string; awsSecretAccessKey?: string; awsRegion?: string; awsDeviceFarmProjectArn?: string; // Auth credentials for Gap 1: native app login credentials?: { username?: string; password?: string; usernameFieldLabel?: string; // hint if default detection fails, e.g. "Email" passwordFieldLabel?: string; loginButtonLabel?: string; }; // Vision fallback AI config for Gap 2: when accessibility tree is empty visionLlmConfig?: { provider: string; model: string; apiKey?: string; baseUrl?: string } | null; // Gesture config for Gap 3 gestureConfig?: { enableSwipe?: boolean; // default true enableLongPress?: boolean; // default true }; // MITM proxy for Gap 4 (when available) mitmProxyPort?: number; } /** A native UI element extracted from the accessibility tree. */ interface NativeElement { role: string; label: string; bounds: NativeBounds; platform: 'android' | 'ios' | 'windows' | 'macos'; resourceId?: string; // Android: resource-id attribute className?: string; // Android: widget class / iOS: XCUIElement type } interface NativeBounds { x: number; y: number; width: number; height: number; } // --------------------------------------------------------------------------- // Internal helpers // --------------------------------------------------------------------------- /** * Build Appium desired capabilities for the given platform. * localAppPath is the local path after downloading from S3 (or a presigned URL * for device farms that fetch the binary themselves). */ function buildCapabilities(opts: NativeCrawlerOptions, localAppPath: string): Record { const appiumServerUrl = opts.appiumServerUrl ?? process.env.APPIUM_SERVER_URL ?? 'http://127.0.0.1:4723'; logger.info({ appiumServerUrl, platform: opts.platform }, 'Building Appium capabilities'); // BrowserStack early-return: https://hub-cloud.browserstack.com/wd/hub if (opts.deviceFarm === 'browserstack' && (opts.platform === 'android' || opts.platform === 'ios')) { const bstackOptions = { userName: opts.browserstackUsername ?? process.env.BROWSERSTACK_USERNAME ?? '', accessKey: opts.browserstackAccessKey ?? process.env.BROWSERSTACK_ACCESS_KEY ?? '', deviceName: opts.deviceModel ?? (opts.platform === 'android' ? 'Google Pixel 7' : 'iPhone 15'), osVersion: opts.osVersion ?? (opts.platform === 'android' ? '13.0' : '17'), realMobile: true, projectName: 'ZeTa-QIP', buildName: `${opts.platform}-crawl`, sessionName: opts.jobId, }; if (opts.platform === 'android') { return { 'platformName': 'android', 'appium:automationName': 'UiAutomator2', 'appium:app': localAppPath, 'appium:newCommandTimeout': 120, 'appium:autoGrantPermissions': true, ...(opts.appActivity ? { 'appium:appActivity': opts.appActivity } : {}), ...(opts.packageName ? { 'appium:appPackage': opts.packageName } : {}), 'bstack:options': bstackOptions, }; } return { 'platformName': 'ios', 'appium:automationName': 'XCUITest', 'appium:app': localAppPath, 'appium:newCommandTimeout': 120, ...(opts.packageName ? { 'appium:bundleId': opts.packageName } : {}), 'bstack:options': bstackOptions, }; } if (opts.platform === 'android') { return { 'appium:platformName': 'Android', 'appium:deviceName': opts.deviceModel ?? 'Pixel 7', 'appium:app': localAppPath, 'appium:automationName': 'UiAutomator2', 'appium:autoGrantPermissions': true, 'appium:newCommandTimeout': 120, 'appium:platformVersion': opts.osVersion ?? '', ...(opts.appActivity ? { 'appium:appActivity': opts.appActivity } : {}), ...(opts.appWaitActivity ? { 'appium:appWaitActivity': opts.appWaitActivity } : {}), 'appium:eventTimings': true, // capture launch time telemetry ...(opts.packageName ? { 'appium:appPackage': opts.packageName } : {}), }; } if (opts.platform === 'windows') { return { 'platformName': 'Windows', 'appium:automationName': 'Windows', 'appium:app': localAppPath, 'appium:deviceName': 'WindowsPC', 'appium:newCommandTimeout': 120, ...(opts.packageName ? { 'appium:appTopLevelWindow': opts.packageName } : {}), }; } if (opts.platform === 'macos') { return { 'platformName': 'mac', 'appium:automationName': 'Mac2', 'appium:app': localAppPath, 'appium:deviceName': 'Mac', 'appium:newCommandTimeout': 120, ...(opts.packageName ? { 'appium:bundleId': opts.packageName } : {}), }; } // iOS return { 'appium:platformName': 'iOS', 'appium:deviceName': opts.deviceModel ?? 'iPhone 15', 'appium:app': localAppPath, 'appium:automationName': 'XCUITest', 'appium:newCommandTimeout': 120, 'appium:platformVersion': opts.osVersion ?? '', ...(opts.packageName ? { 'appium:bundleId': opts.packageName } : {}), }; } /** * Upload a locally downloaded app binary to BrowserStack App Automate. * Returns the bs:// app URL to use as the Appium capability value. */ async function uploadToBrowserStack(localPath: string, opts: NativeCrawlerOptions): Promise { const username = opts.browserstackUsername ?? process.env.BROWSERSTACK_USERNAME ?? ''; const accessKey = opts.browserstackAccessKey ?? process.env.BROWSERSTACK_ACCESS_KEY ?? ''; const credentials = Buffer.from(`${username}:${accessKey}`).toString('base64'); const fileBuffer = await fs.readFile(localPath); const filename = localPath.split('/').pop() ?? 'app.apk'; const form = new (global as any).FormData(); form.append('file', new Blob([new Uint8Array(fileBuffer)]), filename); logger.info({ localPath, filename }, 'Uploading app binary to BrowserStack'); const response = await fetch('https://api-cloud.browserstack.com/app-automate/upload', { method: 'POST', headers: { Authorization: `Basic ${credentials}` }, body: form, }); if (!response.ok) { throw new Error(`BrowserStack upload failed: ${response.status} ${await response.text()}`); } const json = (await response.json()) as { app_url?: string }; if (!json.app_url) throw new Error('BrowserStack upload did not return app_url'); logger.info({ appUrl: json.app_url }, 'App uploaded to BrowserStack'); return json.app_url; // e.g. "bs://1234abc..." } /** * Download app binary from S3 to a local temp file. * For BrowserStack, also uploads the binary to App Automate and returns the bs:// URL. * Returns the local file path (or bs:// URL for BrowserStack). */ async function downloadAppFromS3(appS3Path: string, platform: 'android' | 'ios' | 'windows' | 'macos', opts: NativeCrawlerOptions, storageCfg?: TenantStorageConfig | null): Promise { // Local disk fallback — APK stored on-server when no cloud storage is configured if (appS3Path.startsWith('local://')) { const localPath = appS3Path.slice('local://'.length); logger.info({ localPath }, 'Using locally stored app binary'); if (opts.deviceFarm === 'browserstack') { return uploadToBrowserStack(localPath, opts); } return localPath; } // Parse s3://bucket/key const withoutScheme = appS3Path.replace(/^s3:\/\//, ''); const slashIdx = withoutScheme.indexOf('/'); if (slashIdx === -1) { throw new Error(`Invalid S3 path: ${appS3Path}. Expected format: s3://bucket/path/to/file`); } const bucket = withoutScheme.slice(0, slashIdx); const key = withoutScheme.slice(slashIdx + 1); const extMap: Record = { android: '.apk', ios: '.ipa', windows: '.exe', macos: '.app' }; const ext = extMap[platform] ?? ''; const tmpPath = path.join(os.tmpdir(), `zeta-native-${Date.now()}${ext}`); logger.info({ bucket, key, tmpPath, provider: storageCfg?.provider ?? 'env' }, 'Downloading app binary from storage'); const { S3Client, GetObjectCommand } = await import('@aws-sdk/client-s3' as any); const isR2 = storageCfg?.provider === 'R2'; const s3ClientCfg: Record = { region: isR2 ? 'auto' : (storageCfg?.region ?? process.env.AWS_REGION ?? 'us-east-1'), }; if (storageCfg?.accessKeyId && storageCfg?.secretAccessKey) { s3ClientCfg.credentials = { accessKeyId: storageCfg.accessKeyId, secretAccessKey: storageCfg.secretAccessKey }; } if (isR2 && storageCfg?.accountId) { s3ClientCfg.endpoint = `https://${storageCfg.accountId}.r2.cloudflarestorage.com`; s3ClientCfg.forcePathStyle = true; } const s3 = new S3Client(s3ClientCfg); const response = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key })); // Stream the body to disk const { Readable } = await import('node:stream'); const { pipeline } = await import('node:stream/promises'); const fh = await fs.open(tmpPath, 'w'); try { await pipeline(Readable.from(response.Body as any), fh.createWriteStream()); } finally { await fh.close(); } logger.info({ tmpPath }, 'App binary downloaded'); // For BrowserStack, upload the binary and return the bs:// URL if (opts.deviceFarm === 'browserstack') { return uploadToBrowserStack(tmpPath, opts); } return tmpPath; } /** * Parse the Appium XML page source into a flat list of NativeElement records. * * Android UiAutomator2 XML looks like: * * * iOS XCUITest XML looks like: * */ function parseAccessibilityTree(xmlSource: string, platform: 'android' | 'ios' | 'windows' | 'macos'): NativeElement[] { const elements: NativeElement[] = []; if (platform === 'windows') { // WinAppDriver returns UIAutomation XML like: //